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/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/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..135446f65a --- /dev/null +++ b/sdk/typescript/README.md @@ -0,0 +1,62 @@ +# @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.sandbox.create({ + image: 'ghcr.io/nvidia/openshell-community/sandboxes/python:latest', +}) +await client.sandbox.waitReady(sandbox.name, 120) + +const result = await client.sandbox.exec(sandbox.name, ['/bin/sh', '-c', 'echo hello']) +console.log(result.stdout.toString()) + +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 + +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/buf.gen.yaml b/sdk/typescript/buf.gen.yaml new file mode 100644 index 0000000000..9de46265fb --- /dev/null +++ b/sdk/typescript/buf.gen.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# 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: + - 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/package-lock.json b/sdk/typescript/package-lock.json new file mode 100644 index 0000000000..24bde1ae08 --- /dev/null +++ b/sdk/typescript/package-lock.json @@ -0,0 +1,838 @@ +{ + "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/buf": "^1.71.0", + "@bufbuild/protoc-gen-es": "^2.2.3", + "@types/node": "^24", + "tsx": "^4.19.2", + "typescript": "^6.0.3" + }, + "engines": { + "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", + "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": "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" + } + } +} diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json new file mode 100644 index 0000000000..52d12db167 --- /dev/null +++ b/sdk/typescript/package.json @@ -0,0 +1,50 @@ +{ + "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": "buf generate", + "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/buf": "^1.71.0", + "@bufbuild/protoc-gen-es": "^2.2.3", + "@types/node": "^24", + "tsx": "^4.19.2", + "typescript": "^6.0.3" + } +} diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts new file mode 100644 index 0000000000..8d0a52ac6b --- /dev/null +++ b/sdk/typescript/src/client.ts @@ -0,0 +1,251 @@ +// 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/). 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' +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)) + +// ---- sandbox 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 + + // 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) + } + + static async connect(options: ConnectOptions): Promise { + return new SandboxClient(buildTransport(options)) + } + + async create(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 get(name: string): Promise { + try { + const resp = await this.grpc.getSandbox({ name }) + return sandboxRef(resp.sandbox) + } catch (e) { + throw fromConnect(e) + } + } + + async list(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 delete(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.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 + for (;;) { + try { + await this.get(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.get(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) + } + } +} + +// ---- 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 new file mode 100644 index 0000000000..028e1a0f54 --- /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.sandbox.create(spec) + console.log(`created: ${ref.name} [${ref.phase}]`) + + 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()}`) + + 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)}`) +} + +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..dfa36beb7b --- /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, SandboxClient, 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..cda4d04ef4 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] @@ -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/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..8a5bc2a879 --- /dev/null +++ b/tasks/typescript.toml @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# 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" +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" + +# 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"] +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 (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 +# 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