diff --git a/.github/scripts/release/package-node.mjs b/.github/scripts/release/package-node.mjs new file mode 100644 index 0000000..1963859 --- /dev/null +++ b/.github/scripts/release/package-node.mjs @@ -0,0 +1,84 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { runNpm } from "./utils.mjs"; + +const bindingsDirectory = path.resolve("bindings/js"); +const nativePackagesDirectory = path.join(bindingsDirectory, "npm"); +const outputDirectory = path.resolve("package-artifacts/npm"); +const packagePath = path.join(bindingsDirectory, "package.json"); +const readmePath = path.join(bindingsDirectory, "README.md"); + +const nativePackages = fs + .readdirSync(nativePackagesDirectory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(nativePackagesDirectory, entry.name)) + .sort(); + +if (nativePackages.length === 0) { + throw new Error(`No native packages found in ${nativePackagesDirectory}`); +} + +fs.mkdirSync(outputDirectory, { recursive: true }); + +for (const nativePackage of nativePackages) { + const hasNativeAddon = fs + .readdirSync(nativePackage, { withFileTypes: true }) + .some((entry) => entry.isFile() && entry.name.endsWith(".node")); + if (!hasNativeAddon) { + throw new Error(`Missing native addon in ${nativePackage}`); + } + + runNpm([ + "pack", + nativePackage, + "--pack-destination", + outputDirectory, + ]); +} + +const originalPackage = fs.readFileSync(packagePath, "utf8"); +const originalReadme = fs.readFileSync(readmePath, "utf8"); +const rootPackage = JSON.parse(originalPackage); +rootPackage.optionalDependencies = {}; + +for (const nativePackage of nativePackages) { + const manifest = JSON.parse( + fs.readFileSync(path.join(nativePackage, "package.json"), "utf8"), + ); + rootPackage.optionalDependencies[manifest.name] = manifest.version; +} + +try { + fs.writeFileSync( + packagePath, + `${JSON.stringify(rootPackage, null, 2)}\n`, + ); + runNpm([ + "pack", + bindingsDirectory, + "--ignore-scripts", + "--pack-destination", + outputDirectory, + ]); + + rootPackage.name = "shell-use"; + fs.writeFileSync( + packagePath, + `${JSON.stringify(rootPackage, null, 2)}\n`, + ); + fs.writeFileSync( + readmePath, + originalReadme.replaceAll("@microsoft/shell-use", "shell-use"), + ); + runNpm([ + "pack", + bindingsDirectory, + "--ignore-scripts", + "--pack-destination", + outputDirectory, + ]); +} finally { + fs.writeFileSync(packagePath, originalPackage); + fs.writeFileSync(readmePath, originalReadme); +} diff --git a/.github/scripts/release/publish-npm.mjs b/.github/scripts/release/publish-npm.mjs new file mode 100644 index 0000000..8c3f927 --- /dev/null +++ b/.github/scripts/release/publish-npm.mjs @@ -0,0 +1,57 @@ +import { + listPackageTarballs, + readPackageManifest, + runNpm, + spawnNpm, +} from "./utils.mjs"; + +const packages = listPackageTarballs("npm-packages").map((tarball) => ({ + tarball, + manifest: readPackageManifest(tarball), +})); + +if (packages.length < 10) { + throw new Error("Expected eight native packages and two root packages"); +} + +function publishIfMissing({ tarball, manifest }) { + const packageVersion = `${manifest.name}@${manifest.version}`; + const result = spawnNpm(["view", packageVersion, "version"], { + stdio: "ignore", + }); + + if (result.error) { + throw result.error; + } + if (result.status === 0) { + console.log(`${packageVersion} is already published`); + return; + } + if (result.signal) { + throw new Error( + `npm view ${packageVersion} terminated with ${result.signal}`, + ); + } + + runNpm([ + "publish", + tarball, + "--access", + "public", + "--provenance", + "--tag", + "latest", + ]); +} + +const nativePackages = packages.filter(({ manifest }) => + manifest.name.startsWith("@microsoft/shell-use-"), +); +const rootPackages = packages.filter( + ({ manifest }) => + manifest.name === "@microsoft/shell-use" || manifest.name === "shell-use", +); + +for (const packageArtifact of [...nativePackages, ...rootPackages]) { + publishIfMissing(packageArtifact); +} diff --git a/.github/scripts/release/smoke-node.mjs b/.github/scripts/release/smoke-node.mjs new file mode 100644 index 0000000..a8d70c7 --- /dev/null +++ b/.github/scripts/release/smoke-node.mjs @@ -0,0 +1,54 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; + +import { + listPackageTarballs, + readPackageManifest, + runNpm, +} from "./utils.mjs"; + +const packages = listPackageTarballs("npm-packages").map((tarball) => ({ + tarball, + manifest: readPackageManifest(tarball), +})); + +function findPackage(name) { + const matches = packages.filter(({ manifest }) => manifest.name === name); + if (matches.length !== 1) { + throw new Error(`Expected one ${name} package, found ${matches.length}`); + } + return matches[0].tarball; +} + +const rootPackage = findPackage("@microsoft/shell-use"); +const platformPackage = findPackage("@microsoft/shell-use-linux-x64-gnu"); +const smokeDirectory = path.resolve("smoke"); + +fs.mkdirSync(smokeDirectory); +runNpm(["init", "-y"], { + cwd: smokeDirectory, + stdio: ["ignore", "ignore", "inherit"], +}); +runNpm(["install", "--ignore-scripts", platformPackage], { + cwd: smokeDirectory, +}); +runNpm( + ["install", "--ignore-scripts", "--omit=optional", rootPackage], + { cwd: smokeDirectory }, +); + +const requireFromSmoke = createRequire(path.join(smokeDirectory, "package.json")); +const packageEntry = requireFromSmoke.resolve("@microsoft/shell-use"); +const { ShellUse } = await import(pathToFileURL(packageEntry).href); + +const session = ShellUse.ephemeral("release-smoke"); +try { + await session.open(); + await session.submit("echo release-smoke"); + await session.waitCommand(); + await session.expectText("release-smoke", { strict: false }); +} finally { + await session.closeQuiet(); +} diff --git a/.github/scripts/release/smoke-python.py b/.github/scripts/release/smoke-python.py new file mode 100644 index 0000000..bfee76e --- /dev/null +++ b/.github/scripts/release/smoke-python.py @@ -0,0 +1,52 @@ +import asyncio +import os +import subprocess +import sys +import venv +from pathlib import Path + + +async def smoke_test(): + from shell_use import ShellUse + + async with ShellUse.ephemeral("release-smoke") as session: + await session.open() + await session.submit("echo release-smoke") + await session.wait_command() + await session.expect_text("release-smoke", strict=False) + + +def main(): + if "--run-smoke" in sys.argv: + asyncio.run(smoke_test()) + return + + wheels = sorted(Path("dist").glob("*.whl")) + if not wheels: + raise RuntimeError("No Python wheels found in dist") + + smoke_directory = Path("smoke") + venv.create(smoke_directory, with_pip=True) + python = smoke_directory / ( + "Scripts/python.exe" if os.name == "nt" else "bin/python" + ) + + subprocess.run( + [ + python, + "-m", + "pip", + "install", + "--disable-pip-version-check", + *wheels, + ], + check=True, + ) + subprocess.run( + [python, Path(__file__).resolve(), "--run-smoke"], + check=True, + ) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/release/utils.mjs b/.github/scripts/release/utils.mjs new file mode 100644 index 0000000..0a39dd0 --- /dev/null +++ b/.github/scripts/release/utils.mjs @@ -0,0 +1,49 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +export function run(command, args, options = {}) { + const { stdio = "inherit", ...rest } = options; + execFileSync(command, args, { ...rest, stdio }); +} + +function npmInvocation(args) { + if (process.platform === "win32") { + return { + command: process.env.ComSpec ?? "cmd.exe", + args: ["/d", "/s", "/c", "npm", ...args], + }; + } + return { command: "npm", args }; +} + +export function runNpm(args, options = {}) { + const invocation = npmInvocation(args); + run(invocation.command, invocation.args, options); +} + +export function spawnNpm(args, options = {}) { + const invocation = npmInvocation(args); + return spawnSync(invocation.command, invocation.args, options); +} + +export function listPackageTarballs(directory) { + return fs + .readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".tgz")) + .map((entry) => path.resolve(directory, entry.name)) + .sort(); +} + +export function readPackageManifest(tarball) { + const absoluteTarball = path.resolve(tarball); + const contents = execFileSync( + "tar", + ["-xOf", path.basename(absoluteTarball), "package/package.json"], + { + cwd: path.dirname(absoluteTarball), + encoding: "utf8", + }, + ); + return JSON.parse(contents); +} diff --git a/.github/scripts/release/verify-versions.mjs b/.github/scripts/release/verify-versions.mjs new file mode 100644 index 0000000..078a360 --- /dev/null +++ b/.github/scripts/release/verify-versions.mjs @@ -0,0 +1,31 @@ +import fs from "node:fs"; + +const releaseTag = process.env.RELEASE_TAG; +if (!releaseTag) { + throw new Error("RELEASE_TAG is required"); +} + +const expected = releaseTag.replace(/^v/, ""); +const jsPackage = JSON.parse( + fs.readFileSync("bindings/js/package.json", "utf8"), +); +const versions = { + "Cargo.toml": fs.readFileSync("Cargo.toml", "utf8").match(/^\s*version\s*=\s*"([^"]+)"/m)?.[1], + "bindings/js/package.json": jsPackage.version, + "bindings/js/src/version.ts": fs.readFileSync("bindings/js/src/version.ts", "utf8").match(/VERSION\s*=\s*"([^"]+)"/)?.[1], + "bindings/python/pyproject.toml": fs.readFileSync("bindings/python/pyproject.toml", "utf8").match(/^\s*version\s*=\s*"([^"]+)"/m)?.[1], + "bindings/python/src/shell_use/_config.py": fs.readFileSync("bindings/python/src/shell_use/_config.py", "utf8").match(/VERSION\s*=\s*"([^"]+)"/)?.[1], +}; + +for (const [file, version] of Object.entries(versions)) { + if (version !== expected) { + throw new Error(`${file} has version ${version}; expected ${expected}`); + } +} + +const nativeLoader = fs.readFileSync("bindings/js/native/index.js", "utf8"); +if (!nativeLoader.includes(`'${expected}'`)) { + throw new Error( + `bindings/js/native/index.js was not regenerated for ${expected}`, + ); +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5e9bf2..ab29f67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,49 +40,102 @@ jobs: key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo- - - run: cargo fmt --all -- --check - - run: cargo clippy --workspace --all-targets --all-features -- -D warnings - - run: cargo build - - run: cargo test --workspace - - uses: actions/setup-python@v5 with: - python-version: "3.x" + python-version: "3.11" + - uses: actions/setup-node@v4 with: node-version: "22" + cache: npm + cache-dependency-path: bindings/js/package-lock.json + + - run: cargo fmt --all -- --check + - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + - run: cargo build + - run: cargo test --workspace + + - name: Install Python binding + run: python -m pip install --disable-pip-version-check -e ./bindings/python + + - name: Test Python binding + run: python -m unittest discover -s bindings/python/tests -v + + - name: Install Node dependencies + run: npm ci --prefix bindings/js + + - name: Test Node binding + run: npm run test:node --prefix bindings/js + + - name: Verify generated Node loader + shell: bash + run: | + git diff --exit-code -- bindings/js/native/index.js bindings/js/native/index.d.ts + ! grep -q "JsonValue" bindings/js/native/index.d.ts + - uses: oven-sh/setup-bun@v2 + if: runner.os == 'Linux' with: bun-version: latest + - uses: denoland/setup-deno@v2 + if: runner.os == 'Linux' with: deno-version: v2.x - - name: Test Python bindings - working-directory: bindings/python - shell: bash - env: - PYTHONPATH: src - run: python -m unittest discover -s tests -v + - name: Test Node binding with Bun + if: runner.os == 'Linux' + continue-on-error: true + run: npm run test:bun --prefix bindings/js - - name: Setup JS - working-directory: bindings/js - shell: bash - run: | - npm ci - npm run build + - name: Test Node binding with Deno + if: runner.os == 'Linux' + continue-on-error: true + run: npm run test:deno --prefix bindings/js - - name: Test JS bindings (Node) - working-directory: bindings/js - shell: bash - run: npm run test:node + compatibility: + name: ${{ matrix.name }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: Python 3.8 + runtime: python + version: "3.8" + - name: Python current + runtime: python + version: "3.14" + - name: Node 20 + runtime: node + version: "20" + steps: + - uses: actions/checkout@v4 - - name: Test JS bindings (Bun) - working-directory: bindings/js - shell: bash - run: npm run test:bun + - run: | + rustup toolchain install stable --profile default + rustup default stable - - name: Test JS bindings (Deno) - working-directory: bindings/js - shell: bash - run: npm run test:deno + - uses: actions/setup-python@v5 + if: matrix.runtime == 'python' + with: + python-version: ${{ matrix.version }} + + - uses: actions/setup-node@v4 + if: matrix.runtime == 'node' + with: + node-version: ${{ matrix.version }} + cache: npm + cache-dependency-path: bindings/js/package-lock.json + + - name: Test Python compatibility + if: matrix.runtime == 'python' + run: | + python -m pip install --disable-pip-version-check -e ./bindings/python + python -m unittest discover -s bindings/python/tests -v + + - name: Test Node compatibility + if: matrix.runtime == 'node' + run: | + npm ci --prefix bindings/js + npm run test:node --prefix bindings/js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ac82d6..71702c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,19 +22,34 @@ concurrency: cancel-in-progress: false jobs: - build: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_TAG }} + + - uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Verify release versions + run: node .github/scripts/release/verify-versions.mjs + + build-cli: + needs: verify if: github.event_name == 'push' strategy: matrix: include: - target: x86_64-unknown-linux-gnu - os: ubuntu-latest + os: ubuntu-22.04 - target: aarch64-unknown-linux-gnu - os: ubuntu-latest + os: ubuntu-22.04 - target: x86_64-unknown-linux-musl - os: ubuntu-latest + os: ubuntu-22.04 - target: aarch64-unknown-linux-musl - os: ubuntu-latest + os: ubuntu-22.04 - target: x86_64-apple-darwin os: macos-latest - target: aarch64-apple-darwin @@ -69,11 +84,10 @@ jobs: sudo apt-get update sudo apt-get install -y musl-tools gcc-aarch64-linux-gnu - - run: cargo build --release --target ${{ matrix.target }} + - run: cargo build --release --locked -p shell-use-cli --target ${{ matrix.target }} env: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: aarch64-linux-gnu-gcc - RUSTFLAGS: ${{ contains(matrix.target, 'windows-msvc') && '-C target-feature=+crt-static' || '' }} - if: runner.os != 'Windows' run: | @@ -93,8 +107,39 @@ jobs: shell-use-*.tar.gz shell-use-*.zip - build-bindings: - runs-on: ubuntu-latest + build-node: + needs: verify + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + args: --target x86_64-unknown-linux-gnu --use-napi-cross + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + args: --target aarch64-unknown-linux-gnu --use-napi-cross + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + args: --target x86_64-unknown-linux-musl -x + zig: true + - target: aarch64-unknown-linux-musl + os: ubuntu-latest + args: --target aarch64-unknown-linux-musl -x + zig: true + - target: x86_64-apple-darwin + os: macos-latest + args: --target x86_64-apple-darwin + - target: aarch64-apple-darwin + os: macos-latest + args: --target aarch64-apple-darwin + - target: x86_64-pc-windows-msvc + os: windows-latest + args: --target x86_64-pc-windows-msvc + - target: aarch64-pc-windows-msvc + os: windows-latest + args: --target aarch64-pc-windows-msvc + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 with: @@ -106,78 +151,68 @@ jobs: cache: npm cache-dependency-path: bindings/js/package-lock.json - - uses: actions/setup-python@v5 + - run: | + rustup toolchain install stable --profile default + rustup default stable + rustup target add ${{ matrix.target }} + + - uses: mlugg/setup-zig@v2 + if: matrix.zig with: - python-version: "3.11" + version: "0.14.1" - - name: Verify release versions - shell: bash - run: | - node <<'NODE' - const fs = require("node:fs"); - - const expected = process.env.RELEASE_TAG.replace(/^v/, ""); - const versions = { - "Cargo.toml": fs.readFileSync("Cargo.toml", "utf8").match( - /^\s*version\s*=\s*"([^"]+)"/m, - )?.[1], - "bindings/js/package.json": JSON.parse( - fs.readFileSync("bindings/js/package.json", "utf8"), - ).version, - "bindings/js/src/version.ts": fs.readFileSync( - "bindings/js/src/version.ts", - "utf8", - ).match(/VERSION\s*=\s*"([^"]+)"/)?.[1], - "bindings/python/pyproject.toml": fs.readFileSync( - "bindings/python/pyproject.toml", - "utf8", - ).match(/^\s*version\s*=\s*"([^"]+)"/m)?.[1], - "bindings/python/src/shell_use/_config.py": fs.readFileSync( - "bindings/python/src/shell_use/_config.py", - "utf8", - ).match(/VERSION\s*=\s*"([^"]+)"/)?.[1], - }; - - for (const [file, version] of Object.entries(versions)) { - if (version !== expected) { - throw new Error(`${file} has version ${version}; expected ${expected}`); - } - } - NODE - - - name: Build npm packages - shell: bash - run: | - npm ci --prefix bindings/js - npm test --prefix bindings/js - - mkdir -p package-artifacts/npm - npm pack ./bindings/js --pack-destination package-artifacts/npm - node -e ' - const fs = require("node:fs"); - const packagePath = "bindings/js/package.json"; - const readmePath = "bindings/js/README.md"; - const pkg = JSON.parse(fs.readFileSync(packagePath, "utf8")); - pkg.name = "shell-use"; - fs.writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + "\n"); - fs.writeFileSync( - readmePath, - fs.readFileSync(readmePath, "utf8").replaceAll( - "@microsoft/shell-use", - "shell-use", - ), - ); - ' - npm pack ./bindings/js --pack-destination package-artifacts/npm - - - name: Build Python package + - uses: taiki-e/install-action@v2 + if: matrix.zig + with: + tool: cargo-zigbuild + + - run: npm ci + working-directory: bindings/js + + - name: Build Node addon + working-directory: bindings/js shell: bash - run: | - python -m unittest discover -s bindings/python/tests -v - python -m pip install --disable-pip-version-check build - python -m build bindings/python --outdir package-artifacts/pypi - env: - PYTHONPATH: bindings/python/src + run: npm run build:native -- ${{ matrix.args }} + + - uses: actions/upload-artifact@v4 + with: + name: node-${{ matrix.target }} + path: bindings/js/native/*.node + if-no-files-found: error + + package-node: + needs: build-node + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_TAG }} + + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + cache-dependency-path: bindings/js/package-lock.json + + - run: npm ci + working-directory: bindings/js + + - run: | + npm run build:ts + npm run create:npm-dirs + working-directory: bindings/js + + - uses: actions/download-artifact@v4 + with: + pattern: node-* + path: bindings/js/artifacts + merge-multiple: true + + - run: npm run artifacts + working-directory: bindings/js + + - name: Pack Node packages + run: node .github/scripts/release/package-node.mjs - uses: actions/upload-artifact@v4 with: @@ -185,31 +220,101 @@ jobs: path: package-artifacts/npm/*.tgz if-no-files-found: error + build-python: + needs: verify + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + compatibility: "2_28" + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + compatibility: "2_28" + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + compatibility: "musllinux_1_2" + - target: aarch64-unknown-linux-musl + os: ubuntu-latest + compatibility: "musllinux_1_2" + - target: x86_64-apple-darwin + os: macos-latest + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-pc-windows-msvc + os: windows-latest + - target: aarch64-pc-windows-msvc + os: windows-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_TAG }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - uses: docker/setup-qemu-action@v3 + if: runner.os == 'Linux' && contains(matrix.target, 'aarch64') + with: + platforms: arm64 + + - name: Build Python wheel + uses: PyO3/maturin-action@v1 + with: + command: build + working-directory: bindings/python + target: ${{ matrix.target }} + manylinux: ${{ matrix.compatibility || 'auto' }} + args: --release --locked --out dist --compatibility pypi + + - uses: actions/upload-artifact@v4 + with: + name: python-wheel-${{ matrix.target }} + path: bindings/python/dist/*.whl + if-no-files-found: error + + build-python-sdist: + needs: verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_TAG }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - run: python -m pip install --disable-pip-version-check "maturin>=1.5,<2" + + - name: Build Python sdist + working-directory: bindings/python + run: maturin sdist --manifest-path native/Cargo.toml --out dist + - uses: actions/upload-artifact@v4 with: - name: python-distributions - path: package-artifacts/pypi/* + name: python-sdist + path: bindings/python/dist/*.tar.gz if-no-files-found: error publish-npm: needs: - - build-bindings + - smoke-node - release - if: always() && needs.build-bindings.result == 'success' && (needs.release.result == 'success' || github.event_name == 'workflow_dispatch') + if: always() && needs.smoke-node.result == 'success' && (needs.release.result == 'success' || github.event_name == 'workflow_dispatch') runs-on: ubuntu-latest environment: npm permissions: contents: read id-token: write - strategy: - fail-fast: false - matrix: - include: - - package: shell-use - tarball-prefix: shell-use - - package: "@microsoft/shell-use" - tarball-prefix: microsoft-shell-use steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_TAG }} + - uses: actions/download-artifact@v4 with: name: npm-packages @@ -220,26 +325,15 @@ jobs: node-version: "24" registry-url: https://registry.npmjs.org - - name: Publish ${{ matrix.package }} to npm - shell: bash - run: | - version="${RELEASE_TAG#v}" - - if npm view "${{ matrix.package }}@${version}" version >/dev/null 2>&1; then - echo "${{ matrix.package }}@${version} is already published" - exit 0 - fi - - npm publish \ - "./npm-packages/${{ matrix.tarball-prefix }}-${version}.tgz" \ - --access public \ - --tag latest + - name: Publish native and root packages + run: node .github/scripts/release/publish-npm.mjs publish-pypi: needs: - - build-bindings + - smoke-python + - build-python-sdist - release - if: always() && needs.build-bindings.result == 'success' && (needs.release.result == 'success' || github.event_name == 'workflow_dispatch') + if: always() && needs.smoke-python.result == 'success' && needs.build-python-sdist.result == 'success' && (needs.release.result == 'success' || github.event_name == 'workflow_dispatch') runs-on: ubuntu-latest environment: name: pypi @@ -250,7 +344,13 @@ jobs: steps: - uses: actions/download-artifact@v4 with: - name: python-distributions + pattern: python-wheel-* + path: dist + merge-multiple: true + + - uses: actions/download-artifact@v4 + with: + name: python-sdist path: dist - name: Publish shell-use to PyPI @@ -260,8 +360,10 @@ jobs: release: needs: - - build - - build-bindings + - build-cli + - smoke-node + - smoke-python + - build-python-sdist if: github.event_name == 'push' runs-on: ubuntu-latest permissions: @@ -283,3 +385,43 @@ jobs: gh release create "$RELEASE_TAG" \ --generate-notes \ artifacts/* + + smoke-node: + needs: package-node + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_TAG }} + + - uses: actions/download-artifact@v4 + with: + name: npm-packages + path: npm-packages + + - uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Install packed root and platform packages + run: node .github/scripts/release/smoke-node.mjs + + smoke-python: + needs: build-python + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_TAG }} + + - uses: actions/download-artifact@v4 + with: + name: python-wheel-x86_64-unknown-linux-gnu + path: dist + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install built wheel + run: python .github/scripts/release/smoke-python.py diff --git a/.gitignore b/.gitignore index ea2a993..0287909 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ /target *.gif *.trace +*.node .shell-use/ +bindings/js/target +bindings/js/native/target +bindings/python/target +bindings/python/native/target \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 2b3f15f..e00cc8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,6 +149,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -242,6 +252,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -301,6 +320,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctor" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f" + [[package]] name = "cursor-icon" version = "1.2.0" @@ -394,6 +419,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flate2" version = "1.1.9" @@ -404,12 +435,94 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -489,6 +602,16 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libredox" version = "0.1.17" @@ -562,6 +685,66 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "napi" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f71d6bc097c4a6eb853c3f24991ab8c9f50f57d1f719e305175541482217e36" +dependencies = [ + "bitflags 2.13.1", + "ctor", + "futures", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "napi-build" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5282704fbe8d49b0cf8b08e3f33233416a528658f205c7e5ace63b582de0b11c" + +[[package]] +name = "napi-derive" +version = "3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9002b2940f0184444754546e0fcd15182f56948e6f381968b019d549387c42" +dependencies = [ + "convert_case", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "napi-derive-backend" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d60b5d773ad46c698c8cc2cd9fde0b283d39cbb7f71c04bee633c7bdba4423bd" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "semver", + "syn", +] + +[[package]] +name = "napi-sys" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85fbf1fa9f1babfe396d74bbbf52b3643770243e8f5b0b46715d4caf7f0dfc9a" +dependencies = [ + "libloading", +] + [[package]] name = "nix" version = "0.28.0" @@ -574,6 +757,12 @@ dependencies = [ "libc", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "once_cell" version = "1.21.4" @@ -646,6 +835,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + [[package]] name = "portable-pty" version = "0.9.0" @@ -676,6 +871,84 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pyo3" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" +dependencies = [ + "python3-dll-a", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "python3-dll-a" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d80ba7540edb18890d444c5aa8e1f1f99b1bdf26fb26ae383135325f4a36042b" +dependencies = [ + "cc", +] + +[[package]] +name = "pythonize" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b79f670c9626c8b651c0581011b57b6ba6970bb69faf01a7c4c0cfc81c43f95" +dependencies = [ + "pyo3", + "serde", +] + [[package]] name = "quote" version = "1.0.46" @@ -740,6 +1013,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustix" version = "0.38.44" @@ -789,6 +1068,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -878,6 +1163,7 @@ dependencies = [ "regex", "serde", "serde_json", + "sha2", "ttf-parser", ] @@ -896,12 +1182,39 @@ dependencies = [ "shell-use", ] +[[package]] +name = "shell-use-node" +version = "0.0.1-beta.5" +dependencies = [ + "napi", + "napi-build", + "napi-derive", + "serde_json", + "shell-use", +] + +[[package]] +name = "shell-use-python" +version = "0.0.1-beta.5" +dependencies = [ + "pyo3", + "pythonize", + "serde_json", + "shell-use", +] + [[package]] name = "shell-words" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.3.18" @@ -949,6 +1262,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.2" @@ -978,6 +1297,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "thiserror" version = "1.0.69" @@ -1018,6 +1343,15 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -1036,6 +1370,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unicode-width" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 7b23491..89f1c8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,7 @@ [workspace] members = [ + "bindings/js", + "bindings/python/native", "crates/shell-use", "crates/shell-use-cli", ] @@ -12,6 +14,7 @@ resolver = "2" [workspace.package] version = "0.0.1-beta.5" edition = "2021" +rust-version = "1.88" license = "MIT" repository = "https://github.com/microsoft/shell-use" diff --git a/README.md b/README.md index a6cc424..ac6b08a 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Each command returns a stable exit code (see [Exit codes](#exit-codes)), so an a ## Programmatic usage -`shell-use` has python & node client libraries that drive the daemon with the same commands as the cli. The `shell-use` binary still needs to be on your `PATH` (or pointed to with `SHELL_USE_BIN`). The clients manage the daemon for you, similar to the cli. +`shell-use` python & node client libraries that drive shell-use with the same commands as the cli. The clients manage the sessions for you without a daemon. ### Python ([`shell-use`](bindings/python/README.md)) @@ -104,14 +104,14 @@ async def main(): asyncio.run(main()) ``` -### Node / Deno / Bun ([`@microsoft/shell-use`](bindings/js/README.md)) +### Node ([`@microsoft/shell-use`](bindings/js/README.md)) ```sh npm install @microsoft/shell-use # Node 20+ -bun add @microsoft/shell-use # Bun +bun add @microsoft/shell-use # Bun (best effort) -deno add npm:@microsoft/shell-use # Deno 2 +deno add npm:@microsoft/shell-use # Deno 2 (best effort) ``` ```js @@ -126,7 +126,7 @@ await su.expectExitCode(0); await su.close(); ``` -> Note: On Windows, Deno requires all permissions (`-A` / `--allow-all`) instead of just `--allow-read --allow-write` due to the use of named pipes for IPC with the daemon. +Node is the supported runtime. Bun and Deno compatibility is best effort; Deno requires a local `node_modules` directory and `--allow-ffi` to load the native addon. ## Command reference @@ -277,7 +277,7 @@ re-fits the frame. | Command | Description | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `usage` | Compact command cheatsheet. | -| `agent-context` | Versioned JSON describing every command, flag, enum, default, and the exit-code taxonomy (generated from the CLI, so it can't drift). | +| `agent-context` | Versioned JSON describing every command, flag, enum, default, and the exit-code taxonomy (generated from the cli, so it can't drift). | | `skill` | Long-form workflow guide ([SKILL.md](SKILL.md)). | ### Exit codes diff --git a/SKILL.md b/SKILL.md index 717ba02..a0084da 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,12 +1,12 @@ --- name: shell-use -description: 'Drive, inspect, assert on, record, and watch a real terminal from the command line with the shell-use CLI. Use when running shells (bash, zsh, fish, PowerShell, pwsh, cmd, xonsh, elvish, nushell) or TUI programs (vim, less, top, etc.) in a headless PTY; sending keystrokes, key combos, or mouse input; resizing, writing raw bytes, or signaling the child; waiting for a command to finish or the screen to settle; asserting on terminal text, colors, exit codes, output, or snapshots; capturing text or full-color SVG screenshots; recording and replaying asciinema sessions; watching a live session while an agent drives it; or doing any of this from Python or Node with the shell-use bindings.' +description: 'Drive, inspect, assert on, record, and watch a real terminal from the command line with the shell-use cli. Use when running shells (bash, zsh, fish, PowerShell, pwsh, cmd, xonsh, elvish, nushell) or TUI programs (vim, less, top, etc.) in a headless PTY; sending keystrokes, key combos, or mouse input; resizing, writing raw bytes, or signaling the child; waiting for a command to finish or the screen to settle; asserting on terminal text, colors, exit codes, output, or snapshots; capturing text or full-color SVG screenshots; recording and replaying asciinema sessions; watching a live session while an agent drives it; or doing any of this from Python or Node with the shell-use bindings.' --- # shell-use `shell-use` controls a real terminal from the command line. It runs shells and -TUI programs in a headless PTY behind a background daemon: a stateless CLI front +TUI programs in a headless PTY behind a background daemon: a stateless cli front end talks to a daemon that owns the PTY and renders it into a full terminal emulator. Each call connects, acts, and exits, and they all share one live session. With it you can spawn a session, read the rendered screen, send keys @@ -18,7 +18,7 @@ session. Three commands let an agent look up the rest of the surface instead of guessing: - `shell-use agent-context`: versioned JSON describing every command, flag, - enum, default, and the exit-code taxonomy. It is generated from the CLI, so it + enum, default, and the exit-code taxonomy. It is generated from the cli, so it stays in sync. Read this first when you need exact argument shapes. - `shell-use usage`: a one-screen command cheatsheet. - `shell-use skill`: this guide. @@ -29,7 +29,7 @@ Three commands let an agent look up the rest of the surface instead of guessing: selects a terminal. The first command auto-starts that session's daemon; the session persists across calls until `close`. Sessions are independent. - **Stateless calls.** Each invocation connects to the daemon, acts, and exits. - State (screen, cwd, last command) lives in the daemon, not the CLI. + State (screen, cwd, last command) lives in the daemon, not the cli. - **JSON.** Pass `--json` on any command for machine-readable output. Data goes to stdout, diagnostics to stderr. On failure the JSON carries a `"kind"` (`assertion` / `usage` / `no_session` / `internal`). @@ -229,10 +229,10 @@ the commands the agent runs; resizing the window re-fits the frame. ## Programmatic use (Python, Node, Deno & Bun) Two client libraries drive the same daemon from code instead of the shell, with -methods that mirror the CLI command surface. Both are async and dependency-free, +methods that mirror the cli command surface. Both are async and dependency-free, and both need the `shell-use` binary on `PATH` (or pointed to with the `SHELL_USE_BIN` env var, or a `binary` argument). They start and reuse the daemon -exactly like the CLI, so a session opened from code can be watched with +exactly like the cli, so a session opened from code can be watched with `shell-use monitor` from another terminal. The JavaScript package is a single ESM module that runs on Node, Deno, and Bun; it imports only built-in modules, so it pulls in nothing extra on any of them. @@ -278,7 +278,7 @@ await su.close(); On Windows, Deno needs `-A` (`--allow-all`) rather than just `--allow-read --allow-write`, because the daemon IPC uses a named pipe. -Methods mirror the CLI commands: `open` / `run`, `submit` / `type` / `write`, +Methods mirror the cli commands: `open` / `run`, `submit` / `type` / `write`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `get` (plus shorthands `get_command` / `get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / `get_size`), diff --git a/bindings/js/.gitignore b/bindings/js/.gitignore index f4e2c6d..19f2db2 100644 --- a/bindings/js/.gitignore +++ b/bindings/js/.gitignore @@ -1,3 +1,6 @@ node_modules/ dist/ *.tsbuildinfo +native/*.node +npm/ +target/ diff --git a/bindings/js/Cargo.toml b/bindings/js/Cargo.toml new file mode 100644 index 0000000..923bf6f --- /dev/null +++ b/bindings/js/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "shell-use-node" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "napi-rs Node.js addon for the shell_use terminal engine" +repository.workspace = true +publish = false + +[lib] +name = "shell_use_node" +path = "native/lib.rs" +crate-type = ["cdylib", "rlib"] +test = false + +[dependencies] +napi = { version = "3", default-features = false, features = ["napi4", "dyn-symbols", "serde-json", "async"] } +napi-derive = "3" +serde_json.workspace = true +shell-use.workspace = true + +[build-dependencies] +napi-build = "2" diff --git a/bindings/js/README.md b/bindings/js/README.md index e29957b..c986ee8 100644 --- a/bindings/js/README.md +++ b/bindings/js/README.md @@ -1,8 +1,6 @@ # @microsoft/shell-use -A Node client for the [`shell-use`](https://github.com/microsoft/shell-use) terminal daemon. - -The `shell-use` binary must be on your `PATH` (or point to it with the `SHELL_USE_BIN` environment variable or the `binary` option). The client talks to the per-session daemon directly over its local socket (a named pipe on Windows, a Unix socket elsewhere) and starts the daemon automatically. + Node bindings for [`shell-use`](https://github.com/microsoft/shell-use); a terminal engine for driving and asserting on real shells and TUI programs. ## Install @@ -19,10 +17,8 @@ The package is only ESM ## Runtime Requirements - Node: 20+ -- Deno: 2 -- Bun: * - -> Note: On Windows, Deno requires all permissions (`-A` / `--allow-all`) instead of just `--allow-read --allow-write` due to the use of named pipes for IPC with the daemon. +- Bun: treated as best effort +- Deno: 2, treated as best effort. Requires a local `node_modules` directory (`deno install` / `--node-modules-dir`) and `--allow-ffi` (in addition to `--allow-read --allow-write`) ## Quick start @@ -40,33 +36,26 @@ await su.close(); ## Errors -Every failure maps to one of the daemon's exit codes: +Every failure maps to one of the engine's error kinds: | Class | `exitCode` | Meaning | | --- | --- | --- | | `ExpectationError` | 1 | an `expect`/`wait` condition was not met | | `UsageError` | 2 | invalid argument (e.g. a bad regex) | | `NoSessionError` | 3 | no active session | -| `DaemonError` | 4 | daemon could not be reached or started | -| `VersionMismatchError` | 4 | the daemon's version differs from this package | -| `InternalError` | 5 | internal daemon error | +| `InternalError` | 5 | internal engine error | All derive from `ShellUseError` and carry `kind` and `exitCode`. `waitX` and `expectX` reject with `ExpectationError` on failure. Assertion errors include the current visible terminal content. -On its first call, a client checks that the running daemon's version matches the -package version and throws `VersionMismatchError` if they differ. Stop the daemon -(`daemonStop`) so it restarts with the current binary, or point `SHELL_USE_BIN` -at a matching one. - ## API -`new ShellUse(session?, { binary?, home?, isolated?, timeouts?, artifacts? })` mirrors the CLI: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `get` (+ `getCommand` / `getOutput` / `getExitCode` / `getCwd` / `getCursor` / `getSize`), `screenshot`, `waitText` / `waitIdle` / `waitCommand` / `waitExit` / `waitReady`, `expectText` / `expectExitCode` / `expectOutput` / `expectSnapshot`, `close`, and `closeQuiet`. +`new ShellUse(session?, { timeouts?, artifacts? })` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `get` (+ `getCommand` / `getOutput` / `getExitCode` / `getCwd` / `getCursor` / `getSize`), `screenshot`, `waitText` / `waitIdle` / `waitCommand` / `waitExit` / `waitReady`, `expectText` / `expectExitCode` / `expectOutput` / `expectSnapshot`, `close`, and `closeQuiet`. -Module-level helpers: `sessions()`, `closeAll()`, `daemonStatus()`, `daemonStop()`, `getRecording()`, `uniqueSession()`. +Module-level helpers: `sessions()`, `closeAll()`, `getRecording()`, `uniqueSession()`. `open` and `run` accept `{ cols, rows, cwd, env, waitReady, retries, timeouts }`. The timeout classes are `text`, `idle`, `command`, `exit`, and `ready`; `timeouts` sets session defaults, the constructor sets client-wide ones. Unknown class names throw. -`isolated: true` gives the client a private daemon home, deleted on `close()`, and scopes `sessions()` to that client. `ShellUse.ephemeral(prefix?, opts?)` does the same with a unique session name. `artifacts: { dir, onFailure }` attaches the terminal contents to an `ExpectationError`. +`ShellUse.ephemeral(prefix?, opts?)` creates a client bound to a unique session name (via `uniqueSession()`), useful for parallel test workers that shouldn't collide. All sessions as process local. `artifacts: { dir, onFailure }` attaches the terminal contents to an `ExpectationError`. `@microsoft/shell-use/test` has helpers for terminal tests: `createTerminal`, `withTerminal`, `closeAllTracked`, `defaultShell`, and `terminalSnapshot`. @@ -80,13 +69,18 @@ await withTerminal({}, async (t) => { }); ``` -Each terminal is isolated and uniquely named, so parallel workers don't collide. `setTerminalDefaults(...)` sets suite-wide options (`binary`, `artifacts`, ...). +Each terminal is isolated and uniquely named, so parallel workers don't collide. `setTerminalDefaults(...)` sets suite-wide options (`artifacts`, `timeouts`, ...). + +## Cancellation and recordings + +Cancelling a promise does not cancel the underlying Rust operation. Operations for single sessoins wait for completion (ex: `close()`, `closeAll()`). + +Closing a session removes it from `sessions()`, but keeps its recording. `getRecording()` can read that recording for the rest of the +process. The 1024 most recently closed session have their recordings retained. ## Configuration | Variable | Purpose | | --- | --- | -| `SHELL_USE_BIN` | path to the `shell-use` binary | | `SHELL_USE_SESSION` | default session name | -| `SHELL_USE_HOME` | daemon state directory (sockets, pids) | | `SHELL_USE_TIMEOUT__MS` | fallback timeout for one class (`TEXT`, `IDLE`, `COMMAND`, `EXIT`, `READY`) | diff --git a/bindings/js/build.rs b/bindings/js/build.rs new file mode 100644 index 0000000..0f1b010 --- /dev/null +++ b/bindings/js/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/bindings/js/native/index.d.ts b/bindings/js/native/index.d.ts new file mode 100644 index 0000000..8ffb94e --- /dev/null +++ b/bindings/js/native/index.d.ts @@ -0,0 +1,15 @@ +/* auto-generated by NAPI-RS */ +/* eslint-disable */ +export declare class NativeSession { + constructor(name: string) + name(): string + request(payload: any): Promise +} + +export declare function closeAll(): Promise + +export declare function closeAllSync(): void + +export declare function recording(name: string): Promise + +export declare function sessions(): Promise> diff --git a/bindings/js/native/index.js b/bindings/js/native/index.js new file mode 100644 index 0000000..d6a634f --- /dev/null +++ b/bindings/js/native/index.js @@ -0,0 +1,598 @@ +// prettier-ignore +/* eslint-disable */ +// @ts-nocheck +/* auto-generated by NAPI-RS */ + +import { createRequire } from 'module' +const require = createRequire(import.meta.url) +const __dirname = new URL('.', import.meta.url).pathname + +const { readFileSync } = require('fs') +let nativeBinding = null +const loadErrors = [] + +const isMusl = () => { + let musl = false + if (process.platform === 'linux') { + musl = isMuslFromFilesystem() + if (musl === null) { + musl = isMuslFromReport() + } + if (musl === null) { + musl = isMuslFromChildProcess() + } + } + return musl +} + +const isFileMusl = (f) => f.includes('libc.musl-') || f.includes('ld-musl-') + +const isMuslFromFilesystem = () => { + try { + return readFileSync('/usr/bin/ldd', 'utf-8').includes('musl') + } catch { + return null + } +} + +const isMuslFromReport = () => { + let report = null + if (process.report && typeof process.report.getReport === 'function') { + process.report.excludeNetwork = true + report = process.report.getReport() + } + if (!report) { + return null + } + if (report.header && report.header.glibcVersionRuntime) { + return false + } + if (Array.isArray(report.sharedObjects)) { + if (report.sharedObjects.some(isFileMusl)) { + return true + } + } + return false +} + +const isMuslFromChildProcess = () => { + try { + return require('child_process').execSync('ldd --version', { encoding: 'utf8' }).includes('musl') + } catch (e) { + // If we reach this case, we don't know if the system is musl or not, so is better to just fallback to false + return false + } +} + +function requireNative() { + if (process.env.NAPI_RS_NATIVE_LIBRARY_PATH) { + try { + return require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH); + } catch (err) { + loadErrors.push(err) + } + } else if (process.platform === 'android') { + if (process.arch === 'arm64') { + try { + return require('./shell-use.android-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-android-arm64') + const bindingPackageVersion = require('@microsoft/shell-use-android-arm64/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm') { + try { + return require('./shell-use.android-arm-eabi.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-android-arm-eabi') + const bindingPackageVersion = require('@microsoft/shell-use-android-arm-eabi/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on Android ${process.arch}`)) + } + } else if (process.platform === 'win32') { + if (process.arch === 'x64') { + if ((process.config && process.config.variables && process.config.variables.shlib_suffix === 'dll.a') || (process.config && process.config.variables && process.config.variables.node_target_type === 'shared_library')) { + try { + return require('./shell-use.win32-x64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-win32-x64-gnu') + const bindingPackageVersion = require('@microsoft/shell-use-win32-x64-gnu/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./shell-use.win32-x64-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-win32-x64-msvc') + const bindingPackageVersion = require('@microsoft/shell-use-win32-x64-msvc/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'ia32') { + try { + return require('./shell-use.win32-ia32-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-win32-ia32-msvc') + const bindingPackageVersion = require('@microsoft/shell-use-win32-ia32-msvc/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm64') { + try { + return require('./shell-use.win32-arm64-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-win32-arm64-msvc') + const bindingPackageVersion = require('@microsoft/shell-use-win32-arm64-msvc/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on Windows: ${process.arch}`)) + } + } else if (process.platform === 'darwin') { + try { + return require('./shell-use.darwin-universal.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-darwin-universal') + const bindingPackageVersion = require('@microsoft/shell-use-darwin-universal/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + if (process.arch === 'x64') { + try { + return require('./shell-use.darwin-x64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-darwin-x64') + const bindingPackageVersion = require('@microsoft/shell-use-darwin-x64/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm64') { + try { + return require('./shell-use.darwin-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-darwin-arm64') + const bindingPackageVersion = require('@microsoft/shell-use-darwin-arm64/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on macOS: ${process.arch}`)) + } + } else if (process.platform === 'freebsd') { + if (process.arch === 'x64') { + try { + return require('./shell-use.freebsd-x64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-freebsd-x64') + const bindingPackageVersion = require('@microsoft/shell-use-freebsd-x64/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm64') { + try { + return require('./shell-use.freebsd-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-freebsd-arm64') + const bindingPackageVersion = require('@microsoft/shell-use-freebsd-arm64/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on FreeBSD: ${process.arch}`)) + } + } else if (process.platform === 'linux') { + if (process.arch === 'x64') { + if (isMusl()) { + try { + return require('./shell-use.linux-x64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-linux-x64-musl') + const bindingPackageVersion = require('@microsoft/shell-use-linux-x64-musl/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./shell-use.linux-x64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-linux-x64-gnu') + const bindingPackageVersion = require('@microsoft/shell-use-linux-x64-gnu/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'arm64') { + if (isMusl()) { + try { + return require('./shell-use.linux-arm64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-linux-arm64-musl') + const bindingPackageVersion = require('@microsoft/shell-use-linux-arm64-musl/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./shell-use.linux-arm64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-linux-arm64-gnu') + const bindingPackageVersion = require('@microsoft/shell-use-linux-arm64-gnu/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'arm') { + if (isMusl()) { + try { + return require('./shell-use.linux-arm-musleabihf.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-linux-arm-musleabihf') + const bindingPackageVersion = require('@microsoft/shell-use-linux-arm-musleabihf/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./shell-use.linux-arm-gnueabihf.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-linux-arm-gnueabihf') + const bindingPackageVersion = require('@microsoft/shell-use-linux-arm-gnueabihf/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'loong64') { + if (isMusl()) { + try { + return require('./shell-use.linux-loong64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-linux-loong64-musl') + const bindingPackageVersion = require('@microsoft/shell-use-linux-loong64-musl/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./shell-use.linux-loong64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-linux-loong64-gnu') + const bindingPackageVersion = require('@microsoft/shell-use-linux-loong64-gnu/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'riscv64') { + if (isMusl()) { + try { + return require('./shell-use.linux-riscv64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-linux-riscv64-musl') + const bindingPackageVersion = require('@microsoft/shell-use-linux-riscv64-musl/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./shell-use.linux-riscv64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-linux-riscv64-gnu') + const bindingPackageVersion = require('@microsoft/shell-use-linux-riscv64-gnu/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'ppc64') { + try { + return require('./shell-use.linux-ppc64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-linux-ppc64-gnu') + const bindingPackageVersion = require('@microsoft/shell-use-linux-ppc64-gnu/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 's390x') { + try { + return require('./shell-use.linux-s390x-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-linux-s390x-gnu') + const bindingPackageVersion = require('@microsoft/shell-use-linux-s390x-gnu/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on Linux: ${process.arch}`)) + } + } else if (process.platform === 'openharmony') { + if (process.arch === 'arm64') { + try { + return require('./shell-use.openharmony-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-openharmony-arm64') + const bindingPackageVersion = require('@microsoft/shell-use-openharmony-arm64/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'x64') { + try { + return require('./shell-use.openharmony-x64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-openharmony-x64') + const bindingPackageVersion = require('@microsoft/shell-use-openharmony-x64/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm') { + try { + return require('./shell-use.openharmony-arm.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@microsoft/shell-use-openharmony-arm') + const bindingPackageVersion = require('@microsoft/shell-use-openharmony-arm/package.json').version + if (bindingPackageVersion !== '0.0.1-beta.5' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.0.1-beta.5 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on OpenHarmony: ${process.arch}`)) + } + } else { + loadErrors.push(new Error(`Unsupported OS: ${process.platform}, architecture: ${process.arch}`)) + } +} + +nativeBinding = requireNative() + +// NAPI_RS_FORCE_WASI is a tri-state flag: +// unset / any other value → native binding preferred, WASI is only a fallback +// 'true' → force WASI fallback even if native loaded +// 'error' → force WASI and throw if no WASI binding is found +// Treating any non-empty string as truthy (the historical behavior) meant +// NAPI_RS_FORCE_WASI=false, NAPI_RS_FORCE_WASI=0, etc. inadvertently triggered +// the WASI path, causing ENOENT for packages shipped without a .wasi.cjs file. +const forceWasi = + process.env.NAPI_RS_FORCE_WASI === 'true' || process.env.NAPI_RS_FORCE_WASI === 'error' + +if (!nativeBinding || forceWasi) { + let wasiBinding = null + let wasiBindingError = null + try { + wasiBinding = require('./shell-use.wasi.cjs') + nativeBinding = wasiBinding + } catch (err) { + if (forceWasi) { + wasiBindingError = err + } + } + if (!nativeBinding || forceWasi) { + try { + wasiBinding = require('@microsoft/shell-use-wasm32-wasi') + nativeBinding = wasiBinding + } catch (err) { + if (forceWasi) { + if (!wasiBindingError) { + wasiBindingError = err + } else { + wasiBindingError.cause = err + } + loadErrors.push(err) + } + } + } + if (process.env.NAPI_RS_FORCE_WASI === 'error' && !wasiBinding) { + const error = new Error('WASI binding not found and NAPI_RS_FORCE_WASI is set to error') + error.cause = wasiBindingError + throw error + } +} + +if (!nativeBinding) { + if (loadErrors.length > 0) { + const error = new Error( + `Cannot find native binding. ` + + `npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). ` + + 'Please try `npm i` again after removing both package-lock.json and node_modules directory.', + ) + // assign instead of the `new Error(message, { cause })` options form, + // which Node < 16.9 silently ignores + error.cause = loadErrors.reduce((err, cur) => { + cur.cause = err + return cur + }) + throw error + } + throw new Error(`Failed to load native binding`) +} + +const { NativeSession, closeAll, closeAllSync, recording, sessions } = nativeBinding +export { NativeSession } +export { closeAll } +export { closeAllSync } +export { recording } +export { sessions } diff --git a/bindings/js/native/lib.rs b/bindings/js/native/lib.rs new file mode 100644 index 0000000..a374887 --- /dev/null +++ b/bindings/js/native/lib.rs @@ -0,0 +1,103 @@ +#![deny(clippy::all)] + +use napi::bindgen_prelude::{spawn_blocking, ToNapiValue, TypeName}; +use napi::{sys, Error, Result, Status, ValueType}; +use napi_derive::napi; +use serde_json::Value; +use shell_use::runtime::global_registry; + +fn internal_error(context: &str, error: impl std::fmt::Display) -> Error { + Error::new(Status::GenericFailure, format!("{context}: {error}")) +} + +// `Task::JsValue` requires `TypeName`, which `serde_json::Value` lacks. +pub struct JsonValue(Value); + +impl TypeName for JsonValue { + fn type_name() -> &'static str { + "unknown" + } + + fn value_type() -> ValueType { + ValueType::Unknown + } +} + +impl ToNapiValue for JsonValue { + unsafe fn to_napi_value(env: sys::napi_env, val: Self) -> Result { + unsafe { Value::to_napi_value(env, val.0) } + } +} + +#[napi] +pub struct NativeSession { + name: String, +} + +#[napi] +impl NativeSession { + #[napi(constructor)] + pub fn new(name: String) -> Self { + NativeSession { name } + } + + #[napi] + pub fn name(&self) -> String { + self.name.clone() + } + + #[napi(ts_return_type = "Promise")] + pub async fn request(&self, payload: Value) -> Result { + let name = self.name.clone(); + let output = spawn_blocking(move || { + let response = global_registry().response_value(&name, payload); + serde_json::to_value(response) + .map_err(|error| internal_error("failed to encode shell-use response", error)) + }) + .await + .map_err(|error| internal_error("native request task failed", error))??; + Ok(JsonValue(output)) + } +} + +#[napi] +pub async fn sessions() -> Result> { + spawn_blocking(|| global_registry().sessions()) + .await + .map_err(|error| internal_error("native sessions task failed", error)) +} + +#[napi] +pub async fn close_all() -> Result<()> { + spawn_blocking(|| { + global_registry().close_all(); + }) + .await + .map_err(|error| internal_error("native close task failed", error)) +} + +#[napi] +pub fn close_all_sync() { + global_registry().close_all(); +} + +#[napi] +pub async fn recording(name: String) -> Result { + spawn_blocking(move || { + global_registry().recording(&name).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Error::new( + Status::GenericFailure, + format!("no recording for session '{name}'"), + ) + } else { + internal_error( + &format!("failed to read the recording for session '{name}'"), + error, + ) + } + }) + }) + .await + .map_err(|error| internal_error("native recording task failed", error))? +} diff --git a/bindings/js/package-lock.json b/bindings/js/package-lock.json index b3a0fc5..d916085 100644 --- a/bindings/js/package-lock.json +++ b/bindings/js/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1-beta.5", "license": "MIT", "devDependencies": { + "@napi-rs/cli": "^3.7.4", "@types/node": "^20.14.0", "typescript": "^5.5.0" }, @@ -16,20 +17,927 @@ "node": ">=20" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/external-editor": "^3.0.3", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.5.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.1", + "@inquirer/confirm": "^6.1.1", + "@inquirer/editor": "^5.2.2", + "@inquirer/expand": "^5.1.1", + "@inquirer/input": "^5.1.2", + "@inquirer/number": "^4.1.1", + "@inquirer/password": "^5.1.1", + "@inquirer/rawlist": "^5.3.1", + "@inquirer/search": "^4.2.1", + "@inquirer/select": "^5.2.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@napi-rs/cli": { + "version": "3.7.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^8.5.2", + "@napi-rs/cross-toolchain": "^1.0.3", + "@napi-rs/wasm-tools": "^1.0.1", + "@octokit/rest": "^22.0.1", + "clipanion": "^4.0.0-rc.4", + "colorette": "^2.0.20", + "emnapi": "^1.11.1", + "es-toolkit": "^1.47.0", + "js-yaml": "^4.2.0", + "obug": "^2.1.2", + "semver": "^7.8.2", + "typanion": "^3.14.0" + }, + "bin": { + "napi": "dist/cli.js", + "napi-raw": "cli.mjs" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/runtime": "^1.7.1" + }, + "peerDependenciesMeta": { + "@emnapi/runtime": { + "optional": true + } + } + }, + "node_modules/@napi-rs/cross-toolchain": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "workspaces": [ + ".", + "arm64/*", + "x64/*" + ], + "dependencies": { + "@napi-rs/lzma": "^1.4.5", + "@napi-rs/tar": "^1.1.0", + "debug": "^4.4.1" + }, + "peerDependencies": { + "@napi-rs/cross-toolchain-arm64-target-aarch64": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-armv7": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-ppc64le": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-s390x": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-x86_64": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-aarch64": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-armv7": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-ppc64le": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-s390x": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-x86_64": "^1.0.3" + }, + "peerDependenciesMeta": { + "@napi-rs/cross-toolchain-arm64-target-aarch64": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-armv7": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-ppc64le": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-s390x": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-x86_64": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-aarch64": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-armv7": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-ppc64le": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-s390x": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-x86_64": { + "optional": true + } + } + }, + "node_modules/@napi-rs/lzma": { + "version": "1.5.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.20 || ^24.12 || >=25" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/lzma-android-arm-eabi": "1.5.1", + "@napi-rs/lzma-android-arm64": "1.5.1", + "@napi-rs/lzma-darwin-arm64": "1.5.1", + "@napi-rs/lzma-darwin-x64": "1.5.1", + "@napi-rs/lzma-freebsd-x64": "1.5.1", + "@napi-rs/lzma-linux-arm-gnueabihf": "1.5.1", + "@napi-rs/lzma-linux-arm64-gnu": "1.5.1", + "@napi-rs/lzma-linux-arm64-musl": "1.5.1", + "@napi-rs/lzma-linux-ppc64-gnu": "1.5.1", + "@napi-rs/lzma-linux-riscv64-gnu": "1.5.1", + "@napi-rs/lzma-linux-s390x-gnu": "1.5.1", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@napi-rs/lzma-linux-x64-musl": "1.5.1", + "@napi-rs/lzma-wasm32-wasi": "1.5.1", + "@napi-rs/lzma-win32-arm64-msvc": "1.5.1", + "@napi-rs/lzma-win32-ia32-msvc": "1.5.1", + "@napi-rs/lzma-win32-x64-msvc": "1.5.1" + } + }, + "node_modules/@napi-rs/lzma-win32-x64-msvc": { + "version": "1.5.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/tar": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/tar-android-arm-eabi": "1.1.1", + "@napi-rs/tar-android-arm64": "1.1.1", + "@napi-rs/tar-darwin-arm64": "1.1.1", + "@napi-rs/tar-darwin-x64": "1.1.1", + "@napi-rs/tar-freebsd-x64": "1.1.1", + "@napi-rs/tar-linux-arm-gnueabihf": "1.1.1", + "@napi-rs/tar-linux-arm64-gnu": "1.1.1", + "@napi-rs/tar-linux-arm64-musl": "1.1.1", + "@napi-rs/tar-linux-ppc64-gnu": "1.1.1", + "@napi-rs/tar-linux-s390x-gnu": "1.1.1", + "@napi-rs/tar-linux-x64-gnu": "1.1.1", + "@napi-rs/tar-linux-x64-musl": "1.1.1", + "@napi-rs/tar-wasm32-wasi": "1.1.1", + "@napi-rs/tar-win32-arm64-msvc": "1.1.1", + "@napi-rs/tar-win32-ia32-msvc": "1.1.1", + "@napi-rs/tar-win32-x64-msvc": "1.1.1" + } + }, + "node_modules/@napi-rs/tar-win32-x64-msvc": { + "version": "1.1.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.22.0" + }, + "optionalDependencies": { + "@napi-rs/wasm-tools-android-arm-eabi": "1.1.0", + "@napi-rs/wasm-tools-android-arm64": "1.1.0", + "@napi-rs/wasm-tools-darwin-arm64": "1.1.0", + "@napi-rs/wasm-tools-darwin-x64": "1.1.0", + "@napi-rs/wasm-tools-freebsd-x64": "1.1.0", + "@napi-rs/wasm-tools-linux-arm64-gnu": "1.1.0", + "@napi-rs/wasm-tools-linux-arm64-musl": "1.1.0", + "@napi-rs/wasm-tools-linux-x64-gnu": "1.1.0", + "@napi-rs/wasm-tools-linux-x64-musl": "1.1.0", + "@napi-rs/wasm-tools-wasm32-wasi": "1.1.0", + "@napi-rs/wasm-tools-win32-arm64-msvc": "1.1.0", + "@napi-rs/wasm-tools-win32-ia32-msvc": "1.1.0", + "@napi-rs/wasm-tools-win32-x64-msvc": "1.1.0" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-x64-msvc": { + "version": "1.1.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "content-type": "^2.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, "node_modules/@types/node": { "version": "20.19.43", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", - "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "dev": true, + "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/chardet": { + "version": "2.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-width": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/clipanion": { + "version": "4.0.0-rc.4", + "dev": true, + "license": "MIT", + "workspaces": [ + "website" + ], + "dependencies": { + "typanion": "^3.8.0" + }, + "peerDependencies": { + "typanion": "*" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "dev": true, + "license": "MIT" + }, + "node_modules/content-type": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/emnapi": { + "version": "1.11.3", + "dev": true, + "license": "MIT", + "peerDependencies": { + "node-addon-api": ">= 6.1.0" + }, + "peerDependenciesMeta": { + "node-addon-api": { + "optional": true + } + } + }, + "node_modules/es-toolkit": { + "version": "1.50.0", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types" + ] + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-with-bigint": { + "version": "3.5.10", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "license": "0BSD", + "optional": true, + "peer": true + }, + "node_modules/typanion": { + "version": "3.14.0", + "dev": true, + "license": "MIT", + "workspaces": [ + "website" + ] + }, "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" @@ -40,9 +948,13 @@ }, "node_modules/undici-types": { "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "dev": true, + "license": "ISC" } } } diff --git a/bindings/js/package.json b/bindings/js/package.json index 792b49b..9ebf948 100644 --- a/bindings/js/package.json +++ b/bindings/js/package.json @@ -1,7 +1,7 @@ { "name": "@microsoft/shell-use", "version": "0.0.1-beta.5", - "description": "Node client for the shell-use terminal daemon", + "description": "Node bindings for the shell_use terminal engine", "type": "module", "exports": { ".": { @@ -16,18 +16,39 @@ "types": "./dist/index.d.ts", "files": [ "dist", + "native/index.js", + "native/index.d.ts", + "native/*.node", "README.md" ], "engines": { "node": ">=20" }, "sideEffects": false, + "napi": { + "binaryName": "shell-use", + "packageName": "@microsoft/shell-use", + "targets": [ + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-musl" + ] + }, "scripts": { - "build": "tsc -p tsconfig.json", + "build:native": "napi build --platform --esm --release --strip --output-dir native --js index.js --dts index.d.ts", + "build:native:debug": "napi build --platform --esm --output-dir native --js index.js --dts index.d.ts", + "build:ts": "tsc -p tsconfig.json", + "build": "npm run build:native && npm run build:ts", + "create:npm-dirs": "napi create-npm-dirs", + "artifacts": "napi artifacts --output-dir artifacts --npm-dir npm", "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", - "prepublishOnly": "npm run clean && npm run build", - "test": "npm run build && node --test \"./test/**/*.test.mjs\"", - "test:node": "npm run build && node --test \"./test/**/*.test.mjs\"", + "test": "npm run test:node", + "test:node": "npm run build && node --test ./test/conformance.test.mjs ./test/integration.test.mjs ./test/options.test.mjs ./test/protocol.test.mjs ./test/helpers.test.mjs", "test:bun": "npm run build && bun test ./test/conformance.test.mjs && bun test ./test/integration.test.mjs && bun test ./test/options.test.mjs && bun test ./test/protocol.test.mjs && bun test ./test/helpers.test.mjs", "test:deno": "npm run build && deno test -A ./test", "test:all": "npm run test:node && npm run test:bun && npm run test:deno" @@ -51,6 +72,7 @@ }, "dependencies": {}, "devDependencies": { + "@napi-rs/cli": "^3.7.4", "@types/node": "^20.14.0", "typescript": "^5.5.0" } diff --git a/bindings/js/src/client.ts b/bindings/js/src/client.ts index f88b8ee..ca76e31 100644 --- a/bindings/js/src/client.ts +++ b/bindings/js/src/client.ts @@ -5,18 +5,15 @@ import { DEFAULT_COLS, DEFAULT_ROWS, assertTimeoutClasses, - resolveBinary, - resolveHome, resolveSession, resolveTimeout, timeoutsPayload, } from "./config.js"; import type { TimeoutClass } from "./config.js"; -import { createTempHome, removeTempHome, uniqueSession } from "./ephemeral.js"; -import { DaemonError, ExpectationError, NoSessionError } from "./errors.js"; +import { uniqueSession } from "./ephemeral.js"; +import { ExpectationError } from "./errors.js"; +import { NativeRuntime } from "./native.js"; import { envPairs, unwrap } from "./protocol.js"; -import * as transport from "./transport.js"; -import { checkVersion } from "./version.js"; import type { Cell, ClientOptions, @@ -49,7 +46,6 @@ export interface MouseButtonOptions { const TERMINAL_MARKER = "Terminal content:\n"; -/** Pulls boxed terminal content from assertion messages, dropping trailing newlines like the Python binding. */ function extractTerminalContent(message: string): string | undefined { const idx = message.indexOf(TERMINAL_MARKER); if (idx < 0) { @@ -140,67 +136,29 @@ class Mouse { export class ShellUse { readonly session: string; readonly mouse: Mouse; - #binary: string; - #home?: string; - #isolated: boolean; - #tempHomePath?: string; + #runtime: NativeRuntime; #options: ClientOptions; - #versionChecked = false; - #closed = false; #artifactCounter = 0; constructor(session?: string, opts: ClientOptions = {}) { this.session = resolveSession(session); - this.#binary = resolveBinary(opts.binary); - this.#isolated = opts.isolated ?? false; - if (!this.#isolated) { - this.#home = resolveHome(opts.home); - } if (opts.timeouts) { assertTimeoutClasses(opts.timeouts); } this.#options = opts; + this.#runtime = new NativeRuntime(this.session); this.mouse = new Mouse(this); } static ephemeral(prefix?: string, opts: ClientOptions = {}): ShellUse { - return new ShellUse(uniqueSession(prefix), { ...opts, isolated: true }); + return new ShellUse(uniqueSession(prefix), opts); } async send(payload: unknown): Promise { - const home = await this.#resolveHome(); - await this.#checkVersion(home); - const resp = await transport.request(this.session, home, this.#binary, payload); + const resp = await this.#runtime.request(payload); return unwrap(resp); } - async #resolveHome(): Promise { - if (!this.#isolated) { - return this.#home; - } - if (!this.#tempHomePath) { - this.#tempHomePath = await createTempHome(); - } - return this.#tempHomePath; - } - - #currentHome(): string | undefined { - return this.#isolated ? this.#tempHomePath : this.#home; - } - - async #cleanupTempHome(): Promise { - const dir = this.#tempHomePath; - if (!dir) { - return; - } - this.#tempHomePath = undefined; - try { - await removeTempHome(dir); - } catch { - /* best effort; the exit sweeper retries */ - } - } - #timeout(cls: TimeoutClass, callTimeout?: number): number | undefined { return resolveTimeout(cls, callTimeout, this.#options); } @@ -217,18 +175,6 @@ export class ShellUse { return payload; } - async #checkVersion(home: string | undefined): Promise { - if (this.#versionChecked) { - return; - } - const resp = await transport.request(this.session, home, this.#binary, { - kind: "status", - }); - const data = unwrap(resp) as { version?: string } | undefined; - checkVersion(data?.version); - this.#versionChecked = true; - } - async #guard(operation: string, action: () => Promise): Promise { try { return await action(); @@ -265,15 +211,12 @@ export class ShellUse { if (terminal.text !== undefined || terminal.screenshot !== undefined) { error.terminal = terminal; } - } catch { - /* best effort; never mask the original error */ - } + } catch {} } async #spawn(payload: Record, retries: number): Promise { let lastError: unknown; for (let attempt = 0; attempt <= retries; attempt++) { - this.#closed = false; try { return (await this.send(payload)) as OpenResult; } catch (error) { @@ -327,39 +270,13 @@ export class ShellUse { } async close(): Promise { - if (this.#closed) { - return; - } - this.#closed = true; - try { - if (!this.#isolated || this.#tempHomePath) { - const home = this.#currentHome(); - if (await transport.canConnect(this.session, home)) { - const resp = await transport.request( - this.session, - home, - this.#binary, - { kind: "close" }, - false, - ); - unwrap(resp); - } - } - } catch (error) { - if (!(error instanceof DaemonError) && !(error instanceof NoSessionError)) { - throw error; - } - } finally { - await this.#cleanupTempHome(); - } + unwrap(await this.#runtime.request({ kind: "close" })); } async closeQuiet(): Promise { try { await this.close(); - } catch { - /* swallow everything; safe for finally blocks */ - } + } catch {} } async type(text: string): Promise { diff --git a/bindings/js/src/config.ts b/bindings/js/src/config.ts index baae6dc..a88b7af 100644 --- a/bindings/js/src/config.ts +++ b/bindings/js/src/config.ts @@ -1,7 +1,3 @@ -import { createHash } from "node:crypto"; -import os from "node:os"; -import path from "node:path"; - import type { Timeouts } from "./types.js"; export const DEFAULT_COLS = 80; @@ -10,62 +6,10 @@ export const DEFAULT_ROWS = 30; export const IS_WINDOWS = process.platform === "win32"; export const IS_MACOS = process.platform === "darwin"; -const SOCKET_PATH_MAX = 100; -const SOCKET_DIGEST_HEX_LEN = 16; - export function resolveSession(session?: string): string { return session || process.env.SHELL_USE_SESSION || "default"; } -export function resolveBinary(binary?: string): string { - return binary || process.env.SHELL_USE_BIN || "shell-use"; -} - -export function resolveHome(home?: string): string | undefined { - return home || process.env.SHELL_USE_HOME || undefined; -} - -export function homeDir(home?: string): string { - return home || path.join(os.homedir(), ".shell-use"); -} - -export function socketPathIn(directory: string, session: string): string { - const candidate = path.join(directory, `${session}.sock`); - if (Buffer.byteLength(candidate) <= SOCKET_PATH_MAX) { - return candidate; - } - const digest = createHash("sha256").update(session, "utf8").digest("hex"); - return path.join(directory, `${digest.slice(0, SOCKET_DIGEST_HEX_LEN)}.sock`); -} - -export function socketPath(session: string, home?: string): string { - if (IS_WINDOWS) { - return `\\\\.\\pipe\\shell-use-${session}.sock`; - } - return socketPathIn(homeDir(home), session); -} - -function cacheDir(): string { - if (IS_WINDOWS) { - return process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"); - } - if (process.platform === "darwin") { - return path.join(os.homedir(), "Library", "Caches"); - } - return process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"); -} - -export function recordingDir(home?: string): string { - if (home) { - return path.join(home, "recordings"); - } - return path.join(cacheDir(), "shell-use"); -} - -export function recordingPath(session: string, home?: string): string { - return path.join(recordingDir(home), `${session}.cast`); -} - export type TimeoutClass = "text" | "idle" | "command" | "exit" | "ready"; const TIMEOUT_CLASSES: readonly TimeoutClass[] = [ @@ -76,7 +20,6 @@ const TIMEOUT_CLASSES: readonly TimeoutClass[] = [ "ready", ]; -/** Resolves a client-side timeout; returns `undefined` so callers omit `timeout_ms` and let the daemon decide. */ export function resolveTimeout( cls: TimeoutClass, callTimeout?: number, @@ -92,7 +35,6 @@ export function resolveTimeout( return undefined; } -/** Builds an open/run timeout payload; returns `undefined` when empty and throws on an unrecognised class. */ export function timeoutsPayload( timeouts?: Timeouts, ): Record | undefined { diff --git a/bindings/js/src/ephemeral.ts b/bindings/js/src/ephemeral.ts index 2476499..5818039 100644 --- a/bindings/js/src/ephemeral.ts +++ b/bindings/js/src/ephemeral.ts @@ -1,8 +1,3 @@ -import { rmSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - let sessionCounter = 0; export function uniqueSession(prefix?: string): string { @@ -12,34 +7,3 @@ export function uniqueSession(prefix?: string): string { const room = Math.max(1, 64 - suffix.length); return `${sanitized.slice(0, room)}${suffix}`; } - -const tempHomes = new Set(); -let sweeperRegistered = false; - -function registerTempHomeSweeper(): void { - if (sweeperRegistered) { - return; - } - sweeperRegistered = true; - process.on("exit", () => { - for (const dir of tempHomes) { - try { - rmSync(dir, { recursive: true, force: true }); - } catch { - /* best effort */ - } - } - }); -} - -export async function createTempHome(): Promise { - const dir = await mkdtemp(path.join(os.tmpdir(), "shell-use-")); - tempHomes.add(dir); - registerTempHomeSweeper(); - return dir; -} - -export async function removeTempHome(dir: string): Promise { - await rm(dir, { recursive: true, force: true }); - tempHomes.delete(dir); -} diff --git a/bindings/js/src/errors.ts b/bindings/js/src/errors.ts index 4d54884..d0bba82 100644 --- a/bindings/js/src/errors.ts +++ b/bindings/js/src/errors.ts @@ -1,12 +1,6 @@ import type { TerminalArtifact } from "./types.js"; -export type ErrorKind = - | "assertion" - | "usage" - | "no_session" - | "daemon" - | "version_mismatch" - | "internal"; +export type ErrorKind = "assertion" | "usage" | "no_session" | "internal"; export class ShellUseError extends Error { readonly kind: ErrorKind; @@ -39,18 +33,6 @@ export class NoSessionError extends ShellUseError { } } -export class DaemonError extends ShellUseError { - constructor(message: string) { - super(message, "daemon", 4); - } -} - -export class VersionMismatchError extends ShellUseError { - constructor(message: string) { - super(message, "version_mismatch", 4); - } -} - export class InternalError extends ShellUseError { constructor(message: string) { super(message, "internal", 5); @@ -65,8 +47,6 @@ export function makeError(kind: string | undefined, message: string): ShellUseEr return new UsageError(message); case "no_session": return new NoSessionError(message); - case "daemon": - return new DaemonError(message); default: return new InternalError(message); } diff --git a/bindings/js/src/index.ts b/bindings/js/src/index.ts index 3340719..09b37a7 100644 --- a/bindings/js/src/index.ts +++ b/bindings/js/src/index.ts @@ -5,21 +5,13 @@ export type { WaitTextOptions, } from "./client.js"; export { uniqueSession } from "./ephemeral.js"; +export { closeAll, getRecording, sessions } from "./sessions.js"; export { - closeAll, - daemonStatus, - daemonStop, - getRecording, - sessions, -} from "./sessions.js"; -export { - DaemonError, ExpectationError, InternalError, NoSessionError, ShellUseError, UsageError, - VersionMismatchError, } from "./errors.js"; export type { ErrorKind } from "./errors.js"; export { VERSION } from "./version.js"; @@ -29,8 +21,6 @@ export type { ClientOptions, Color, Cursor, - DaemonStatus, - HomeOptions, OpenResult, Shell, Size, diff --git a/bindings/js/src/native.ts b/bindings/js/src/native.ts new file mode 100644 index 0000000..36126e1 --- /dev/null +++ b/bindings/js/src/native.ts @@ -0,0 +1,93 @@ +import type { Response } from "./types.js"; + +export interface NativeSessionHandle { + name(): string; + request(payload: unknown): Promise; +} + +interface NativeBinding { + NativeSession: new (name: string) => NativeSessionHandle; + sessions(): Promise; + closeAll(): Promise; + closeAllSync(): void; + recording(name: string): Promise; +} + +let bindingPromise: Promise | undefined; +let cachedBinding: NativeBinding | undefined; +let exitHookInstalled = false; + +function installExitHook(): void { + if (exitHookInstalled) { + return; + } + exitHookInstalled = true; + process.once("exit", () => { + try { + cachedBinding?.closeAllSync(); + } catch {} + }); +} + +async function importBinding(): Promise { + try { + const module = await import("../native/index.js"); + const binding = module as unknown as NativeBinding; + cachedBinding = binding; + installExitHook(); + return binding; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + "failed to load the @microsoft/shell-use native addon: " + + `${message}. Build it with \`npm run build:native\` (requires a Rust ` + + "toolchain), or install a matching prebuilt platform package.", + { cause: error }, + ); + } +} + +async function loadBinding(): Promise { + if (!bindingPromise) { + bindingPromise = importBinding(); + } + try { + return await bindingPromise; + } catch (error) { + bindingPromise = undefined; + throw error; + } +} + +async function createSession(name: string): Promise { + const binding = await loadBinding(); + return new binding.NativeSession(name); +} + +export class NativeRuntime { + #session: Promise; + + constructor(name: string) { + this.#session = createSession(name); + } + + async request(payload: unknown): Promise { + const session = await this.#session; + return session.request(payload); + } +} + +export async function sessions(): Promise { + const binding = await loadBinding(); + return binding.sessions(); +} + +export async function closeAll(): Promise { + const binding = await loadBinding(); + await binding.closeAll(); +} + +export async function recording(name: string): Promise { + const binding = await loadBinding(); + return binding.recording(name); +} diff --git a/bindings/js/src/sessions.ts b/bindings/js/src/sessions.ts index ee04093..07653e3 100644 --- a/bindings/js/src/sessions.ts +++ b/bindings/js/src/sessions.ts @@ -1,76 +1,24 @@ -import { readFile, readdir } from "node:fs/promises"; - -import { homeDir, recordingPath, resolveBinary, resolveHome, resolveSession } from "./config.js"; +import { resolveSession } from "./config.js"; import { NoSessionError } from "./errors.js"; -import { unwrap } from "./protocol.js"; -import * as transport from "./transport.js"; -import type { DaemonStatus, HomeOptions } from "./types.js"; - -export async function sessions(opts: { home?: string } = {}): Promise { - const home = resolveHome(opts.home); - const dir = homeDir(home); - const out: string[] = []; - let entries: string[]; - try { - entries = await readdir(dir); - } catch { - return out; - } - for (const entry of entries.sort()) { - if (entry.endsWith(".pid")) { - const name = entry.slice(0, -4); - if (await transport.canConnect(name, home)) { - out.push(name); - } - } - } - return out; -} - -export async function closeAll(opts: HomeOptions = {}): Promise { - const home = resolveHome(opts.home); - const binary = resolveBinary(opts.binary); - for (const name of await sessions({ home })) { - try { - await transport.request(name, home, binary, { kind: "close" }, false); - } catch { - /* best effort */ - } - } -} +import * as native from "./native.js"; -export async function daemonStatus( - session?: string, - opts: HomeOptions = {}, -): Promise { - const s = resolveSession(session); - const home = resolveHome(opts.home); - const binary = resolveBinary(opts.binary); - return unwrap(await transport.request(s, home, binary, { kind: "status" })) as DaemonStatus; +export async function sessions(): Promise { + return native.sessions(); } -export async function daemonStop(session?: string, opts: HomeOptions = {}): Promise { - const s = resolveSession(session); - const home = resolveHome(opts.home); - const binary = resolveBinary(opts.binary); - if (!(await transport.canConnect(s, home))) { - return; - } - unwrap(await transport.request(s, home, binary, { kind: "shutdown" }, false)); +export async function closeAll(): Promise { + await native.closeAll(); } -export async function getRecording( - session?: string, - opts: { home?: string } = {}, -): Promise { - const s = resolveSession(session); - const home = resolveHome(opts.home); +export async function getRecording(session?: string): Promise { + const name = resolveSession(session); try { - return await readFile(recordingPath(s, home), "utf8"); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - throw new NoSessionError(`no recording for session '${s}'`); + return await native.recording(name); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes(`no recording for session '${name}'`)) { + throw new NoSessionError(`no recording for session '${name}'`); } - throw err; + throw error; } } diff --git a/bindings/js/src/test/index.ts b/bindings/js/src/test/index.ts index 204bfa9..174fb22 100644 --- a/bindings/js/src/test/index.ts +++ b/bindings/js/src/test/index.ts @@ -1,5 +1,5 @@ import { ShellUse } from "../client.js"; -import { IS_MACOS, IS_WINDOWS, resolveBinary } from "../config.js"; +import { IS_MACOS, IS_WINDOWS } from "../config.js"; import { uniqueSession } from "../ephemeral.js"; import type { ArtifactOptions, @@ -12,7 +12,6 @@ import type { export type { Shell } from "../types.js"; export { ShellUse } from "../client.js"; -/** Options accepted by {@link createTerminal}. */ export interface CreateTerminalOptions { shell?: Shell; program?: string[]; @@ -26,7 +25,6 @@ export interface CreateTerminalOptions { waitReady?: boolean; timeouts?: Timeouts; artifacts?: ArtifactOptions; - binary?: string; } let defaults: Partial = {}; @@ -74,8 +72,8 @@ export function trackedCount(): number { } function clientOptions(opts: CreateTerminalOptions): ClientOptions { - const client: ClientOptions = { isolated: true }; - for (const key of ["binary", "timeouts", "artifacts"] as const) { + const client: ClientOptions = {}; + for (const key of ["timeouts", "artifacts"] as const) { const value = opts[key]; if (value !== undefined) { Object.assign(client, { [key]: value }); @@ -95,7 +93,6 @@ function spawnOptions(opts: CreateTerminalOptions): SpawnOptions { return spawn; } -/** Creates and tracks a ready-to-drive terminal, using `run()` when `program` is given. */ export async function createTerminal( options: CreateTerminalOptions = {}, ): Promise { @@ -119,7 +116,6 @@ export async function createTerminal( return terminal; } -/** Creates a terminal for `fn`, then always closes and untracks it. */ export async function withTerminal( options: CreateTerminalOptions, fn: (terminal: ShellUse) => Promise | T, @@ -135,7 +131,6 @@ export async function withTerminal( export const defaultShell: Shell = IS_WINDOWS ? "powershell" : IS_MACOS ? "zsh" : "bash"; -/** Normalises terminal text for stable snapshots by trimming trailing whitespace and blank lines. */ export function terminalSnapshot(text: string): string { const lines = text.split("\n").map((line) => line.replace(/\s+$/u, "")); while (lines.length > 0 && lines[lines.length - 1] === "") { @@ -143,5 +138,3 @@ export function terminalSnapshot(text: string): string { } return lines.join("\n"); } - -export { resolveBinary }; diff --git a/bindings/js/src/transport.ts b/bindings/js/src/transport.ts deleted file mode 100644 index 0eddc7f..0000000 --- a/bindings/js/src/transport.ts +++ /dev/null @@ -1,130 +0,0 @@ -import net from "node:net"; -import { spawn } from "node:child_process"; - -import { socketPath } from "./config.js"; -import { DaemonError } from "./errors.js"; -import type { Response } from "./types.js"; - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function spawnDaemon( - binary: string, - args: string[], - env: NodeJS.ProcessEnv, -): Promise { - return new Promise((resolve, reject) => { - const child = spawn(binary, args, { env, stdio: "ignore", windowsHide: true }); - child.once("error", reject); - child.once("exit", () => resolve()); - }); -} - -function connect(target: string): Promise { - return new Promise((resolve, reject) => { - const sock = new net.Socket(); - const onError = (err: Error) => { - sock.destroy(); - reject(err); - }; - sock.once("error", onError); - sock.once("connect", () => { - sock.removeListener("error", onError); - resolve(sock); - }); - sock.connect(target); - }); -} - -export async function canConnect(session: string, home?: string): Promise { - try { - const sock = await connect(socketPath(session, home)); - sock.destroy(); - return true; - } catch { - return false; - } -} - -export async function ensureDaemon( - session: string, - home: string | undefined, - binary: string, -): Promise { - if (await canConnect(session, home)) { - return; - } - const env = { ...process.env }; - if (home) { - env.SHELL_USE_HOME = home; - } - try { - await spawnDaemon(binary, ["--session", session, "daemon", "start"], env); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - throw new DaemonError( - `could not find the '${binary}' binary on PATH; set SHELL_USE_BIN or pass { binary }`, - ); - } - throw new DaemonError(`failed to start daemon: ${(err as Error).message}`); - } - for (let i = 0; i < 100; i++) { - if (await canConnect(session, home)) { - return; - } - await delay(50); - } - throw new DaemonError(`daemon for session '${session}' did not become ready`); -} - -export async function request( - session: string, - home: string | undefined, - binary: string, - payload: unknown, - autostart = true, -): Promise { - if (autostart) { - await ensureDaemon(session, home, binary); - } - let sock: net.Socket; - try { - sock = await connect(socketPath(session, home)); - } catch (err) { - throw new DaemonError( - `could not connect to session '${session}': ${(err as Error).message}`, - ); - } - return new Promise((resolve, reject) => { - let buf = ""; - let settled = false; - sock.setEncoding("utf8"); - sock.on("data", (chunk: string) => { - buf += chunk; - const nl = buf.indexOf("\n"); - if (nl >= 0 && !settled) { - settled = true; - sock.destroy(); - try { - resolve(JSON.parse(buf.slice(0, nl)) as Response); - } catch (err) { - reject(new DaemonError(`invalid response from daemon: ${(err as Error).message}`)); - } - } - }); - sock.on("error", (err) => { - if (!settled) { - settled = true; - reject(new DaemonError((err as Error).message)); - } - }); - sock.on("close", () => { - if (!settled) { - settled = true; - reject(new DaemonError("daemon closed the connection without responding")); - } - }); - sock.write(JSON.stringify(payload) + "\n"); - }); -} diff --git a/bindings/js/src/types.ts b/bindings/js/src/types.ts index 9c5dfc5..f0cecdf 100644 --- a/bindings/js/src/types.ts +++ b/bindings/js/src/types.ts @@ -70,25 +70,12 @@ export interface State { } export interface OpenResult { - pid: number; shell_pid: number | null; session: string; ready: boolean; recording: string; } -export interface DaemonStatus { - session: string; - /** The daemon process, or `null` when no daemon is running. */ - pid: number | null; - shell_pid?: number | null; - cols?: number; - rows?: number; - shell?: string | null; - exited?: number | null; - log: string | null; -} - export interface Response { ok: boolean; data?: unknown; @@ -125,17 +112,6 @@ export interface ArtifactOptions { } export interface ClientOptions { - binary?: string; - /** Daemon state directory. Ignored when `isolated` is set. */ - home?: string; - /** Use a private daemon home, created on first use and removed on close. */ - isolated?: boolean; timeouts?: Timeouts; artifacts?: ArtifactOptions; } - -/** Module-level helper options; no `isolated` because a fresh private home cannot contain an existing daemon. */ -export interface HomeOptions { - binary?: string; - home?: string; -} diff --git a/bindings/js/src/version.ts b/bindings/js/src/version.ts index a9102e4..0ce3517 100644 --- a/bindings/js/src/version.ts +++ b/bindings/js/src/version.ts @@ -1,13 +1 @@ -import { VersionMismatchError } from "./errors.js"; - export const VERSION = "0.0.1-beta.5"; - -export function checkVersion(daemonVersion: string | undefined): void { - if (daemonVersion !== VERSION) { - throw new VersionMismatchError( - `shell-use version mismatch: client ${VERSION}, daemon ${daemonVersion ?? "unknown"}. ` + - "Ensure the shell-use binary matches the @microsoft/shell-use package version, " + - "or stop the daemon (daemonStop) so it restarts with the current binary.", - ); - } -} diff --git a/bindings/js/test/conformance.test.mjs b/bindings/js/test/conformance.test.mjs index f35493e..dd7b5ba 100644 --- a/bindings/js/test/conformance.test.mjs +++ b/bindings/js/test/conformance.test.mjs @@ -24,7 +24,6 @@ const MAPPING = { run: [["client", "run"]], close: [["client", "close"], ["module", "closeAll"]], sessions: [["module", "sessions"]], - daemon: [["module", "daemonStatus"], ["module", "daemonStop"]], state: [["client", "state"]], text: [["client", "text"]], screenshot: [["client", "screenshot"]], @@ -44,15 +43,15 @@ const MAPPING = { "get-recording": [["module", "getRecording"]], }; -const EXCLUDED = new Set(["monitor", "usage", "agent-context", "skill"]); +const EXCLUDED = new Set(["monitor", "status", "daemon", "usage", "agent-context", "skill"]); -test("every CLI command is mapped or excluded", { skip: !schema }, () => { +test("every cli command is mapped or excluded", { skip: !schema }, () => { const instance = new ShellUse("conformance"); for (const command of Object.keys(schema.commands)) { if (EXCLUDED.has(command)) { continue; } - assert.ok(MAPPING[command], `CLI command '${command}' has no SDK mapping`); + assert.ok(MAPPING[command], `cli command '${command}' has no SDK mapping`); for (const [scope, name] of MAPPING[command]) { const target = scope === "client" ? instance : sdk; assert.ok( @@ -67,6 +66,5 @@ test("error exit codes match the taxonomy", () => { assert.equal(new sdk.ExpectationError("x").exitCode, 1); assert.equal(new sdk.UsageError("x").exitCode, 2); assert.equal(new sdk.NoSessionError("x").exitCode, 3); - assert.equal(new sdk.DaemonError("x").exitCode, 4); assert.equal(new sdk.InternalError("x").exitCode, 5); }); diff --git a/bindings/js/test/helpers.test.mjs b/bindings/js/test/helpers.test.mjs index c642b85..e7945b6 100644 --- a/bindings/js/test/helpers.test.mjs +++ b/bindings/js/test/helpers.test.mjs @@ -13,9 +13,6 @@ import { withTerminal, } from "../dist/test/index.js"; -const BIN = process.env.SHELL_USE_BIN; -const skip = !BIN; - test("terminalSnapshot trims trailing whitespace per line", () => { assert.equal(terminalSnapshot("a \nb\t\nc"), "a\nb\nc"); }); @@ -94,7 +91,6 @@ test("untrackTerminal removes a terminal from the registry", async () => { test( "createTerminal + withTerminal drive a real shell", - { skip }, async () => { await closeAllTracked(); const marker = `helper-e2e-${process.pid}`; @@ -112,7 +108,6 @@ test( test( "createTerminal registers the terminal for automatic cleanup", - { skip }, async () => { await closeAllTracked(); const terminal = await createTerminal({ prefix: "helpers-track" }); @@ -129,7 +124,6 @@ test( test( "createTerminal can run a raw program", - { skip }, async () => { await closeAllTracked(); const evalArgs = diff --git a/bindings/js/test/integration.test.mjs b/bindings/js/test/integration.test.mjs index ea58ff7..5a12593 100644 --- a/bindings/js/test/integration.test.mjs +++ b/bindings/js/test/integration.test.mjs @@ -1,21 +1,28 @@ import assert from "node:assert/strict"; import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { test } from "node:test"; -import { ExpectationError, ShellUse, sessions, uniqueSession } from "../dist/index.js"; +import { + ExpectationError, + NoSessionError, + ShellUse, + UsageError, + getRecording, + sessions, + uniqueSession, +} from "../dist/index.js"; import { withTerminal } from "../dist/test/index.js"; -const BIN = process.env.SHELL_USE_BIN; -const skip = !BIN; const shell = process.platform === "win32" ? "pwsh" : undefined; const evalArgs = typeof globalThis.Deno === "undefined" ? ["-e", "console.log('ready'); setInterval(() => {}, 1000)"] : ["eval", "console.log('ready'); setInterval(() => {}, 1000)"]; -test("echo roundtrip drives a real session", { skip }, async () => { +test("echo roundtrip drives a real session", async () => { await withTerminal({ shell }, async (su) => { await su.submit("echo hello-sdk"); await su.waitCommand(); @@ -26,9 +33,18 @@ test("echo roundtrip drives a real session", { skip }, async () => { }); }); +test("cli control requests are rejected by native sessions", async () => { + await withTerminal({ shell }, async (session) => { + await assert.rejects( + session.send({ kind: "shutdown" }), + (error) => error instanceof UsageError, + ); + assert.ok((await session.state()).cols > 0); + }); +}); + test( "assertion errors include the current terminal", - { skip }, async () => { await withTerminal({ program: [process.execPath, ...evalArgs] }, async (su) => { await su.waitText("ready", { timeout: 2000 }); @@ -62,7 +78,76 @@ test( }, ); -test("sessions lists an open session", { skip }, async () => { +test("a blocking native wait runs off the JS event loop", async () => { + await withTerminal({ program: [process.execPath, ...evalArgs] }, async (su) => { + await su.waitText("ready", { timeout: 2000 }); + + const intervalMs = 10; + const timeoutMs = 300; + let ticks = 0; + const heartbeat = setInterval(() => { + ticks += 1; + }, intervalMs); + const start = Date.now(); + try { + await assert.rejects( + su.waitText("text-that-will-never-appear-xyz", { timeout: timeoutMs }), + (error) => error instanceof ExpectationError, + ); + } finally { + clearInterval(heartbeat); + } + const elapsed = Date.now() - start; + assert.ok( + elapsed >= timeoutMs, + `expected the wait to run for at least ${timeoutMs}ms, took ${elapsed}ms`, + ); + const expectedTicks = Math.floor(timeoutMs / intervalMs); + assert.ok( + ticks >= expectedTicks * 0.5, + `expected at least half of ~${expectedTicks} heartbeat ticks during the ` + + `blocking wait, got ${ticks}; the event loop appears to have stalled`, + ); + }); +}); + +test("concurrent waits do not starve filesystem work", async () => { + const root = mkdtempSync(join(tmpdir(), "shell-use-pool-")); + const terminals = Array.from( + { length: 6 }, + (_, index) => ShellUse.ephemeral(`pool-${index}`), + ); + try { + await Promise.all( + terminals.map((terminal) => terminal.run(process.execPath, evalArgs)), + ); + await Promise.all( + terminals.map((terminal) => terminal.waitText("ready", { timeout: 2000 })), + ); + + const waitStart = Date.now(); + const waits = terminals.map((terminal) => + assert.rejects( + terminal.waitText("text-that-will-never-appear-pool", { timeout: 800 }), + (error) => error instanceof ExpectationError, + ), + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const start = Date.now(); + await writeFile(join(root, "probe.txt"), "ready"); + const elapsed = Date.now() - start; + + assert.ok(elapsed < 400, `filesystem work was delayed by ${elapsed}ms`); + await Promise.all(waits); + assert.ok(Date.now() - waitStart < 2500); + } finally { + await Promise.all(terminals.map((terminal) => terminal.closeQuiet())); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("sessions lists an open session", async () => { const su = new ShellUse(uniqueSession("nodetest")); await su.open({ shell }); try { @@ -73,7 +158,43 @@ test("sessions lists an open session", { skip }, async () => { } }); -test("snapshot lands in the client cwd", { skip }, async () => { +test("close evicts the session and retains its recording", async () => { + const name = uniqueSession("recording"); + const session = new ShellUse(name); + await session.open({ shell }); + await session.submit("echo retained-recording"); + await session.waitCommand(); + await session.close(); + + assert.ok(!(await sessions()).includes(name)); + await assert.rejects(session.state(), (error) => error instanceof NoSessionError); + await assert.rejects( + session.send({ kind: "shutdown" }), + (error) => error instanceof UsageError, + ); + assert.match(await getRecording(name), /retained-recording/); + await assert.rejects( + getRecording(uniqueSession("missing-recording")), + (error) => error instanceof NoSessionError, + ); +}); + +test("any shared handle can close a reopened named session", async () => { + const name = uniqueSession("shared-close"); + const first = new ShellUse(name); + const second = new ShellUse(name); + try { + await first.open({ shell }); + await first.close(); + await second.open({ shell }); + await first.close(); + assert.ok(!(await sessions()).includes(name)); + } finally { + await second.closeQuiet(); + } +}); + +test("snapshot lands in the client cwd", async () => { const snapRoot = mkdtempSync(join(tmpdir(), "shell-use-snap-")); const name = `snap-${basename(snapRoot)}`; const original = process.cwd(); diff --git a/bindings/js/test/options.test.mjs b/bindings/js/test/options.test.mjs index b8b26bd..4425d18 100644 --- a/bindings/js/test/options.test.mjs +++ b/bindings/js/test/options.test.mjs @@ -1,16 +1,8 @@ import assert from "node:assert/strict"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; import { test } from "node:test"; import { ExpectationError, ShellUse, uniqueSession } from "../dist/index.js"; -import { - resolveHome, - resolveTimeout, - socketPathIn, - timeoutsPayload, -} from "../dist/config.js"; -import { createTempHome, removeTempHome } from "../dist/ephemeral.js"; +import { resolveTimeout, timeoutsPayload } from "../dist/config.js"; import { envPairs } from "../dist/protocol.js"; class CapturingClient extends ShellUse { @@ -60,15 +52,6 @@ function withEnv(vars, fn) { } } -test("long socket paths match the Rust and Python digest", () => { - const home = - "/var/folders/9k/hd3xzq_s0mn1c7b2v8t4wxyz0000gn/T/shell-use-Ab12Cd34"; - assert.equal( - socketPathIn(home, "helpers-track-54321-9f8e7d6c-1"), - join(home, "9ba800cbf25eaece.sock"), - ); -}); - test("resolveTimeout returns undefined when nothing is configured", () => { for (const cls of CLASSES) { assert.equal(resolveTimeout(cls), undefined, `expected ${cls} -> undefined`); @@ -83,7 +66,7 @@ test("resolveTimeout precedence: per-call beats timeouts[class] beats omitted", assert.equal(resolveTimeout("text", undefined, { timeouts: { idle: 333 } }), undefined); }); -test("resolveTimeout never reads an environment variable (daemon owns ranks 3-5)", () => { +test("resolveTimeout never reads an environment variable (engine owns ranks 3-5)", () => { const vars = Object.fromEntries(ALL_TIMEOUT_ENV_VARS.map((name) => [name, "1234"])); withEnv(vars, () => { for (const cls of CLASSES) { @@ -201,35 +184,20 @@ test("envPairs passes array form through and handles empty input", () => { assert.deepEqual(envPairs(), []); }); -test("close is idempotent and needs no daemon", async () => { +test("close is idempotent and needs no prior open", async () => { const su = new ShellUse(uniqueSession("close-idempotency")); await su.close(); await su.close(); await su.closeQuiet(); }); -test("`isolated` is a flag, so no home string can ever request a temp dir", () => { - assert.equal(resolveHome("temp"), "temp"); - withEnv({ SHELL_USE_HOME: "temp" }, () => { - assert.equal(resolveHome(undefined), "temp"); - }); -}); - -test("a private home is a real directory that is removed again", async () => { - const dir = await createTempHome(); - assert.ok(existsSync(dir), `expected ${dir} to exist`); - await removeTempHome(dir); - assert.ok(!existsSync(dir), `expected ${dir} to be removed`); -}); - -test("an isolated client closes cleanly without ever provisioning", async () => { +test("an ephemeral client closes cleanly without ever opening", async () => { const su = ShellUse.ephemeral("never-opened"); await su.close(); await su.close(); }); test("timeoutsPayload rejects an unknown class", () => { - // The daemon ignores fields it does not know, so a typo must not be sent. assert.throws(() => timeoutsPayload({ comand: 100 }), /comand/); }); diff --git a/bindings/js/test/protocol.test.mjs b/bindings/js/test/protocol.test.mjs index 8fc6c09..8fcaa44 100644 --- a/bindings/js/test/protocol.test.mjs +++ b/bindings/js/test/protocol.test.mjs @@ -1,8 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { ShellUse, VersionMismatchError } from "../dist/index.js"; -import { VERSION, checkVersion } from "../dist/version.js"; +import { ShellUse } from "../dist/index.js"; class CapturingClient extends ShellUse { constructor(...args) { @@ -105,15 +104,3 @@ test("expectText is strict by default and forwards colors", async () => { assert.equal(c.sent[0].fg, "#ff0000"); assert.ok(!("timeout_ms" in c.sent[0])); }); - -test("checkVersion passes when versions match", () => { - checkVersion(VERSION); -}); - -test("checkVersion throws on a version mismatch", () => { - assert.throws(() => checkVersion("9.9.9"), VersionMismatchError); -}); - -test("checkVersion throws when the daemon reports no version", () => { - assert.throws(() => checkVersion(undefined), VersionMismatchError); -}); diff --git a/bindings/python/.gitignore b/bindings/python/.gitignore index 81188b7..63182eb 100644 --- a/bindings/python/.gitignore +++ b/bindings/python/.gitignore @@ -4,3 +4,8 @@ build/ dist/ *.egg-info/ .pytest_cache/ +.venv/ +src/shell_use/_native*.pyd +src/shell_use/_native*.pdb +src/shell_use/_native*.so +src/shell_use/_native*.dylib diff --git a/bindings/python/README.md b/bindings/python/README.md index ad35116..f07bac9 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -1,8 +1,6 @@ # shell-use (Python) -A Python client for the [`shell-use`](https://github.com/microsoft/shell-use) terminal daemon. - -The `shell-use` binary must be on your `PATH` (or point to it with the `SHELL_USE_BIN` environment variable or the `binary=` argument). The client talks to the per-session daemon directly over its local socket (a named pipe on Windows, a Unix socket elsewhere) and starts the daemon automatically. +Python bindings for [`shell-use`](https://github.com/microsoft/shell-use); a terminal automation, inspection, assertion, and recording engine written in Rust. ## Install @@ -10,7 +8,7 @@ The `shell-use` binary must be on your `PATH` (or point to it with the `SHELL_US pip install shell-use ``` -Requires Python 3.8+. +Requires Python 3.8+. Wheels are published for common platforms via `maturin` ## Quick start @@ -43,33 +41,26 @@ async with ShellUse("vim-session") as su: ## Errors -Every failure maps to one of the daemon's exit codes: +Every failure maps to one of the engine's error kinds: | Exception | Exit code | Meaning | | --- | --- | --- | | `ExpectationError` | 1 | an `expect`/`wait` condition was not met | | `UsageError` | 2 | invalid argument (e.g. a bad regex) | | `NoSessionError` | 3 | no active session | -| `DaemonError` | 4 | daemon could not be reached or started | -| `VersionMismatchError` | 4 | the daemon's version differs from this package | -| `InternalError` | 5 | internal daemon error | +| `InternalError` | 5 | internal engine error | All derive from `ShellUseError`. `wait_*` and `expect_*` raise `ExpectationError` on failure. Assertion errors include the current visible terminal content. -On its first call, a client checks that the running daemon's version matches the -package version and raises `VersionMismatchError` if they differ. Stop the daemon -(`daemon_stop`) so it restarts with the current binary, or point `SHELL_USE_BIN` -at a matching one. - ## API -`ShellUse(session="default", *, binary=None, home=None, isolated=False, timeouts=None, artifacts=None)` mirrors the CLI: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `get` (+ `get_command` / `get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / `get_size`), `screenshot`, `wait_text` / `wait_idle` / `wait_command` / `wait_exit` / `wait_ready`, `expect_text` / `expect_exit_code` / `expect_output` / `expect_snapshot`, `close`, and `close_quiet`. +`ShellUse(session="default", *, timeouts=None, artifacts=None)` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `get` (+ `get_command` / `get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / `get_size`), `screenshot`, `wait_text` / `wait_idle` / `wait_command` / `wait_exit` / `wait_ready`, `expect_text` / `expect_exit_code` / `expect_output` / `expect_snapshot`, `close`, and `close_quiet`. -Module-level helpers: `sessions()`, `close_all()`, `daemon_status()`, `daemon_stop()`, `get_recording()`, `unique_session()`. +Module-level helpers: `sessions()`, `close_all()`, `get_recording()`, `unique_session()`. `open()` and `run()` accept `wait_ready=`, `retries=`, and `timeouts=`. The timeout classes are `text`, `idle`, `command`, `exit`, and `ready`; `timeouts=` sets session defaults, the constructor takes the same `Timeouts` (or a dict) as a client-wide default. Unknown class names raise. -`isolated=True` gives the client a private daemon home, deleted on `close()`, and scopes `sessions()` to that client. `ShellUse.ephemeral(prefix=None, **kwargs)` does the same with a unique session name. `artifacts={"dir": ..., "on_failure": ...}` attaches the terminal contents to an `ExpectationError`. +`ShellUse.ephemeral(prefix=None, **kwargs)` binds a client to a unique, process local session name. `artifacts={"dir": ..., "on_failure": ...}` attaches the terminal contents to an `ExpectationError`. `shell_use.testing` has helpers for terminal tests: `create_terminal`, `terminal` (an async context manager), `close_all_tracked`, `DEFAULT_SHELL`, and `terminal_snapshot`. @@ -83,13 +74,18 @@ async def test_echo(): await t.expect_text("hi") ``` -Each terminal is isolated and uniquely named, so parallel workers don't collide. `set_terminal_defaults(...)` sets suite-wide options (`binary`, `artifacts`, ...). +Each terminal is uniquely named, so parallel workers don't collide. `set_terminal_defaults(...)` sets suite-wide options (`timeouts`, `artifacts`, ...). + +## Cancellation and recordings + +Cancelling a promise does not cancel the underlying Rust operation. Operations for single sessoins wait for completion (ex: `close()`, `close_all()`). + +Closing a session removes it from `sessions()`, but keeps its recording. `get_recordings()` can read that recording for the rest of the +process. The 1024 most recently closed session have their recordings retained. ## Configuration | Variable | Purpose | | --- | --- | -| `SHELL_USE_BIN` | path to the `shell-use` binary | | `SHELL_USE_SESSION` | default session name | -| `SHELL_USE_HOME` | daemon state directory (sockets, pids) | | `SHELL_USE_TIMEOUT__MS` | fallback timeout for one class (`TEXT`, `IDLE`, `COMMAND`, `EXIT`, `READY`) | diff --git a/bindings/python/native/Cargo.toml b/bindings/python/native/Cargo.toml new file mode 100644 index 0000000..8e9aa86 --- /dev/null +++ b/bindings/python/native/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "shell-use-python" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "PyO3 bindings for the shell_use in-process terminal engine" +license.workspace = true +repository.workspace = true +publish = false + +[lib] +name = "_native" +crate-type = ["cdylib", "rlib"] +test = false + +[dependencies] +shell-use.workspace = true +pyo3 = { version = "0.28", features = ["abi3-py38", "generate-import-lib"] } +pythonize = "0.28" +serde_json.workspace = true + +[features] +extension-module = ["pyo3/extension-module"] diff --git a/bindings/python/native/src/lib.rs b/bindings/python/native/src/lib.rs new file mode 100644 index 0000000..6e32e98 --- /dev/null +++ b/bindings/python/native/src/lib.rs @@ -0,0 +1,74 @@ +use pyo3::exceptions::{PyFileNotFoundError, PyRuntimeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::PyModule; +use pythonize::{depythonize, pythonize}; +use shell_use::runtime::global_registry; + +#[pyclass(module = "shell_use._native", frozen)] +struct NativeSession { + name: String, +} + +#[pymethods] +impl NativeSession { + #[new] + fn new(name: String) -> Self { + NativeSession { name } + } + + #[getter] + fn name(&self) -> &str { + &self.name + } + + fn request<'py>(&self, py: Python<'py>, payload: Bound<'py, PyAny>) -> PyResult> { + let request: serde_json::Value = + depythonize(&payload).map_err(|e| PyValueError::new_err(e.to_string()))?; + let name = self.name.clone(); + let response = py.detach(move || global_registry().response_value(&name, request)); + let response = serde_json::to_value(&response) + .map_err(|e| PyRuntimeError::new_err(format!("failed to encode response: {e}")))?; + Ok(pythonize(py, &response) + .map_err(|e| PyRuntimeError::new_err(e.to_string()))? + .unbind()) + } + + fn recording(&self, py: Python<'_>) -> PyResult { + let name = self.name.clone(); + py.detach(move || global_registry().recording(&name)) + .map_err(|e| io_error_to_py(&e)) + } +} + +#[pyfunction] +fn sessions(py: Python<'_>) -> Vec { + py.detach(|| global_registry().sessions()) +} + +#[pyfunction] +fn close_all(py: Python<'_>) { + py.detach(|| global_registry().close_all()); +} + +#[pyfunction] +fn recording(py: Python<'_>, name: String) -> PyResult { + py.detach(move || global_registry().recording(&name)) + .map_err(|e| io_error_to_py(&e)) +} + +fn io_error_to_py(error: &std::io::Error) -> PyErr { + if error.kind() == std::io::ErrorKind::NotFound { + PyFileNotFoundError::new_err(error.to_string()) + } else { + PyRuntimeError::new_err(error.to_string()) + } +} + +#[pymodule] +fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_function(wrap_pyfunction!(sessions, m)?)?; + m.add_function(wrap_pyfunction!(close_all, m)?)?; + m.add_function(wrap_pyfunction!(recording, m)?)?; + Ok(()) +} diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index d5e3338..ec6d7c9 100644 --- a/bindings/python/pyproject.toml +++ b/bindings/python/pyproject.toml @@ -1,11 +1,11 @@ [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +requires = ["maturin>=1.5,<2.0"] +build-backend = "maturin" [project] name = "shell-use" version = "0.0.1-beta.5" -description = "Python client for the shell-use terminal daemon" +description = "Python bindings for shell-use terminal automation" readme = "README.md" requires-python = ">=3.8" license = { text = "MIT" } @@ -22,8 +22,8 @@ dependencies = [] Homepage = "https://github.com/microsoft/shell-use" Repository = "https://github.com/microsoft/shell-use" -[tool.hatch.build.targets.wheel] -packages = ["src/shell_use"] - -[tool.hatch.build.targets.sdist] -include = ["src/shell_use", "README.md"] +[tool.maturin] +manifest-path = "native/Cargo.toml" +module-name = "shell_use._native" +python-source = "src" +features = ["extension-module"] diff --git a/bindings/python/src/shell_use/__init__.py b/bindings/python/src/shell_use/__init__.py index 632e409..0247fac 100644 --- a/bindings/python/src/shell_use/__init__.py +++ b/bindings/python/src/shell_use/__init__.py @@ -2,23 +2,14 @@ from ._config import VERSION as __version__ from ._ephemeral import unique_session -from .client import ( - ShellUse, - close_all, - daemon_status, - daemon_stop, - get_recording, - sessions, -) +from .client import ShellUse, close_all, get_recording, sessions from .errors import ( - DaemonError, ExpectationError, InternalError, NoSessionError, ShellUseError, TerminalArtifact, UsageError, - VersionMismatchError, ) from .types import Cell, State, Timeouts @@ -26,16 +17,12 @@ "ShellUse", "sessions", "close_all", - "daemon_status", - "daemon_stop", "get_recording", "unique_session", "ShellUseError", "ExpectationError", "UsageError", "NoSessionError", - "DaemonError", - "VersionMismatchError", "InternalError", "TerminalArtifact", "Cell", diff --git a/bindings/python/src/shell_use/_config.py b/bindings/python/src/shell_use/_config.py index 1adafa0..c7bea8e 100644 --- a/bindings/python/src/shell_use/_config.py +++ b/bindings/python/src/shell_use/_config.py @@ -2,10 +2,8 @@ import collections.abc import dataclasses -import hashlib import os import sys -from pathlib import Path from typing import Dict, Mapping, Optional VERSION = "0.0.1-beta.5" @@ -16,60 +14,11 @@ IS_WINDOWS = sys.platform == "win32" IS_MACOS = sys.platform == "darwin" -_SOCKET_PATH_MAX = 100 -_SOCKET_DIGEST_HEX_LEN = 16 - def resolve_session(session: Optional[str]) -> str: return session or os.environ.get("SHELL_USE_SESSION") or "default" -def resolve_binary(binary: Optional[str]) -> str: - return binary or os.environ.get("SHELL_USE_BIN") or "shell-use" - - -def resolve_home(home: Optional[str]) -> Optional[str]: - return home or os.environ.get("SHELL_USE_HOME") or None - - -def home_dir(home: Optional[str]) -> Path: - return Path(home) if home else Path.home() / ".shell-use" - - -def _socket_path_in(directory: Path, session: str) -> Path: - candidate = directory / f"{session}.sock" - if len(os.fsencode(candidate)) <= _SOCKET_PATH_MAX: - return candidate - digest = hashlib.sha256(session.encode("utf-8")).hexdigest() - return directory / f"{digest[:_SOCKET_DIGEST_HEX_LEN]}.sock" - - -def socket_path(session: str, home: Optional[str]) -> str: - if IS_WINDOWS: - return rf"\\.\pipe\shell-use-{session}.sock" - return str(_socket_path_in(home_dir(home), session)) - - -def _cache_dir() -> Path: - if IS_WINDOWS: - base = os.environ.get("LOCALAPPDATA") - return Path(base) if base else Path.home() / "AppData" / "Local" - if sys.platform == "darwin": - return Path.home() / "Library" / "Caches" - xdg = os.environ.get("XDG_CACHE_HOME") - return Path(xdg) if xdg else Path.home() / ".cache" - - -def recording_dir(home: Optional[str]) -> Path: - if home: - return Path(home) / "recordings" - return _cache_dir() / "shell-use" - - -def recording_path(session: str, home: Optional[str]) -> Path: - return recording_dir(home) / f"{session}.cast" - - _TIMEOUT_CLASSES = ("text", "idle", "command", "exit", "ready") @@ -79,7 +28,6 @@ def resolve_timeout( call: Optional[int] = None, timeouts: Optional[Mapping[str, Optional[int]]] = None, ) -> Optional[int]: - """Resolve a client-side timeout; ``None`` means omit it so the daemon applies its own default.""" if call is not None: return call if timeouts is not None: @@ -88,7 +36,6 @@ def resolve_timeout( def normalize_timeouts(timeouts: object) -> Optional[Dict[str, Optional[int]]]: - """Coerce timeouts to a dict; unrecognised keys raise instead of being ignored by the daemon.""" if timeouts is None: return None if dataclasses.is_dataclass(timeouts) and not isinstance(timeouts, type): @@ -108,7 +55,6 @@ def normalize_timeouts(timeouts: object) -> Optional[Dict[str, Optional[int]]]: def session_timeouts_payload(timeouts: object) -> Optional[Dict[str, int]]: - """Build the session timeout payload, omitting unset fields so the daemon applies its own default.""" normalized = normalize_timeouts(timeouts) if not normalized: return None diff --git a/bindings/python/src/shell_use/_ephemeral.py b/bindings/python/src/shell_use/_ephemeral.py index c383bae..b8a34ec 100644 --- a/bindings/python/src/shell_use/_ephemeral.py +++ b/bindings/python/src/shell_use/_ephemeral.py @@ -1,13 +1,10 @@ from __future__ import annotations -import atexit import os import re import secrets -import shutil -import tempfile import threading -from typing import Optional, Set +from typing import Optional _session_counter = 0 _session_counter_lock = threading.Lock() @@ -24,40 +21,3 @@ def unique_session(prefix: Optional[str] = None) -> str: if max_prefix < 1: return (base + suffix)[:64] return (base[:max_prefix] + suffix)[:64] - - -_temp_homes = set() # type: Set[str] -_temp_homes_lock = threading.Lock() -_sweeper_registered = False - - -def _register_sweeper() -> None: - global _sweeper_registered - if _sweeper_registered: - return - _sweeper_registered = True - atexit.register(_sweep_temp_homes) - - -def provision_temp_home() -> str: - """Create and register a private temp directory to use as a daemon home.""" - path = tempfile.mkdtemp(prefix="shell-use-") - with _temp_homes_lock: - _temp_homes.add(path) - _register_sweeper() - return path - - -def remove_temp_home(path: str) -> None: - """Best-effort removal of a previously provisioned temp home.""" - with _temp_homes_lock: - _temp_homes.discard(path) - shutil.rmtree(path, ignore_errors=True) - - -def _sweep_temp_homes() -> None: - with _temp_homes_lock: - paths = list(_temp_homes) - _temp_homes.clear() - for path in paths: - shutil.rmtree(path, ignore_errors=True) diff --git a/bindings/python/src/shell_use/_native.pyi b/bindings/python/src/shell_use/_native.pyi new file mode 100644 index 0000000..eca38e3 --- /dev/null +++ b/bindings/python/src/shell_use/_native.pyi @@ -0,0 +1,21 @@ +from typing import Any, Dict + + +class NativeSession: + def __init__(self, name: str) -> None: ... + + @property + def name(self) -> str: ... + + def request(self, payload: Dict[str, Any]) -> Dict[str, Any]: ... + + def recording(self) -> str: ... + + +def sessions() -> list[str]: ... + + +def close_all() -> None: ... + + +def recording(name: str) -> str: ... diff --git a/bindings/python/src/shell_use/_transport.py b/bindings/python/src/shell_use/_transport.py deleted file mode 100644 index 736037c..0000000 --- a/bindings/python/src/shell_use/_transport.py +++ /dev/null @@ -1,104 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import os -from typing import Any, Dict, Optional, Tuple - -from . import _config as cfg -from .errors import DaemonError - -_Streams = Tuple[asyncio.StreamReader, asyncio.StreamWriter] - - -async def _open(session: str, home: Optional[str]) -> _Streams: - path = cfg.socket_path(session, home) - if cfg.IS_WINDOWS: - loop = asyncio.get_running_loop() - create = getattr(loop, "create_pipe_connection", None) - if create is None: - raise DaemonError( - "named-pipe client requires the Proactor event loop on Windows " - "(the default since Python 3.8)" - ) - reader = asyncio.StreamReader() - protocol = asyncio.StreamReaderProtocol(reader) - transport, _ = await create(lambda: protocol, path) - writer = asyncio.StreamWriter(transport, protocol, reader, loop) - return reader, writer - return await asyncio.open_unix_connection(path) - - -async def _close(writer: asyncio.StreamWriter) -> None: - writer.close() - try: - await writer.wait_closed() - except Exception: - pass - - -async def can_connect(session: str, home: Optional[str]) -> bool: - try: - _, writer = await _open(session, home) - except (FileNotFoundError, ConnectionRefusedError, OSError): - return False - await _close(writer) - return True - - -async def ensure_daemon(session: str, home: Optional[str], binary: str) -> None: - if await can_connect(session, home): - return - env = dict(os.environ) - if home: - env["SHELL_USE_HOME"] = home - try: - proc = await asyncio.create_subprocess_exec( - binary, - "--session", - session, - "daemon", - "start", - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL, - env=env, - ) - except FileNotFoundError: - raise DaemonError( - f"could not find the '{binary}' binary on PATH; " - "set SHELL_USE_BIN or pass binary=" - ) - await proc.wait() - for _ in range(100): - if await can_connect(session, home): - return - await asyncio.sleep(0.05) - raise DaemonError(f"daemon for session '{session}' did not become ready") - - -async def request( - session: str, - home: Optional[str], - binary: str, - payload: Dict[str, Any], - *, - autostart: bool = True, -) -> Dict[str, Any]: - if autostart: - await ensure_daemon(session, home, binary) - try: - reader, writer = await _open(session, home) - except (FileNotFoundError, ConnectionRefusedError, OSError) as e: - raise DaemonError(f"could not connect to session '{session}': {e}") - try: - writer.write(json.dumps(payload).encode("utf-8") + b"\n") - await writer.drain() - line = await reader.readline() - finally: - await _close(writer) - if not line: - raise DaemonError("daemon closed the connection without responding") - try: - return json.loads(line.decode("utf-8")) - except json.JSONDecodeError as e: - raise DaemonError(f"invalid response from daemon: {e}") diff --git a/bindings/python/src/shell_use/client.py b/bindings/python/src/shell_use/client.py index 2dd4c99..0f69cea 100644 --- a/bindings/python/src/shell_use/client.py +++ b/bindings/python/src/shell_use/client.py @@ -1,33 +1,37 @@ from __future__ import annotations +import asyncio +import atexit import os import time -from typing import Any, Dict, List, Optional, Union +from typing import Any, Callable, Dict, List, Optional, TypeVar from . import _config as cfg from . import _ephemeral as ephemeral -from . import _transport as transport +from . import _native as native from ._protocol import EnvLike, env_pairs, unwrap -from .errors import ( - DaemonError, - ExpectationError, - NoSessionError, - TerminalArtifact, - VersionMismatchError, -) +from .errors import ExpectationError, NoSessionError, TerminalArtifact from .types import Cell, State, Timeouts _TERMINAL_MARKER = "Terminal content:\n" +_T = TypeVar("_T") -def check_version(daemon_version: Optional[str]) -> None: - if daemon_version != cfg.VERSION: - raise VersionMismatchError( - f"shell-use version mismatch: client {cfg.VERSION}, daemon " - f"{daemon_version or 'unknown'}. Ensure the shell-use binary matches the " - "shell-use package version, or stop the daemon (daemon_stop) so it " - "restarts with the current binary." - ) + +async def _to_thread(func: Callable[..., _T], *args: Any) -> _T: + # asyncio.to_thread requires Python 3.9. + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, func, *args) + + +def _atexit_close_all() -> None: + try: + native.close_all() + except Exception: + pass + + +atexit.register(_atexit_close_all) def _extract_terminal_text(message: Optional[str]) -> Optional[str]: @@ -111,83 +115,36 @@ def __init__( self, session: Optional[str] = None, *, - binary: Optional[str] = None, - home: Optional[str] = None, - isolated: bool = False, timeouts: Optional[Timeouts] = None, artifacts: Optional[Dict[str, Any]] = None, ) -> None: self._session = cfg.resolve_session(session) - self._binary = cfg.resolve_binary(binary) - self._home_input = home - self._isolated = isolated - self._temp_home = None # type: Optional[str] - self._resolved_home = None # type: Optional[str] - self._home_ready = False + self._native = native.NativeSession(self._session) self._timeouts = cfg.normalize_timeouts(timeouts) self._artifacts = artifacts self._artifact_counter = 0 - self._version_checked = False - self._closed = False self.mouse = _Mouse(self) @classmethod def ephemeral(cls, prefix: Optional[str] = None, **kwargs: Any) -> "ShellUse": - """Return a client bound to a unique session and an isolated home.""" - kwargs["isolated"] = True return cls(ephemeral.unique_session(prefix), **kwargs) @property def session(self) -> str: return self._session - def _ensure_home(self) -> Optional[str]: - if self._home_ready: - return self._resolved_home - if self._isolated: - home = ephemeral.provision_temp_home() - self._temp_home = home - else: - home = cfg.resolve_home(self._home_input) - self._resolved_home = home - self._home_ready = True - return home - - def _cleanup_temp_home(self) -> None: - temp = self._temp_home - if temp is None: - return - self._temp_home = None - if self._resolved_home == temp: - self._resolved_home = None - self._home_ready = False - ephemeral.remove_temp_home(temp) - def _with_timeout( self, payload: Dict[str, Any], class_name: str, call: Optional[int] ) -> Dict[str, Any]: - """Omit ``timeout_ms`` when unset so the daemon applies the session default / env / built-in.""" value = cfg.resolve_timeout(class_name, call=call, timeouts=self._timeouts) if value is not None: payload["timeout_ms"] = value return payload async def send(self, payload: Dict[str, Any]) -> Any: - home = self._ensure_home() - await self._check_version(home) - resp = await transport.request(self._session, home, self._binary, payload) + resp = await _to_thread(self._native.request, payload) return unwrap(resp) - async def _check_version(self, home: Optional[str]) -> None: - if self._version_checked: - return - resp = await transport.request( - self._session, home, self._binary, {"kind": "status"} - ) - data = unwrap(resp) - check_version(data.get("version") if isinstance(data, dict) else None) - self._version_checked = True - async def _guarded(self, op_name: str, payload: Dict[str, Any]) -> Any: try: return await self.send(payload) @@ -238,7 +195,6 @@ async def _write_artifact_svg(self) -> Optional[str]: async def _spawn(self, payload: Dict[str, Any], retries: int) -> Dict[str, Any]: attempts = retries + 1 if retries > 0 else 1 for attempt in range(attempts): - self._closed = False try: return await self.send(payload) except Exception: @@ -304,24 +260,7 @@ async def run( return await self._spawn(payload, retries) async def close(self) -> None: - if self._closed: - return - if not self._home_ready and self._isolated: - # A private home that was never provisioned has no daemon to close. - self._closed = True - return - self._closed = True - home = self._ensure_home() - try: - if await transport.can_connect(self._session, home): - resp = await transport.request( - self._session, home, self._binary, {"kind": "close"}, autostart=False - ) - unwrap(resp) - except (DaemonError, NoSessionError): - pass - finally: - self._cleanup_temp_home() + await self.send({"kind": "close"}) async def close_quiet(self) -> None: try: @@ -502,68 +441,17 @@ async def __aexit__(self, *exc: Any) -> None: await self.close_quiet() -async def sessions(*, home: Optional[str] = None) -> List[str]: - h = cfg.resolve_home(home) - directory = cfg.home_dir(h) - out: List[str] = [] - if directory.is_dir(): - for entry in sorted(directory.iterdir()): - if entry.suffix == ".pid": - name = entry.stem - if await transport.can_connect(name, h): - out.append(name) - return out +async def sessions() -> List[str]: + return await _to_thread(native.sessions) -async def close_all(*, binary: Optional[str] = None, home: Optional[str] = None) -> None: - h = cfg.resolve_home(home) - b = cfg.resolve_binary(binary) - for name in await sessions(home=h): - try: - await transport.request(name, h, b, {"kind": "close"}, autostart=False) - except Exception: - pass +async def close_all() -> None: + await _to_thread(native.close_all) -async def daemon_status( - session: Optional[str] = None, - *, - binary: Optional[str] = None, - home: Optional[str] = None, -) -> Dict[str, Any]: - s = cfg.resolve_session(session) - h = cfg.resolve_home(home) - b = cfg.resolve_binary(binary) - return unwrap(await transport.request(s, h, b, {"kind": "status"})) - - -async def daemon_stop( - session: Optional[str] = None, - *, - binary: Optional[str] = None, - home: Optional[str] = None, -) -> None: - s = cfg.resolve_session(session) - h = cfg.resolve_home(home) - b = cfg.resolve_binary(binary) - if not await transport.can_connect(s, h): - return - unwrap(await transport.request(s, h, b, {"kind": "shutdown"}, autostart=False)) - - -async def get_recording( - session: Optional[str] = None, *, home: Optional[str] = None -) -> str: - import asyncio - - s = cfg.resolve_session(session) - h = cfg.resolve_home(home) - path = cfg.recording_path(s, h) - loop = asyncio.get_running_loop() +async def get_recording(session: Optional[str] = None) -> str: + name = cfg.resolve_session(session) try: - data = await loop.run_in_executor(None, path.read_bytes) + return await _to_thread(native.recording, name) except FileNotFoundError: - from .errors import NoSessionError - - raise NoSessionError(f"no recording for session '{s}'") - return data.decode("utf-8", errors="replace") + raise NoSessionError(f"no recording for session '{name}'") diff --git a/bindings/python/src/shell_use/errors.py b/bindings/python/src/shell_use/errors.py index fcad662..1f063c0 100644 --- a/bindings/python/src/shell_use/errors.py +++ b/bindings/python/src/shell_use/errors.py @@ -6,8 +6,6 @@ @dataclass class TerminalArtifact: - """Best-effort failure artifacts captured alongside an error.""" - text: Optional[str] = None screenshot: Optional[str] = None @@ -37,16 +35,6 @@ class NoSessionError(ShellUseError): exit_code = 3 -class DaemonError(ShellUseError): - kind = "daemon" - exit_code = 4 - - -class VersionMismatchError(ShellUseError): - kind = "version_mismatch" - exit_code = 4 - - class InternalError(ShellUseError): kind = "internal" exit_code = 5 @@ -56,11 +44,9 @@ class InternalError(ShellUseError): "assertion": ExpectationError, "usage": UsageError, "no_session": NoSessionError, - "daemon": DaemonError, "internal": InternalError, } def make_error(kind: Optional[str], message: str) -> ShellUseError: - """Construct the typed error for a daemon ``kind`` string.""" return _BY_KIND.get(kind or "", InternalError)(message) diff --git a/bindings/python/src/shell_use/testing.py b/bindings/python/src/shell_use/testing.py index 6b18536..702d4c4 100644 --- a/bindings/python/src/shell_use/testing.py +++ b/bindings/python/src/shell_use/testing.py @@ -1,14 +1,3 @@ -"""Helpers for writing terminal tests against a real shell. - - from shell_use.testing import terminal - - async def test_echo(): - async with terminal() as t: - await t.submit("echo hi") - await t.wait_command() - await t.expect_text("hi") -""" - from __future__ import annotations import asyncio @@ -27,7 +16,6 @@ async def test_echo(): Set, ) -from . import _ephemeral from ._config import IS_MACOS, IS_WINDOWS from ._ephemeral import unique_session from .client import ShellUse @@ -52,8 +40,6 @@ async def test_echo(): @dataclass class TerminalOptions: - """Options accepted by :func:`create_terminal`.""" - shell: Optional[str] = None program: Optional[Sequence[str]] = None cols: Optional[int] = None @@ -66,7 +52,6 @@ class TerminalOptions: wait_ready: Optional[bool] = None timeouts: Optional[Timeouts] = None artifacts: Optional[Dict[str, Any]] = None - binary: Optional[str] = None _DEFAULTABLE = frozenset(TerminalOptions.__dataclass_fields__) @@ -75,7 +60,6 @@ class TerminalOptions: def set_terminal_defaults(**values: Any) -> None: - """Merge process-wide defaults into every terminal created by :func:`create_terminal`.""" unknown = sorted(set(values) - _DEFAULTABLE) if unknown: raise TypeError( @@ -106,7 +90,6 @@ def _install_safety_net() -> None: if _safety_net_installed: return _safety_net_installed = True - _ephemeral._register_sweeper() atexit.register(_close_all_tracked_blocking) @@ -133,26 +116,22 @@ async def _close_quietly(terminals: Iterable[ShellUse]) -> None: def track_terminal(term: ShellUse) -> None: - """Register a terminal so the registry can close it on cleanup.""" with _tracked_lock: _tracked.add(term) _install_safety_net() def untrack_terminal(term: ShellUse) -> None: - """Stop tracking a terminal (e.g. after it was closed explicitly).""" with _tracked_lock: _tracked.discard(term) def tracked_count() -> int: - """Number of currently tracked terminals.""" with _tracked_lock: return len(_tracked) async def close_all_tracked() -> None: - """Close every tracked terminal with ``close_quiet()`` and forget them.""" with _tracked_lock: pending = list(_tracked) _tracked.clear() @@ -161,9 +140,7 @@ async def close_all_tracked() -> None: def _client_kwargs(opts: TerminalOptions) -> Dict[str, Any]: - kwargs = {"isolated": True} # type: Dict[str, Any] - if opts.binary is not None: - kwargs["binary"] = opts.binary + kwargs = {} # type: Dict[str, Any] if opts.timeouts is not None: kwargs["timeouts"] = opts.timeouts if opts.artifacts is not None: @@ -181,7 +158,6 @@ def _spawn_kwargs(opts: TerminalOptions) -> Dict[str, Any]: async def create_terminal(**options: Any) -> ShellUse: - """Create a started terminal and register it for automatic cleanup.""" per_call = TerminalOptions(**options) merged = dict(_defaults.__dict__) for key, value in per_call.__dict__.items(): @@ -208,7 +184,6 @@ async def create_terminal(**options: Any) -> ShellUse: @asynccontextmanager async def terminal(**options: Any) -> AsyncIterator[ShellUse]: - """Scoped :func:`create_terminal` that always closes the terminal.""" term = await create_terminal(**options) try: yield term @@ -221,7 +196,6 @@ async def terminal(**options: Any) -> AsyncIterator[ShellUse]: def terminal_snapshot(text: str) -> str: - """Normalise terminal text for stable snapshots.""" lines = [_TRAILING_WS.sub("", line) for line in text.split("\n")] while lines and lines[-1] == "": lines.pop() diff --git a/bindings/python/src/shell_use/types.py b/bindings/python/src/shell_use/types.py index 59fecf2..afe6d5e 100644 --- a/bindings/python/src/shell_use/types.py +++ b/bindings/python/src/shell_use/types.py @@ -10,8 +10,6 @@ @dataclass class Timeouts: - """Client-level default timeouts in milliseconds; unset fields are omitted so the daemon supplies them.""" - text: Optional[int] = None idle: Optional[int] = None command: Optional[int] = None diff --git a/bindings/python/tests/test_conformance.py b/bindings/python/tests/test_conformance.py index 9a24c5e..8919ba4 100644 --- a/bindings/python/tests/test_conformance.py +++ b/bindings/python/tests/test_conformance.py @@ -13,7 +13,6 @@ "run": [("client", "run")], "close": [("client", "close"), ("module", "close_all")], "sessions": [("module", "sessions")], - "daemon": [("module", "daemon_status"), ("module", "daemon_stop")], "state": [("client", "state")], "text": [("client", "text")], "screenshot": [("client", "screenshot")], @@ -33,7 +32,7 @@ "get-recording": [("module", "get_recording")], } -EXCLUDED = {"monitor", "usage", "agent-context", "skill"} +EXCLUDED = {"monitor", "usage", "agent-context", "skill", "daemon"} def _have_binary(): @@ -54,7 +53,7 @@ def test_every_command_is_mapped_or_excluded(self): for command in commands: if command in EXCLUDED: continue - self.assertIn(command, MAPPING, f"CLI command '{command}' has no SDK mapping") + self.assertIn(command, MAPPING, f"cli command '{command}' has no SDK mapping") instance = ShellUse("conformance") for scope, attr in MAPPING[command]: target = instance if scope == "client" else shell_use @@ -70,7 +69,6 @@ def test_exit_codes_match(self): self.assertEqual(shell_use.ExpectationError.exit_code, 1) self.assertEqual(shell_use.UsageError.exit_code, 2) self.assertEqual(shell_use.NoSessionError.exit_code, 3) - self.assertEqual(shell_use.DaemonError.exit_code, 4) self.assertEqual(shell_use.InternalError.exit_code, 5) self.assertIn("1", codes) self.assertIn("3", codes) diff --git a/bindings/python/tests/test_integration.py b/bindings/python/tests/test_integration.py index 8c5281c..2c80849 100644 --- a/bindings/python/tests/test_integration.py +++ b/bindings/python/tests/test_integration.py @@ -7,9 +7,16 @@ from pathlib import Path import shell_use -from shell_use import ExpectationError, ShellUse, testing, unique_session +from shell_use import ( + ExpectationError, + NoSessionError, + ShellUse, + UsageError, + get_recording, + testing, + unique_session, +) -BIN = os.environ.get("SHELL_USE_BIN") SHELL = "pwsh" if sys.platform == "win32" else None @@ -17,7 +24,6 @@ def run(coro): return asyncio.run(coro) -@unittest.skipUnless(BIN, "set SHELL_USE_BIN to the shell-use binary to run integration tests") class IntegrationTests(unittest.TestCase): def _client(self): return ShellUse.ephemeral("pytest") @@ -35,6 +41,16 @@ async def scenario(): run(scenario()) + def test_cli_control_requests_are_rejected(self): + async def scenario(): + async with self._client() as su: + await su.open(shell=SHELL) + with self.assertRaises(UsageError): + await su.send({"kind": "shutdown"}) + self.assertGreater((await su.state()).cols, 0) + + run(scenario()) + def test_expect_text_error_includes_terminal(self): async def scenario(): async with self._client() as su: @@ -59,6 +75,32 @@ async def scenario(): run(scenario()) + def test_blocking_wait_does_not_block_event_loop(self): + async def scenario(): + async with self._client() as su: + await su.open(shell=SHELL) + ticks = [] + stop = asyncio.Event() + + async def heartbeat(): + while not stop.is_set(): + ticks.append(asyncio.get_running_loop().time()) + await asyncio.sleep(0.01) + + heartbeat_task = asyncio.create_task(heartbeat()) + try: + with self.assertRaises(ExpectationError): + await su.wait_text( + "text-that-will-never-appear-on-screen", timeout=300 + ) + finally: + stop.set() + await heartbeat_task + + self.assertGreaterEqual(len(ticks), 5) + + run(scenario()) + def test_sessions_lists_open_session(self): async def scenario(): su = ShellUse(unique_session("pytest")) @@ -71,6 +113,42 @@ async def scenario(): run(scenario()) + def test_close_evicts_session_and_retains_recording(self): + async def scenario(): + name = unique_session("recording") + su = ShellUse(name) + await su.open(shell=SHELL) + await su.submit("echo retained-recording") + await su.wait_command() + await su.close() + + self.assertNotIn(name, await shell_use.sessions()) + with self.assertRaises(NoSessionError): + await su.state() + with self.assertRaises(UsageError): + await su.send({"kind": "shutdown"}) + self.assertIn("retained-recording", await get_recording(name)) + with self.assertRaises(NoSessionError): + await get_recording(unique_session("missing-recording")) + + run(scenario()) + + def test_any_shared_handle_can_close_a_reopened_named_session(self): + async def scenario(): + name = unique_session("shared-close") + first = ShellUse(name) + second = ShellUse(name) + try: + await first.open(shell=SHELL) + await first.close() + await second.open(shell=SHELL) + await first.close() + self.assertNotIn(name, await shell_use.sessions()) + finally: + await second.close_quiet() + + run(scenario()) + def test_snapshot_lands_in_client_cwd(self): async def scenario(): original = os.getcwd() @@ -88,8 +166,8 @@ async def scenario(): self.assertEqual(status, "written") created = Path(snap_root) / "__snapshots__" / f"{name}.snap" self.assertTrue(created.is_file()) - daemon_side = Path(original) / "__snapshots__" / f"{name}.snap" - self.assertFalse(daemon_side.exists()) + other_cwd = Path(original) / "__snapshots__" / f"{name}.snap" + self.assertFalse(other_cwd.exists()) self.assertEqual(await su.expect_snapshot(name), "passed") finally: os.chdir(original) @@ -99,7 +177,6 @@ async def scenario(): run(scenario()) -@unittest.skipUnless(BIN, "set SHELL_USE_BIN to the shell-use binary to run integration tests") class TestingHelperTests(unittest.TestCase): def tearDown(self): run(testing.close_all_tracked()) diff --git a/bindings/python/tests/test_options.py b/bindings/python/tests/test_options.py index d057012..275ed11 100644 --- a/bindings/python/tests/test_options.py +++ b/bindings/python/tests/test_options.py @@ -1,9 +1,6 @@ import asyncio -import os import re import unittest -from pathlib import Path -from unittest import mock from shell_use import _config as cfg from shell_use import _ephemeral as ephemeral @@ -16,28 +13,7 @@ def run(coro): return asyncio.run(coro) -class SocketPathTests(unittest.TestCase): - def test_short_path_keeps_the_session_name(self): - home = Path("/tmp/shell-use") - self.assertEqual( - cfg._socket_path_in(home, "work"), - home / "work.sock", - ) - - def test_long_path_matches_the_rust_and_javascript_digest(self): - home = Path( - "/var/folders/9k/hd3xzq_s0mn1c7b2v8t4wxyz0000gn/T/" - "shell-use-Ab12Cd34" - ) - self.assertEqual( - cfg._socket_path_in(home, "helpers-track-54321-9f8e7d6c-1"), - home / "9ba800cbf25eaece.sock", - ) - - class _CapturingClient(client.ShellUse): - """Records payloads instead of touching the transport.""" - def __init__(self, *a, **k): super().__init__(*a, **k) self.sent = [] @@ -319,78 +295,19 @@ async def boom(*a, **k): class CloseIdempotencyTests(unittest.TestCase): - def test_close_is_idempotent_without_daemon(self): + def test_close_is_idempotent_without_an_open_terminal(self): async def scenario(): - with mock.patch.object(client.transport, "can_connect") as cc: - async def _false(session, home): - return False - - cc.side_effect = _false - c = client.ShellUse("idem", home="ignored-dir") - await c.close() - await c.close() - await c.close_quiet() + c = client.ShellUse(ephemeral.unique_session("idem")) + await c.close() + await c.close() + await c.close_quiet() run(scenario()) - def test_unused_temp_home_close_is_noop(self): + def test_ephemeral_uses_a_unique_process_local_session(self): c = client.ShellUse.ephemeral("worker") - run(c.close()) - run(c.close()) - self.assertIsNone(c._temp_home) - - def test_close_only_talks_to_the_daemon_once(self): - calls = [] - - async def _connect(session, home): - calls.append(session) - return False - - async def scenario(): - with mock.patch.object(client.transport, "can_connect", _connect): - c = client.ShellUse("idem-once", home="ignored-dir") - await c.close() - await c.close() - - run(scenario()) - self.assertEqual(len(calls), 1) - - -class IsolatedHomeTests(unittest.TestCase): - def test_isolated_provisions_a_private_directory(self): - c = client.ShellUse("s", isolated=True) - home = c._ensure_home() - try: - self.assertIsNotNone(home) - self.assertEqual(home, c._temp_home) - self.assertTrue(os.path.isdir(home)) - finally: - c._cleanup_temp_home() - self.assertFalse(os.path.exists(home)) - - def test_a_directory_named_temp_is_just_a_path(self): - c = client.ShellUse("s", home="temp") - self.assertEqual(c._ensure_home(), "temp") - self.assertIsNone(c._temp_home) - - def test_shell_use_home_env_is_honoured_verbatim(self): - with mock.patch.dict("os.environ", {"SHELL_USE_HOME": "temp"}): - c = client.ShellUse("s") - self.assertEqual(c._ensure_home(), "temp") - self.assertIsNone(c._temp_home) - - def test_isolated_ignores_home(self): - c = client.ShellUse("s", home="ignored-dir", isolated=True) - home = c._ensure_home() - try: - self.assertNotEqual(home, "ignored-dir") - finally: - c._cleanup_temp_home() - - def test_ephemeral_is_isolated(self): - c = client.ShellUse.ephemeral("worker") - self.assertTrue(c._isolated) self.assertNotEqual(c.session, "default") + run(c.close()) class UnknownTimeoutClassTests(unittest.TestCase): diff --git a/bindings/python/tests/test_protocol.py b/bindings/python/tests/test_protocol.py index 068ce5b..a5a7394 100644 --- a/bindings/python/tests/test_protocol.py +++ b/bindings/python/tests/test_protocol.py @@ -1,14 +1,13 @@ import asyncio import unittest -from shell_use import __version__, _transport, client +from shell_use import client from shell_use._protocol import env_pairs, unwrap from shell_use.errors import ( ExpectationError, InternalError, NoSessionError, UsageError, - VersionMismatchError, ) @@ -138,18 +137,5 @@ def test_wait_command_omits_timeout_when_unset(self): self.assertEqual(c.sent[0], {"kind": "wait_command"}) -class VersionCheckTests(unittest.TestCase): - def test_matching_version_passes(self): - client.check_version(__version__) - - def test_mismatched_version_raises(self): - with self.assertRaises(VersionMismatchError): - client.check_version("9.9.9") - - def test_missing_version_raises(self): - with self.assertRaises(VersionMismatchError): - client.check_version(None) - - if __name__ == "__main__": unittest.main() diff --git a/bindings/python/tests/test_testing.py b/bindings/python/tests/test_testing.py index 3f6c9c9..39b8a2b 100644 --- a/bindings/python/tests/test_testing.py +++ b/bindings/python/tests/test_testing.py @@ -10,8 +10,6 @@ def run(coro): class _FakeTerminal: - """Stands in for a ShellUse in registry tests.""" - def __init__(self): self.closed = 0 @@ -34,7 +32,7 @@ def test_all_blank_collapses_to_empty(self): class DefaultShellTests(unittest.TestCase): - def test_matches_the_daemon_default(self): + def test_matches_the_engine_default(self): import sys if sys.platform == "win32": @@ -71,16 +69,12 @@ def test_untracking_an_unknown_terminal_is_safe(self): class SafetyNetTests(unittest.TestCase): - def test_registers_the_home_sweeper_before_the_terminal_closer(self): + def test_registers_the_terminal_closer_at_exit(self): calls = [] installed = testing._safety_net_installed testing._safety_net_installed = False try: with mock.patch.object( - testing._ephemeral, - "_register_sweeper", - side_effect=lambda: calls.append("sweeper"), - ), mock.patch.object( testing.atexit, "register", side_effect=lambda callback: calls.append(callback.__name__), @@ -89,7 +83,7 @@ def test_registers_the_home_sweeper_before_the_terminal_closer(self): finally: testing._safety_net_installed = installed - self.assertEqual(calls, ["sweeper", "_close_all_tracked_blocking"]) + self.assertEqual(calls, ["_close_all_tracked_blocking"]) class DefaultsTests(unittest.TestCase): @@ -97,12 +91,12 @@ def tearDown(self): testing.reset_terminal_defaults() def test_defaults_are_merged_and_reset(self): - testing.set_terminal_defaults(binary="/custom/shell-use", retries=5) + testing.set_terminal_defaults(retries=5, cols=101) defaults = testing.get_terminal_defaults() - self.assertEqual(defaults.binary, "/custom/shell-use") self.assertEqual(defaults.retries, 5) + self.assertEqual(defaults.cols, 101) testing.reset_terminal_defaults() - self.assertIsNone(testing.get_terminal_defaults().binary) + self.assertIsNone(testing.get_terminal_defaults().cols) def test_unknown_option_is_rejected(self): with self.assertRaises(TypeError) as raised: @@ -114,12 +108,10 @@ class OptionPlumbingTests(unittest.TestCase): def tearDown(self): testing.reset_terminal_defaults() - def test_every_terminal_is_isolated(self): - kwargs = testing._client_kwargs(testing.TerminalOptions()) - self.assertIs(kwargs["isolated"], True) - self.assertNotIn("home", kwargs) + def test_terminal_options_has_no_removed_process_fields(self): self.assertFalse(hasattr(testing.TerminalOptions(), "home")) self.assertFalse(hasattr(testing.TerminalOptions(), "isolated")) + self.assertFalse(hasattr(testing.TerminalOptions(), "binary")) def test_retries_default_to_two(self): self.assertEqual(testing._spawn_kwargs(testing.TerminalOptions())["retries"], 2) @@ -147,12 +139,12 @@ async def open(self, **kwargs): async def close_quiet(self): pass - testing.set_terminal_defaults(cols=100, binary="/from/defaults") + testing.set_terminal_defaults(cols=100, artifacts={"dir": "from-defaults"}) with mock.patch.object(testing, "ShellUse", FakeShellUse), \ mock.patch.object(testing, "track_terminal"): run(testing.create_terminal(cols=42)) self.assertEqual(created[0].open_kwargs["cols"], 42) - self.assertEqual(created[0].kwargs["binary"], "/from/defaults") + self.assertEqual(created[0].kwargs["artifacts"], {"dir": "from-defaults"}) def test_unknown_create_option_is_rejected(self): with self.assertRaises(TypeError): diff --git a/crates/shell-use-cli/Cargo.toml b/crates/shell-use-cli/Cargo.toml index 7bb2482..8244d3b 100644 --- a/crates/shell-use-cli/Cargo.toml +++ b/crates/shell-use-cli/Cargo.toml @@ -2,6 +2,7 @@ name = "shell-use-cli" version.workspace = true edition.workspace = true +rust-version.workspace = true description = "The shell-use command-line interface and per-session daemon" license.workspace = true repository.workspace = true diff --git a/crates/shell-use-cli/src/agent_context.rs b/crates/shell-use-cli/src/agent_context.rs index b6dc60a..242deb9 100644 --- a/crates/shell-use-cli/src/agent_context.rs +++ b/crates/shell-use-cli/src/agent_context.rs @@ -1,4 +1,4 @@ -//! Machine-readable description of the full CLI surface, generated from the +//! Machine-readable description of the full cli surface, generated from the //! clap command model so it can never drift from the real implementation. //! Printed by `shell-use agent-context`. diff --git a/crates/shell-use-cli/src/cli.rs b/crates/shell-use-cli/src/cli.rs index e7198d9..e8b5538 100644 --- a/crates/shell-use-cli/src/cli.rs +++ b/crates/shell-use-cli/src/cli.rs @@ -68,7 +68,7 @@ impl From for TimeoutDefaults { } #[derive(Parser)] -#[command(name = "shell-use", version, about = "Headless terminal CLI + daemon")] +#[command(name = "shell-use", version, about = "Headless terminal cli + daemon")] pub struct Cli { /// Target a named session (env: SHELL_USE_SESSION). #[arg(long, global = true)] @@ -270,10 +270,10 @@ pub enum Command { Monitor, /// Print a compact command cheatsheet for agents. Usage, - /// Print a machine-readable description of the full CLI surface (JSON). + /// Print a machine-readable description of the full cli surface (JSON). /// /// Versioned via `schema_version`; lists every command, flag, type, enum, - /// default, and the exit-code taxonomy. Generated from the CLI definition. + /// default, and the exit-code taxonomy. Generated from the cli definition. AgentContext, /// Print or install the long-form agent skill manifest (SKILL.md). Skill { diff --git a/crates/shell-use-cli/src/daemon.rs b/crates/shell-use-cli/src/daemon.rs index 83a472d..640c60a 100644 --- a/crates/shell-use-cli/src/daemon.rs +++ b/crates/shell-use-cli/src/daemon.rs @@ -1,4 +1,4 @@ -//! CLI daemon host: local socket listener, idle watchdog, monitor streaming, +//! cli daemon host: local socket listener, idle watchdog, monitor streaming, //! and process state files around the reusable in-process engine. use std::io::Write; diff --git a/crates/shell-use-cli/src/ipc.rs b/crates/shell-use-cli/src/ipc.rs index 4183ec3..859da47 100644 --- a/crates/shell-use-cli/src/ipc.rs +++ b/crates/shell-use-cli/src/ipc.rs @@ -1,4 +1,4 @@ -//! CLI ↔ daemon transport over an `interprocess` local socket. +//! cli ↔ daemon transport over an `interprocess` local socket. //! One JSON request line per connection, one JSON response line back. use std::io::{BufRead, BufReader, Read, Write}; diff --git a/crates/shell-use-cli/src/main.rs b/crates/shell-use-cli/src/main.rs index 554af0e..245e401 100644 --- a/crates/shell-use-cli/src/main.rs +++ b/crates/shell-use-cli/src/main.rs @@ -641,7 +641,7 @@ fn compact(value: &serde_json::Value) -> String { } fn usage_text() -> &'static str { - "shell-use: headless terminal CLI + daemon\n\ + "shell-use: headless terminal cli + daemon\n\ \n\ SESSION open [--shell S] [--cols N --rows N] [--cwd D] [--env K=V]\n\ run [args...]\n\ @@ -659,7 +659,7 @@ EXPECT expect text \"T\" [--regex --full --not --fg C --bg C --timeout MS]\n\ RECORD sessions auto-record; get-recording [session] > out.cast (asciinema v2)\n\ play with `asciinema play out.cast`, render GIF with `agg out.cast out.gif`\n\ WATCH monitor (live full-color view in another terminal; q/Esc/Ctrl-C to detach)\n\ -AGENT agent-context (JSON CLI schema) | skill [--add] (workflow guide)\n\ +AGENT agent-context (JSON cli schema) | skill [--add] (workflow guide)\n\ GLOBAL --session NAME | --json | --verbose (log PTY traffic to ~/.shell-use/.log)\n\ EXIT 0 ok | 1 assertion/wait failed | 2 usage | 3 no session | 4 daemon/IPC | 5 internal\n\ " diff --git a/crates/shell-use-cli/tests/session_lifecycle.rs b/crates/shell-use-cli/tests/session_lifecycle.rs index e67633d..7e2e6f0 100644 --- a/crates/shell-use-cli/tests/session_lifecycle.rs +++ b/crates/shell-use-cli/tests/session_lifecycle.rs @@ -1,4 +1,4 @@ -//! End-to-end coverage for session lifecycle over the real CLI + daemon. +//! End-to-end coverage for session lifecycle over the real cli + daemon. use std::path::PathBuf; use std::process::{Command, Output}; @@ -61,8 +61,8 @@ impl Sandbox { self.try_run(args).unwrap_or_else(|| { panic!( "[{}] `shell-use {}` produced no result within {:?}. Either it could not be \ - spawned (see stderr above), or the CLI process exited but left its stdout pipe \ - open, which happens when the detached daemon inherits the CLI's standard handles.", + spawned (see stderr above), or the cli process exited but left its stdout pipe \ + open, which happens when the detached daemon inherits the cli's standard handles.", self.label, args.join(" "), CALL_TIMEOUT diff --git a/crates/shell-use/Cargo.toml b/crates/shell-use/Cargo.toml index a805364..4871ebb 100644 --- a/crates/shell-use/Cargo.toml +++ b/crates/shell-use/Cargo.toml @@ -2,6 +2,7 @@ name = "shell-use" version.workspace = true edition.workspace = true +rust-version.workspace = true description = "In-process terminal automation, inspection, assertions, and recording" license.workspace = true repository.workspace = true @@ -22,4 +23,5 @@ portable-pty.workspace = true regex.workspace = true serde.workspace = true serde_json.workspace = true +sha2.workspace = true ttf-parser.workspace = true diff --git a/crates/shell-use/src/engine.rs b/crates/shell-use/src/engine.rs index be5175b..33d418f 100644 --- a/crates/shell-use/src/engine.rs +++ b/crates/shell-use/src/engine.rs @@ -1,7 +1,7 @@ //! Reusable in-process terminal engine. use std::path::PathBuf; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{Duration, Instant}; use serde_json::json; @@ -18,6 +18,7 @@ use crate::terminal::locator::{self, Pattern}; pub struct Engine { name: String, + operations: Mutex<()>, session: Mutex>, live: Arc>>, logger: Arc, @@ -65,6 +66,7 @@ impl Engine { pub fn new(name: String, logger: Arc, recording_path: PathBuf) -> Self { Engine { name, + operations: Mutex::new(()), session: Mutex::new(None), live: Arc::new(Mutex::new(None)), logger, @@ -73,6 +75,10 @@ impl Engine { } pub fn handle(&self, req: Request) -> (Response, bool) { + let _operation = self + .operations + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); if self.logger.enabled() { self.logger.event(&format!("req {}", req_summary(&req))); } @@ -94,7 +100,7 @@ impl Engine { ), Request::Close => { *self.live.lock().unwrap() = None; - if let Some(s) = self.session.lock().unwrap().as_ref() { + if let Some(s) = self.lock_session().take() { s.kill(); } (Response::ok(), true) @@ -116,6 +122,10 @@ impl Engine { wait_ready: Option, timeouts: TimeoutDefaults, ) -> Response { + *self.live.lock().unwrap() = None; + if let Some(previous) = self.lock_session().take() { + previous.kill(); + } match Session::open( shell, program.clone(), @@ -151,7 +161,7 @@ impl Engine { state: s.state.clone(), shell: s.shell.map(|sh| sh.as_str()), }; - *self.session.lock().unwrap() = Some(s); + *self.lock_session() = Some(s); *self.live.lock().unwrap() = Some(live); Response::with(json!({ "shell_pid": shell_pid, @@ -166,7 +176,7 @@ impl Engine { } fn status(&self) -> Response { - let guard = self.session.lock().unwrap(); + let guard = self.lock_session(); match guard.as_ref() { Some(s) => { let st = s.state.lock().unwrap(); @@ -188,7 +198,7 @@ impl Engine { } fn with_session Response>(&self, f: F) -> Response { - let mut guard = self.session.lock().unwrap(); + let mut guard = self.lock_session(); match guard.as_mut() { Some(s) => f(s), None => Response::no_session(), @@ -212,6 +222,30 @@ impl Engine { pub fn log_event(&self, message: &str) { self.logger.event(message); } + + pub fn is_open(&self) -> bool { + self.lock_session().is_some() + } + + pub fn recording_path(&self) -> &PathBuf { + &self.recording_path + } + + fn lock_session(&self) -> MutexGuard<'_, Option> { + self.session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl Drop for Engine { + fn drop(&mut self) { + if let Ok(session) = self.session.get_mut() { + if let Some(session) = session.take() { + session.kill(); + } + } + } } /// Cap only `open`'s implicit ready wait when no ready budget is configured. diff --git a/crates/shell-use/src/lib.rs b/crates/shell-use/src/lib.rs index 4656175..b9d041b 100644 --- a/crates/shell-use/src/lib.rs +++ b/crates/shell-use/src/lib.rs @@ -5,6 +5,7 @@ pub mod input; pub mod logger; pub mod protocol; pub mod render; +pub mod runtime; pub mod session; pub mod shell; pub mod terminal; diff --git a/crates/shell-use/src/protocol.rs b/crates/shell-use/src/protocol.rs index 7865e10..78d96df 100644 --- a/crates/shell-use/src/protocol.rs +++ b/crates/shell-use/src/protocol.rs @@ -27,7 +27,7 @@ impl TimeoutDefaults { } } -/// A request sent from a stateless CLI invocation to the session daemon. +/// A terminal operation shared by native bindings and the cli adapter. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum Request { @@ -191,7 +191,7 @@ pub enum MouseAction { }, } -/// Classifies a failure so the CLI can map it to a stable process exit code. +/// Classifies a failure so the cli can map it to a stable process exit code. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ErrorKind { @@ -217,7 +217,7 @@ impl ErrorKind { } } -/// A response returned by the daemon to the CLI. +/// A terminal operation result shared by native bindings and the cli adapter. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Response { pub ok: bool, @@ -227,7 +227,7 @@ pub struct Response { /// Error or assertion-failure message when `ok` is false. #[serde(default, skip_serializing_if = "Option::is_none")] pub message: Option, - /// Failure classification when `ok` is false; drives the CLI exit code. + /// Failure classification when `ok` is false; drives the cli exit code. #[serde(default, skip_serializing_if = "Option::is_none")] pub kind: Option, } diff --git a/crates/shell-use/src/runtime.rs b/crates/shell-use/src/runtime.rs new file mode 100644 index 0000000..8988d80 --- /dev/null +++ b/crates/shell-use/src/runtime.rs @@ -0,0 +1,487 @@ +use std::collections::{HashMap, VecDeque}; +use std::fmt; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, RwLock, Weak}; + +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::engine::Engine; +use crate::logger::Logger; +use crate::protocol::{ErrorKind, Request, Response}; + +const MAX_COMPLETED_RECORDINGS: usize = 1024; + +#[derive(Debug, Clone)] +pub struct ShellUseError { + pub kind: ErrorKind, + pub message: String, +} + +impl fmt::Display for ShellUseError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for ShellUseError {} + +#[derive(Clone)] +pub struct Runtime { + name: Arc, + engine: Arc, +} + +impl Runtime { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + let recording_path = native_recording_path(&name); + Runtime { + name: Arc::from(name.as_str()), + engine: Arc::new(Engine::new( + name, + Arc::new(Logger::disabled()), + recording_path, + )), + } + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn response(&self, request: Request) -> Response { + if matches!( + request, + Request::Ping | Request::Status | Request::Monitor { .. } | Request::Shutdown + ) { + return Response::usage("request is only available through the cli daemon"); + } + catch_unwind(AssertUnwindSafe(|| self.engine.handle(request).0)).unwrap_or_else(|payload| { + Response::internal(format!( + "native terminal operation panicked: {}", + panic_message(payload.as_ref()) + )) + }) + } + + pub fn response_value(&self, request: Value) -> Response { + match serde_json::from_value(request) { + Ok(request) => self.response(request), + Err(error) => Response::usage(format!("invalid request: {error}")), + } + } + + pub fn request(&self, request: Request) -> Result { + unwrap_response(self.response(request)) + } + + pub fn request_value(&self, request: Value) -> Result { + unwrap_response(self.response_value(request)) + } + + pub fn is_open(&self) -> bool { + self.engine.is_open() + } + + pub fn close(&self) -> Result<(), ShellUseError> { + self.request(Request::Close).map(|_| ()) + } + + pub fn recording_path(&self) -> &Path { + self.engine.recording_path() + } + + pub fn recording(&self) -> std::io::Result { + std::fs::read_to_string(self.recording_path()) + } +} + +pub struct SessionRegistry { + sessions: Mutex>, + recordings: Mutex, + generations: Mutex>>>, + lifecycle: RwLock<()>, +} + +#[derive(Default)] +struct CompletedRecordings { + paths: HashMap, + order: VecDeque, +} + +impl Default for SessionRegistry { + fn default() -> Self { + SessionRegistry { + sessions: Mutex::new(HashMap::new()), + recordings: Mutex::new(CompletedRecordings::default()), + generations: Mutex::new(HashMap::new()), + lifecycle: RwLock::new(()), + } + } +} + +impl SessionRegistry { + fn get_or_create_locked(&self, name: String) -> Runtime { + let mut sessions = self.lock_sessions(); + sessions + .entry(name.clone()) + .or_insert_with(|| Runtime::new(name)) + .clone() + } + + pub fn response_value(&self, name: &str, request: Value) -> Response { + match serde_json::from_value(request) { + Ok(request) => self.response(name, request), + Err(error) => Response::usage(format!("invalid request: {error}")), + } + } + + pub fn response(&self, name: &str, request: Request) -> Response { + if matches!( + request, + Request::Ping | Request::Status | Request::Monitor { .. } | Request::Shutdown + ) { + return Response::usage("request is only available through the cli daemon"); + } + let _lifecycle = self + .lifecycle + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let generation = self.generation(name); + let _generation = generation + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match request { + Request::Open { .. } => self + .get_or_create_locked(name.to_string()) + .response(request), + Request::Close => self.close_response_locked(name), + other => { + let runtime = self.lock_sessions().get(name).cloned(); + runtime + .map(|runtime| runtime.response(other)) + .unwrap_or_else(Response::no_session) + } + } + } + + pub fn sessions(&self) -> Vec { + let sessions = self + .lock_sessions() + .iter() + .map(|(name, runtime)| (name.clone(), runtime.clone())) + .collect::>(); + let mut names = sessions + .into_iter() + .filter_map(|(name, runtime)| runtime.is_open().then_some(name)) + .collect::>(); + names.sort(); + names + } + + pub fn close(&self, name: &str) -> Result<(), ShellUseError> { + let _lifecycle = self + .lifecycle + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let generation = self.generation(name); + let _generation = generation + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + unwrap_response(self.close_response_locked(name)).map(|_| ()) + } + + pub fn close_all(&self) { + let _lifecycle = self + .lifecycle + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (sessions, removed) = { + let mut recordings = self.lock_recordings(); + let sessions = std::mem::take(&mut *self.lock_sessions()); + let mut removed = Vec::new(); + for (name, runtime) in &sessions { + let path = runtime.recording_path(); + if path.is_file() { + removed.extend(Self::cache_recording( + &mut recordings, + name.clone(), + path.to_path_buf(), + )); + } + } + (sessions, removed) + }; + Self::remove_recording_files(removed); + for runtime in sessions.into_values() { + let _ = runtime.close(); + } + } + + pub fn recording(&self, name: &str) -> std::io::Result { + let _lifecycle = self + .lifecycle + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let generation = self.generation(name); + let _generation = generation + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let recordings = self.lock_recordings(); + let runtime = self.lock_sessions().get(name).cloned(); + if let Some(runtime) = runtime { + let result = runtime.recording(); + drop(recordings); + return result; + } + let path = recordings.paths.get(name).ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "unknown native session") + })?; + std::fs::read_to_string(path) + } + + fn lock_sessions(&self) -> MutexGuard<'_, HashMap> { + self.sessions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn lock_recordings(&self) -> MutexGuard<'_, CompletedRecordings> { + self.recordings + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn generation(&self, name: &str) -> Arc> { + let mut generations = self + .generations + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + generations.retain(|_, generation| generation.strong_count() > 0); + if let Some(generation) = generations.get(name).and_then(Weak::upgrade) { + return generation; + } + let generation = Arc::new(Mutex::new(())); + generations.insert(name.to_string(), Arc::downgrade(&generation)); + generation + } + + fn close_response_locked(&self, name: &str) -> Response { + let (runtime, removed) = { + let mut recordings = self.lock_recordings(); + let Some(runtime) = self.lock_sessions().remove(name) else { + return Response::ok(); + }; + let path = runtime.recording_path(); + let removed = if path.is_file() { + Self::cache_recording(&mut recordings, name.to_string(), path.to_path_buf()) + } else { + Vec::new() + }; + (runtime, removed) + }; + Self::remove_recording_files(removed); + runtime.response(Request::Close) + } + + #[cfg(test)] + fn remember_recording(&self, name: String, path: PathBuf) { + let removed = Self::cache_recording(&mut self.lock_recordings(), name, path); + Self::remove_recording_files(removed); + } + + fn cache_recording( + recordings: &mut CompletedRecordings, + name: String, + path: PathBuf, + ) -> Vec { + let mut removed = Vec::new(); + if let Some(previous) = recordings.paths.insert(name.clone(), path.clone()) { + if previous != path { + removed.push(previous); + } + recordings.order.retain(|entry| entry != &name); + } + recordings.order.push_back(name); + while recordings.paths.len() > MAX_COMPLETED_RECORDINGS { + let Some(oldest) = recordings.order.pop_front() else { + break; + }; + if let Some(path) = recordings.paths.remove(&oldest) { + removed.push(path); + } + } + removed + } + + fn remove_recording_files(paths: Vec) { + for path in paths { + let _ = std::fs::remove_file(path); + } + } +} + +pub fn global_registry() -> &'static SessionRegistry { + static REGISTRY: OnceLock = OnceLock::new(); + REGISTRY.get_or_init(SessionRegistry::default) +} + +fn native_recording_path(name: &str) -> PathBuf { + static RECORDING_SEQUENCE: AtomicU64 = AtomicU64::new(0); + let digest = format!("{:x}", Sha256::digest(name.as_bytes())); + let sequence = RECORDING_SEQUENCE.fetch_add(1, Ordering::Relaxed); + dirs::cache_dir() + .unwrap_or_else(std::env::temp_dir) + .join("shell-use") + .join("native") + .join(std::process::id().to_string()) + .join(format!("{}-{sequence}.cast", &digest[..16])) +} + +fn unwrap_response(response: Response) -> Result { + if response.ok { + return Ok(response.data.unwrap_or(Value::Null)); + } + Err(ShellUseError { + kind: response.kind.unwrap_or(ErrorKind::Internal), + message: response + .message + .unwrap_or_else(|| "shell-use operation failed".to_string()), + }) +} + +fn panic_message(payload: &(dyn std::any::Any + Send)) -> &str { + if let Some(message) = payload.downcast_ref::<&'static str>() { + message + } else if let Some(message) = payload.downcast_ref::() { + message.as_str() + } else { + "unknown panic" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn registry_reuses_names_and_lists_only_open_sessions() { + let registry = SessionRegistry::default(); + let first = registry.get_or_create_locked("same".to_string()); + let second = registry.get_or_create_locked("same".to_string()); + assert!(Arc::ptr_eq(&first.engine, &second.engine)); + assert!(registry.sessions().is_empty()); + } + + #[test] + fn invalid_request_is_a_usage_error() { + let runtime = Runtime::new("invalid-request"); + let response = runtime.response_value(json!({"kind": "missing"})); + assert_eq!(response.kind, Some(ErrorKind::Usage)); + } + + #[test] + fn cli_control_requests_are_rejected() { + let runtime = Runtime::new("cli-control"); + for request in [ + Request::Ping, + Request::Status, + Request::Monitor { cols: 80, rows: 24 }, + Request::Shutdown, + ] { + let response = runtime.response(request); + assert_eq!(response.kind, Some(ErrorKind::Usage)); + } + } + + #[test] + fn completed_recordings_are_bounded() { + let registry = SessionRegistry::default(); + let root = + std::env::temp_dir().join(format!("shell-use-recording-cache-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + + for index in 0..=MAX_COMPLETED_RECORDINGS { + let name = format!("session-{index}"); + let path = root.join(format!("{index}.cast")); + std::fs::write(&path, index.to_string()).unwrap(); + registry.remember_recording(name, path); + } + + assert_eq!( + registry.lock_recordings().paths.len(), + MAX_COMPLETED_RECORDINGS + ); + assert!(registry.recording("session-0").is_err()); + assert_eq!( + registry + .recording(&format!("session-{MAX_COMPLETED_RECORDINGS}")) + .unwrap(), + MAX_COMPLETED_RECORDINGS.to_string() + ); + assert!(!root.join("0.cast").exists()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn non_open_requests_do_not_hide_completed_recordings() { + let registry = SessionRegistry::default(); + let path = std::env::temp_dir().join(format!( + "shell-use-retained-recording-{}.cast", + std::process::id() + )); + std::fs::write(&path, "retained").unwrap(); + registry.remember_recording("retained".to_string(), path.clone()); + + assert_eq!( + registry.response("retained", Request::State).kind, + Some(ErrorKind::NoSession) + ); + assert_eq!( + registry.response("retained", Request::Shutdown).kind, + Some(ErrorKind::Usage) + ); + assert_eq!(registry.recording("retained").unwrap(), "retained"); + assert!(registry.sessions().is_empty()); + + let _ = std::fs::remove_file(path); + } + + #[test] + fn closing_never_opened_names_does_not_evict_recordings() { + let registry = SessionRegistry::default(); + let path = std::env::temp_dir().join(format!( + "shell-use-valid-recording-{}.cast", + std::process::id() + )); + std::fs::write(&path, "valid").unwrap(); + registry.remember_recording("valid".to_string(), path.clone()); + + for index in 0..=MAX_COMPLETED_RECORDINGS { + registry.close(&format!("empty-{index}")).unwrap(); + } + + assert_eq!(registry.recording("valid").unwrap(), "valid"); + assert_eq!(registry.lock_recordings().paths.len(), 1); + let _ = std::fs::remove_file(path); + } + + #[test] + fn active_runtime_does_not_fall_back_to_prior_recording() { + let registry = SessionRegistry::default(); + let path = std::env::temp_dir().join(format!( + "shell-use-prior-recording-{}.cast", + std::process::id() + )); + std::fs::write(&path, "prior").unwrap(); + registry.remember_recording("same".to_string(), path.clone()); + registry.get_or_create_locked("same".to_string()); + + assert!(registry.recording("same").is_err()); + let _ = std::fs::remove_file(path); + } +} diff --git a/crates/shell-use/tests/runtime.rs b/crates/shell-use/tests/runtime.rs new file mode 100644 index 0000000..0044715 --- /dev/null +++ b/crates/shell-use/tests/runtime.rs @@ -0,0 +1,135 @@ +use shell_use::config::{DEFAULT_COLS, DEFAULT_ROWS}; +use shell_use::protocol::{ErrorKind, Request, TimeoutDefaults}; +use shell_use::runtime::{global_registry, SessionRegistry}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +#[test] +fn named_runtimes_share_a_process_local_terminal() { + let name = format!("native-runtime-{}", std::process::id()); + let registry = global_registry(); + let response = registry.response( + &name, + Request::Open { + shell: None, + program: None, + cols: DEFAULT_COLS, + rows: DEFAULT_ROWS, + cwd: None, + env: Vec::new(), + wait_ready: None, + timeouts: TimeoutDefaults::default(), + }, + ); + assert!(response.ok); + assert!( + registry + .response( + &name, + Request::Submit { + data: Some("echo native-runtime".to_string()), + }, + ) + .ok + ); + assert!( + registry + .response( + &name, + Request::WaitCommand { + timeout_ms: Some(30_000), + }, + ) + .ok + ); + assert!( + registry + .response( + &name, + Request::ExpectText { + text: "native-runtime".to_string(), + regex: false, + full: false, + strict: false, + not: false, + fg: None, + bg: None, + timeout_ms: Some(5_000), + }, + ) + .ok + ); + + assert!(registry.sessions().contains(&name)); + registry.close(&name).expect("close terminal"); + assert!(!registry.sessions().contains(&name)); + assert!(registry + .recording(&name) + .expect("read closed recording") + .contains("native-runtime")); + + assert!( + registry + .response( + &name, + Request::Open { + shell: None, + program: None, + cols: DEFAULT_COLS, + rows: DEFAULT_ROWS, + cwd: None, + env: Vec::new(), + wait_ready: Some(false), + timeouts: TimeoutDefaults::default(), + }, + ) + .ok + ); + registry.close(&name).expect("close replacement"); +} + +#[test] +fn unrelated_session_state_does_not_wait_behind_another_session() { + let registry = Arc::new(SessionRegistry::default()); + for name in ["waiting", "responsive"] { + assert!( + registry + .response( + name, + Request::Open { + shell: None, + program: None, + cols: DEFAULT_COLS, + rows: DEFAULT_ROWS, + cwd: None, + env: Vec::new(), + wait_ready: Some(false), + timeouts: TimeoutDefaults::default(), + }, + ) + .ok + ); + } + + let waiting = Arc::clone(®istry); + let wait = std::thread::spawn(move || { + waiting.response( + "waiting", + Request::WaitText { + text: "text-that-will-never-appear".to_string(), + regex: false, + full: false, + timeout_ms: Some(700), + not: false, + }, + ) + }); + std::thread::sleep(Duration::from_millis(100)); + + let start = Instant::now(); + assert!(registry.response("responsive", Request::State).ok); + assert!(start.elapsed() < Duration::from_millis(400)); + assert_eq!(wait.join().unwrap().kind, Some(ErrorKind::Assertion)); + + registry.close_all(); +}