From 0f48671c5bfa90a82580e8db5e7e0a49d71919d6 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Thu, 2 Jul 2026 14:15:55 -0400 Subject: [PATCH 01/11] feat(sdk): add TypeScript SDK (@nvidia/openshell-sdk) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First native, per-language SDK for the OpenShell gateway: a thin, idiomatic TypeScript client over proto-generated gRPC stubs (connect-es), no FFI. Covers the v0.1 surface — sandbox lifecycle (create/get/list/delete + waitReady/ waitDeleted), health, and streamed exec. - sdk/typescript/: package, client/transport/errors, protoc + protoc-gen-es codegen (gen/ gitignored, absorbed into dist/ at build), committed lockfile. - tasks/typescript.toml: sdk:ts install/proto/typecheck/build/ci/publish; sdk:ts:typecheck wired into `check`; sdk-typescript job in branch-checks (typecheck, build, and a --dry-run publish that validates the release path). - Enforce SPDX headers on .ts/.tsx/.mts/.cts (skip node_modules and gen/); back-fill docs/_components/jsx.d.ts and fern/components/CustomFooter.tsx. - release.py gains an npm version format; release-tag.yml publishes to GitHub Packages on tag, stamping the version (0.0.0 placeholder in git); prerelease builds publish under the `next` dist-tag, not `latest`. Ships as @nvidia/openshell-sdk on GitHub Packages pre-GA; public npm (@openshell/sdk) follows at GA with an unchanged public API. Signed-off-by: Max Dubrinsky --- .github/workflows/branch-checks.yml | 29 ++ .github/workflows/release-tag.yml | 40 ++ docs/_components/jsx.d.ts | 3 + fern/components/CustomFooter.tsx | 3 + scripts/update_license_headers.py | 9 + sdk/typescript/.gitignore | 5 + sdk/typescript/README.md | 50 ++ sdk/typescript/gen.sh | 29 ++ sdk/typescript/package-lock.json | 693 ++++++++++++++++++++++++++++ sdk/typescript/package.json | 46 ++ sdk/typescript/src/client.ts | 217 +++++++++ sdk/typescript/src/demo.ts | 56 +++ sdk/typescript/src/errors.ts | 54 +++ sdk/typescript/src/index.ts | 20 + sdk/typescript/src/transport.ts | 57 +++ sdk/typescript/tsconfig.build.json | 13 + sdk/typescript/tsconfig.json | 15 + tasks/ci.toml | 2 +- tasks/scripts/release.py | 16 + tasks/typescript.toml | 77 ++++ 20 files changed, 1433 insertions(+), 1 deletion(-) create mode 100644 sdk/typescript/.gitignore create mode 100644 sdk/typescript/README.md create mode 100644 sdk/typescript/gen.sh create mode 100644 sdk/typescript/package-lock.json create mode 100644 sdk/typescript/package.json create mode 100644 sdk/typescript/src/client.ts create mode 100644 sdk/typescript/src/demo.ts create mode 100644 sdk/typescript/src/errors.ts create mode 100644 sdk/typescript/src/index.ts create mode 100644 sdk/typescript/src/transport.ts create mode 100644 sdk/typescript/tsconfig.build.json create mode 100644 sdk/typescript/tsconfig.json create mode 100644 tasks/typescript.toml diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 7713febb6f..08ae357f96 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -190,3 +190,32 @@ jobs: - name: Lint run: mise run markdown:lint + + sdk-typescript: + name: TypeScript SDK + needs: pr_metadata + if: needs.pr_metadata.outputs.should_run == 'true' + runs-on: linux-amd64-cpu8 + container: + image: ghcr.io/nvidia/openshell/ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install tools + run: mise install --locked + + - name: Check TypeScript SDK + run: mise run sdk:ts:ci + + # Exercise the full release publish path (version stamp, dist-tag, + # prepublishOnly, tarball) without uploading. Uses the off-tag dev + # version, which validates the prerelease dist-tag branch too. + - name: Verify publishable artifact (dry-run) + env: + OPENSHELL_NPM_PUBLISH_ARGS: --dry-run + run: | + OPENSHELL_NPM_VERSION="$(uv run python tasks/scripts/release.py get-version --npm)" \ + mise run sdk:ts:publish diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index ec95ab5ea1..1485e62615 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -40,6 +40,7 @@ jobs: outputs: python_version: ${{ steps.v.outputs.python }} cargo_version: ${{ steps.v.outputs.cargo }} + npm_version: ${{ steps.v.outputs.npm }} deb_version: ${{ steps.v.outputs.deb }} rpm_version: ${{ steps.v.outputs.rpm_version }} rpm_release: ${{ steps.v.outputs.rpm_release }} @@ -65,6 +66,7 @@ jobs: set -euo pipefail echo "python=$(uv run python tasks/scripts/release.py get-version --python)" >> "$GITHUB_OUTPUT" echo "cargo=$(uv run python tasks/scripts/release.py get-version --cargo)" >> "$GITHUB_OUTPUT" + echo "npm=$(uv run python tasks/scripts/release.py get-version --npm)" >> "$GITHUB_OUTPUT" echo "deb=$(uv run python tasks/scripts/release.py get-version --deb)" >> "$GITHUB_OUTPUT" echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --rpm-version)" >> "$GITHUB_OUTPUT" echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --rpm-release)" >> "$GITHUB_OUTPUT" @@ -1067,6 +1069,44 @@ jobs: working-directory: ./fern run: fern generate --docs + publish-sdk-typescript: + name: Publish TypeScript SDK + needs: [compute-versions, release] + runs-on: linux-amd64-cpu8 + timeout-minutes: 15 + permissions: + contents: read + packages: write + container: + image: ghcr.io/nvidia/openshell/ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.tag || github.ref }} + + - name: Mark workspace safe for git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Install tools + run: mise install --locked + + - name: Configure npm auth for GitHub Packages + working-directory: ./sdk/typescript + run: | + { + echo "@nvidia:registry=https://npm.pkg.github.com" + echo '//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}' + } > .npmrc + + - name: Publish + env: + OPENSHELL_NPM_VERSION: ${{ needs.compute-versions.outputs.npm_version }} + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: mise run sdk:ts:publish + release-helm: name: Release Helm Chart (OCI) needs: [compute-versions, release, tag-ghcr-release] diff --git a/docs/_components/jsx.d.ts b/docs/_components/jsx.d.ts index b03bbc0f27..7aceb005a5 100644 --- a/docs/_components/jsx.d.ts +++ b/docs/_components/jsx.d.ts @@ -1 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + declare const React: unknown; \ No newline at end of file diff --git a/fern/components/CustomFooter.tsx b/fern/components/CustomFooter.tsx index fab392c407..49601bb017 100644 --- a/fern/components/CustomFooter.tsx +++ b/fern/components/CustomFooter.tsx @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + /** * Custom footer for NVIDIA docs (Fern native header/footer). * Markup and class names match the original custom-app footer 1:1 so that diff --git a/scripts/update_license_headers.py b/scripts/update_license_headers.py index f56dbc2935..55d009d956 100755 --- a/scripts/update_license_headers.py +++ b/scripts/update_license_headers.py @@ -43,6 +43,10 @@ ".yaml": "#", ".yml": "#", ".rego": "#", + ".ts": "//", + ".tsx": "//", + ".mts": "//", + ".cts": "//", } # Directories to skip entirely (relative to repo root). @@ -55,6 +59,7 @@ ".git", ".cache", "python/openshell/_proto", + "sdk/typescript/src/gen", } # Individual filenames to skip. @@ -103,6 +108,10 @@ def is_excluded(rel: Path) -> bool: """Return True if a path should be skipped.""" rel_str = str(rel) + # Vendored dependencies never carry our headers, at any depth. + if "node_modules" in rel.parts: + return True + # Exact filename exclusions. if rel.name in EXCLUDE_FILES: return True diff --git a/sdk/typescript/.gitignore b/sdk/typescript/.gitignore new file mode 100644 index 0000000000..63f9dcce4f --- /dev/null +++ b/sdk/typescript/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +src/gen/ +dist/ +*.tsbuildinfo +.npmrc diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md new file mode 100644 index 0000000000..479147314a --- /dev/null +++ b/sdk/typescript/README.md @@ -0,0 +1,50 @@ +# @nvidia/openshell-sdk + +TypeScript client for the OpenShell gateway — thin, idiomatic bindings generated from the OpenShell protobufs. + +Distributed via GitHub Packages during pre-GA. Public npm (`@openshell/sdk`) follows at GA; the public API is unchanged across the move, so only the install specifier changes. + +## Install + +Published to GitHub Packages, so add a project `.npmrc`: + +```shell +@nvidia:registry=https://npm.pkg.github.com +``` + +Authenticate with a GitHub token that has `read:packages`, then: + +```shell +npm install @nvidia/openshell-sdk +``` + +## Usage + +```ts +import { OpenShellClient } from '@nvidia/openshell-sdk' + +const client = await OpenShellClient.connect({ + gateway: 'https://gateway.example.com', + oidcToken: process.env.OPENSHELL_TOKEN, +}) + +const sandbox = await client.createSandbox({ + image: 'ghcr.io/nvidia/openshell-community/sandboxes/python:latest', +}) +await client.waitReady(sandbox.name, 120) + +const result = await client.exec(sandbox.name, ['/bin/sh', '-c', 'echo hello']) +console.log(result.stdout.toString()) + +await client.deleteSandbox(sandbox.name) +``` + +## Development + +The version field is a `0.0.0` placeholder; CI stamps the real version from the git release tag at publish time, matching the Rust and Python packages. + +```shell +mise run sdk:ts:proto # generate stubs from proto/ (protoc + protoc-gen-es) +mise run sdk:ts:typecheck # tsc --noEmit +mise run sdk:ts:build # emit dist/ +``` diff --git a/sdk/typescript/gen.sh b/sdk/typescript/gen.sh new file mode 100644 index 0000000000..a1f3b51565 --- /dev/null +++ b/sdk/typescript/gen.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Generate idiomatic TypeScript from the OpenShell protos using the pinned +# `protoc` toolchain (mise) + the connect-es plugin (@bufbuild/protoc-gen-es). +# No `buf` required. Well-known types (Struct, Timestamp, ...) resolve via +# protoc's bundled include path and render through @bufbuild/protobuf/wkt, so +# we do not generate them ourselves. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +PROTO_DIR="$HERE/../../proto" +OUT="$HERE/src/gen" +PLUGIN="$HERE/node_modules/.bin/protoc-gen-es" + +rm -rf "$OUT" +mkdir -p "$OUT" + +# Only the files the client-facing surface needs (transitive imports included). +protoc \ + -I "$PROTO_DIR" \ + --plugin=protoc-gen-es="$PLUGIN" \ + --es_out="$OUT" \ + --es_opt=target=ts,import_extension=js \ + datamodel.proto sandbox.proto openshell.proto + +echo "generated into $OUT:" +ls -1 "$OUT" diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json new file mode 100644 index 0000000000..227896dd68 --- /dev/null +++ b/sdk/typescript/package-lock.json @@ -0,0 +1,693 @@ +{ + "name": "@nvidia/openshell-sdk", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@nvidia/openshell-sdk", + "version": "0.0.0", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^2.2.3", + "@connectrpc/connect": "^2.0.0", + "@connectrpc/connect-node": "^2.0.0" + }, + "devDependencies": { + "@bufbuild/protoc-gen-es": "^2.2.3", + "@types/node": "^24.0.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.1.tgz", + "integrity": "sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@bufbuild/protoc-gen-es": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.12.1.tgz", + "integrity": "sha512-SWa7XvRYRouMo+vBQmpNFZ+ZEqQ8AC0LpL4QWAo1gvstLhFh/Y7Nf/a+MK7ZxDq5LZSThwfk974L1sFxO3OaGw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.12.1", + "@bufbuild/protoplugin": "2.12.1" + }, + "bin": { + "protoc-gen-es": "bin/protoc-gen-es" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@bufbuild/protobuf": "2.12.1" + }, + "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + } + } + }, + "node_modules/@bufbuild/protoplugin": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.12.1.tgz", + "integrity": "sha512-PY58KxQVAD1BnnKtStOctsMoegEVGfBnY5AOqVQOIu711nA13oYtTqJM8df5lUQg2J1DR3XxUXptE+fWX5oLdA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.12.1", + "@typescript/vfs": "^1.6.2", + "typescript": "5.4.5" + } + }, + "node_modules/@bufbuild/protoplugin/node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@connectrpc/connect": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.2.tgz", + "integrity": "sha512-MXkBijtcX09R10Eb6sFeIetc6w6746eio6xtfuyVOH7oQAacT1X0GzMIQFux6Qy8cq3W/T5qX5Bei8YbFtmRGA==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0" + } + }, + "node_modules/@connectrpc/connect-node": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-node/-/connect-node-2.1.2.tgz", + "integrity": "sha512-+i/aAOpsI8sIx1mbYp6d99zvxaUSF6t/jP9Ux9maAmjsZPgmIQ3JuIeYi0zJIP9zlCnBlJjkpPosshCgdRuThQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0", + "@connectrpc/connect": "2.1.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@typescript/vfs": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", + "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json new file mode 100644 index 0000000000..6fb1322232 --- /dev/null +++ b/sdk/typescript/package.json @@ -0,0 +1,46 @@ +{ + "name": "@nvidia/openshell-sdk", + "version": "0.0.0", + "description": "Official TypeScript SDK for the OpenShell gateway.", + "license": "Apache-2.0", + "type": "module", + "homepage": "https://github.com/NVIDIA/OpenShell", + "repository": { + "type": "git", + "url": "git+https://github.com/NVIDIA/OpenShell.git", + "directory": "sdk/typescript" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": ["dist", "README.md"], + "engines": { + "node": ">=20" + }, + "scripts": { + "gen": "bash gen.sh", + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "demo": "tsx src/demo.ts", + "prepublishOnly": "npm run gen && npm run build" + }, + "dependencies": { + "@bufbuild/protobuf": "^2.2.3", + "@connectrpc/connect": "^2.0.0", + "@connectrpc/connect-node": "^2.0.0" + }, + "devDependencies": { + "@bufbuild/protoc-gen-es": "^2.2.3", + "@types/node": "^24.0.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts new file mode 100644 index 0000000000..96ee8b0d51 --- /dev/null +++ b/sdk/typescript/src/client.ts @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// The OpenShell gateway client: a thin, idiomatic ergonomics layer over the +// protobuf-generated gRPC stubs (src/gen/). It owns proto request assembly, +// curated public types, the ExecSandbox server-stream drain, and the +// waitReady/waitDeleted poll loops. Transport and auth live in transport.ts; +// the error taxonomy in errors.ts. + +import { createClient, type Client } from '@connectrpc/connect' +import { OpenShell, SandboxPhase, ServiceStatus } from './gen/openshell_pb.js' +import type { Sandbox } from './gen/openshell_pb.js' +import { buildTransport, type ConnectOptions } from './transport.js' +import { errorCode, fromConnect, SdkError } from './errors.js' + +export { errorCode } +export type { ConnectOptions } + +// ---- Curated public types -------------------------------------------------- + +export interface Health { + status: string + version: string +} + +export interface SandboxSpec { + name?: string + image?: string + labels?: Record + environment?: Record + providers?: string[] + gpu?: boolean +} + +export interface SandboxRef { + id: string + name: string + phase: string + labels: Record + /** u64 rendered as a string — JS numbers can't hold it safely. */ + resourceVersion: string +} + +export interface ListOptions { + limit?: number + offset?: number + labelSelector?: string +} + +export interface ExecOptions { + workdir?: string + environment?: Record + timeoutSecs?: number + stdin?: Buffer +} + +export interface ExecResult { + exitCode: number + stdout: Buffer + stderr: Buffer +} + +// ---- enum → lowercase string ----------------------------------------------- + +function phaseName(p: SandboxPhase): string { + return (SandboxPhase[p] ?? 'UNSPECIFIED').toLowerCase() +} +function statusName(s: ServiceStatus): string { + return (ServiceStatus[s] ?? 'UNSPECIFIED').toLowerCase() +} + +function sandboxRef(sandbox: Sandbox | undefined): SandboxRef { + if (!sandbox) throw new SdkError('invalid_config', 'sandbox missing from gateway response') + const meta = sandbox.metadata + return { + id: meta?.id ?? '', + name: meta?.name ?? '', + phase: phaseName(sandbox.status?.phase ?? SandboxPhase.UNSPECIFIED), + labels: meta?.labels ?? {}, + resourceVersion: (meta?.resourceVersion ?? 0n).toString(), + } +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +// ---- The client ------------------------------------------------------------ + +export class OpenShellClient { + private constructor(private readonly grpc: Client) {} + + static async connect(options: ConnectOptions): Promise { + return new OpenShellClient(createClient(OpenShell, buildTransport(options))) + } + + async health(): Promise { + try { + const resp = await this.grpc.health({}) + return { status: statusName(resp.status), version: resp.version } + } catch (e) { + throw fromConnect(e) + } + } + + async createSandbox(spec: SandboxSpec): Promise { + try { + const resp = await this.grpc.createSandbox({ + name: spec.name ?? '', + labels: spec.labels ?? {}, + spec: { + environment: spec.environment ?? {}, + providers: spec.providers ?? [], + template: spec.image ? { image: spec.image } : undefined, + resourceRequirements: spec.gpu ? { gpu: {} } : undefined, + }, + }) + return sandboxRef(resp.sandbox) + } catch (e) { + throw fromConnect(e) + } + } + + async getSandbox(name: string): Promise { + try { + const resp = await this.grpc.getSandbox({ name }) + return sandboxRef(resp.sandbox) + } catch (e) { + throw fromConnect(e) + } + } + + async listSandboxes(options?: ListOptions | null): Promise { + try { + const resp = await this.grpc.listSandboxes({ + limit: options?.limit ?? 0, + offset: options?.offset ?? 0, + labelSelector: options?.labelSelector ?? '', + }) + return resp.sandboxes.map((s) => sandboxRef(s)) + } catch (e) { + throw fromConnect(e) + } + } + + async deleteSandbox(name: string): Promise { + try { + const resp = await this.grpc.deleteSandbox({ name }) + return resp.deleted + } catch (e) { + throw fromConnect(e) + } + } + + async waitReady(name: string, timeoutSecs: number): Promise { + const deadline = Date.now() + timeoutSecs * 1000 + let delay = 250 + for (;;) { + const ref = await this.getSandbox(name) + if (ref.phase === 'ready') return ref + if (ref.phase === 'error') throw new SdkError('connect', `sandbox '${name}' entered error phase`) + if (Date.now() >= deadline) throw new SdkError('connect', `timed out waiting for sandbox '${name}'`) + await sleep(delay) + delay = Math.min(delay * 2, 2000) + } + } + + async waitDeleted(name: string, timeoutSecs: number): Promise { + const deadline = Date.now() + timeoutSecs * 1000 + let delay = 250 + for (;;) { + try { + await this.getSandbox(name) + } catch (e) { + if (e instanceof SdkError && e.code === 'not_found') return + throw e + } + if (Date.now() >= deadline) throw new SdkError('connect', `timed out waiting for sandbox '${name}' to delete`) + await sleep(delay) + delay = Math.min(delay * 2, 2000) + } + } + + async exec(name: string, command: string[], options?: ExecOptions | null): Promise { + try { + // Resolve the sandbox id first, exactly like the gateway client. + const sandbox = await this.getSandbox(name) + const stream = this.grpc.execSandbox({ + sandboxId: sandbox.id, + command, + workdir: options?.workdir ?? '', + environment: options?.environment ?? {}, + timeoutSeconds: options?.timeoutSecs ?? 0, + stdin: options?.stdin ? new Uint8Array(options.stdin) : new Uint8Array(), + tty: false, + }) + + const stdout: Uint8Array[] = [] + const stderr: Uint8Array[] = [] + let exitCode = -1 + for await (const event of stream) { + switch (event.payload.case) { + case 'stdout': + stdout.push(event.payload.value.data) + break + case 'stderr': + stderr.push(event.payload.value.data) + break + case 'exit': + exitCode = event.payload.value.exitCode + break + } + } + return { exitCode, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) } + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e) + } + } +} diff --git a/sdk/typescript/src/demo.ts b/sdk/typescript/src/demo.ts new file mode 100644 index 0000000000..cbe7540733 --- /dev/null +++ b/sdk/typescript/src/demo.ts @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Runnable smoke test exercising the v0.1 surface against any OpenShell gateway: +// +// OPENSHELL_GATEWAY=http://127.0.0.1:8080 \ +// OPENSHELL_DEFAULT_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/python:latest \ +// npm run demo +// +// Auth: set OPENSHELL_OIDC_TOKEN / OPENSHELL_EDGE_TOKEN / OPENSHELL_CA_CERT / +// OPENSHELL_INSECURE as needed. With none set it assumes a plaintext local gateway. + +import { readFileSync } from 'node:fs' +import { OpenShellClient, errorCode, type ExecResult, type SandboxSpec } from './index.js' + +const env = process.env +const gateway = env.OPENSHELL_GATEWAY ?? 'http://127.0.0.1:8080' +const caCert = env.OPENSHELL_CA_CERT ? readFileSync(env.OPENSHELL_CA_CERT) : undefined + +async function main() { + const client = await OpenShellClient.connect({ + gateway, + caCert, + oidcToken: env.OPENSHELL_OIDC_TOKEN, + edgeToken: env.OPENSHELL_EDGE_TOKEN, + insecureSkipVerify: env.OPENSHELL_INSECURE === '1', + }) + + const health = await client.health() + console.log(`health: ${health.status} (v${health.version})`) + + const spec: SandboxSpec = { + image: env.OPENSHELL_DEFAULT_IMAGE, + labels: { 'openshell.dev/demo': 'sdk-ts' }, + } + const ref = await client.createSandbox(spec) + console.log(`created: ${ref.name} [${ref.phase}]`) + + await client.waitReady(ref.name, 120) + console.log(`ready: ${ref.name}`) + + const result: ExecResult = await client.exec(ref.name, ['/bin/sh', '-c', 'echo hello from $(hostname)'], { + timeoutSecs: 30, + }) + console.log(`exec exit=${result.exitCode} stdout=${result.stdout.toString().trim()}`) + + const all = await client.listSandboxes({ labelSelector: 'openshell.dev/demo=sdk-ts' }) + console.log(`listed ${all.length} demo sandbox(es)`) + + console.log(`deleted: ${await client.deleteSandbox(ref.name)}`) +} + +main().catch((e) => { + console.error(`demo failed [code=${errorCode(e) ?? 'unknown'}]:`, e instanceof Error ? e.message : e) + process.exit(1) +}) diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts new file mode 100644 index 0000000000..20a72d816f --- /dev/null +++ b/sdk/typescript/src/errors.ts @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Error taxonomy — every thrown error message is prefixed with `[code] ` so +// callers can discriminate with errorCode(). This mirrors the shape the (now +// retired) napi binding exposed, kept stable so consumers migrating off it see +// an identical contract. + +import { Code, ConnectError } from '@connectrpc/connect' + +export type SdkErrorCode = + | 'invalid_config' + | 'tls' + | 'connect' + | 'auth' + | 'io' + | 'not_found' + | 'already_exists' + | 'rpc' + +export class SdkError extends Error { + readonly code: SdkErrorCode + constructor(code: SdkErrorCode, message: string) { + // Format `[code] message` so errorCode() can recover the code from any Error. + super(`[${code}] ${message}`) + this.name = 'SdkError' + this.code = code + } +} + +// Map a gRPC status (surfaced by connect-es as ConnectError) onto our codes. +export function fromConnect(err: unknown): SdkError { + const ce = ConnectError.from(err) + switch (ce.code) { + case Code.NotFound: + return new SdkError('not_found', ce.rawMessage) + case Code.AlreadyExists: + return new SdkError('already_exists', ce.rawMessage) + case Code.InvalidArgument: + return new SdkError('invalid_config', ce.rawMessage) + case Code.Unauthenticated: + case Code.PermissionDenied: + return new SdkError('auth', ce.rawMessage) + default: + return new SdkError('rpc', ce.rawMessage) + } +} + +// Extract the `[code]` prefix from any error message. +export function errorCode(err: unknown): string | null { + const msg = err instanceof Error ? err.message : String(err) + const m = /^\[([a-z_]+)\]/.exec(msg) + return m ? m[1] : null +} diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts new file mode 100644 index 0000000000..7939ee7492 --- /dev/null +++ b/sdk/typescript/src/index.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Public API surface for @nvidia/openshell-sdk. +// +// OidcRefresher (single-flight OIDC refresh) is intentionally not yet exported. +// It is the one piece of genuinely shared, cross-language behavior; it will be +// added alongside a conformance suite that pins it byte-identical across the +// TypeScript, Python, and Go SDKs. + +export { OpenShellClient, errorCode } from './client.js' +export type { + ConnectOptions, + ExecOptions, + ExecResult, + Health, + ListOptions, + SandboxRef, + SandboxSpec, +} from './client.js' diff --git a/sdk/typescript/src/transport.ts b/sdk/typescript/src/transport.ts new file mode 100644 index 0000000000..4f40051572 --- /dev/null +++ b/sdk/typescript/src/transport.ts @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Transport + auth layer. h2c for `http://` (local dev), Node TLS passthrough +// for `https://` (CA pinning, insecure-skip-verify), and an interceptor that +// attaches the OIDC bearer or Cloudflare Access headers. +// +// Not covered here: the Cloudflare-Access WebSocket tunnel (the gateway's edge +// proxy). That ships as a language-agnostic sidecar bound to 127.0.0.1 — point +// `gateway` at it. When the edge passes gRPC POST directly, the header mode +// below suffices. + +import { createGrpcTransport } from '@connectrpc/connect-node' +import type { Interceptor, Transport } from '@connectrpc/connect' + +export interface ConnectOptions { + /** Gateway URL (`http://...` or `https://...`). */ + gateway: string + /** CA certificate (PEM). Omit to use system roots. */ + caCert?: Buffer + /** Bearer token for direct OIDC auth. Mutually exclusive with edgeToken. */ + oidcToken?: string + /** Cloudflare Access token. See the sidecar note above for CF-fronted gateways. */ + edgeToken?: string + /** Disable TLS verification (dev/debug only). */ + insecureSkipVerify?: boolean +} + +// OIDC bearer takes precedence; otherwise attach the Cloudflare Access header + +// cookie. No-op when neither token is set. +function authInterceptor(opts: ConnectOptions): Interceptor { + return (next) => async (req) => { + if (opts.oidcToken) { + req.header.set('authorization', `Bearer ${opts.oidcToken}`) + } else if (opts.edgeToken) { + req.header.set('cf-access-jwt-assertion', opts.edgeToken) + req.header.set('cookie', `CF_Authorization=${opts.edgeToken}`) + } + return next(req) + } +} + +export function buildTransport(opts: ConnectOptions): Transport { + const isTls = opts.gateway.startsWith('https://') + return createGrpcTransport({ + baseUrl: opts.gateway, + interceptors: [authInterceptor(opts)], + // For https:// gateways, pass Node TLS options straight through. For + // http:// (local dev) these are ignored and the client speaks h2c. + nodeOptions: isTls + ? { + ca: opts.caCert, + rejectUnauthorized: opts.insecureSkipVerify ? false : undefined, + } + : undefined, + }) +} diff --git a/sdk/typescript/tsconfig.build.json b/sdk/typescript/tsconfig.build.json new file mode 100644 index 0000000000..8875968cbd --- /dev/null +++ b/sdk/typescript/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/demo.ts", "src/**/*.test.ts"] +} diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json new file mode 100644 index 0000000000..9fc969d5d5 --- /dev/null +++ b/sdk/typescript/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/tasks/ci.toml b/tasks/ci.toml index 9696517af2..47c86b6e5d 100644 --- a/tasks/ci.toml +++ b/tasks/ci.toml @@ -37,7 +37,7 @@ run = [ [check] description = "Run fast compile and type checks" -depends = ["rust:check", "python:typecheck"] +depends = ["rust:check", "python:typecheck", "sdk:ts:typecheck"] hide = true [clean] diff --git a/tasks/scripts/release.py b/tasks/scripts/release.py index f00bd19d34..5ebe41c030 100644 --- a/tasks/scripts/release.py +++ b/tasks/scripts/release.py @@ -20,6 +20,7 @@ class Versions: python: str cargo: str + npm: str docker: str deb: str snap: str @@ -106,6 +107,12 @@ def _versions_from_parts( # 0.1.0.dev3+gabcdef -> 0.1.0-dev.3+gabcdef cargo_version = re.sub(r"\.dev(\d+)", r"-dev.\1", python_version) + # npm follows SemVer 2.0 like Cargo, but fold the '+g' build metadata + # into the prerelease (npm/registries treat build metadata as insignificant + # for version identity, so each dev build must differ in the prerelease). + # 0.1.0-dev.3+gabcdef -> 0.1.0-dev.3.gabcdef ; a tagged release stays 0.1.0. + npm_version = cargo_version.replace("+", ".") + # Docker tags can't contain '+'. docker_version = cargo_version.replace("+", "-") @@ -123,6 +130,7 @@ def _versions_from_parts( return Versions( python=python_version, cargo=cargo_version, + npm=npm_version, docker=docker_version, deb=deb_version, snap=snap_version, @@ -154,6 +162,7 @@ def _compute_versions() -> Versions: def _print_env(versions: Versions) -> None: print(f"VERSION_PY={versions.python}") print(f"VERSION_CARGO={versions.cargo}") + print(f"VERSION_NPM={versions.npm}") print(f"VERSION_DOCKER={versions.docker}") print(f"VERSION_DEB={versions.deb}") print(f"VERSION_SNAP={versions.snap}") @@ -170,6 +179,8 @@ def get_version(format: str) -> None: print(versions.python) elif format == "cargo": print(versions.cargo) + elif format == "npm": + print(versions.npm) elif format == "docker": print(versions.docker) elif format == "deb": @@ -408,6 +419,9 @@ def build_parser() -> argparse.ArgumentParser: get_version_parser.add_argument( "--cargo", action="store_true", help="Print Cargo version only." ) + get_version_parser.add_argument( + "--npm", action="store_true", help="Print npm version only." + ) get_version_parser.add_argument( "--docker", action="store_true", help="Print Docker version only." ) @@ -461,6 +475,8 @@ def main() -> None: get_version("python") elif args.cargo: get_version("cargo") + elif args.npm: + get_version("npm") elif args.docker: get_version("docker") elif args.deb: diff --git a/tasks/typescript.toml b/tasks/typescript.toml new file mode 100644 index 0000000000..6fee1439be --- /dev/null +++ b/tasks/typescript.toml @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# TypeScript SDK tasks (sdk/typescript). Codegen uses the pinned protoc (mise) +# + the connect-es plugin from the package's own devDependencies. + +["sdk:ts:install"] +description = "Install TypeScript SDK dependencies" +dir = "sdk/typescript" +run = "npm ci" +hide = true + +["sdk:ts:proto"] +description = "Generate TypeScript protobuf stubs for the SDK" +depends = ["sdk:ts:install"] +dir = "sdk/typescript" +run = "npm run gen" + +["sdk:ts:typecheck"] +description = "Type-check the TypeScript SDK" +depends = ["sdk:ts:proto"] +dir = "sdk/typescript" +run = "npm run typecheck" + +["sdk:ts:build"] +description = "Build the TypeScript SDK (emit dist/)" +depends = ["sdk:ts:proto"] +dir = "sdk/typescript" +run = "npm run build" + +["sdk:ts:ci"] +description = "TypeScript SDK checks (codegen, typecheck, build)" +depends = ["sdk:ts:typecheck", "sdk:ts:build"] +hide = true + +# Publish to the registry in package.json publishConfig. Set OPENSHELL_NPM_VERSION +# to stamp the version from the release tag (release.py get-version --npm); the +# package.json placeholder 0.0.0 is restored afterward, mirroring the Cargo +# version stamping in tasks/python.toml. Auth is expected via a .npmrc the caller +# writes (CI) or the user's own npm login. +# +# Prerelease versions (e.g. 0.0.37-dev.N.gSHA from an off-tag build) must not +# claim the `latest` dist-tag, so they publish under `next` instead — npm also +# refuses a bare `npm publish` for a prerelease. Set OPENSHELL_NPM_PUBLISH_ARGS +# (e.g. `--dry-run`) to pass extra flags through; CI uses this to validate the +# publishable artifact on PRs without uploading. +["sdk:ts:publish"] +description = "Publish the TypeScript SDK to its configured registry" +depends = ["sdk:ts:build"] +dir = "sdk/typescript" +run = """ +#!/usr/bin/env bash +set -euo pipefail + +ORIGINAL_PKG="" +cleanup() { + if [ -n "$ORIGINAL_PKG" ] && [ -f "$ORIGINAL_PKG" ]; then + cp "$ORIGINAL_PKG" package.json + rm -f "$ORIGINAL_PKG" + fi +} +trap cleanup EXIT + +DIST_TAG="latest" +if [ -n "${OPENSHELL_NPM_VERSION:-}" ]; then + ORIGINAL_PKG=$(mktemp) + cp package.json "$ORIGINAL_PKG" + npm pkg set version="$OPENSHELL_NPM_VERSION" + # A hyphen means a SemVer prerelease (X.Y.Z-dev.N...) -> publish off `latest`. + case "$OPENSHELL_NPM_VERSION" in + *-*) DIST_TAG="next" ;; + esac +fi + +npm publish --tag "$DIST_TAG" ${OPENSHELL_NPM_PUBLISH_ARGS:-} +""" +hide = true From 2142aa18e7b8f553f205bce87c049e85f81f980c Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Mon, 6 Jul 2026 14:40:27 -0400 Subject: [PATCH 02/11] chore(sdk): adopt TypeScript 6, tidy @types/node range - typescript ^5.7.2 -> ^6.0.3 (6.0 is now `latest`; the old caret capped at 5.x) - @types/node ^24.0.0 -> ^24 (same range, tidier) No source changes; codegen, typecheck, and build pass on 6.0.3. Verified the emitted d.ts still type-check for downstream consumers on TypeScript 5.0.4 through 5.9.3, so this does not raise the SDK's consumer TS floor. Signed-off-by: Max Dubrinsky --- sdk/typescript/package-lock.json | 10 +++++----- sdk/typescript/package.json | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index 227896dd68..8fe4c20e92 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -15,9 +15,9 @@ }, "devDependencies": { "@bufbuild/protoc-gen-es": "^2.2.3", - "@types/node": "^24.0.0", + "@types/node": "^24", "tsx": "^4.19.2", - "typescript": "^5.7.2" + "typescript": "^6.0.3" }, "engines": { "node": ">=20" @@ -669,9 +669,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 6fb1322232..6b09ad436b 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -39,8 +39,8 @@ }, "devDependencies": { "@bufbuild/protoc-gen-es": "^2.2.3", - "@types/node": "^24.0.0", + "@types/node": "^24", "tsx": "^4.19.2", - "typescript": "^5.7.2" + "typescript": "^6.0.3" } } From 0481aba1fa1eb67fda861905d6fd86b4838b5b0a Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Mon, 6 Jul 2026 15:34:34 -0400 Subject: [PATCH 03/11] refactor(sdk): group operations under a composable SandboxClient Reshape the client from flat methods (createSandbox, listSandboxes, exec) to a scoped SandboxClient reached as `client.sandbox.create/get/list/delete/exec` (+ waitReady/waitDeleted), mirroring the CLI's noun-verb model and the Python SDK's SandboxClient. SandboxClient is also usable standalone via SandboxClient.connect(); OpenShellClient composes it over a single shared transport, so future service/provider clients reuse one connection. health() stays top-level as a gateway call. No behavior change; types are unchanged. Signed-off-by: Max Dubrinsky --- sdk/typescript/README.md | 20 +++++++-- sdk/typescript/src/client.ts | 84 +++++++++++++++++++++++++----------- sdk/typescript/src/demo.ts | 10 ++--- sdk/typescript/src/index.ts | 2 +- 4 files changed, 81 insertions(+), 35 deletions(-) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 479147314a..135446f65a 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -28,15 +28,27 @@ const client = await OpenShellClient.connect({ oidcToken: process.env.OPENSHELL_TOKEN, }) -const sandbox = await client.createSandbox({ +const sandbox = await client.sandbox.create({ image: 'ghcr.io/nvidia/openshell-community/sandboxes/python:latest', }) -await client.waitReady(sandbox.name, 120) +await client.sandbox.waitReady(sandbox.name, 120) -const result = await client.exec(sandbox.name, ['/bin/sh', '-c', 'echo hello']) +const result = await client.sandbox.exec(sandbox.name, ['/bin/sh', '-c', 'echo hello']) console.log(result.stdout.toString()) -await client.deleteSandbox(sandbox.name) +await client.sandbox.delete(sandbox.name) +``` + +### Scoped clients + +`client.sandbox` is a `SandboxClient`. If you only need sandboxes, connect one +directly — same API, one less hop: + +```ts +import { SandboxClient } from '@nvidia/openshell-sdk' + +const sandbox = await SandboxClient.connect({ gateway, oidcToken }) +await sandbox.create({ image }) ``` ## Development diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 96ee8b0d51..8d0a52ac6b 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -2,12 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 // The OpenShell gateway client: a thin, idiomatic ergonomics layer over the -// protobuf-generated gRPC stubs (src/gen/). It owns proto request assembly, -// curated public types, the ExecSandbox server-stream drain, and the -// waitReady/waitDeleted poll loops. Transport and auth live in transport.ts; -// the error taxonomy in errors.ts. - -import { createClient, type Client } from '@connectrpc/connect' +// protobuf-generated gRPC stubs (src/gen/). Resource operations live on scoped +// clients (`SandboxClient`, mirroring the Python SDK) that OpenShellClient +// composes as `client.sandbox.*`, mirroring the CLI's noun-verb model; each +// scoped client is also usable standalone via its own `connect()`. Gateway- +// scoped calls (`health`) stay top-level. A scoped client owns proto request +// assembly, the curated public types, the ExecSandbox server-stream drain, and +// the waitReady/waitDeleted poll loops. Transport and auth live in +// transport.ts; the error taxonomy in errors.ts. + +import { createClient, type Client, type Transport } from '@connectrpc/connect' import { OpenShell, SandboxPhase, ServiceStatus } from './gen/openshell_pb.js' import type { Sandbox } from './gen/openshell_pb.js' import { buildTransport, type ConnectOptions } from './transport.js' @@ -83,25 +87,26 @@ function sandboxRef(sandbox: Sandbox | undefined): SandboxRef { const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) -// ---- The client ------------------------------------------------------------ +// ---- sandbox client -------------------------------------------------------- -export class OpenShellClient { - private constructor(private readonly grpc: Client) {} +// Sandbox lifecycle + exec. Usable standalone via `SandboxClient.connect()`, +// or reached as `client.sandbox` on an OpenShellClient, which shares one +// transport (one connection) across all of its scoped clients. +export class SandboxClient { + private readonly grpc: Client - static async connect(options: ConnectOptions): Promise { - return new OpenShellClient(createClient(OpenShell, buildTransport(options))) + // Takes a transport rather than options so OpenShellClient can compose + // several scoped clients over a single connection. For standalone use, + // prefer the SandboxClient.connect() factory below. + constructor(transport: Transport) { + this.grpc = createClient(OpenShell, transport) } - async health(): Promise { - try { - const resp = await this.grpc.health({}) - return { status: statusName(resp.status), version: resp.version } - } catch (e) { - throw fromConnect(e) - } + static async connect(options: ConnectOptions): Promise { + return new SandboxClient(buildTransport(options)) } - async createSandbox(spec: SandboxSpec): Promise { + async create(spec: SandboxSpec): Promise { try { const resp = await this.grpc.createSandbox({ name: spec.name ?? '', @@ -119,7 +124,7 @@ export class OpenShellClient { } } - async getSandbox(name: string): Promise { + async get(name: string): Promise { try { const resp = await this.grpc.getSandbox({ name }) return sandboxRef(resp.sandbox) @@ -128,7 +133,7 @@ export class OpenShellClient { } } - async listSandboxes(options?: ListOptions | null): Promise { + async list(options?: ListOptions | null): Promise { try { const resp = await this.grpc.listSandboxes({ limit: options?.limit ?? 0, @@ -141,7 +146,7 @@ export class OpenShellClient { } } - async deleteSandbox(name: string): Promise { + async delete(name: string): Promise { try { const resp = await this.grpc.deleteSandbox({ name }) return resp.deleted @@ -154,7 +159,7 @@ export class OpenShellClient { const deadline = Date.now() + timeoutSecs * 1000 let delay = 250 for (;;) { - const ref = await this.getSandbox(name) + const ref = await this.get(name) if (ref.phase === 'ready') return ref if (ref.phase === 'error') throw new SdkError('connect', `sandbox '${name}' entered error phase`) if (Date.now() >= deadline) throw new SdkError('connect', `timed out waiting for sandbox '${name}'`) @@ -168,7 +173,7 @@ export class OpenShellClient { let delay = 250 for (;;) { try { - await this.getSandbox(name) + await this.get(name) } catch (e) { if (e instanceof SdkError && e.code === 'not_found') return throw e @@ -182,7 +187,7 @@ export class OpenShellClient { async exec(name: string, command: string[], options?: ExecOptions | null): Promise { try { // Resolve the sandbox id first, exactly like the gateway client. - const sandbox = await this.getSandbox(name) + const sandbox = await this.get(name) const stream = this.grpc.execSandbox({ sandboxId: sandbox.id, command, @@ -215,3 +220,32 @@ export class OpenShellClient { } } } + +// ---- The client ------------------------------------------------------------ + +export class OpenShellClient { + /** Sandbox lifecycle + exec: create/get/list/delete, waitReady/waitDeleted, exec. */ + readonly sandbox: SandboxClient + + private readonly grpc: Client + + private constructor(transport: Transport) { + // One transport (one connection) shared across every scoped client. + this.grpc = createClient(OpenShell, transport) + this.sandbox = new SandboxClient(transport) + } + + static async connect(options: ConnectOptions): Promise { + return new OpenShellClient(buildTransport(options)) + } + + // Gateway-scoped, so it stays top-level rather than under a namespace. + async health(): Promise { + try { + const resp = await this.grpc.health({}) + return { status: statusName(resp.status), version: resp.version } + } catch (e) { + throw fromConnect(e) + } + } +} diff --git a/sdk/typescript/src/demo.ts b/sdk/typescript/src/demo.ts index cbe7540733..028e1a0f54 100644 --- a/sdk/typescript/src/demo.ts +++ b/sdk/typescript/src/demo.ts @@ -33,21 +33,21 @@ async function main() { image: env.OPENSHELL_DEFAULT_IMAGE, labels: { 'openshell.dev/demo': 'sdk-ts' }, } - const ref = await client.createSandbox(spec) + const ref = await client.sandbox.create(spec) console.log(`created: ${ref.name} [${ref.phase}]`) - await client.waitReady(ref.name, 120) + await client.sandbox.waitReady(ref.name, 120) console.log(`ready: ${ref.name}`) - const result: ExecResult = await client.exec(ref.name, ['/bin/sh', '-c', 'echo hello from $(hostname)'], { + const result: ExecResult = await client.sandbox.exec(ref.name, ['/bin/sh', '-c', 'echo hello from $(hostname)'], { timeoutSecs: 30, }) console.log(`exec exit=${result.exitCode} stdout=${result.stdout.toString().trim()}`) - const all = await client.listSandboxes({ labelSelector: 'openshell.dev/demo=sdk-ts' }) + const all = await client.sandbox.list({ labelSelector: 'openshell.dev/demo=sdk-ts' }) console.log(`listed ${all.length} demo sandbox(es)`) - console.log(`deleted: ${await client.deleteSandbox(ref.name)}`) + console.log(`deleted: ${await client.sandbox.delete(ref.name)}`) } main().catch((e) => { diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 7939ee7492..dfa36beb7b 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -8,7 +8,7 @@ // added alongside a conformance suite that pins it byte-identical across the // TypeScript, Python, and Go SDKs. -export { OpenShellClient, errorCode } from './client.js' +export { OpenShellClient, SandboxClient, errorCode } from './client.js' export type { ConnectOptions, ExecOptions, From a44a79ac921d216b2199ef83e72324eb9b781b9b Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Mon, 6 Jul 2026 20:35:47 -0400 Subject: [PATCH 04/11] chore(sdk): generate TypeScript SDK stubs with buf Replace the protoc gen.sh with `buf generate` + buf.gen.yaml. `buf` (@bufbuild/buf) is a package devDependency and self-compiles the protos, so the TS SDK no longer depends on the mise-pinned protoc; it drives the same connect-es plugin. Generation stays limited to the client-surface closure (openshell/sandbox/datamodel) via the input paths. Output is byte-identical to the previous protoc + protoc-gen-es pipeline. Lays the groundwork for a shared buf.yaml (lint/breaking/LSP) as a follow-up. Signed-off-by: Max Dubrinsky --- sdk/typescript/buf.gen.yaml | 22 +++++ sdk/typescript/gen.sh | 29 ------- sdk/typescript/package-lock.json | 145 +++++++++++++++++++++++++++++++ sdk/typescript/package.json | 8 +- tasks/typescript.toml | 5 +- 5 files changed, 176 insertions(+), 33 deletions(-) create mode 100644 sdk/typescript/buf.gen.yaml delete mode 100644 sdk/typescript/gen.sh diff --git a/sdk/typescript/buf.gen.yaml b/sdk/typescript/buf.gen.yaml new file mode 100644 index 0000000000..339ccc8607 --- /dev/null +++ b/sdk/typescript/buf.gen.yaml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Code generation for the TypeScript SDK. buf compiles proto/ with its own +# compiler (no protoc) and drives the connect-es plugin from this package's +# devDependencies. Limited to the client-surface closure so we don't emit the +# unused inference/compute/test protos; well-known types resolve through +# @bufbuild/protobuf/wkt and are not generated. +version: v2 +clean: true +inputs: + - directory: ../../proto + paths: + - ../../proto/openshell.proto + - ../../proto/sandbox.proto + - ../../proto/datamodel.proto +plugins: + - local: ./node_modules/.bin/protoc-gen-es + out: src/gen + opt: + - target=ts + - import_extension=js diff --git a/sdk/typescript/gen.sh b/sdk/typescript/gen.sh deleted file mode 100644 index a1f3b51565..0000000000 --- a/sdk/typescript/gen.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Generate idiomatic TypeScript from the OpenShell protos using the pinned -# `protoc` toolchain (mise) + the connect-es plugin (@bufbuild/protoc-gen-es). -# No `buf` required. Well-known types (Struct, Timestamp, ...) resolve via -# protoc's bundled include path and render through @bufbuild/protobuf/wkt, so -# we do not generate them ourselves. -set -euo pipefail - -HERE="$(cd "$(dirname "$0")" && pwd)" -PROTO_DIR="$HERE/../../proto" -OUT="$HERE/src/gen" -PLUGIN="$HERE/node_modules/.bin/protoc-gen-es" - -rm -rf "$OUT" -mkdir -p "$OUT" - -# Only the files the client-facing surface needs (transitive imports included). -protoc \ - -I "$PROTO_DIR" \ - --plugin=protoc-gen-es="$PLUGIN" \ - --es_out="$OUT" \ - --es_opt=target=ts,import_extension=js \ - datamodel.proto sandbox.proto openshell.proto - -echo "generated into $OUT:" -ls -1 "$OUT" diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index 8fe4c20e92..24bde1ae08 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -14,6 +14,7 @@ "@connectrpc/connect-node": "^2.0.0" }, "devDependencies": { + "@bufbuild/buf": "^1.71.0", "@bufbuild/protoc-gen-es": "^2.2.3", "@types/node": "^24", "tsx": "^4.19.2", @@ -23,6 +24,150 @@ "node": ">=20" } }, + "node_modules/@bufbuild/buf": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.71.0.tgz", + "integrity": "sha512-GDcjBCwLgHT/4nX4YSnYatZ7sDZDpHV6dxQvoT2/P6gKvV23O6hl8NryzLIRKmeau0FRXpQKHVy1dMfnBSpy+w==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "buf": "bin/buf", + "protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking", + "protoc-gen-buf-lint": "bin/protoc-gen-buf-lint" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@bufbuild/buf-darwin-arm64": "1.71.0", + "@bufbuild/buf-darwin-x64": "1.71.0", + "@bufbuild/buf-linux-aarch64": "1.71.0", + "@bufbuild/buf-linux-armv7": "1.71.0", + "@bufbuild/buf-linux-x64": "1.71.0", + "@bufbuild/buf-win32-arm64": "1.71.0", + "@bufbuild/buf-win32-x64": "1.71.0" + } + }, + "node_modules/@bufbuild/buf-darwin-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.71.0.tgz", + "integrity": "sha512-qZ7xZQyen/jOKFPVs3dlN9pMA56PI4YEo3r4/9ixtiH9gyFgfowR31axsocUgXGThjiN8mvOA8WfpG2tvaSvsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-darwin-x64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.71.0.tgz", + "integrity": "sha512-2w95pc3X+z06/J66i6uNzA8QPuVOpbPrwyb6tkK0AcJFNvKPVYr4BxVC2koyImrQ3rxY1n9q8qviWMjSvq9fOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-aarch64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.71.0.tgz", + "integrity": "sha512-dwxErryMI3MRwtP/IgfdrqEjiAmVpttGhmO3xihiJIV2EAXt9J5yjzHhEDvnSgQ6nmNjEvO5QczcIaQjZEwF6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-armv7": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-armv7/-/buf-linux-armv7-1.71.0.tgz", + "integrity": "sha512-pfc+Qexm5C59VeRUjVmEvxkCXT5QbMR1R/CUtcSlk+spOFVwna0bSpkqIsky3kkHfzxiNSOsz3iki9/pAVX+CA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-x64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.71.0.tgz", + "integrity": "sha512-Y7jLxr3wpMkpQSqZU/MrDmDSCkF4GxvhIL7wnNdSRpkhYAY6TPRHN+5nNgV7jp6mQ0zQSYh0MGxBeMgt/UVdmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-win32-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.71.0.tgz", + "integrity": "sha512-UrxtD99zLE1qImtQC/W3a9cuj0/kB53B1bK38kmCMRFow939FhdZtqTRjbnZWauRi/pzAsjDyPCvnTa2XKT8Cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-win32-x64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.71.0.tgz", + "integrity": "sha512-+npiOimJ7ggeLul3KFwSlOjZnAZYwt3el64dJ3nJQMnui0avyvsRmU02o1bZI5yUnBvhcnTWdEbfRXUnkkVtgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@bufbuild/protobuf": { "version": "2.12.1", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.1.tgz", diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 6b09ad436b..52d12db167 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -21,12 +21,15 @@ "import": "./dist/index.js" } }, - "files": ["dist", "README.md"], + "files": [ + "dist", + "README.md" + ], "engines": { "node": ">=20" }, "scripts": { - "gen": "bash gen.sh", + "gen": "buf generate", "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit", "demo": "tsx src/demo.ts", @@ -38,6 +41,7 @@ "@connectrpc/connect-node": "^2.0.0" }, "devDependencies": { + "@bufbuild/buf": "^1.71.0", "@bufbuild/protoc-gen-es": "^2.2.3", "@types/node": "^24", "tsx": "^4.19.2", diff --git a/tasks/typescript.toml b/tasks/typescript.toml index 6fee1439be..c278c8640a 100644 --- a/tasks/typescript.toml +++ b/tasks/typescript.toml @@ -1,8 +1,9 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# TypeScript SDK tasks (sdk/typescript). Codegen uses the pinned protoc (mise) -# + the connect-es plugin from the package's own devDependencies. +# TypeScript SDK tasks (sdk/typescript). Codegen runs `buf generate` (buf and +# the connect-es plugin come from the package's own devDependencies); buf +# self-compiles proto/, so no protoc is required. ["sdk:ts:install"] description = "Install TypeScript SDK dependencies" From 7ada490d9f21fa4602e3e9049b4e25e07ff303d5 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Tue, 7 Jul 2026 10:54:35 -0400 Subject: [PATCH 05/11] build(proto): add repo-level buf module with lint Declare proto/ as a single buf v2 module in a root buf.yaml so buf generate, lint, breaking, and the editor LSP resolve imports the same way. Lint uses STANDARD with six documented exceptions for deviations the current protos intentionally make: the flat proto/ layout with nested packages (DIRECTORY_SAME_PACKAGE, PACKAGE_DIRECTORY_MATCH) and the established API shape with unsuffixed services and reused request/response messages (RPC_REQUEST_RESPONSE_UNIQUE, RPC_REQUEST_STANDARD_NAME, RPC_RESPONSE_STANDARD_NAME, SERVICE_SUFFIX). Every other STANDARD rule now enforces on future protos. Breaking uses FILE. Code generation stays package-scoped in sdk/typescript/buf.gen.yaml since it binds to that package's connect-es plugin and output dir; its inputs are unchanged and regeneration is byte-identical. Wire the check in via a proto:lint mise task that runs buf from the SDK devDependencies. It is a dependency of both sdk:ts:ci (so the TypeScript SDK CI job enforces it) and the top-level lint aggregate (so local pre-commit covers it). Signed-off-by: Max Dubrinsky --- buf.yaml | 30 ++++++++++++++++++++++++++++++ sdk/typescript/buf.gen.yaml | 12 +++++++----- tasks/ci.toml | 2 +- tasks/typescript.toml | 13 +++++++++++-- 4 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 buf.yaml diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000000..3da2e54e22 --- /dev/null +++ b/buf.yaml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Repo-level buf module. Declares proto/ as the single module so buf generate, +# buf lint, buf breaking, and the editor LSP all resolve imports the same way. +# Code generation lives with each consumer (see sdk/typescript/buf.gen.yaml); +# this file owns the module boundary and proto validation policy. +version: v2 +modules: + - path: proto +lint: + use: + - STANDARD + except: + # Flat proto/ layout: all files live in one directory with nested + # packages (openshell.v1, openshell.sandbox.v1, ...). Adopting these + # would require restructuring the tree into openshell//v1/ and + # updating every Rust/Python/TS codegen path and import. + - DIRECTORY_SAME_PACKAGE + - PACKAGE_DIRECTORY_MATCH + # Established API shape: services are unsuffixed (OpenShell, not + # OpenShellService) and RPCs reuse shared request/response messages with + # short names. Renaming these is a breaking change across the codebase. + - RPC_REQUEST_RESPONSE_UNIQUE + - RPC_REQUEST_STANDARD_NAME + - RPC_RESPONSE_STANDARD_NAME + - SERVICE_SUFFIX +breaking: + use: + - FILE diff --git a/sdk/typescript/buf.gen.yaml b/sdk/typescript/buf.gen.yaml index 339ccc8607..9de46265fb 100644 --- a/sdk/typescript/buf.gen.yaml +++ b/sdk/typescript/buf.gen.yaml @@ -1,11 +1,13 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Code generation for the TypeScript SDK. buf compiles proto/ with its own -# compiler (no protoc) and drives the connect-es plugin from this package's -# devDependencies. Limited to the client-surface closure so we don't emit the -# unused inference/compute/test protos; well-known types resolve through -# @bufbuild/protobuf/wkt and are not generated. +# Code generation for the TypeScript SDK. The proto module boundary and +# validation policy live in the repo-level buf.yaml; this template only drives +# generation. buf compiles the module with its own compiler (no protoc) and +# runs the connect-es plugin from this package's devDependencies. Limited to +# the client-surface closure so we don't emit the unused inference/compute/test +# protos; well-known types resolve through @bufbuild/protobuf/wkt and are not +# generated. version: v2 clean: true inputs: diff --git a/tasks/ci.toml b/tasks/ci.toml index 47c86b6e5d..cda4d04ef4 100644 --- a/tasks/ci.toml +++ b/tasks/ci.toml @@ -51,7 +51,7 @@ hide = true [lint] description = "Run repository lint checks" -depends = ["license:check", "rust:format:check", "rust:lint", "python:format:check", "python:lint", "helm:lint", "helm:docs:check", "markdown:lint"] +depends = ["license:check", "rust:format:check", "rust:lint", "python:format:check", "python:lint", "helm:lint", "helm:docs:check", "markdown:lint", "proto:lint"] hide = true [ci] diff --git a/tasks/typescript.toml b/tasks/typescript.toml index c278c8640a..8a5bc2a879 100644 --- a/tasks/typescript.toml +++ b/tasks/typescript.toml @@ -17,6 +17,15 @@ depends = ["sdk:ts:install"] dir = "sdk/typescript" run = "npm run gen" +# Lints the repo-level proto module (buf.yaml at the root) against STANDARD. +# buf ships only in the SDK's devDependencies today, so this depends on the +# SDK install and runs buf from there; the target is all of proto/, not just +# the SDK's client-surface subset. +["proto:lint"] +description = "Lint proto/ with buf (repo-level buf.yaml)" +depends = ["sdk:ts:install"] +run = "./sdk/typescript/node_modules/.bin/buf lint" + ["sdk:ts:typecheck"] description = "Type-check the TypeScript SDK" depends = ["sdk:ts:proto"] @@ -30,8 +39,8 @@ dir = "sdk/typescript" run = "npm run build" ["sdk:ts:ci"] -description = "TypeScript SDK checks (codegen, typecheck, build)" -depends = ["sdk:ts:typecheck", "sdk:ts:build"] +description = "TypeScript SDK checks (proto lint, codegen, typecheck, build)" +depends = ["proto:lint", "sdk:ts:typecheck", "sdk:ts:build"] hide = true # Publish to the registry in package.json publishConfig. Set OPENSHELL_NPM_VERSION From 0f2c18c6ced704096a82750894dca83de2be8c19 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Thu, 9 Jul 2026 13:37:27 -0400 Subject: [PATCH 06/11] chore(sdk): publish as unscoped openshell-sdk on public npm Rename the package from @nvidia/openshell-sdk to the unscoped openshell-sdk and target public npm (registry.npmjs.org) instead of GitHub Packages. GitHub Packages requires a scope matching the owning org, and the @openshell scope is blocked by an unrelated existing package, so an unscoped name on public npm is the lowest-friction distribution path and needs no org approval. Rework the release-tag publish job to auth against registry.npmjs.org with NPM_TOKEN (the job now only needs packages: read to pull the CI image). Update the README install instructions and usage imports. Signed-off-by: Max Dubrinsky --- .github/workflows/release-tag.yml | 14 ++++++-------- sdk/typescript/README.md | 20 ++++++-------------- sdk/typescript/package-lock.json | 4 ++-- sdk/typescript/package.json | 4 ++-- sdk/typescript/src/index.ts | 2 +- 5 files changed, 17 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 1485e62615..5c93b6044b 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -1076,7 +1076,9 @@ jobs: timeout-minutes: 15 permissions: contents: read - packages: write + # read-only: needed to pull the private CI image from ghcr, not to + # publish. The SDK publishes to public npm (registry.npmjs.org). + packages: read container: image: ghcr.io/nvidia/openshell/ci:latest credentials: @@ -1093,18 +1095,14 @@ jobs: - name: Install tools run: mise install --locked - - name: Configure npm auth for GitHub Packages + - name: Configure npm auth for npmjs.org working-directory: ./sdk/typescript - run: | - { - echo "@nvidia:registry=https://npm.pkg.github.com" - echo '//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}' - } > .npmrc + run: echo '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}' > .npmrc - name: Publish env: OPENSHELL_NPM_VERSION: ${{ needs.compute-versions.outputs.npm_version }} - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: mise run sdk:ts:publish release-helm: diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 135446f65a..a188492296 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -1,27 +1,19 @@ -# @nvidia/openshell-sdk +# openshell-sdk TypeScript client for the OpenShell gateway — thin, idiomatic bindings generated from the OpenShell protobufs. -Distributed via GitHub Packages during pre-GA. Public npm (`@openshell/sdk`) follows at GA; the public API is unchanged across the move, so only the install specifier changes. +Published on public npm. ## Install -Published to GitHub Packages, so add a project `.npmrc`: - -```shell -@nvidia:registry=https://npm.pkg.github.com -``` - -Authenticate with a GitHub token that has `read:packages`, then: - ```shell -npm install @nvidia/openshell-sdk +npm install openshell-sdk ``` ## Usage ```ts -import { OpenShellClient } from '@nvidia/openshell-sdk' +import { OpenShellClient } from 'openshell-sdk' const client = await OpenShellClient.connect({ gateway: 'https://gateway.example.com', @@ -45,7 +37,7 @@ await client.sandbox.delete(sandbox.name) directly — same API, one less hop: ```ts -import { SandboxClient } from '@nvidia/openshell-sdk' +import { SandboxClient } from 'openshell-sdk' const sandbox = await SandboxClient.connect({ gateway, oidcToken }) await sandbox.create({ image }) @@ -56,7 +48,7 @@ await sandbox.create({ image }) The version field is a `0.0.0` placeholder; CI stamps the real version from the git release tag at publish time, matching the Rust and Python packages. ```shell -mise run sdk:ts:proto # generate stubs from proto/ (protoc + protoc-gen-es) +mise run sdk:ts:proto # generate stubs from proto/ with buf mise run sdk:ts:typecheck # tsc --noEmit mise run sdk:ts:build # emit dist/ ``` diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index 24bde1ae08..428fe59625 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@nvidia/openshell-sdk", + "name": "openshell-sdk", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@nvidia/openshell-sdk", + "name": "openshell-sdk", "version": "0.0.0", "license": "Apache-2.0", "dependencies": { diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 52d12db167..93f204f7fc 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -1,5 +1,5 @@ { - "name": "@nvidia/openshell-sdk", + "name": "openshell-sdk", "version": "0.0.0", "description": "Official TypeScript SDK for the OpenShell gateway.", "license": "Apache-2.0", @@ -11,7 +11,7 @@ "directory": "sdk/typescript" }, "publishConfig": { - "registry": "https://npm.pkg.github.com" + "registry": "https://registry.npmjs.org" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index dfa36beb7b..af5d25de5a 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -// Public API surface for @nvidia/openshell-sdk. +// Public API surface for openshell-sdk. // // OidcRefresher (single-flight OIDC refresh) is intentionally not yet exported. // It is the one piece of genuinely shared, cross-language behavior; it will be From 6908672e22a9a2fb23e43e94f4db8507c5595b39 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Thu, 9 Jul 2026 13:44:20 -0400 Subject: [PATCH 07/11] chore(sdk): publish @nvidia/openshell-sdk to GitHub Packages Revert the unscoped-name switch. GitHub Packages only accepts scoped names matching the owning org, so shipping there first (which needs no external npm org or NPM_TOKEN, just the repo's GITHUB_TOKEN) requires the @nvidia scope. Keeping the @nvidia/openshell-sdk name also lets a later public-npm release use the same install specifier, so adding public npm becomes a second publish step rather than a rename. Restore the GitHub Packages publish auth in the release-tag job and the scoped install instructions in the README (keeping the buf codegen note). Signed-off-by: Max Dubrinsky --- .github/workflows/release-tag.yml | 14 ++++++++------ sdk/typescript/README.md | 18 +++++++++++++----- sdk/typescript/package-lock.json | 4 ++-- sdk/typescript/package.json | 4 ++-- sdk/typescript/src/index.ts | 2 +- 5 files changed, 26 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 5c93b6044b..1485e62615 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -1076,9 +1076,7 @@ jobs: timeout-minutes: 15 permissions: contents: read - # read-only: needed to pull the private CI image from ghcr, not to - # publish. The SDK publishes to public npm (registry.npmjs.org). - packages: read + packages: write container: image: ghcr.io/nvidia/openshell/ci:latest credentials: @@ -1095,14 +1093,18 @@ jobs: - name: Install tools run: mise install --locked - - name: Configure npm auth for npmjs.org + - name: Configure npm auth for GitHub Packages working-directory: ./sdk/typescript - run: echo '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}' > .npmrc + run: | + { + echo "@nvidia:registry=https://npm.pkg.github.com" + echo '//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}' + } > .npmrc - name: Publish env: OPENSHELL_NPM_VERSION: ${{ needs.compute-versions.outputs.npm_version }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: mise run sdk:ts:publish release-helm: diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index a188492296..5461e9565f 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -1,19 +1,27 @@ -# openshell-sdk +# @nvidia/openshell-sdk TypeScript client for the OpenShell gateway — thin, idiomatic bindings generated from the OpenShell protobufs. -Published on public npm. +Distributed via GitHub Packages. A public npm release under the same name follows once the npm org is in place; the install specifier and API are unchanged across that move. ## Install +Published to GitHub Packages, so point the `@nvidia` scope at it with a project `.npmrc`: + +```shell +@nvidia:registry=https://npm.pkg.github.com +``` + +Authenticate with a GitHub token that has `read:packages`, then: + ```shell -npm install openshell-sdk +npm install @nvidia/openshell-sdk ``` ## Usage ```ts -import { OpenShellClient } from 'openshell-sdk' +import { OpenShellClient } from '@nvidia/openshell-sdk' const client = await OpenShellClient.connect({ gateway: 'https://gateway.example.com', @@ -37,7 +45,7 @@ await client.sandbox.delete(sandbox.name) directly — same API, one less hop: ```ts -import { SandboxClient } from 'openshell-sdk' +import { SandboxClient } from '@nvidia/openshell-sdk' const sandbox = await SandboxClient.connect({ gateway, oidcToken }) await sandbox.create({ image }) diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index 428fe59625..24bde1ae08 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -1,11 +1,11 @@ { - "name": "openshell-sdk", + "name": "@nvidia/openshell-sdk", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "openshell-sdk", + "name": "@nvidia/openshell-sdk", "version": "0.0.0", "license": "Apache-2.0", "dependencies": { diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 93f204f7fc..52d12db167 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -1,5 +1,5 @@ { - "name": "openshell-sdk", + "name": "@nvidia/openshell-sdk", "version": "0.0.0", "description": "Official TypeScript SDK for the OpenShell gateway.", "license": "Apache-2.0", @@ -11,7 +11,7 @@ "directory": "sdk/typescript" }, "publishConfig": { - "registry": "https://registry.npmjs.org" + "registry": "https://npm.pkg.github.com" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index af5d25de5a..dfa36beb7b 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -// Public API surface for openshell-sdk. +// Public API surface for @nvidia/openshell-sdk. // // OidcRefresher (single-flight OIDC refresh) is intentionally not yet exported. // It is the one piece of genuinely shared, cross-language behavior; it will be From ed32948263c5d90a37d1e9bb474a9317f0666e95 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Wed, 15 Jul 2026 19:02:42 -0400 Subject: [PATCH 08/11] feat(sdk-ts): add streaming exec, forward, ssh, provider, and config methods Grow SandboxClient to the surface the first two consumers need. execStream yields stdout/stderr chunks as they arrive and exec now drains it, keeping its buffered ExecResult and signature unchanged. execInteractive is the TTY + stdin transport primitive (start-first framing, output/write/resize/close/done, no terminal glue). forward binds a local TCP listener that tunnels each accepted connection into the sandbox for the process lifetime, minting and revoking a per-socket SSH session token around a forwardTcp bidi. Adds createSshSession / revokeSshSession, attach/detach/listProviders, and getConfig / setPolicy / setSetting (sandbox-scoped, network-policy-only, with an optional wait poll). Signed-off-by: Max Dubrinsky --- sdk/typescript/src/client.ts | 816 +++++++++++++++++++++++++++++++---- sdk/typescript/src/demo.ts | 128 +++++- sdk/typescript/src/index.ts | 19 +- 3 files changed, 850 insertions(+), 113 deletions(-) diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 8d0a52ac6b..6671ae325f 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -11,81 +11,335 @@ // the waitReady/waitDeleted poll loops. Transport and auth live in // transport.ts; the error taxonomy in errors.ts. -import { createClient, type Client, type Transport } from '@connectrpc/connect' -import { OpenShell, SandboxPhase, ServiceStatus } from './gen/openshell_pb.js' -import type { Sandbox } from './gen/openshell_pb.js' -import { buildTransport, type ConnectOptions } from './transport.js' -import { errorCode, fromConnect, SdkError } from './errors.js' - -export { errorCode } -export type { ConnectOptions } +import type { AddressInfo } from 'node:net'; +import * as net from 'node:net'; +import type { MessageInitShape } from '@bufbuild/protobuf'; +import { type Client, createClient, type Transport } from '@connectrpc/connect'; +import { errorCode, fromConnect, SdkError } from './errors.js'; +import type { Provider } from './gen/datamodel_pb.js'; +import type { Sandbox, UpdateConfigResponse } from './gen/openshell_pb.js'; +import { + type ExecSandboxInputSchema, + OpenShell, + SandboxPhase, + ServiceStatus, + type TcpForwardFrameSchema, +} from './gen/openshell_pb.js'; +import type { EffectiveSetting, GetSandboxConfigResponse, SandboxPolicy, SettingValue } from './gen/sandbox_pb.js'; +import { PolicySource, type SandboxPolicySchema, SettingScope, type SettingValueSchema } from './gen/sandbox_pb.js'; +import { buildTransport, type ConnectOptions } from './transport.js'; + +// The policy and setting value shapes are the generated protobuf messages; +// re-export them rather than re-curating a parallel surface. Callers round-trip +// `getConfig().policy` back into `setPolicy`, and build `SettingValue`s inline. +export type { SandboxPolicy, SettingValue } from './gen/sandbox_pb.js'; +export type { ConnectOptions }; +export { errorCode }; // ---- Curated public types -------------------------------------------------- export interface Health { - status: string - version: string + status: string; + version: string; } export interface SandboxSpec { - name?: string - image?: string - labels?: Record - environment?: Record - providers?: string[] - gpu?: boolean + name?: string; + image?: string; + labels?: Record; + environment?: Record; + providers?: string[]; + gpu?: boolean; } export interface SandboxRef { - id: string - name: string - phase: string - labels: Record + id: string; + name: string; + phase: string; + labels: Record; /** u64 rendered as a string — JS numbers can't hold it safely. */ - resourceVersion: string + resourceVersion: string; } export interface ListOptions { - limit?: number - offset?: number - labelSelector?: string + limit?: number; + offset?: number; + labelSelector?: string; } export interface ExecOptions { - workdir?: string - environment?: Record - timeoutSecs?: number - stdin?: Buffer + workdir?: string; + environment?: Record; + timeoutSecs?: number; + stdin?: Buffer; } export interface ExecResult { - exitCode: number - stdout: Buffer - stderr: Buffer + exitCode: number; + stdout: Buffer; + stderr: Buffer; +} + +/** One stdout/stderr chunk yielded by `execStream`/`execInteractive`. */ +export interface ExecStreamChunk { + stream: 'stdout' | 'stderr'; + data: Buffer; +} + +export interface ExecInteractiveOptions { + workdir?: string; + environment?: Record; + timeoutSecs?: number; + /** Request a pseudo-terminal (default true). */ + tty?: boolean; + /** Initial terminal columns (0 = server default). */ + cols?: number; + /** Initial terminal rows (0 = server default). */ + rows?: number; +} + +// The transport half of an interactive exec: raw stdin/stdout/stderr plus +// resize, with no terminal glue. Drive it by consuming `output`; `done` +// resolves with the exit code once the stream reaches its exit event. +export interface ExecInteractiveSession { + output: AsyncIterable; + write(data: Buffer): void; + resize(cols: number, rows: number): void; + close(): void; + done: Promise; +} + +export interface ForwardOptions { + /** Loopback TCP port inside the sandbox to dial. */ + targetPort: number; + /** Target host inside the sandbox (loopback only). Default 127.0.0.1. */ + targetHost?: string; + /** Local port to bind. Default 0 (ephemeral). */ + localPort?: number; + /** Local address to bind. Default 127.0.0.1. */ + localHost?: string; +} + +// A process-lifetime local listener that tunnels each accepted connection into +// the sandbox. Call `close()` on teardown; `closed` resolves once the listener +// is fully torn down. An in-process forward cannot outlive the Node process. +export interface ForwardHandle { + localHost: string; + localPort: number; + targetHost: string; + targetPort: number; + close(): Promise; + closed: Promise; +} + +export interface SshSession { + sandboxId: string; + token: string; + gatewayHost: string; + gatewayPort: number; + gatewayScheme: string; + hostKeyFingerprint?: string; + /** int64 ms-since-epoch rendered as a string; omitted when 0 (no expiry). */ + expiresAtMs?: string; +} + +export interface ProviderRef { + id: string; + name: string; + type: string; + labels: Record; + /** u64 rendered as a string. */ + resourceVersion: string; +} + +export interface ProviderChange { + sandbox: SandboxRef; + /** True when the attach/detach actually changed the attachment set. */ + changed: boolean; +} + +export interface ProviderChangeOptions { + /** Pin the sandbox resource version for optimistic concurrency (u64 as string). */ + expectedResourceVersion?: string; +} + +/** Effective value of one setting plus the scope it resolved from. */ +export interface EffectiveSettingView { + value?: SettingValue; + /** 'unspecified' | 'sandbox' | 'global'. */ + scope: string; +} + +export interface SandboxConfig { + policy?: SandboxPolicy; + version: number; + policyHash: string; + settings: Record; + /** u64 rendered as a string. */ + configRevision: string; + /** 'unspecified' | 'sandbox' | 'global'. */ + policySource: string; + globalPolicyVersion: number; + /** u64 rendered as a string. */ + providerEnvRevision: string; +} + +export interface SetPolicyOptions { + /** Pin the sandbox resource version for optimistic concurrency (u64 as string). */ + expectedResourceVersion?: string; + /** Poll getConfig until the applied policy hash is observed. */ + wait?: boolean; +} + +export interface UpdateConfigResult { + version: number; + policyHash: string; + /** u64 rendered as a string. */ + settingsRevision: string; + deleted: boolean; } // ---- enum → lowercase string ----------------------------------------------- function phaseName(p: SandboxPhase): string { - return (SandboxPhase[p] ?? 'UNSPECIFIED').toLowerCase() + return (SandboxPhase[p] ?? 'UNSPECIFIED').toLowerCase(); } function statusName(s: ServiceStatus): string { - return (ServiceStatus[s] ?? 'UNSPECIFIED').toLowerCase() + return (ServiceStatus[s] ?? 'UNSPECIFIED').toLowerCase(); +} +function scopeName(s: SettingScope): string { + return (SettingScope[s] ?? 'UNSPECIFIED').toLowerCase(); +} +function policySourceName(s: PolicySource): string { + return (PolicySource[s] ?? 'UNSPECIFIED').toLowerCase(); } function sandboxRef(sandbox: Sandbox | undefined): SandboxRef { - if (!sandbox) throw new SdkError('invalid_config', 'sandbox missing from gateway response') - const meta = sandbox.metadata + if (!sandbox) throw new SdkError('invalid_config', 'sandbox missing from gateway response'); + const meta = sandbox.metadata; return { id: meta?.id ?? '', name: meta?.name ?? '', phase: phaseName(sandbox.status?.phase ?? SandboxPhase.UNSPECIFIED), labels: meta?.labels ?? {}, resourceVersion: (meta?.resourceVersion ?? 0n).toString(), + }; +} + +function providerRef(provider: Provider): ProviderRef { + const meta = provider.metadata; + return { + id: meta?.id ?? '', + name: meta?.name ?? '', + type: provider.type, + labels: meta?.labels ?? {}, + resourceVersion: (meta?.resourceVersion ?? 0n).toString(), + }; +} + +function sandboxConfig(resp: GetSandboxConfigResponse): SandboxConfig { + const settings: Record = {}; + for (const [key, setting] of Object.entries(resp.settings)) { + settings[key] = effectiveSetting(setting); } + return { + ...(resp.policy ? { policy: resp.policy } : {}), + version: resp.version, + policyHash: resp.policyHash, + settings, + configRevision: resp.configRevision.toString(), + policySource: policySourceName(resp.policySource), + globalPolicyVersion: resp.globalPolicyVersion, + providerEnvRevision: resp.providerEnvRevision.toString(), + }; +} + +function effectiveSetting(setting: EffectiveSetting): EffectiveSettingView { + return { + ...(setting.value ? { value: setting.value } : {}), + scope: scopeName(setting.scope), + }; +} + +function updateConfigResult(resp: UpdateConfigResponse): UpdateConfigResult { + return { + version: resp.version, + policyHash: resp.policyHash, + settingsRevision: resp.settingsRevision.toString(), + deleted: resp.deleted, + }; } -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) +// Optimistic-concurrency version pin: absent/empty means 0n (server uses the +// current version, backward-compatible). A mismatch surfaces as Aborted → rpc. +function versionPin(value: string | undefined): bigint { + return value ? BigInt(value) : 0n; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const FORWARD_CHUNK = 64 * 1024; + +// An async-iterable queue for the client-send half of bidi streams. Producers +// `push()` frames; the connect transport consumes them as it drains the send +// side. `end()` closes the stream (optionally with an error). `onDrain` fires +// when the buffered queue empties via consumption, so callers can relieve TCP +// backpressure. +class Pushable implements AsyncIterable { + private readonly queue: T[] = []; + private readonly waiting: ((r: IteratorResult) => void)[] = []; + private ended = false; + private error: unknown; + onDrain?: () => void; + + get size(): number { + return this.queue.length; + } + + push(value: T): void { + if (this.ended) return; + const resolve = this.waiting.shift(); + if (resolve) { + resolve({ value, done: false }); + } else { + this.queue.push(value); + } + } + + end(error?: unknown): void { + if (this.ended) return; + this.ended = true; + this.error = error; + let resolve = this.waiting.shift(); + while (resolve) { + resolve({ value: undefined as never, done: true }); + resolve = this.waiting.shift(); + } + } + + async *[Symbol.asyncIterator](): AsyncIterator { + for (;;) { + if (this.queue.length > 0) { + const value = this.queue.shift() as T; + if (this.queue.length === 0) this.onDrain?.(); + yield value; + continue; + } + if (this.ended) { + if (this.error !== undefined) throw this.error; + return; + } + const next = await new Promise>((resolve) => { + this.waiting.push(resolve); + }); + if (next.done) { + if (this.error !== undefined) throw this.error; + return; + } + yield next.value; + } + } +} // ---- sandbox client -------------------------------------------------------- @@ -93,17 +347,17 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) // or reached as `client.sandbox` on an OpenShellClient, which shares one // transport (one connection) across all of its scoped clients. export class SandboxClient { - private readonly grpc: Client + private readonly grpc: Client; // Takes a transport rather than options so OpenShellClient can compose // several scoped clients over a single connection. For standalone use, // prefer the SandboxClient.connect() factory below. constructor(transport: Transport) { - this.grpc = createClient(OpenShell, transport) + this.grpc = createClient(OpenShell, transport); } static async connect(options: ConnectOptions): Promise { - return new SandboxClient(buildTransport(options)) + return new SandboxClient(buildTransport(options)); } async create(spec: SandboxSpec): Promise { @@ -117,19 +371,19 @@ export class SandboxClient { template: spec.image ? { image: spec.image } : undefined, resourceRequirements: spec.gpu ? { gpu: {} } : undefined, }, - }) - return sandboxRef(resp.sandbox) + }); + return sandboxRef(resp.sandbox); } catch (e) { - throw fromConnect(e) + throw fromConnect(e); } } async get(name: string): Promise { try { - const resp = await this.grpc.getSandbox({ name }) - return sandboxRef(resp.sandbox) + const resp = await this.grpc.getSandbox({ name }); + return sandboxRef(resp.sandbox); } catch (e) { - throw fromConnect(e) + throw fromConnect(e); } } @@ -139,55 +393,63 @@ export class SandboxClient { limit: options?.limit ?? 0, offset: options?.offset ?? 0, labelSelector: options?.labelSelector ?? '', - }) - return resp.sandboxes.map((s) => sandboxRef(s)) + }); + return resp.sandboxes.map((s) => sandboxRef(s)); } catch (e) { - throw fromConnect(e) + throw fromConnect(e); } } async delete(name: string): Promise { try { - const resp = await this.grpc.deleteSandbox({ name }) - return resp.deleted + const resp = await this.grpc.deleteSandbox({ name }); + return resp.deleted; } catch (e) { - throw fromConnect(e) + throw fromConnect(e); } } async waitReady(name: string, timeoutSecs: number): Promise { - const deadline = Date.now() + timeoutSecs * 1000 - let delay = 250 + const deadline = Date.now() + timeoutSecs * 1000; + let delay = 250; for (;;) { - const ref = await this.get(name) - if (ref.phase === 'ready') return ref - if (ref.phase === 'error') throw new SdkError('connect', `sandbox '${name}' entered error phase`) - if (Date.now() >= deadline) throw new SdkError('connect', `timed out waiting for sandbox '${name}'`) - await sleep(delay) - delay = Math.min(delay * 2, 2000) + const ref = await this.get(name); + if (ref.phase === 'ready') return ref; + if (ref.phase === 'error') throw new SdkError('connect', `sandbox '${name}' entered error phase`); + if (Date.now() >= deadline) throw new SdkError('connect', `timed out waiting for sandbox '${name}'`); + await sleep(delay); + delay = Math.min(delay * 2, 2000); } } async waitDeleted(name: string, timeoutSecs: number): Promise { - const deadline = Date.now() + timeoutSecs * 1000 - let delay = 250 + const deadline = Date.now() + timeoutSecs * 1000; + let delay = 250; for (;;) { try { - await this.get(name) + await this.get(name); } catch (e) { - if (e instanceof SdkError && e.code === 'not_found') return - throw e + if (e instanceof SdkError && e.code === 'not_found') return; + throw e; } - if (Date.now() >= deadline) throw new SdkError('connect', `timed out waiting for sandbox '${name}' to delete`) - await sleep(delay) - delay = Math.min(delay * 2, 2000) + if (Date.now() >= deadline) throw new SdkError('connect', `timed out waiting for sandbox '${name}' to delete`); + await sleep(delay); + delay = Math.min(delay * 2, 2000); } } - async exec(name: string, command: string[], options?: ExecOptions | null): Promise { + // Stream stdout/stderr as they arrive. The terminal ExecResult carries the + // exit code; its stdout/stderr are empty — the yielded chunks are the data. + // `exec()` drains this to reconstruct the buffered result, so both share one + // path. + async *execStream( + name: string, + command: string[], + options?: ExecOptions | null, + ): AsyncGenerator { try { // Resolve the sandbox id first, exactly like the gateway client. - const sandbox = await this.get(name) + const sandbox = await this.get(name); const stream = this.grpc.execSandbox({ sandboxId: sandbox.id, command, @@ -196,27 +458,403 @@ export class SandboxClient { timeoutSeconds: options?.timeoutSecs ?? 0, stdin: options?.stdin ? new Uint8Array(options.stdin) : new Uint8Array(), tty: false, - }) + }); - const stdout: Uint8Array[] = [] - const stderr: Uint8Array[] = [] - let exitCode = -1 + let exitCode = -1; for await (const event of stream) { switch (event.payload.case) { case 'stdout': - stdout.push(event.payload.value.data) - break + yield { + stream: 'stdout', + data: Buffer.from(event.payload.value.data), + }; + break; case 'stderr': - stderr.push(event.payload.value.data) - break + yield { + stream: 'stderr', + data: Buffer.from(event.payload.value.data), + }; + break; case 'exit': - exitCode = event.payload.value.exitCode - break + exitCode = event.payload.value.exitCode; + break; + } + } + return { exitCode, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }; + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e); + } + } + + async exec(name: string, command: string[], options?: ExecOptions | null): Promise { + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + const stream = this.execStream(name, command, options); + let next = await stream.next(); + for (; next.done !== true; next = await stream.next()) { + const chunk = next.value; + if (chunk.stream === 'stdout') stdout.push(chunk.data); + else stderr.push(chunk.data); + } + return { + exitCode: next.value.exitCode, + stdout: Buffer.concat(stdout), + stderr: Buffer.concat(stderr), + }; + } + + // TTY + stdin transport half of an interactive exec. The first client frame + // is the `start` variant carrying the exec request; subsequent frames are + // `stdin`/`resize`. No terminal glue: raw mode, signal forwarding, and + // SIGWINCH stay with the caller. + async execInteractive( + name: string, + command: string[], + options?: ExecInteractiveOptions | null, + ): Promise { + let sandboxId: string; + try { + sandboxId = (await this.get(name)).id; + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e); + } + + const input = new Pushable>(); + input.push({ + payload: { + case: 'start', + value: { + sandboxId, + command, + workdir: options?.workdir ?? '', + environment: options?.environment ?? {}, + timeoutSeconds: options?.timeoutSecs ?? 0, + stdin: new Uint8Array(), + tty: options?.tty ?? true, + cols: options?.cols ?? 0, + rows: options?.rows ?? 0, + }, + }, + }); + + const stream = this.grpc.execSandboxInteractive(input); + let resolveDone!: (code: number) => void; + let rejectDone!: (err: unknown) => void; + const done = new Promise((resolve, reject) => { + resolveDone = resolve; + rejectDone = reject; + }); + + async function* output(): AsyncGenerator { + let exitCode = -1; + try { + for await (const event of stream) { + switch (event.payload.case) { + case 'stdout': + yield { + stream: 'stdout', + data: Buffer.from(event.payload.value.data), + }; + break; + case 'stderr': + yield { + stream: 'stderr', + data: Buffer.from(event.payload.value.data), + }; + break; + case 'exit': + exitCode = event.payload.value.exitCode; + break; + } + } + resolveDone(exitCode); + } catch (e) { + const err = e instanceof SdkError ? e : fromConnect(e); + rejectDone(err); + throw err; + } finally { + input.end(); + } + } + + return { + output: output(), + write(data: Buffer): void { + input.push({ payload: { case: 'stdin', value: new Uint8Array(data) } }); + }, + resize(cols: number, rows: number): void { + input.push({ payload: { case: 'resize', value: { cols, rows } } }); + }, + close(): void { + input.end(); + }, + done, + }; + } + + // Bind a local TCP listener that tunnels each accepted connection into the + // sandbox. Mirrors the CLI service forward: READY check, then per socket mint + // a short-lived SSH session token, open a forwardTcp bidi whose first frame is + // the `init` (TCP target + token), relay bytes both ways in ~64 KiB chunks, + // and revoke the token on close. Process-lifetime only. + async forward(name: string, opts: ForwardOptions): Promise { + const targetHost = opts.targetHost ?? '127.0.0.1'; + const targetPort = opts.targetPort; + const localHost = opts.localHost ?? '127.0.0.1'; + const localPort = opts.localPort ?? 0; + + let sandboxId: string; + try { + const ref = await this.get(name); + if (ref.phase !== 'ready') { + throw new SdkError('connect', `sandbox '${name}' is not ready (phase: ${ref.phase})`); + } + sandboxId = ref.id; + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e); + } + + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + void this.forwardConnection(socket, sandboxId, name, targetHost, targetPort); + }); + + let resolveClosed!: () => void; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + server.on('close', () => resolveClosed()); + + await new Promise((resolve, reject) => { + const onError = (err: unknown): void => { + reject( + new SdkError( + 'io', + `failed to bind local forward on ${localHost}:${localPort}: ${err instanceof Error ? err.message : String(err)}`, + ), + ); + }; + server.once('error', onError); + server.listen(localPort, localHost, () => { + server.removeListener('error', onError); + resolve(); + }); + }); + + const addr = server.address() as AddressInfo | null; + return { + localHost, + localPort: addr ? addr.port : localPort, + targetHost, + targetPort, + close: async (): Promise => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + }, + closed, + }; + } + + private async forwardConnection( + socket: net.Socket, + sandboxId: string, + name: string, + targetHost: string, + targetPort: number, + ): Promise { + let token: string | undefined; + const input = new Pushable>(); + input.onDrain = () => socket.resume(); + try { + const session = await this.grpc.createSshSession({ sandboxId }); + token = session.token; + input.push({ + payload: { + case: 'init', + value: { + sandboxId, + serviceId: `service-forward:${name}:${targetHost}:${targetPort}`, + target: { + case: 'tcp', + value: { host: targetHost, port: targetPort }, + }, + authorizationToken: token, + }, + }, + }); + + socket.on('data', (chunk: Buffer) => { + for (let off = 0; off < chunk.length; off += FORWARD_CHUNK) { + const slice = chunk.subarray(off, Math.min(off + FORWARD_CHUNK, chunk.length)); + input.push({ + payload: { case: 'data', value: new Uint8Array(slice) }, + }); + } + if (input.size >= 64) socket.pause(); + }); + socket.on('end', () => input.end()); + socket.on('error', () => input.end()); + socket.on('close', () => input.end()); + + for await (const frame of this.grpc.forwardTcp(input)) { + if (frame.payload.case !== 'data') continue; + const data = frame.payload.value; + if (data.length > 0) socket.write(Buffer.from(data)); + } + socket.end(); + } catch { + socket.destroy(); + } finally { + input.end(); + if (token !== undefined) { + try { + await this.grpc.revokeSshSession({ token }); + } catch { + // Best-effort revoke; the token expires on its own regardless. } } - return { exitCode, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) } + } + } + + // Mint a short-lived SSH session token for the sandbox — the input side of + // ssh-config / ProxyCommand and forwardTcp authorization. + async createSshSession(name: string): Promise { + try { + const sandbox = await this.get(name); + const resp = await this.grpc.createSshSession({ sandboxId: sandbox.id }); + return { + sandboxId: resp.sandboxId, + token: resp.token, + gatewayHost: resp.gatewayHost, + gatewayPort: resp.gatewayPort, + gatewayScheme: resp.gatewayScheme, + ...(resp.hostKeyFingerprint ? { hostKeyFingerprint: resp.hostKeyFingerprint } : {}), + ...(resp.expiresAtMs !== 0n ? { expiresAtMs: resp.expiresAtMs.toString() } : {}), + }; + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e); + } + } + + async revokeSshSession(token: string): Promise { + try { + const resp = await this.grpc.revokeSshSession({ token }); + return resp.revoked; + } catch (e) { + throw fromConnect(e); + } + } + + async attachProvider( + name: string, + provider: string, + options?: ProviderChangeOptions | null, + ): Promise { + try { + const resp = await this.grpc.attachSandboxProvider({ + sandboxName: name, + providerName: provider, + expectedResourceVersion: versionPin(options?.expectedResourceVersion), + }); + return { sandbox: sandboxRef(resp.sandbox), changed: resp.attached }; + } catch (e) { + throw fromConnect(e); + } + } + + async detachProvider( + name: string, + provider: string, + options?: ProviderChangeOptions | null, + ): Promise { + try { + const resp = await this.grpc.detachSandboxProvider({ + sandboxName: name, + providerName: provider, + expectedResourceVersion: versionPin(options?.expectedResourceVersion), + }); + return { sandbox: sandboxRef(resp.sandbox), changed: resp.detached }; + } catch (e) { + throw fromConnect(e); + } + } + + async listProviders(name: string): Promise { + try { + const resp = await this.grpc.listSandboxProviders({ sandboxName: name }); + return resp.providers.map((p) => providerRef(p)); + } catch (e) { + throw fromConnect(e); + } + } + + async getConfig(name: string): Promise { + try { + const sandbox = await this.get(name); + const resp = await this.grpc.getSandboxConfig({ sandboxId: sandbox.id }); + return sandboxConfig(resp); + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e); + } + } + + // Update the sandbox-scoped policy. Sandbox scope (global=false) may only + // change network_policies; static fields must match the create-time policy or + // the gateway rejects the update. With `wait`, poll getConfig until the + // applied policy hash is observed. + async setPolicy( + name: string, + policy: MessageInitShape, + options?: SetPolicyOptions | null, + ): Promise { + try { + const resp = await this.grpc.updateConfig({ + name, + policy, + global: false, + expectedResourceVersion: versionPin(options?.expectedResourceVersion), + }); + const result = updateConfigResult(resp); + if (options?.wait) await this.waitForPolicyHash(name, result.policyHash); + return result; + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e); + } + } + + // Upsert a single sandbox-scoped setting. Sandbox-scoped deletes are rejected + // by the gateway, so there is no sandbox-scoped delete on this surface. + async setSetting( + name: string, + key: string, + value: MessageInitShape, + ): Promise { + try { + const resp = await this.grpc.updateConfig({ + name, + settingKey: key, + settingValue: value, + global: false, + }); + return updateConfigResult(resp); } catch (e) { - throw e instanceof SdkError ? e : fromConnect(e) + throw fromConnect(e); + } + } + + private async waitForPolicyHash(name: string, policyHash: string, timeoutSecs = 60): Promise { + const deadline = Date.now() + timeoutSecs * 1000; + let delay = 100; + for (;;) { + const config = await this.getConfig(name); + if (config.policyHash === policyHash) return; + if (Date.now() >= deadline) { + throw new SdkError('connect', `timed out waiting for policy '${policyHash}' on sandbox '${name}'`); + } + await sleep(delay); + delay = Math.min(delay * 2, 2000); } } } @@ -225,27 +863,27 @@ export class SandboxClient { export class OpenShellClient { /** Sandbox lifecycle + exec: create/get/list/delete, waitReady/waitDeleted, exec. */ - readonly sandbox: SandboxClient + readonly sandbox: SandboxClient; - private readonly grpc: Client + private readonly grpc: Client; private constructor(transport: Transport) { // One transport (one connection) shared across every scoped client. - this.grpc = createClient(OpenShell, transport) - this.sandbox = new SandboxClient(transport) + this.grpc = createClient(OpenShell, transport); + this.sandbox = new SandboxClient(transport); } static async connect(options: ConnectOptions): Promise { - return new OpenShellClient(buildTransport(options)) + return new OpenShellClient(buildTransport(options)); } // Gateway-scoped, so it stays top-level rather than under a namespace. async health(): Promise { try { - const resp = await this.grpc.health({}) - return { status: statusName(resp.status), version: resp.version } + const resp = await this.grpc.health({}); + return { status: statusName(resp.status), version: resp.version }; } catch (e) { - throw fromConnect(e) + throw fromConnect(e); } } } diff --git a/sdk/typescript/src/demo.ts b/sdk/typescript/src/demo.ts index 028e1a0f54..4ed2b8157b 100644 --- a/sdk/typescript/src/demo.ts +++ b/sdk/typescript/src/demo.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -// Runnable smoke test exercising the v0.1 surface against any OpenShell gateway: +// Runnable smoke test exercising the SDK surface against any OpenShell gateway: // // OPENSHELL_GATEWAY=http://127.0.0.1:8080 \ // OPENSHELL_DEFAULT_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/python:latest \ @@ -9,13 +9,28 @@ // // Auth: set OPENSHELL_OIDC_TOKEN / OPENSHELL_EDGE_TOKEN / OPENSHELL_CA_CERT / // OPENSHELL_INSECURE as needed. With none set it assumes a plaintext local gateway. +// Set OPENSHELL_DEMO_PROVIDER= to exercise the provider attach/detach path +// against an existing gateway provider. -import { readFileSync } from 'node:fs' -import { OpenShellClient, errorCode, type ExecResult, type SandboxSpec } from './index.js' +import { readFileSync } from 'node:fs'; +import * as http from 'node:http'; +import { type ExecResult, errorCode, OpenShellClient, type SandboxSpec } from './index.js'; -const env = process.env -const gateway = env.OPENSHELL_GATEWAY ?? 'http://127.0.0.1:8080' -const caCert = env.OPENSHELL_CA_CERT ? readFileSync(env.OPENSHELL_CA_CERT) : undefined +const env = process.env; +const gateway = env.OPENSHELL_GATEWAY ?? 'http://127.0.0.1:8080'; +const caCert = env.OPENSHELL_CA_CERT ? readFileSync(env.OPENSHELL_CA_CERT) : undefined; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +function httpGet(url: string): Promise { + return new Promise((resolve, reject) => { + const req = http.get(url, (res) => { + res.resume(); + resolve(res.statusCode ?? 0); + }); + req.on('error', reject); + }); +} async function main() { const client = await OpenShellClient.connect({ @@ -24,33 +39,102 @@ async function main() { oidcToken: env.OPENSHELL_OIDC_TOKEN, edgeToken: env.OPENSHELL_EDGE_TOKEN, insecureSkipVerify: env.OPENSHELL_INSECURE === '1', - }) + }); - const health = await client.health() - console.log(`health: ${health.status} (v${health.version})`) + const health = await client.health(); + console.log(`health: ${health.status} (v${health.version})`); const spec: SandboxSpec = { image: env.OPENSHELL_DEFAULT_IMAGE, labels: { 'openshell.dev/demo': 'sdk-ts' }, - } - const ref = await client.sandbox.create(spec) - console.log(`created: ${ref.name} [${ref.phase}]`) + }; + const ref = await client.sandbox.create(spec); + console.log(`created: ${ref.name} [${ref.phase}]`); - await client.sandbox.waitReady(ref.name, 120) - console.log(`ready: ${ref.name}`) + await client.sandbox.waitReady(ref.name, 120); + console.log(`ready: ${ref.name}`); const result: ExecResult = await client.sandbox.exec(ref.name, ['/bin/sh', '-c', 'echo hello from $(hostname)'], { timeoutSecs: 30, - }) - console.log(`exec exit=${result.exitCode} stdout=${result.stdout.toString().trim()}`) + }); + console.log(`exec exit=${result.exitCode} stdout=${result.stdout.toString().trim()}`); + + // execStream: incremental chunks as they arrive. + process.stdout.write('execStream: '); + for await (const chunk of client.sandbox.execStream(ref.name, [ + '/bin/sh', + '-c', + 'for i in 1 2 3; do echo line $i; done', + ])) { + process.stdout.write(chunk.data); + } + + // execInteractive: scripted (non-TTY) bidi round-trip through `cat`. + const session = await client.sandbox.execInteractive(ref.name, ['cat'], { + tty: false, + }); + let echoed = ''; + const collect = (async () => { + for await (const chunk of session.output) echoed += chunk.data.toString(); + })(); + session.write(Buffer.from('interactive hello\n')); + await sleep(300); + session.close(); + await collect; + console.log(`execInteractive: echo=${echoed.trim()} exit=${await session.done}`); + + // SSH session mint + revoke. + const ssh = await client.sandbox.createSshSession(ref.name); + console.log( + `ssh session: token=${ssh.token.slice(0, 8)}… gateway=${ssh.gatewayScheme}://${ssh.gatewayHost}:${ssh.gatewayPort}`, + ); + console.log(`ssh revoked: ${await client.sandbox.revokeSshSession(ssh.token)}`); + + // Config + policy round-trip (network-policy-safe: re-apply the current policy). + const config = await client.sandbox.getConfig(ref.name); + console.log( + `config: policyHash=${config.policyHash} version=${config.version} settings=${Object.keys(config.settings).length}`, + ); + if (config.policy) { + const update = await client.sandbox.setPolicy(ref.name, config.policy, { + wait: true, + }); + const after = await client.sandbox.getConfig(ref.name); + console.log(`setPolicy: version=${update.version} hashMatches=${after.policyHash === update.policyHash}`); + } + + // Providers (optional: requires an existing gateway provider). + const providerName = env.OPENSHELL_DEMO_PROVIDER; + if (providerName) { + const attach = await client.sandbox.attachProvider(ref.name, providerName); + console.log(`attach ${providerName}: changed=${attach.changed}`); + const providers = await client.sandbox.listProviders(ref.name); + console.log(`providers: ${providers.map((p) => `${p.name}(${p.type})`).join(', ')}`); + const detach = await client.sandbox.detachProvider(ref.name, providerName); + console.log(`detach ${providerName}: changed=${detach.changed}`); + } + + // Forward: start an in-sandbox listener and drive a request through the tunnel. + await client.sandbox.exec(ref.name, [ + '/bin/sh', + '-c', + 'nohup python3 -m http.server 8111 >/tmp/http.log 2>&1 & sleep 1', + ]); + const forward = await client.sandbox.forward(ref.name, { targetPort: 8111 }); + console.log(`forward: 127.0.0.1:${forward.localPort} -> sandbox:8111`); + const status = await httpGet(`http://127.0.0.1:${forward.localPort}/`); + console.log(`forward GET status: ${status}`); + await forward.close(); - const all = await client.sandbox.list({ labelSelector: 'openshell.dev/demo=sdk-ts' }) - console.log(`listed ${all.length} demo sandbox(es)`) + const all = await client.sandbox.list({ + labelSelector: 'openshell.dev/demo=sdk-ts', + }); + console.log(`listed ${all.length} demo sandbox(es)`); - console.log(`deleted: ${await client.sandbox.delete(ref.name)}`) + console.log(`deleted: ${await client.sandbox.delete(ref.name)}`); } main().catch((e) => { - console.error(`demo failed [code=${errorCode(e) ?? 'unknown'}]:`, e instanceof Error ? e.message : e) - process.exit(1) -}) + console.error(`demo failed [code=${errorCode(e) ?? 'unknown'}]:`, e instanceof Error ? e.message : e); + process.exit(1); +}); diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index dfa36beb7b..bb12231127 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -8,13 +8,28 @@ // added alongside a conformance suite that pins it byte-identical across the // TypeScript, Python, and Go SDKs. -export { OpenShellClient, SandboxClient, errorCode } from './client.js' export type { ConnectOptions, + EffectiveSettingView, + ExecInteractiveOptions, + ExecInteractiveSession, ExecOptions, ExecResult, + ExecStreamChunk, + ForwardHandle, + ForwardOptions, Health, ListOptions, + ProviderChange, + ProviderChangeOptions, + ProviderRef, + SandboxConfig, + SandboxPolicy, SandboxRef, SandboxSpec, -} from './client.js' + SetPolicyOptions, + SettingValue, + SshSession, + UpdateConfigResult, +} from './client.js'; +export { errorCode, OpenShellClient, SandboxClient } from './client.js'; From 427d5bd4c7dba194d65a8af9ba00265ce27a1acc Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Wed, 15 Jul 2026 19:02:48 -0400 Subject: [PATCH 09/11] build(sdk-ts): add Biome and Vitest tooling The TypeScript SDK had no formatter or linter and no test runner. Add Biome (format + lint, generated src/gen excluded) enforcing 2-space indent, single quotes, semicolons, and a 120-column width, and reformat the existing hand-written sources accordingly. Add Vitest for unit tests. Wire sdk:ts:format, sdk:ts:lint, and sdk:ts:test mise tasks into the fmt/lint aggregates, the root test suite, and sdk:ts:ci so they run in CI. Signed-off-by: Max Dubrinsky --- sdk/typescript/biome.json | 37 + sdk/typescript/package-lock.json | 1655 ++++++++++++++++++++++++++++-- sdk/typescript/package.json | 7 +- sdk/typescript/src/errors.ts | 30 +- sdk/typescript/src/transport.ts | 28 +- sdk/typescript/vitest.config.ts | 11 + tasks/ci.toml | 15 +- tasks/test.toml | 12 +- tasks/typescript.toml | 28 +- 9 files changed, 1680 insertions(+), 143 deletions(-) create mode 100644 sdk/typescript/biome.json create mode 100644 sdk/typescript/vitest.config.ts diff --git a/sdk/typescript/biome.json b/sdk/typescript/biome.json new file mode 100644 index 0000000000..37fefbbf74 --- /dev/null +++ b/sdk/typescript/biome.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!src/gen", "!dist"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 120 + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended" + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index 24bde1ae08..eec8842000 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -14,16 +14,193 @@ "@connectrpc/connect-node": "^2.0.0" }, "devDependencies": { + "@biomejs/biome": "^2.5.4", "@bufbuild/buf": "^1.71.0", "@bufbuild/protoc-gen-es": "^2.2.3", "@types/node": "^24", "tsx": "^4.19.2", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.10" }, "engines": { "node": ">=20" } }, + "node_modules/@biomejs/biome": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.4.tgz", + "integrity": "sha512-xy5FNE5kQJKyK5MR1gJy6ztXYx4WBAbYGlK04lMEgmyPRWKybY9NFwiG9yo0XdzOU8Xvhj41u034J1ywfoWfMw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.4", + "@biomejs/cli-darwin-x64": "2.5.4", + "@biomejs/cli-linux-arm64": "2.5.4", + "@biomejs/cli-linux-arm64-musl": "2.5.4", + "@biomejs/cli-linux-x64": "2.5.4", + "@biomejs/cli-linux-x64-musl": "2.5.4", + "@biomejs/cli-win32-arm64": "2.5.4", + "@biomejs/cli-win32-x64": "2.5.4" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.4.tgz", + "integrity": "sha512-4o3NFRobXHynkgcFVrlZsoDAFtF2ldlEGN8sORSws5ZQqyY4PXnPUIylu4ksfyHuwkfvDREuWh3JK+niRwGq3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.4.tgz", + "integrity": "sha512-D32P5HkU2Y6PySuC/WsVDTOgsDwVFmujzhhhOQjajtATpVWFDXuVd3oRbsWNSEA+aaFzyzZm22szsyydBYlSyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.4.tgz", + "integrity": "sha512-pSEfW7B8kTsXUjUxC1xVVK+y85Ht3C5XxZ9gclmC7/3Ku9Vqz8jmI7k0p/BNIjQ6t4sFERI2sFeH73ybiZl6YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.4.tgz", + "integrity": "sha512-Rpm5/AT1m+DlJmUoYvS4/vXc+0tXJPJ2NQz25TGPyHVF5JrWy75PE0GH6kVxsKtQDuCH4OgzquZq0R4kj/wCVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.4.tgz", + "integrity": "sha512-FNxojWJkL7EajAuzBgoLe0T2G0y112M4lBrDIFl/DomFTx8yqenYOIdsRLNXvOvBBofE8hJi85LjzLmBDpY7/Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.4.tgz", + "integrity": "sha512-aby/PohmmgbShcHqFsZVzG8H6D98+P+A6xRWRrQcLW1pCjabcov5UUlke4UqNQBYTkDQav+jB4zyyDDeKB2GaA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.4.tgz", + "integrity": "sha512-emoXexPZIPAZkz2RKmA95WJUqK3I5MJNYtwEbL5ESciRzhmFMMyekDhNG8hpeOaK+ZGRDxAU4wvGuA5IHQ0h0w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.4.tgz", + "integrity": "sha512-U1jaluLw1qQc2Tx7/CeSoL9N5XcqIH+GWjpUAy1ouB5nVjSCMNO+NNHdY3RAs8zxNurLWAdj6pehQdCA2zyU+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@bufbuild/buf": { "version": "1.71.0", "resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.71.0.tgz", @@ -247,6 +424,40 @@ "@connectrpc/connect": "2.1.2" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -689,150 +900,1380 @@ "node": ">=18" } }, - "node_modules/@types/node": { - "version": "24.13.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", - "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } + "license": "MIT" }, - "node_modules/@typescript/vfs": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", - "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "debug": "^4.4.3" + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "typescript": "*" + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=14.17" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@typescript/vfs": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", + "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 52d12db167..572b723e90 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -32,6 +32,9 @@ "gen": "buf generate", "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit", + "format": "biome check --write .", + "lint": "biome ci .", + "test": "vitest run", "demo": "tsx src/demo.ts", "prepublishOnly": "npm run gen && npm run build" }, @@ -41,10 +44,12 @@ "@connectrpc/connect-node": "^2.0.0" }, "devDependencies": { + "@biomejs/biome": "^2.5.4", "@bufbuild/buf": "^1.71.0", "@bufbuild/protoc-gen-es": "^2.2.3", "@types/node": "^24", "tsx": "^4.19.2", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.10" } } diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index 20a72d816f..71c3ac9890 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -6,7 +6,7 @@ // retired) napi binding exposed, kept stable so consumers migrating off it see // an identical contract. -import { Code, ConnectError } from '@connectrpc/connect' +import { Code, ConnectError } from '@connectrpc/connect'; export type SdkErrorCode = | 'invalid_config' @@ -16,39 +16,39 @@ export type SdkErrorCode = | 'io' | 'not_found' | 'already_exists' - | 'rpc' + | 'rpc'; export class SdkError extends Error { - readonly code: SdkErrorCode + readonly code: SdkErrorCode; constructor(code: SdkErrorCode, message: string) { // Format `[code] message` so errorCode() can recover the code from any Error. - super(`[${code}] ${message}`) - this.name = 'SdkError' - this.code = code + super(`[${code}] ${message}`); + this.name = 'SdkError'; + this.code = code; } } // Map a gRPC status (surfaced by connect-es as ConnectError) onto our codes. export function fromConnect(err: unknown): SdkError { - const ce = ConnectError.from(err) + const ce = ConnectError.from(err); switch (ce.code) { case Code.NotFound: - return new SdkError('not_found', ce.rawMessage) + return new SdkError('not_found', ce.rawMessage); case Code.AlreadyExists: - return new SdkError('already_exists', ce.rawMessage) + return new SdkError('already_exists', ce.rawMessage); case Code.InvalidArgument: - return new SdkError('invalid_config', ce.rawMessage) + return new SdkError('invalid_config', ce.rawMessage); case Code.Unauthenticated: case Code.PermissionDenied: - return new SdkError('auth', ce.rawMessage) + return new SdkError('auth', ce.rawMessage); default: - return new SdkError('rpc', ce.rawMessage) + return new SdkError('rpc', ce.rawMessage); } } // Extract the `[code]` prefix from any error message. export function errorCode(err: unknown): string | null { - const msg = err instanceof Error ? err.message : String(err) - const m = /^\[([a-z_]+)\]/.exec(msg) - return m ? m[1] : null + const msg = err instanceof Error ? err.message : String(err); + const m = /^\[([a-z_]+)\]/.exec(msg); + return m ? m[1] : null; } diff --git a/sdk/typescript/src/transport.ts b/sdk/typescript/src/transport.ts index 4f40051572..6d6f1396d7 100644 --- a/sdk/typescript/src/transport.ts +++ b/sdk/typescript/src/transport.ts @@ -10,20 +10,20 @@ // `gateway` at it. When the edge passes gRPC POST directly, the header mode // below suffices. -import { createGrpcTransport } from '@connectrpc/connect-node' -import type { Interceptor, Transport } from '@connectrpc/connect' +import type { Interceptor, Transport } from '@connectrpc/connect'; +import { createGrpcTransport } from '@connectrpc/connect-node'; export interface ConnectOptions { /** Gateway URL (`http://...` or `https://...`). */ - gateway: string + gateway: string; /** CA certificate (PEM). Omit to use system roots. */ - caCert?: Buffer + caCert?: Buffer; /** Bearer token for direct OIDC auth. Mutually exclusive with edgeToken. */ - oidcToken?: string + oidcToken?: string; /** Cloudflare Access token. See the sidecar note above for CF-fronted gateways. */ - edgeToken?: string + edgeToken?: string; /** Disable TLS verification (dev/debug only). */ - insecureSkipVerify?: boolean + insecureSkipVerify?: boolean; } // OIDC bearer takes precedence; otherwise attach the Cloudflare Access header + @@ -31,17 +31,17 @@ export interface ConnectOptions { function authInterceptor(opts: ConnectOptions): Interceptor { return (next) => async (req) => { if (opts.oidcToken) { - req.header.set('authorization', `Bearer ${opts.oidcToken}`) + req.header.set('authorization', `Bearer ${opts.oidcToken}`); } else if (opts.edgeToken) { - req.header.set('cf-access-jwt-assertion', opts.edgeToken) - req.header.set('cookie', `CF_Authorization=${opts.edgeToken}`) + req.header.set('cf-access-jwt-assertion', opts.edgeToken); + req.header.set('cookie', `CF_Authorization=${opts.edgeToken}`); } - return next(req) - } + return next(req); + }; } export function buildTransport(opts: ConnectOptions): Transport { - const isTls = opts.gateway.startsWith('https://') + const isTls = opts.gateway.startsWith('https://'); return createGrpcTransport({ baseUrl: opts.gateway, interceptors: [authInterceptor(opts)], @@ -53,5 +53,5 @@ export function buildTransport(opts: ConnectOptions): Transport { rejectUnauthorized: opts.insecureSkipVerify ? false : undefined, } : undefined, - }) + }); } diff --git a/sdk/typescript/vitest.config.ts b/sdk/typescript/vitest.config.ts new file mode 100644 index 0000000000..82fd245477 --- /dev/null +++ b/sdk/typescript/vitest.config.ts @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + environment: 'node', + }, +}); diff --git a/tasks/ci.toml b/tasks/ci.toml index cda4d04ef4..045fbdbd59 100644 --- a/tasks/ci.toml +++ b/tasks/ci.toml @@ -46,12 +46,23 @@ run = "cargo clean" [fmt] description = "Format code" -depends = ["rust:format", "python:format", "markdown:format"] +depends = ["rust:format", "python:format", "markdown:format", "sdk:ts:format"] hide = true [lint] description = "Run repository lint checks" -depends = ["license:check", "rust:format:check", "rust:lint", "python:format:check", "python:lint", "helm:lint", "helm:docs:check", "markdown:lint", "proto:lint"] +depends = [ + "license:check", + "rust:format:check", + "rust:lint", + "python:format:check", + "python:lint", + "helm:lint", + "helm:docs:check", + "markdown:lint", + "proto:lint", + "sdk:ts:lint", +] hide = true [ci] diff --git a/tasks/test.toml b/tasks/test.toml index c08fcc5a04..6ab23fc69d 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -4,8 +4,16 @@ # Test tasks (Rust + Python) [test] -description = "Run all tests (Rust + Python)" -depends = ["test:rust", "test:python", "test:sbom", "test:install-sh", "test:packaging-assets", "test:docs-website"] +description = "Run all tests (Rust + Python + TypeScript SDK)" +depends = [ + "test:rust", + "test:python", + "sdk:ts:test", + "test:sbom", + "test:install-sh", + "test:packaging-assets", + "test:docs-website", +] ["test:docs-website"] description = "Test the docs-website sync script" diff --git a/tasks/typescript.toml b/tasks/typescript.toml index 8a5bc2a879..823a881359 100644 --- a/tasks/typescript.toml +++ b/tasks/typescript.toml @@ -32,15 +32,39 @@ depends = ["sdk:ts:proto"] dir = "sdk/typescript" run = "npm run typecheck" +["sdk:ts:lint"] +description = "Lint + format-check the TypeScript SDK (Biome, read-only)" +depends = ["sdk:ts:install"] +dir = "sdk/typescript" +run = "npm run lint" + +["sdk:ts:format"] +description = "Format the TypeScript SDK and apply safe fixes (Biome, writes)" +depends = ["sdk:ts:install"] +dir = "sdk/typescript" +run = "npm run format" + ["sdk:ts:build"] description = "Build the TypeScript SDK (emit dist/)" depends = ["sdk:ts:proto"] dir = "sdk/typescript" run = "npm run build" +["sdk:ts:test"] +description = "Run TypeScript SDK unit tests (Vitest, in-memory transport)" +depends = ["sdk:ts:proto"] +dir = "sdk/typescript" +run = "npm test" + ["sdk:ts:ci"] -description = "TypeScript SDK checks (proto lint, codegen, typecheck, build)" -depends = ["proto:lint", "sdk:ts:typecheck", "sdk:ts:build"] +description = "TypeScript SDK checks (proto lint, Biome lint, codegen, typecheck, test, build)" +depends = [ + "proto:lint", + "sdk:ts:lint", + "sdk:ts:typecheck", + "sdk:ts:test", + "sdk:ts:build", +] hide = true # Publish to the registry in package.json publishConfig. Set OPENSHELL_NPM_VERSION From 4b98970693e898cfd6da4340313f994b6a302642 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Wed, 15 Jul 2026 19:02:53 -0400 Subject: [PATCH 10/11] test(sdk-ts): cover the sandbox surface with in-memory transport tests Exercise SandboxClient against an in-memory OpenShell service built with createRouterTransport: request assembly and id resolution, u64/int64 rendered as strings, enum lowercasing, fromConnect code mapping, the exec/execStream drain plus a backward-compat check on exec, execInteractive start-first ordering and done resolution, and a forward() byte relay against a loopback echo with close() teardown. Signed-off-by: Max Dubrinsky --- sdk/typescript/src/client.test.ts | 469 ++++++++++++++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 sdk/typescript/src/client.test.ts diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts new file mode 100644 index 0000000000..d7d2f911ae --- /dev/null +++ b/sdk/typescript/src/client.test.ts @@ -0,0 +1,469 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Unit tests for SandboxClient against an in-memory OpenShell service. Every +// RPC is stubbed with createRouterTransport, so these exercise request +// assembly, u64/int64->string rendering, enum lowercasing, fromConnect code +// mapping, the exec/execStream drain, execInteractive framing, and the +// forward() byte relay without a running gateway. + +import * as net from 'node:net'; +import type { MessageInitShape } from '@bufbuild/protobuf'; +import { Code, ConnectError, createRouterTransport, type ServiceImpl, type Transport } from '@connectrpc/connect'; +import { describe, expect, it } from 'vitest'; +import { errorCode, SandboxClient } from './client.js'; +import { OpenShell, SandboxPhase } from './gen/openshell_pb.js'; +import { PolicySource, SettingScope } from './gen/sandbox_pb.js'; + +function client(impl: Partial>): SandboxClient { + const transport: Transport = createRouterTransport((router) => { + router.service(OpenShell, impl); + }); + return new SandboxClient(transport); +} + +function readySandbox( + name: string, + id: string, + resourceVersion = 7n, +): MessageInitShape { + return { + sandbox: { + metadata: { id, name, labels: { team: 'aire' }, resourceVersion }, + status: { phase: SandboxPhase.READY }, + }, + }; +} + +const enc = (s: string) => new TextEncoder().encode(s); + +describe('exec / execStream', () => { + it('resolves the id via get, frames tty:false, and buffers the result (backward compat)', async () => { + let execReq: { sandboxId?: string; tty?: boolean; command?: string[] } = {}; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // eslint-disable-next-line require-yield + execSandbox: async function* (req) { + execReq = req; + yield { payload: { case: 'stdout', value: { data: enc('hello ') } } }; + yield { payload: { case: 'stderr', value: { data: enc('warn') } } }; + yield { payload: { case: 'stdout', value: { data: enc('world') } } }; + yield { payload: { case: 'exit', value: { exitCode: 3 } } }; + }, + }); + + const result = await sandbox.exec('sb', ['/bin/sh', '-c', 'echo hi']); + expect(execReq.sandboxId).toBe('sb-id-1'); + expect(execReq.tty).toBe(false); + expect(execReq.command).toEqual(['/bin/sh', '-c', 'echo hi']); + expect(result.exitCode).toBe(3); + expect(result.stdout.toString()).toBe('hello world'); + expect(result.stderr.toString()).toBe('warn'); + expect(Buffer.isBuffer(result.stdout)).toBe(true); + }); + + it('execStream yields incremental chunks and returns an exit-only ExecResult', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // eslint-disable-next-line require-yield + execSandbox: async function* () { + yield { payload: { case: 'stdout', value: { data: enc('a') } } }; + yield { payload: { case: 'stderr', value: { data: enc('b') } } }; + yield { payload: { case: 'exit', value: { exitCode: 0 } } }; + }, + }); + + const chunks: Array<{ stream: string; data: string }> = []; + const gen = sandbox.execStream('sb', ['x']); + let next = await gen.next(); + for (; next.done !== true; next = await gen.next()) { + chunks.push({ + stream: next.value.stream, + data: next.value.data.toString(), + }); + } + expect(chunks).toEqual([ + { stream: 'stdout', data: 'a' }, + { stream: 'stderr', data: 'b' }, + ]); + expect(next.value.exitCode).toBe(0); + expect(next.value.stdout.length).toBe(0); + expect(next.value.stderr.length).toBe(0); + }); + + it('maps a NotFound from get() to an SdkError not_found', async () => { + const sandbox = client({ + getSandbox: () => { + throw new ConnectError('missing', Code.NotFound); + }, + }); + await expect(sandbox.exec('sb', ['x'])).rejects.toMatchObject({ + code: 'not_found', + }); + await expect(sandbox.exec('sb', ['x'])).rejects.toSatisfy((e) => errorCode(e) === 'not_found'); + }); +}); + +describe('execInteractive', () => { + it('sends start first with tty/cols/rows, streams output, and resolves done', async () => { + const cases: string[] = []; + let started: { tty?: boolean; cols?: number; rows?: number; sandboxId?: string } | undefined; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-9'), + execSandboxInteractive: async function* (requests) { + for await (const input of requests) { + cases.push(input.payload.case ?? 'none'); + if (input.payload.case === 'start') { + started = input.payload.value; + yield { + payload: { case: 'stdout', value: { data: enc('ready\n') } }, + }; + } else if (input.payload.case === 'stdin') { + yield { + payload: { case: 'stdout', value: { data: input.payload.value } }, + }; + } + } + yield { payload: { case: 'exit', value: { exitCode: 0 } } }; + }, + }); + + const session = await sandbox.execInteractive('sb', ['bash'], { + cols: 120, + rows: 40, + }); + const out: string[] = []; + const collector = (async () => { + for await (const chunk of session.output) out.push(chunk.data.toString()); + })(); + + session.write(Buffer.from('echo hi')); + // Let the echo round-trip before closing the input stream. + await new Promise((r) => setTimeout(r, 20)); + session.close(); + + await collector; + const code = await session.done; + expect(code).toBe(0); + expect(cases[0]).toBe('start'); + expect(started?.tty).toBe(true); + expect(started?.cols).toBe(120); + expect(started?.rows).toBe(40); + expect(started?.sandboxId).toBe('sb-id-9'); + expect(out.join('')).toContain('ready\n'); + expect(out.join('')).toContain('echo hi'); + }); +}); + +describe('providers', () => { + it('attach/detach assemble the request and map the changed flag + sandbox ref', async () => { + let attachReq: { + sandboxName?: string; + providerName?: string; + expectedResourceVersion?: bigint; + } = {}; + let detachReq: { expectedResourceVersion?: bigint } = {}; + const sandbox = client({ + attachSandboxProvider: (req) => { + attachReq = req; + return { sandbox: readySandbox('sb', 'sb-id').sandbox, attached: true }; + }, + detachSandboxProvider: (req) => { + detachReq = req; + return { + sandbox: readySandbox('sb', 'sb-id').sandbox, + detached: false, + }; + }, + }); + + const attach = await sandbox.attachProvider('sb', 'claude'); + expect(attachReq.sandboxName).toBe('sb'); + expect(attachReq.providerName).toBe('claude'); + expect(attachReq.expectedResourceVersion).toBe(0n); + expect(attach.changed).toBe(true); + expect(attach.sandbox.resourceVersion).toBe('7'); + + const detach = await sandbox.detachProvider('sb', 'claude', { + expectedResourceVersion: '42', + }); + expect(detachReq.expectedResourceVersion).toBe(42n); + expect(detach.changed).toBe(false); + }); + + it('lists providers with u64 resourceVersion rendered as a string', async () => { + const sandbox = client({ + listSandboxProviders: () => ({ + providers: [ + { + metadata: { + id: 'p1', + name: 'claude', + labels: { a: 'b' }, + resourceVersion: 99n, + }, + type: 'claude', + }, + ], + }), + }); + const providers = await sandbox.listProviders('sb'); + expect(providers).toEqual([ + { + id: 'p1', + name: 'claude', + type: 'claude', + labels: { a: 'b' }, + resourceVersion: '99', + }, + ]); + }); +}); + +describe('config / policy', () => { + it('getConfig lowercases scope + policySource and renders u64 as strings', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + getSandboxConfig: () => ({ + policy: { version: 1, networkPolicies: {} }, + version: 4, + policyHash: 'hash-a', + settings: { + 'net.timeout': { + value: { value: { case: 'intValue', value: 30n } }, + scope: SettingScope.SANDBOX, + }, + }, + configRevision: 123n, + policySource: PolicySource.GLOBAL, + globalPolicyVersion: 2, + providerEnvRevision: 456n, + }), + }); + const config = await sandbox.getConfig('sb'); + expect(config.version).toBe(4); + expect(config.policyHash).toBe('hash-a'); + expect(config.policySource).toBe('global'); + expect(config.configRevision).toBe('123'); + expect(config.providerEnvRevision).toBe('456'); + expect(config.settings['net.timeout']?.scope).toBe('sandbox'); + expect(config.settings['net.timeout']?.value?.value).toEqual({ + case: 'intValue', + value: 30n, + }); + }); + + it('setPolicy sends global=false + version pin and (wait) polls until the hash matches', async () => { + let updateReq: { + name?: string; + global?: boolean; + expectedResourceVersion?: bigint; + policy?: unknown; + } = {}; + let configCalls = 0; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + updateConfig: (req) => { + updateReq = req; + return { + version: 5, + policyHash: 'target', + settingsRevision: 10n, + deleted: false, + }; + }, + getSandboxConfig: () => { + configCalls += 1; + const policyHash = configCalls >= 2 ? 'target' : 'stale'; + return { + policy: { version: 1, networkPolicies: {} }, + version: 5, + policyHash, + settings: {}, + configRevision: 1n, + policySource: PolicySource.SANDBOX, + globalPolicyVersion: 0, + providerEnvRevision: 0n, + }; + }, + }); + + const result = await sandbox.setPolicy( + 'sb', + { + version: 1, + networkPolicies: { web: { name: 'web', endpoints: [], binaries: [] } }, + }, + { wait: true, expectedResourceVersion: '7' }, + ); + expect(updateReq.name).toBe('sb'); + expect(updateReq.global).toBe(false); + expect(updateReq.expectedResourceVersion).toBe(7n); + expect(updateReq.policy).toBeDefined(); + expect(result.version).toBe(5); + expect(result.policyHash).toBe('target'); + expect(result.settingsRevision).toBe('10'); + expect(configCalls).toBeGreaterThanOrEqual(2); + }); + + it('setSetting upserts a single sandbox-scoped setting (global=false)', async () => { + let req: { + name?: string; + settingKey?: string; + global?: boolean; + settingValue?: unknown; + } = {}; + const sandbox = client({ + updateConfig: (r) => { + req = r; + return { + version: 6, + policyHash: '', + settingsRevision: 11n, + deleted: false, + }; + }, + }); + const result = await sandbox.setSetting('sb', 'feature.enabled', { + value: { case: 'boolValue', value: true }, + }); + expect(req.name).toBe('sb'); + expect(req.settingKey).toBe('feature.enabled'); + expect(req.global).toBe(false); + expect(req.settingValue).toMatchObject({ + value: { case: 'boolValue', value: true }, + }); + expect(result.settingsRevision).toBe('11'); + }); +}); + +describe('ssh sessions', () => { + it('creates a session, omitting expiresAtMs when 0 and rendering it as a string otherwise', async () => { + const withExpiry = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + createSshSession: () => ({ + sandboxId: 'sb-id', + token: 'tok-1', + gatewayHost: 'gw.example', + gatewayPort: 8443, + gatewayScheme: 'https', + hostKeyFingerprint: 'SHA256:abc', + expiresAtMs: 1730000000000n, + }), + }); + const session = await withExpiry.createSshSession('sb'); + expect(session).toEqual({ + sandboxId: 'sb-id', + token: 'tok-1', + gatewayHost: 'gw.example', + gatewayPort: 8443, + gatewayScheme: 'https', + hostKeyFingerprint: 'SHA256:abc', + expiresAtMs: '1730000000000', + }); + + const noExpiry = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + createSshSession: () => ({ + sandboxId: 'sb-id', + token: 'tok-2', + gatewayHost: 'gw', + gatewayPort: 80, + gatewayScheme: 'http', + hostKeyFingerprint: '', + expiresAtMs: 0n, + }), + }); + const bare = await noExpiry.createSshSession('sb'); + expect(bare.expiresAtMs).toBeUndefined(); + expect(bare.hostKeyFingerprint).toBeUndefined(); + }); + + it('revokeSshSession returns the revoked flag', async () => { + const sandbox = client({ revokeSshSession: () => ({ revoked: true }) }); + expect(await sandbox.revokeSshSession('tok')).toBe(true); + }); +}); + +describe('forward', () => { + it('binds a local port and relays bytes both ways, minting + revoking a token', async () => { + let sshReq: { sandboxId?: string } = {}; + let revokedToken: string | undefined; + let initFrame: { sandboxId?: string; authorizationToken?: string; target?: unknown } | undefined; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-forward'), + createSshSession: (req) => { + sshReq = req; + return { + sandboxId: 'sb-id-forward', + token: 'fwd-tok', + gatewayHost: 'gw', + gatewayPort: 443, + gatewayScheme: 'https', + hostKeyFingerprint: '', + expiresAtMs: 0n, + }; + }, + revokeSshSession: (req) => { + revokedToken = req.token; + return { revoked: true }; + }, + forwardTcp: async function* (requests) { + for await (const frame of requests) { + if (frame.payload.case === 'init') { + initFrame = frame.payload.value; + } else if (frame.payload.case === 'data') { + yield { payload: { case: 'data', value: frame.payload.value } }; + } + } + }, + }); + + const handle = await sandbox.forward('sb', { targetPort: 9000 }); + expect(handle.localPort).toBeGreaterThan(0); + expect(handle.targetPort).toBe(9000); + expect(handle.targetHost).toBe('127.0.0.1'); + + const echoed = await new Promise((resolve, reject) => { + const socket = net.connect(handle.localPort, handle.localHost, () => { + socket.write('ping-through-forward'); + }); + const buf: Buffer[] = []; + socket.on('data', (d) => { + buf.push(d); + if (Buffer.concat(buf).length >= 'ping-through-forward'.length) { + resolve(Buffer.concat(buf).toString()); + socket.end(); + } + }); + socket.on('error', reject); + }); + + expect(echoed).toBe('ping-through-forward'); + expect(sshReq.sandboxId).toBe('sb-id-forward'); + expect(initFrame?.sandboxId).toBe('sb-id-forward'); + expect(initFrame?.authorizationToken).toBe('fwd-tok'); + expect(initFrame?.target).toMatchObject({ + case: 'tcp', + value: { host: '127.0.0.1', port: 9000 }, + }); + + await handle.close(); + await handle.closed; + // The per-connection revoke is best-effort and fires on teardown. + await new Promise((r) => setTimeout(r, 20)); + expect(revokedToken).toBe('fwd-tok'); + }); + + it('rejects when the sandbox is not ready', async () => { + const sandbox = client({ + getSandbox: () => ({ + sandbox: { + metadata: { id: 'sb-id', name: 'sb' }, + status: { phase: SandboxPhase.PROVISIONING }, + }, + }), + }); + await expect(sandbox.forward('sb', { targetPort: 9000 })).rejects.toMatchObject({ code: 'connect' }); + }); +}); From 114eed67db0ddbd361f27055589e7cbf45d7726c Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Wed, 15 Jul 2026 19:03:01 -0400 Subject: [PATCH 11/11] docs(sdk-ts): document the new surface and connect/upload/download boundaries Document execStream, execInteractive, forward, ssh sessions, providers, and config/policy in the SDK README, and record the intentional boundaries: interactive connect / PTY ownership, upload/download (no file-transfer RPC), and detached forwards stay out of scope. Note the Biome/Vitest dev commands. Signed-off-by: Max Dubrinsky --- sdk/typescript/README.md | 61 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 5461e9565f..20f7250cfd 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -51,12 +51,73 @@ const sandbox = await SandboxClient.connect({ gateway, oidcToken }) await sandbox.create({ image }) ``` +## Streaming and interactive exec + +`execStream` yields stdout/stderr chunks as they arrive, so long or chatty commands surface output incrementally instead of buffering until exit. The terminal value carries the exit code; the chunks carry the bytes. `exec` drains `execStream` internally, so its buffered `ExecResult` is unchanged. + +```ts +for await (const chunk of client.sandbox.execStream(name, ['pytest', '-q'])) { + process[chunk.stream].write(chunk.data) // 'stdout' | 'stderr' +} +``` + +`execInteractive` is the TTY + stdin transport primitive. Drive it by consuming `output`; `done` resolves with the exit code once the stream ends. It ships raw bytes only — raw mode, signal forwarding, and SIGWINCH stay with the caller. + +```ts +const session = await client.sandbox.execInteractive(name, ['bash']) +session.write(Buffer.from('echo hi\n')) +session.resize(120, 40) +for await (const chunk of session.output) process.stdout.write(chunk.data) +const code = await session.done +``` + +## Port forwarding + +`forward` binds a local TCP listener and tunnels each accepted connection into the sandbox for the lifetime of the Node process. Call `close()` on teardown. + +```ts +const fwd = await client.sandbox.forward(name, { targetPort: 8000 }) +// ... reach the sandbox service at 127.0.0.1:fwd.localPort ... +await fwd.close() +``` + +## SSH sessions, providers, config and policy + +```ts +const ssh = await client.sandbox.createSshSession(name) +await client.sandbox.revokeSshSession(ssh.token) + +await client.sandbox.attachProvider(name, 'claude') +await client.sandbox.listProviders(name) +await client.sandbox.detachProvider(name, 'claude') + +const config = await client.sandbox.getConfig(name) +config.policy!.networkPolicies['web'] = { name: 'web', endpoints: [], binaries: [] } +await client.sandbox.setPolicy(name, config.policy!, { wait: true }) +await client.sandbox.setSetting(name, 'feature.enabled', { value: { case: 'boolValue', value: true } }) +``` + +Sandbox-scoped `setPolicy` may only change `networkPolicies`; static fields (`filesystem`, `landlock`, `process`) must match the create-time policy. Sandbox-scoped setting deletes are rejected by the gateway, so only upsert (`setSetting`) is exposed here. + +## Boundaries + +The SDK ships primitives, not the CLI's terminal experience. Some things are intentionally out of scope: + +- **Interactive `connect()` / PTY ownership.** `execInteractive`, `createSshSession`, and `forward` are the transport primitives; raw mode, OpenSSH `ProxyCommand`, and terminal glue stay in the CLI. +- **`upload()` / `download()`.** There is no file-transfer RPC — the CLI does tar-over-SSH. For small payloads, `exec`/`execStream` with `stdin` covers it. A first-class gateway file-transfer RPC is a follow-up. +- **Detached / background forwards.** An in-process forward cannot outlive its caller; `forward` is process-lifetime only. + ## Development The version field is a `0.0.0` placeholder; CI stamps the real version from the git release tag at publish time, matching the Rust and Python packages. ```shell mise run sdk:ts:proto # generate stubs from proto/ with buf +mise run sdk:ts:format # Biome: format + safe fixes (writes) +mise run sdk:ts:lint # Biome: lint + format check (read-only) mise run sdk:ts:typecheck # tsc --noEmit +mise run sdk:ts:test # Vitest unit tests mise run sdk:ts:build # emit dist/ ``` + +Formatting and linting are handled by [Biome](https://biomejs.dev) (`biome.json`): 2-space indent, single quotes, semicolons, 120-column width. Generated `src/gen/` is excluded. `sdk:ts:lint` runs in CI as part of `sdk:ts:ci`.