From ebe382b67de071fd555b824d5d9909285ca21608 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Thu, 2 Jul 2026 14:15:55 -0400 Subject: [PATCH 1/5] 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 c79d0454398770177ff26f6a9c00811c029c2e1e Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Mon, 6 Jul 2026 14:40:27 -0400 Subject: [PATCH 2/5] 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 0200f339f809cda647c12f32531fed7ff2a84486 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Mon, 6 Jul 2026 15:34:34 -0400 Subject: [PATCH 3/5] 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 9b1c7e60a2a5301f8e21a34adaa454510f7dc0c0 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Mon, 6 Jul 2026 20:35:47 -0400 Subject: [PATCH 4/5] 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 52475a0d7cb7b803c9ac432e56d1998b32168852 Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Tue, 7 Jul 2026 10:54:35 -0400 Subject: [PATCH 5/5] 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