diff --git a/.env.example b/.env.example index 4f91f73..d79a910 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,10 @@ # Optional shell environment overrides. Source this file before running chat2shell. -CHAT2SHELL_HOST=127.0.0.1 -CHAT2SHELL_PORT=18788 -CHAT2SHELL_DATA_ROOT="${HOME}/.chat2shell" -CHAT2SHELL_STATE_DIR="${HOME}/.chat2shell/state" -CHAT2SHELL_WORKSPACE_ROOT="${HOME}/.chat2shell/workspaces" -CHAT2SHELL_ALLOWED_HOST_ROOTS="${HOME}/repositories" -CHAT2SHELL_SECRET_DIR="${HOME}/.secrets/tunnel-client" -CHAT2SHELL_TUNNEL_CLIENT="${HOME}/.local/bin/tunnel-client" -CHAT2SHELL_ENABLE_TUNNEL=1 +export CHAT2SHELL_HOST=127.0.0.1 +export CHAT2SHELL_PORT=18788 +export CHAT2SHELL_DATA_ROOT="${HOME}/.chat2shell" +export CHAT2SHELL_STATE_DIR="${HOME}/.chat2shell/state" +export CHAT2SHELL_WORKSPACE_ROOT="${HOME}/.chat2shell/workspaces" +export CHAT2SHELL_ALLOWED_HOST_ROOTS="${HOME}/repositories" +export CHAT2SHELL_SECRET_DIR="${HOME}/.secrets/tunnel-client" +export CHAT2SHELL_TUNNEL_CLIENT="${HOME}/.local/bin/tunnel-client" +export CHAT2SHELL_ENABLE_TUNNEL=1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..20d9b0d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: Release + +on: + release: + types: + - published + +permissions: + contents: read + id-token: write + +jobs: + publish: + name: Publish to npm + if: github.event.release.prerelease == false + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up pnpm + uses: pnpm/action-setup@v6 + with: + version: 11.23.0 + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + registry-url: https://registry.npmjs.org + + - name: Verify release version + run: | + package_version="$(node -p "require('./package.json').version")" + test "v${package_version}" = "${GITHUB_REF_NAME}" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check package + run: pnpm check + + - name: Publish package + run: npm publish --access public --provenance diff --git a/README.md b/README.md index 2e32132..018d49b 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ The static contract deliberately excludes CodexPro's generic supertool, self-tes ## Current policy -This section is the complete product policy. A behavior that contradicts it is a bug; a new behavior must be added here when it is introduced. +This section describes the user-visible authority, workspace, network, credential, and lifecycle guarantees enforced by chat2shell. ### Authority @@ -32,7 +32,7 @@ This section is the complete product policy. A behavior that contradicts it is a - A sandbox receives full shell and sudo-equivalent freedom only inside its microVM, including its own Docker Engine. - Bash is unrestricted inside the sandbox. Commands can modify sandbox files, install packages, access the network, and control the sandbox's private Docker Engine. - chat2shell does not ask for local approval for ordinary sandbox work. Creating a new host-backed workspace is the only local approval boundary. -- The server has no OAuth and treats every request as `local-owner`. The tunnel and ChatGPT app must remain private to the owner. +- The server has no authentication and treats every request as `local-owner`. Secure MCP Tunnel is the recommended transport. Any other exposure must provide its own authentication and access control; never expose the MCP endpoint directly to an untrusted network. ### Workspaces @@ -47,15 +47,15 @@ This section is the complete product policy. A behavior that contradicts it is a ### Network and credentials -- Outbound network access follows the Docker Sandboxes policy installed on this machine. The current prototype intentionally permits general network access. +- General outbound network access follows Docker Sandboxes behavior. +- chat2shell does not add, remove, or override Docker Sandboxes network policy. - `sandbox_expose` publishes one sandbox TCP port on an automatically assigned port on every host IPv4 interface. It is never called automatically, adds no authentication or expiration, and relies on the sandboxed service listening on `0.0.0.0`. - Repeating `sandbox_expose` for the same sandbox port returns the existing mapping. The mapping disappears when the sandbox is removed. - Traffic through an exposed port does not count as a tool call and does not renew the sandbox inactivity deadline. -- chat2shell denies `openrouter.ai` for its sandboxes so Docker's unrelated global `opencodex` credential cannot be used by ChatGPT. - Docker's built-in MCP gateway may exist inside a shell sandbox, but chat2shell and CodexPro do not connect to it. - CodexPro endpoints use random bearer tokens and dynamically allocated loopback ports. - The internal bearer token is stored in the owner-only SQLite state file and is never returned through MCP. -- Tunnel credentials remain outside this repository and are never read by the TypeScript application. +- Tunnel credentials remain outside the npm package. chat2shell reads the tunnel ID and gives tunnel-client the key file path without exposing either value through MCP. ### Lifecycle and failure @@ -88,58 +88,77 @@ Restarting chat2shell invalidates existing sandboxes because their foreground se Destroying an active sandbox follows the same workspace policy, so a managed workspace can be attached to a new sandbox with the same `workspace_id` during its 30-day retention period. -## Setup +## Install and run -Requirements are Node.js 24 or newer, pnpm, Docker Sandboxes (`sbx`), and the previously installed Secure MCP Tunnel client. +Requirements are Node.js 24 or newer and [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/) (`sbx`). Tunnel mode additionally requires Secure MCP Tunnel access, its client, a tunnel ID, and a key file. + +Install the CLI and prepare its pinned CodexPro sandbox template: ```bash -pnpm install -./scripts/setup-template.sh -pnpm check -pnpm test:e2e +npm install --global chat2shell +chat2shell setup ``` -`pnpm check` is the normal development and CI quality gate: formatting, linting, typechecking, unit and integration tests, and the production build. It deliberately excludes real Docker Sandbox E2E tests. Run `pnpm test:e2e` on a trusted host with `sbx` and the local CodexPro template installed. See [`test/README.md`](./test/README.md) for the test boundaries and individual commands. +`setup` checks the required local tunnel files and Docker Sandboxes installation. It creates `chat2shell-codexpro:0.30.0` only when that template does not already exist. The template contains CodexPro and its npm dependencies, but no workspace, application source, credentials, or tunnel secret. -`setup-template.sh` creates the local `chat2shell-codexpro:0.30.0` sandbox template once. -The template contains CodexPro and its npm dependencies, but no workspace, application source, credentials, or tunnel secret. +Run chat2shell in the foreground: + +```bash +chat2shell serve +``` + +The `serve` process owns both the loopback MCP gateway and tunnel-client. It first reconciles sandbox state, then opens the gateway and starts the tunnel. It has no startup timeout, daemon mode, automatic restart, or service installation. Use `Ctrl+C` to stop an interactive process or let an external service manager supervise the same foreground command. + +Inspect a running instance from another terminal: + +```bash +chat2shell status +``` Run locally without opening the tunnel: ```bash -CHAT2SHELL_ENABLE_TUNNEL=0 ./scripts/run.sh +CHAT2SHELL_ENABLE_TUNNEL=0 chat2shell setup +CHAT2SHELL_ENABLE_TUNNEL=0 chat2shell serve ``` -Run with the configured Secure MCP Tunnel: +The MCP endpoint binds to loopback by default. If you publish it through a reverse proxy, another tunnel, or a non-loopback bind, you are responsible for authenticating and restricting that route. + +Update an npm installation through the package manager that owns it: ```bash -./scripts/run.sh +npm update --global chat2shell ``` -Inspect or stop the runtime: +chat2shell intentionally has no self-update command. + +## Development ```bash -./scripts/status.sh -./scripts/stop.sh +pnpm install +pnpm check +pnpm test:e2e ``` +`pnpm check` is the normal development and CI quality gate: formatting, linting, typechecking, unit and integration tests, and the production bundle. It deliberately excludes real Docker Sandbox E2E tests. Run `pnpm test:e2e` on a trusted host with `sbx` and the local CodexPro template installed. See [`test/README.md`](./test/README.md) for the test boundaries and individual commands. + ## Host workspace approval When ChatGPT requests a new host path, `sandbox_create` returns an `approval_id` instead of creating a sandbox. Review and decide it locally: ```bash -pnpm cli approval list -pnpm cli approval approve approval_... -pnpm cli approval reject approval_... +chat2shell approval list +chat2shell approval approve approval_... +chat2shell approval reject approval_... ``` A host operator can also register a path directly: ```bash -pnpm cli workspace add /path/to/repository --mode clone -pnpm cli workspace add /path/to/repository --mode direct -pnpm cli workspace list +chat2shell workspace add /path/to/repository --mode clone +chat2shell workspace add /path/to/repository --mode direct +chat2shell workspace list ``` After approval, call `sandbox_create` with the returned `workspace_id`. @@ -197,8 +216,10 @@ Current locations are: The pinned template, Bash session behavior, and retention values are listed in Current policy above. CPU, memory, and disk use Docker Sandboxes defaults rather than chat2shell policy. -OAuth, reboot persistence, the monitoring dashboard, and a browser approval UI do not exist. They will be considered only after the current prototype proves useful. - ## License chat2shell is available under the MIT License. See `THIRD_PARTY_NOTICES.md` for bundled third-party notices. + +## Roadmap + +See [`ROADMAP.md`](./ROADMAP.md) for the intended product direction. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..024a08b --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,10 @@ +# Roadmap + +chat2shell currently focuses on giving ChatGPT a capable shell inside disposable Docker Sandbox microVMs while keeping the host outside the execution boundary. + +The intended next product directions are: + +- OAuth authentication for deployments that need an authenticated MCP endpoint. +- A web interface for viewing and managing sandboxes and workspaces. + +These are product directions, not release commitments. The README and architecture document describe the behavior available today. diff --git a/docs/architecture.md b/docs/architecture.md index 6be3e4e..d681bd0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,6 +27,12 @@ The Secure MCP Tunnel transports MCP messages to one loopback endpoint and does The chat2shell process owns identity, path approval, lifecycle, expiration, and routing. The `SbxDriver` is the only component allowed to invoke `sbx`, and it accepts structured values rather than raw arguments. +## Runtime ownership + +The npm package exposes one `chat2shell` executable. `chat2shell serve` is the only server entry point and stays in the foreground. It validates local dependencies, reconciles persisted sandbox state, opens the loopback MCP gateway, starts tunnel-client as its child, and closes both on SIGINT or SIGTERM. + +There is no shell-script supervisor, fixed startup timeout, daemon mode, automatic restart, or service installation. A process manager may supervise `chat2shell serve`, but those policies remain outside the product. `chat2shell status` reads the runtime PID and probes both the MCP gateway and tunnel readiness endpoints. + ## Port exposure `sandbox_expose` asks `SbxDriver` to publish one TCP/IPv4 sandbox port on `0.0.0.0` using an automatically assigned host port. The service inside the sandbox must listen on `0.0.0.0`; chat2shell does not start it or check its protocol or health. @@ -39,8 +45,7 @@ The mapping is owned by Docker Sandboxes and disappears with the sandbox. chat2s `workspace_id` identifies persistent files and can be attached to a replacement sandbox after the previous runtime expires. Neither identity depends on a ChatGPT conversation or MCP session, so another conversation under the same authenticated principal can discover it with `sandbox_list` or `workspace_list`. -The current authentication provider maps every accepted tunnel request to `local-owner`. -A future OAuth provider will change principal establishment without changing sandbox or workspace identities. +The current authentication provider maps every accepted MCP request to `local-owner`. Secure MCP Tunnel is the recommended transport, but transport and access control remain outside this provider. ## Workspace modes @@ -112,13 +117,4 @@ The trash directory is not emptied automatically. Host workspaces are never move At controller startup, persisted active records are reconciled with `sbx ls`. Any microVM left by the previous controller is removed and its sandbox record becomes `failed` because the foreground CodexPro session belonged to that controller. The user must destroy the failed sandbox before creating a replacement; chat2shell does not restart CodexPro or recover the old runtime automatically. - -## Deferred boundaries - -- OAuth principal identity and per-user authorization -- A human approval and monitoring dashboard -- Reboot service installation -- Export automation for private clone changes -- Central Docker Sandbox governance profiles - -These additions do not require changing the tunnel endpoint, stable IDs, workspace modes, or public tool names. +Reconciliation completes before the MCP gateway begins listening and has no chat2shell-imposed time limit. diff --git a/package.json b/package.json index 5de1c31..75d4e1d 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,37 @@ { "name": "chat2shell", "version": "0.2.0", - "private": true, "description": "A private MCP control plane for isolated shell and sandbox access from ChatGPT.", + "homepage": "https://github.com/nbsp1221/chat2shell#readme", + "bugs": "https://github.com/nbsp1221/chat2shell/issues", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/nbsp1221/chat2shell.git" + }, + "bin": { + "chat2shell": "dist/cli.mjs" + }, + "files": [ + "dist", + "LICENSE", + "README.md", + "ROADMAP.md", + "THIRD_PARTY_NOTICES.md" + ], "type": "module", + "publishConfig": { + "access": "public", + "provenance": true + }, "scripts": { - "build": "pnpm clean && tsc -p tsconfig.build.json", - "clean": "node scripts/clean-dist.mjs", - "cli": "node dist/src/cli/main.js", + "build": "tsdown", + "cli": "node dist/cli.mjs", "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm build", "format": "oxfmt .", "format:check": "oxfmt --check .", "lint": "oxlint . && eslint .", - "start": "node dist/src/mcp/main.js", + "start": "node dist/cli.mjs serve", "test": "vitest run --project unit --project integration", "test:coverage": "vitest run --coverage --project unit --project integration", "test:e2e": "vitest run --project e2e", @@ -23,6 +42,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "1.30.0", + "cac": "7.0.0", "zod": "3.25.76" }, "devDependencies": { @@ -36,6 +56,7 @@ "oxfmt": "0.59.0", "oxlint": "1.73.0", "oxlint-tsgolint": "0.25.0", + "tsdown": "0.22.14", "typescript": "^5.9.0", "vitest": "4.1.11" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4d075b..ec43426 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@modelcontextprotocol/sdk': specifier: 1.30.0 version: 1.30.0(supports-color@7.2.0)(zod@3.25.76) + cac: + specifier: 7.0.0 + version: 7.0.0 zod: specifier: 3.25.76 version: 3.25.76 @@ -45,6 +48,9 @@ importers: oxlint-tsgolint: specifier: 0.25.0 version: 0.25.0 + tsdown: + specifier: 0.22.14 + version: 0.22.14(typescript@5.9.3) typescript: specifier: ^5.9.0 version: 5.9.3 @@ -265,8 +271,8 @@ packages: '@cfworker/json-schema': optional: true - '@oxc-project/types@0.147.0': - resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + '@oxc-project/types@0.148.0': + resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} '@oxfmt/binding-android-arm-eabi@0.59.0': resolution: {integrity: sha512-bNTnfbuG7sAwb2PakMNaDukx5kXeW9duXOBeWtTOiLz3fXz3q2DlWguufPZ+c2IHEVrRXHD+M4aUgEWm841LDA==} @@ -542,6 +548,9 @@ packages: cpu: [x64] os: [win32] + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + '@retn0/eslint-config-oxlint@0.1.2': resolution: {integrity: sha512-p4UOml78poliyXO9VDlTmVW6eR0MCOaaUSuZYDQ4kDVU4ViY2XTpfBDsADj9KOdvNyPxaOD/F9AI+/POdgwy+g==} engines: {node: '>=24.0.0'} @@ -568,98 +577,98 @@ packages: oxlint: ^1.73.0 oxlint-tsgolint: '>=0.25.0 <0.26.0' - '@rolldown/binding-android-arm-eabi@1.2.6': - resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} + '@rolldown/binding-android-arm-eabi@1.2.7': + resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.6': - resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==} + '@rolldown/binding-android-arm64@1.2.7': + resolution: {integrity: sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.6': - resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==} + '@rolldown/binding-darwin-arm64@1.2.7': + resolution: {integrity: sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.6': - resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==} + '@rolldown/binding-darwin-x64@1.2.7': + resolution: {integrity: sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.6': - resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==} + '@rolldown/binding-freebsd-x64@1.2.7': + resolution: {integrity: sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.6': - resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + resolution: {integrity: sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.6': - resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==} + '@rolldown/binding-linux-arm64-gnu@1.2.7': + resolution: {integrity: sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.6': - resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==} + '@rolldown/binding-linux-arm64-musl@1.2.7': + resolution: {integrity: sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.6': - resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==} + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + resolution: {integrity: sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.6': - resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==} + '@rolldown/binding-linux-s390x-gnu@1.2.7': + resolution: {integrity: sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.6': - resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==} + '@rolldown/binding-linux-x64-gnu@1.2.7': + resolution: {integrity: sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.6': - resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==} + '@rolldown/binding-linux-x64-musl@1.2.7': + resolution: {integrity: sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.6': - resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==} + '@rolldown/binding-openharmony-arm64@1.2.7': + resolution: {integrity: sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.6': - resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==} + '@rolldown/binding-win32-arm64-msvc@1.2.7': + resolution: {integrity: sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.6': - resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==} + '@rolldown/binding-win32-x64-msvc@1.2.7': + resolution: {integrity: sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -835,6 +844,141 @@ packages: '@vitest/utils@4.1.11': resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@yuku-codegen/binding-android-arm64@0.8.7': + resolution: {integrity: sha512-C/0zV5IhgVdYhGJTwrY0v8dknxlhiKwtVJkMUaexu9/QvRmzlV4vfU3hZlUSgqc2BxQHntL1mCVbDq8j0FRFDw==} + cpu: [arm64] + os: [android] + + '@yuku-codegen/binding-darwin-arm64@0.8.7': + resolution: {integrity: sha512-/u+REDMI4a0/lsJXTM4c53/w31OGZLOReZIyg62uhgLs0kc8NHsj/nOcxTdlQjq5gi0zhdkccD9LaLTXcdzPvw==} + cpu: [arm64] + os: [darwin] + + '@yuku-codegen/binding-darwin-x64@0.8.7': + resolution: {integrity: sha512-sMMzFOwCo4WXR+/6zIBThOocSC50iIIZZdfiIDbaLvj0Ax/rWt/iavyfEAqajyvzydLyCqR/ZItdLWSRlu1umw==} + cpu: [x64] + os: [darwin] + + '@yuku-codegen/binding-freebsd-x64@0.8.7': + resolution: {integrity: sha512-MpdpKXix9P+Y1rKgjvcNeNtGjXeL1CmttNhYINrWls8kRpm4xM/oBGTmn6w7to8lAlwj5jm8q03dQPl5mRv4Qw==} + cpu: [x64] + os: [freebsd] + + '@yuku-codegen/binding-linux-arm-gnu@0.8.7': + resolution: {integrity: sha512-rr1srFLlPAmC1vtxfc9C1YLDe3iH09YjfSeeIidBqKhzx1MATjOAq4mjlRUOnhr/L27MotWIYOFKwVsd5JZFOg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm-musl@0.8.7': + resolution: {integrity: sha512-eAufXh8qBRpiSO6ueaMDL+yyoXIGLhpUce72YbcACtZU2qhExwBIJyEtQf5kH2Ki0X2aqkSjSfog4OPtKXan3Q==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-arm64-gnu@0.8.7': + resolution: {integrity: sha512-gw4w6wPoHObBrdIC4duVWLmJOvpdE25j5D7yrM5mACNlK4klRz/lv8hK+ssQk9EJHBgZjSaqZIJVFgqNYbfv7A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm64-musl@0.8.7': + resolution: {integrity: sha512-L68N6Y4XkqcIaKo3Ra88JEvBEH4AHff44A4INcrxeVWZ8CZtu2tCpfVxe3hR8qQQMcvBSQlDn24KpkCKEhVvfA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-x64-gnu@0.8.7': + resolution: {integrity: sha512-dzyAbltJmf3Cqlb8HcFuYIf5Yn0fl1vTr3XJ9HiVNNvOlhqPSArOqtw9vI1p6/VTXTjrMLXlU+s+/kNHiIy/Cw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-x64-musl@0.8.7': + resolution: {integrity: sha512-Otw4MH3404q0Bbvl+YTdW9aoUV5vXmUw8260bWvt1XlaoIX/ceSgI4ygheRDMfBPoHt6FDayQaO9OLVUZAgkFA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-win32-arm64@0.8.7': + resolution: {integrity: sha512-qo/jyrzryiBuKEsFiuWaBCBe3tRMynQ0qFWFgOEjcCMQeZfBm+wKiVEUEFXXLc7bh8YezguAWp0Mtnhq4ARNyA==} + cpu: [arm64] + os: [win32] + + '@yuku-codegen/binding-win32-x64@0.8.7': + resolution: {integrity: sha512-D5lDsVDx6m00E6bWySlWdH72Ca4TPSaphDqB6QjU6MpuNLIJqoGoatYyq2rOmBE8Zv/kunot/o58KGL03P3eiA==} + cpu: [x64] + os: [win32] + + '@yuku-parser/binding-android-arm64@0.8.7': + resolution: {integrity: sha512-eGKYiUDX7Y0V7tDTmg+JTVnXnjMqfXXsorZ+EDf5kxwchQ3Or1HS14MzI2fw+jFhHR85fCWt+mtX33Yao73hIQ==} + cpu: [arm64] + os: [android] + + '@yuku-parser/binding-darwin-arm64@0.8.7': + resolution: {integrity: sha512-Re0RHelKLnjEURulY2/KxW+Ngb8zuNA4BRZuMwgGQNzVumT6u4U2N2hc01oeYVNVof0i7GrXE4UCNBgbpRRnjQ==} + cpu: [arm64] + os: [darwin] + + '@yuku-parser/binding-darwin-x64@0.8.7': + resolution: {integrity: sha512-Hn8DROtQkjlA1ACbPgj4a7eP9IuVOI504oiTwpkWPbpaDWD9KdmnVYCqW+1LfenNK/g7O9NhWGpXEdaCNX7lIA==} + cpu: [x64] + os: [darwin] + + '@yuku-parser/binding-freebsd-x64@0.8.7': + resolution: {integrity: sha512-bAP2OV8wRuzplX/jYxv9+vvqQT8JxyNphI8fLfXGL054Xs+4/J5u33cIm3y4rxY8rdoLmmdiJs2Tq7r7lrDRfA==} + cpu: [x64] + os: [freebsd] + + '@yuku-parser/binding-linux-arm-gnu@0.8.7': + resolution: {integrity: sha512-kTYwJQQgmZeAWdDIWabiReIZMpmfLueIj1tCmjStUtFGhR1Z0qwxonKVfUC4N7h/VhGGzLZ//7O1kgt1QKqgCg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm-musl@0.8.7': + resolution: {integrity: sha512-uL4jE8HPT2BLlxAXyD10LqgPuXa9eDa0BKpCdSANmzIJghq/2eZo3/gQNtaxPZMupWoxjYzSad9IXrwu7aYPXQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-arm64-gnu@0.8.7': + resolution: {integrity: sha512-3gVN4pWSKZmXiNX7cU164dR9MPvesCHnlH6nPfpK+yQsCuYphjKKplcb4SnZBrhiqmXbgb2HR0c2TS0IZUPhgA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm64-musl@0.8.7': + resolution: {integrity: sha512-S0mwfEjoLpxzXeZw802Wa4RaELsQiPtWqG6INcy8j4GtvNFtl4LCX3eGO1XLn9pyLAISLzTRyU3zUCBUPin8lg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-x64-gnu@0.8.7': + resolution: {integrity: sha512-lnbWdPmerE5D1uH1G4IEZKnPzCrWCStRGrtgpSIe1RibAo5bZIjDbbbPYXmMHCEh4F+x/JaJpElh26a3r+BPbg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-x64-musl@0.8.7': + resolution: {integrity: sha512-769uwndMvMzUvATWbAcEvyLHKA+DzhHSCl/obBUrRdYfRo26yxui6S8y3z7uJ+Naup7UKrDxrpK7OnQkxkl9KQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-win32-arm64@0.8.7': + resolution: {integrity: sha512-mEB/9PlaAkisJ6KWGz0zvywXoU6+80dTlR2LwS7s/jcXXoU6fm2+sitBZXtqu3+Q4DcDgPxM45uWMCzPs0TSRw==} + cpu: [arm64] + os: [win32] + + '@yuku-parser/binding-win32-x64@0.8.7': + resolution: {integrity: sha512-8vNB2DP0ou61nGb8tc/qfi41gfyDXz1MHr2zqL3nR+cJ6CEbiuWV/l/a/vv151gCgiZLLAyGkQGENpozdg716w==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.8.7': + resolution: {integrity: sha512-2Z53dNxAJL6UvFoIrDZvYf3zlO8s4VJK4O2hhaB4mXVwwpX/7ajtss3cmfqKvamlNLWyt9FSWs4eoYdlbxpnHA==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -863,6 +1007,10 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -899,6 +1047,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -960,6 +1112,9 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -968,6 +1123,15 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dts-resolver@3.0.0: + resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} + engines: {node: ^22.18.0 || >=24.0.0} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -978,6 +1142,10 @@ packages: electron-to-chromium@1.5.420: resolution: {integrity: sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==} + empathic@2.0.1: + resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} + engines: {node: '>=14'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -1155,8 +1323,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.6: - resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + fast-uri@3.1.7: + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -1214,6 +1382,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-tsconfig@5.0.0-beta.5: + resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} + engines: {node: '>=20.20.0'} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1248,6 +1420,9 @@ packages: resolution: {integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==} engines: {node: '>=16.9.0'} + hookable@6.1.1: + resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -1267,6 +1442,10 @@ packages: resolution: {integrity: sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==} engines: {node: '>= 4'} + import-without-cache@0.4.0: + resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} + engines: {node: ^22.18.0 || >=24.0.0} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -1600,6 +1779,9 @@ packages: resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + range-parser@1.3.0: resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} @@ -1612,8 +1794,30 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - rolldown@1.2.6: - resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown-plugin-dts@0.27.14: + resolution: {integrity: sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@typescript/native-preview': '*' + '@volar/typescript': ~2.4.0 + rolldown: ^1.0.0 + typescript: ^5.0.0 || ^6.0.0 || ~7.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + '@typescript/native-preview': + optional: true + '@volar/typescript': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + rolldown@1.2.7: + resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1715,6 +1919,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -1724,6 +1932,40 @@ packages: ts-pattern@5.9.0: resolution: {integrity: sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==} + tsdown@0.22.14: + resolution: {integrity: sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.22.14 + '@tsdown/exe': 0.22.14 + '@vitejs/devtools': '*' + publint: ^0.3.8 + tsx: '*' + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1744,6 +1986,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -1764,6 +2009,10 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + verkit@0.3.2: + resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} + engines: {node: '>=18.12.0'} + vite@8.2.2: resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1872,6 +2121,15 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yuku-ast@0.8.7: + resolution: {integrity: sha512-h6+4bDfyootiMB9vckk5uKo5r5j0GHrkr17FQTDNfEsFT3DWlN9uu1HJwQwc64pgmLCI945fWM3lbTIqxjT3GQ==} + + yuku-codegen@0.8.7: + resolution: {integrity: sha512-adwDZSh8oVDzhE6Du9PwVWxcOxeV0e2EVhUuMKWfhSY4wkrDq9eqixlxFF3l/XGUV1E7UFzhpz9393MUumkyNw==} + + yuku-parser@0.8.7: + resolution: {integrity: sha512-vRD9nwt4L3aYpxNqeSC4WqLv58xrXef0Ong1Mc45CTXTIpvLafx7JO05sczmQZwdLEZvywrLOGdNC5+Rp5N1BQ==} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -2171,7 +2429,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@oxc-project/types@0.147.0': {} + '@oxc-project/types@0.148.0': {} '@oxfmt/binding-android-arm-eabi@0.59.0': optional: true @@ -2305,6 +2563,10 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.73.0': optional: true + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + '@retn0/eslint-config-oxlint@0.1.2(eslint@10.7.0(supports-color@7.2.0))(oxlint@1.73.0(oxlint-tsgolint@0.25.0))': dependencies: eslint: 10.7.0(supports-color@7.2.0) @@ -2334,49 +2596,49 @@ snapshots: oxlint: 1.73.0(oxlint-tsgolint@0.25.0) oxlint-tsgolint: 0.25.0 - '@rolldown/binding-android-arm-eabi@1.2.6': + '@rolldown/binding-android-arm-eabi@1.2.7': optional: true - '@rolldown/binding-android-arm64@1.2.6': + '@rolldown/binding-android-arm64@1.2.7': optional: true - '@rolldown/binding-darwin-arm64@1.2.6': + '@rolldown/binding-darwin-arm64@1.2.7': optional: true - '@rolldown/binding-darwin-x64@1.2.6': + '@rolldown/binding-darwin-x64@1.2.7': optional: true - '@rolldown/binding-freebsd-x64@1.2.6': + '@rolldown/binding-freebsd-x64@1.2.7': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.6': + '@rolldown/binding-linux-arm64-gnu@1.2.7': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.6': + '@rolldown/binding-linux-arm64-musl@1.2.7': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.6': + '@rolldown/binding-linux-ppc64-gnu@1.2.7': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.6': + '@rolldown/binding-linux-s390x-gnu@1.2.7': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.6': + '@rolldown/binding-linux-x64-gnu@1.2.7': optional: true - '@rolldown/binding-linux-x64-musl@1.2.6': + '@rolldown/binding-linux-x64-musl@1.2.7': optional: true - '@rolldown/binding-openharmony-arm64@1.2.6': + '@rolldown/binding-openharmony-arm64@1.2.7': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.6': + '@rolldown/binding-win32-arm64-msvc@1.2.7': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.6': + '@rolldown/binding-win32-x64-msvc@1.2.7': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -2619,6 +2881,80 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.1 + '@yuku-codegen/binding-android-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-darwin-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.8.7': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-win32-x64@0.8.7': + optional: true + + '@yuku-parser/binding-android-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-darwin-x64@0.8.7': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.8.7': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.8.7': + optional: true + + '@yuku-parser/binding-win32-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-win32-x64@0.8.7': + optional: true + + '@yuku-toolchain/types@0.8.7': {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -2644,10 +2980,12 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.6 + fast-uri: 3.1.7 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansis@4.3.1: {} + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.5: @@ -2690,6 +3028,8 @@ snapshots: bytes@3.1.2: {} + cac@7.0.0: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -2737,10 +3077,14 @@ snapshots: deep-is@0.1.4: {} + defu@6.1.7: {} + depd@2.0.0: {} detect-libc@2.1.2: {} + dts-resolver@3.0.0: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2751,6 +3095,8 @@ snapshots: electron-to-chromium@1.5.420: {} + empathic@2.0.1: {} + encodeurl@2.0.0: {} es-define-property@1.0.1: {} @@ -3021,7 +3367,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.6: {} + fast-uri@3.1.7: {} fdir@6.5.0(picomatch@4.0.7): optionalDependencies: @@ -3083,6 +3429,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + get-tsconfig@5.0.0-beta.5: + dependencies: + resolve-pkg-maps: 1.0.0 + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -3107,6 +3457,8 @@ snapshots: hono@4.13.5: {} + hookable@6.1.1: {} + html-escaper@2.0.2: {} http-errors@2.0.1: @@ -3125,6 +3477,8 @@ snapshots: ignore@7.0.8: {} + import-without-cache@0.4.0: {} + imurmurhash@0.1.4: {} inherits@2.0.4: {} @@ -3411,6 +3765,8 @@ snapshots: es-define-property: 1.0.1 side-channel: 1.1.1 + quansync@1.0.0: {} + range-parser@1.3.0: {} raw-body@3.0.2: @@ -3422,26 +3778,42 @@ snapshots: require-from-string@2.0.2: {} - rolldown@1.2.6: + resolve-pkg-maps@1.0.0: {} + + rolldown-plugin-dts@0.27.14(rolldown@1.2.7)(typescript@5.9.3): + dependencies: + dts-resolver: 3.0.0 + get-tsconfig: 5.0.0-beta.5 + obug: 2.1.4 + rolldown: 1.2.7 + yuku-ast: 0.8.7 + yuku-codegen: 0.8.7 + yuku-parser: 0.8.7 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - oxc-resolver + + rolldown@1.2.7: dependencies: - '@oxc-project/types': 0.147.0 + '@oxc-project/types': 0.148.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.6 - '@rolldown/binding-android-arm64': 1.2.6 - '@rolldown/binding-darwin-arm64': 1.2.6 - '@rolldown/binding-darwin-x64': 1.2.6 - '@rolldown/binding-freebsd-x64': 1.2.6 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.6 - '@rolldown/binding-linux-arm64-gnu': 1.2.6 - '@rolldown/binding-linux-arm64-musl': 1.2.6 - '@rolldown/binding-linux-ppc64-gnu': 1.2.6 - '@rolldown/binding-linux-s390x-gnu': 1.2.6 - '@rolldown/binding-linux-x64-gnu': 1.2.6 - '@rolldown/binding-linux-x64-musl': 1.2.6 - '@rolldown/binding-openharmony-arm64': 1.2.6 - '@rolldown/binding-win32-arm64-msvc': 1.2.6 - '@rolldown/binding-win32-x64-msvc': 1.2.6 + '@rolldown/binding-android-arm-eabi': 1.2.7 + '@rolldown/binding-android-arm64': 1.2.7 + '@rolldown/binding-darwin-arm64': 1.2.7 + '@rolldown/binding-darwin-x64': 1.2.7 + '@rolldown/binding-freebsd-x64': 1.2.7 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.7 + '@rolldown/binding-linux-arm64-gnu': 1.2.7 + '@rolldown/binding-linux-arm64-musl': 1.2.7 + '@rolldown/binding-linux-ppc64-gnu': 1.2.7 + '@rolldown/binding-linux-s390x-gnu': 1.2.7 + '@rolldown/binding-linux-x64-gnu': 1.2.7 + '@rolldown/binding-linux-x64-musl': 1.2.7 + '@rolldown/binding-openharmony-arm64': 1.2.7 + '@rolldown/binding-win32-arm64-msvc': 1.2.7 + '@rolldown/binding-win32-x64-msvc': 1.2.7 router@2.2.0(supports-color@7.2.0): dependencies: @@ -3551,12 +3923,39 @@ snapshots: toidentifier@1.0.1: {} + tree-kill@1.2.2: {} + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 ts-pattern@5.9.0: {} + tsdown@0.22.14(typescript@5.9.3): + dependencies: + ansis: 4.3.1 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.0.1 + hookable: 6.1.1 + import-without-cache: 0.4.0 + obug: 2.1.4 + picomatch: 4.0.7 + rolldown: 1.2.7 + rolldown-plugin-dts: 0.27.14(rolldown@1.2.7)(typescript@5.9.3) + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + verkit: 0.3.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@typescript/native-preview' + - '@volar/typescript' + - oxc-resolver + - vue-tsc + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -3580,6 +3979,11 @@ snapshots: typescript@5.9.3: {} + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + undici-types@7.18.2: {} unpipe@1.0.0: {} @@ -3596,12 +4000,14 @@ snapshots: vary@1.1.2: {} + verkit@0.3.2: {} + vite@8.2.2(@types/node@24.13.3): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 postcss: 8.5.26 - rolldown: 1.2.6 + rolldown: 1.2.7 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.3 @@ -3652,6 +4058,45 @@ snapshots: yocto-queue@0.1.0: {} + yuku-ast@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + + yuku-codegen@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + optionalDependencies: + '@yuku-codegen/binding-android-arm64': 0.8.7 + '@yuku-codegen/binding-darwin-arm64': 0.8.7 + '@yuku-codegen/binding-darwin-x64': 0.8.7 + '@yuku-codegen/binding-freebsd-x64': 0.8.7 + '@yuku-codegen/binding-linux-arm-gnu': 0.8.7 + '@yuku-codegen/binding-linux-arm-musl': 0.8.7 + '@yuku-codegen/binding-linux-arm64-gnu': 0.8.7 + '@yuku-codegen/binding-linux-arm64-musl': 0.8.7 + '@yuku-codegen/binding-linux-x64-gnu': 0.8.7 + '@yuku-codegen/binding-linux-x64-musl': 0.8.7 + '@yuku-codegen/binding-win32-arm64': 0.8.7 + '@yuku-codegen/binding-win32-x64': 0.8.7 + + yuku-parser@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + yuku-ast: 0.8.7 + optionalDependencies: + '@yuku-parser/binding-android-arm64': 0.8.7 + '@yuku-parser/binding-darwin-arm64': 0.8.7 + '@yuku-parser/binding-darwin-x64': 0.8.7 + '@yuku-parser/binding-freebsd-x64': 0.8.7 + '@yuku-parser/binding-linux-arm-gnu': 0.8.7 + '@yuku-parser/binding-linux-arm-musl': 0.8.7 + '@yuku-parser/binding-linux-arm64-gnu': 0.8.7 + '@yuku-parser/binding-linux-arm64-musl': 0.8.7 + '@yuku-parser/binding-linux-x64-gnu': 0.8.7 + '@yuku-parser/binding-linux-x64-musl': 0.8.7 + '@yuku-parser/binding-win32-arm64': 0.8.7 + '@yuku-parser/binding-win32-x64': 0.8.7 + zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/scripts/clean-dist.mjs b/scripts/clean-dist.mjs deleted file mode 100644 index 56718d3..0000000 --- a/scripts/clean-dist.mjs +++ /dev/null @@ -1,10 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const dist = path.join(projectRoot, 'dist'); -if (path.dirname(dist) !== projectRoot || path.basename(dist) !== 'dist') { - throw new Error(`Refusing to clean unexpected path: ${dist}`); -} -fs.rmSync(dist, { recursive: true, force: true }); diff --git a/scripts/run.sh b/scripts/run.sh deleted file mode 100755 index ad835fc..0000000 --- a/scripts/run.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -user_home="${HOME:?HOME must be set}" -state_dir="${CHAT2SHELL_STATE_DIR:-$user_home/.chat2shell/state}" -secret_dir="${CHAT2SHELL_SECRET_DIR:-$user_home/.secrets/tunnel-client}" -tunnel_client="${CHAT2SHELL_TUNNEL_CLIENT:-$user_home/.local/bin/tunnel-client}" -listen_host="${CHAT2SHELL_HOST:-127.0.0.1}" -gateway_port="${CHAT2SHELL_PORT:-18788}" -enable_tunnel="${CHAT2SHELL_ENABLE_TUNNEL:-1}" -pid_file="$state_dir/runtime.pid" - -mkdir -p "$state_dir" -chmod 700 "$state_dir" - -if [[ ! -f "$project_root/dist/src/mcp/main.js" ]]; then - printf 'Build output is missing. Run pnpm install && pnpm build first.\n' >&2 - exit 1 -fi - -if [[ -f "$pid_file" ]]; then - existing_pid="$(tr -d '\r\n' < "$pid_file")" - if [[ "$existing_pid" =~ ^[0-9]+$ ]] && kill -0 "$existing_pid" 2>/dev/null; then - printf 'chat2shell is already running with PID %s.\n' "$existing_pid" >&2 - exit 1 - fi -fi - -if [[ "$enable_tunnel" == "1" ]]; then - for required_file in "$secret_dir/key" "$secret_dir/tunnel-id" "$tunnel_client"; do - if [[ ! -e "$required_file" ]]; then - printf 'Missing required runtime dependency: %s\n' "$required_file" >&2 - exit 1 - fi - done -fi - -gateway_pid="" -tunnel_pid="" -cleanup() { - for child_pid in "$tunnel_pid" "$gateway_pid"; do - if [[ -n "$child_pid" ]]; then - kill -TERM "$child_pid" 2>/dev/null || true - wait "$child_pid" 2>/dev/null || true - fi - done - unlink "$pid_file" 2>/dev/null || true -} -trap cleanup EXIT INT TERM - -printf '%s\n' "$$" > "$pid_file" -chmod 600 "$pid_file" -unlink "$state_dir/health.url" 2>/dev/null || true - -CHAT2SHELL_HOST="$listen_host" CHAT2SHELL_PORT="$gateway_port" \ - node "$project_root/dist/src/mcp/main.js" >"$state_dir/gateway.log" 2>&1 & -gateway_pid="$!" - -gateway_ready="0" -for _ in {1..300}; do - if curl --fail --silent "http://${listen_host}:${gateway_port}/healthz" >/dev/null 2>&1; then - gateway_ready="1" - break - fi - if ! kill -0 "$gateway_pid" 2>/dev/null; then - printf 'chat2shell exited during startup. See %s/gateway.log\n' "$state_dir" >&2 - exit 1 - fi - sleep 0.1 -done -if [[ "$gateway_ready" != "1" ]]; then - printf 'chat2shell did not become healthy within 30 seconds. See %s/gateway.log\n' "$state_dir" >&2 - exit 1 -fi - -printf 'chat2shell ready: http://%s:%s/mcp\n' "$listen_host" "$gateway_port" -if [[ "$enable_tunnel" != "1" ]]; then - printf 'Tunnel disabled; local runtime is ready.\n' - wait "$gateway_pid" - exit 0 -fi - -tunnel_id="$(tr -d '\r\n' < "$secret_dir/tunnel-id")" -if [[ -z "$tunnel_id" ]]; then - printf 'Tunnel ID file is empty.\n' >&2 - exit 1 -fi - -"$tunnel_client" run \ - --mcp.server-url "http://${listen_host}:${gateway_port}/mcp" \ - --control-plane.api-key "file:${secret_dir}/key" \ - --control-plane.tunnel-id "$tunnel_id" \ - --health.listen-addr 127.0.0.1:0 \ - --health.url-file "$state_dir/health.url" \ - --log.file "$state_dir/tunnel-client.log" & -tunnel_pid="$!" -wait "$tunnel_pid" diff --git a/scripts/setup-template.sh b/scripts/setup-template.sh deleted file mode 100755 index a4b2443..0000000 --- a/scripts/setup-template.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -template="chat2shell-codexpro:0.30.0" - -if sbx template ls --json | node -e ' -let input = ""; -process.stdin.on("data", (chunk) => input += chunk); -process.stdin.on("end", () => { - const requested = process.argv[1].replace(/^docker\.io\/library\//, ""); - const found = JSON.parse(input).images.some((image) => `${image.repository}:${image.tag}`.replace(/^docker\.io\/library\//, "") === requested); - process.exit(found ? 0 : 1); -}); -' "$template"; then - printf 'Sandbox template already exists: %s\n' "$template" - exit 0 -fi - -bootstrap_dir="$(mktemp -d /tmp/chat2shell-template.XXXXXX)" -bootstrap_name="c2s-template-$(date +%s)" -cleanup() { - sbx rm --force "$bootstrap_name" >/dev/null 2>&1 || true - gio trash "$bootstrap_dir" >/dev/null 2>&1 || true -} -trap cleanup EXIT - -sbx create --quiet --name "$bootstrap_name" shell "$bootstrap_dir" -sbx exec -u root "$bootstrap_name" npm install --global --omit=dev "codexpro@0.30.0" -sbx stop "$bootstrap_name" -sbx template save "$bootstrap_name" "$template" -printf 'Created sandbox template: %s\n' "$template" diff --git a/scripts/status.sh b/scripts/status.sh deleted file mode 100755 index 37fb548..0000000 --- a/scripts/status.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -user_home="${HOME:?HOME must be set}" -state_dir="${CHAT2SHELL_STATE_DIR:-$user_home/.chat2shell/state}" -pid_file="$state_dir/runtime.pid" - -if [[ ! -f "$pid_file" ]]; then - printf 'stopped\n' - exit 1 -fi - -runtime_pid="$(tr -d '\r\n' < "$pid_file")" -if [[ ! "$runtime_pid" =~ ^[0-9]+$ ]] || ! kill -0 "$runtime_pid" 2>/dev/null; then - printf 'stale PID file\n' >&2 - exit 1 -fi - -printf 'runtime PID: %s\n' "$runtime_pid" -if [[ -f "$state_dir/health.url" ]]; then - health_url="$(tr -d '\r\n' < "$state_dir/health.url")" - printf 'health: ' - curl --fail --silent --show-error "$health_url/healthz" - printf '\nready: ' - curl --fail --silent --show-error "$health_url/readyz" - printf '\nadmin UI: %s/ui\n' "$health_url" -else - printf 'tunnel: disabled\n' -fi diff --git a/scripts/stop.sh b/scripts/stop.sh deleted file mode 100755 index 84cd8de..0000000 --- a/scripts/stop.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -user_home="${HOME:?HOME must be set}" -state_dir="${CHAT2SHELL_STATE_DIR:-$user_home/.chat2shell/state}" -pid_file="$state_dir/runtime.pid" - -if [[ ! -f "$pid_file" ]]; then - printf 'chat2shell is not running.\n' - exit 0 -fi - -runtime_pid="$(tr -d '\r\n' < "$pid_file")" -if [[ ! "$runtime_pid" =~ ^[0-9]+$ ]]; then - printf 'Invalid runtime PID file.\n' >&2 - exit 1 -fi - -cmdline="$(tr '\0' ' ' < "/proc/$runtime_pid/cmdline" 2>/dev/null || true)" -process_cwd="$(readlink -f "/proc/$runtime_pid/cwd" 2>/dev/null || true)" -if [[ "$process_cwd" != "$project_root" ]] || [[ "$cmdline" != *"scripts/run.sh"* ]]; then - printf 'PID %s is not this chat2shell runtime; refusing to stop it.\n' "$runtime_pid" >&2 - exit 1 -fi - -kill -TERM "$runtime_pid" -for _ in {1..100}; do - if ! kill -0 "$runtime_pid" 2>/dev/null; then - printf 'chat2shell stopped.\n' - exit 0 - fi - sleep 0.1 -done - -printf 'chat2shell did not stop within 10 seconds.\n' >&2 -exit 1 diff --git a/src/cli/main.ts b/src/cli/main.ts index 457b3f8..19219e9 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -1,44 +1,8 @@ -import { loadAppConfig } from '../config.js'; -import { StateDatabase } from '../state/database.js'; -import { WorkspaceService } from '../workspaces/service.js'; +#!/usr/bin/env node -const config = loadAppConfig(); -const database = new StateDatabase(config.databasePath); -const workspaces = new WorkspaceService({ - database, - workspaceRoot: config.workspaceRoot, - dataRoot: config.dataRoot, - allowedHostRoots: config.allowedHostRoots, -}); -const [group, action, value, ...rest] = process.argv.slice(2); -const ownerId = 'local-owner'; - -function modeFrom(args: readonly string[]): 'clone' | 'direct' { - const index = args.indexOf('--mode'); - const mode = index >= 0 ? args[index + 1] : 'clone'; - if (mode !== 'clone' && mode !== 'direct') { - throw new Error('--mode must be clone or direct'); - } - return mode; -} +import { runCli } from './program.js'; -try { - if (group === 'workspace' && action === 'list') { - console.log(JSON.stringify(workspaces.list(ownerId), null, 2)); - } else if (group === 'workspace' && action === 'add' && value) { - console.log(JSON.stringify(workspaces.registerHost(ownerId, value, modeFrom(rest)), null, 2)); - } else if (group === 'approval' && action === 'list') { - console.log(JSON.stringify(database.listApprovals(), null, 2)); - } else if (group === 'approval' && action === 'approve' && value) { - console.log(JSON.stringify(workspaces.approve(value), null, 2)); - } else if (group === 'approval' && action === 'reject' && value) { - console.log(JSON.stringify(workspaces.reject(value), null, 2)); - } else { - console.error( - 'Usage:\n chat2shell workspace list\n chat2shell workspace add PATH [--mode clone|direct]\n chat2shell approval list\n chat2shell approval approve ID\n chat2shell approval reject ID', - ); - process.exitCode = 2; - } -} finally { - database.close(); -} +runCli().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/src/cli/program.ts b/src/cli/program.ts new file mode 100644 index 0000000..2c719cf --- /dev/null +++ b/src/cli/program.ts @@ -0,0 +1,149 @@ +import { type CAC, cac } from 'cac'; +import packageJson from '../../package.json' with { type: 'json' }; +import { loadAppConfig, loadRuntimeConfig } from '../config.js'; +import { serve } from '../runtime/serve.js'; +import { StateDatabase } from '../state/database.js'; +import { WorkspaceService } from '../workspaces/service.js'; +import { setup } from './setup.js'; +import { status } from './status.js'; + +const ownerId = 'local-owner'; + +function withWorkspaceServices( + operation: (services: { database: StateDatabase; workspaces: WorkspaceService }) => T, +): T { + const config = loadAppConfig(); + const database = new StateDatabase(config.databasePath); + const workspaces = new WorkspaceService({ + database, + workspaceRoot: config.workspaceRoot, + dataRoot: config.dataRoot, + allowedHostRoots: config.allowedHostRoots, + }); + try { + return operation({ database, workspaces }); + } finally { + database.close(); + } +} + +export function createCli(): CAC { + const cli = cac('chat2shell'); + cli.version(packageJson.version); + cli.help(); + + cli + .command('setup', 'Check prerequisites and create the CodexPro sandbox template') + .action(() => setup(loadRuntimeConfig())); + + cli + .command('serve', 'Run the MCP gateway and Secure MCP Tunnel in the foreground') + .action(() => serve(loadRuntimeConfig())); + + cli.command('status', 'Show service, MCP, and tunnel readiness').action(async () => { + if (!(await status(loadRuntimeConfig()))) { + process.exitCode = 1; + } + }); + + cli + .command('workspace [path]', 'List or register workspaces') + .option('--mode ', 'Workspace mode: clone or direct', { default: 'clone' }) + .action((action: string, workspacePath: string | undefined, options: { mode: string }) => { + if (action === 'list') { + if (workspacePath) { + throw new Error('workspace list does not accept a path'); + } + console.log( + JSON.stringify( + withWorkspaceServices(({ workspaces }) => workspaces.list(ownerId)), + null, + 2, + ), + ); + return; + } + if (action !== 'add') { + throw new Error(`Unknown workspace action: ${action}`); + } + if (!workspacePath) { + throw new Error('workspace add requires a path'); + } + if (options.mode !== 'clone' && options.mode !== 'direct') { + throw new Error('--mode must be clone or direct'); + } + console.log( + JSON.stringify( + withWorkspaceServices(({ workspaces }) => + workspaces.registerHost(ownerId, workspacePath, options.mode as 'clone' | 'direct'), + ), + null, + 2, + ), + ); + }); + + cli + .command('approval [id]', 'List or decide workspace approval requests') + .action((action: string, id?: string) => { + if (action === 'list') { + if (id) { + throw new Error('approval list does not accept an ID'); + } + console.log( + JSON.stringify( + withWorkspaceServices(({ database }) => database.listApprovals()), + null, + 2, + ), + ); + return; + } + if (action !== 'approve' && action !== 'reject') { + throw new Error(`Unknown approval action: ${action}`); + } + if (!id) { + throw new Error(`approval ${action} requires an ID`); + } + console.log( + JSON.stringify( + withWorkspaceServices(({ workspaces }) => + action === 'approve' ? workspaces.approve(id) : workspaces.reject(id), + ), + null, + 2, + ), + ); + }); + + cli.command('help [command]', 'Show help for a command').action((commandName?: string) => { + if (!commandName) { + cli.unsetMatchedCommand(); + cli.outputHelp(); + return; + } + const command = cli.commands.find((candidate) => candidate.name === commandName); + if (!command) { + throw new Error(`Unknown command: ${commandName}`); + } + command.outputHelp(); + }); + + return cli; +} + +export async function runCli(argv: string[] = process.argv): Promise { + const cli = createCli(); + if (argv.length <= 2) { + cli.outputHelp(); + return; + } + cli.parse(argv, { run: false }); + if (!cli.matchedCommand) { + if (cli.options.help || cli.options.version) { + return; + } + throw new Error(`Unknown command: ${String(cli.args[0])}`); + } + await cli.runMatchedCommand(); +} diff --git a/src/cli/setup.ts b/src/cli/setup.ts new file mode 100644 index 0000000..061454b --- /dev/null +++ b/src/cli/setup.ts @@ -0,0 +1,89 @@ +import { execFile } from 'node:child_process'; +import { access, mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import type { RuntimeConfig } from '../config.js'; + +const execFileAsync = promisify(execFile); + +interface TemplateImage { + readonly repository: string; + readonly tag: string; +} + +function normalizedImage(value: string): string { + return value.replace(/^docker\.io\/library\//, ''); +} + +async function templateExists(config: RuntimeConfig): Promise { + const { stdout } = await execFileAsync(config.sbxBinary, ['template', 'ls', '--json']); + const parsed = JSON.parse(stdout) as { images: TemplateImage[] }; + return parsed.images.some( + (image) => normalizedImage(`${image.repository}:${image.tag}`) === config.sandboxTemplate, + ); +} + +async function assertTunnelFiles(config: RuntimeConfig): Promise { + if (!config.tunnelEnabled) { + return; + } + for (const requiredPath of [config.tunnelClient, config.tunnelKeyPath, config.tunnelIdPath]) { + try { + await access(requiredPath); + } catch { + throw new Error(`Missing required tunnel file: ${requiredPath}`); + } + } +} + +export async function setup(config: RuntimeConfig): Promise { + await assertTunnelFiles(config); + await execFileAsync(config.sbxBinary, ['version']); + if (await templateExists(config)) { + console.log(`Sandbox template ready: ${config.sandboxTemplate}`); + console.log('chat2shell setup complete'); + return; + } + + const bootstrapDirectory = await mkdtemp(path.join(os.tmpdir(), 'chat2shell-template-')); + const bootstrapName = `c2s-template-${Date.now()}`; + const codexProVersion = config.sandboxTemplate.split(':').at(-1); + if (!codexProVersion) { + throw new Error(`Sandbox template has no CodexPro version: ${config.sandboxTemplate}`); + } + try { + console.log(`Creating sandbox template: ${config.sandboxTemplate}`); + await execFileAsync(config.sbxBinary, [ + 'create', + '--quiet', + '--name', + bootstrapName, + 'shell', + bootstrapDirectory, + ]); + await execFileAsync(config.sbxBinary, [ + 'exec', + '-u', + 'root', + bootstrapName, + 'npm', + 'install', + '--global', + '--omit=dev', + `codexpro@${codexProVersion}`, + ]); + await execFileAsync(config.sbxBinary, ['stop', bootstrapName]); + await execFileAsync(config.sbxBinary, [ + 'template', + 'save', + bootstrapName, + config.sandboxTemplate, + ]); + } finally { + await execFileAsync(config.sbxBinary, ['rm', '--force', bootstrapName]).catch(() => undefined); + await rm(bootstrapDirectory, { force: true, recursive: true }); + } + console.log(`Sandbox template ready: ${config.sandboxTemplate}`); + console.log('chat2shell setup complete'); +} diff --git a/src/cli/status.ts b/src/cli/status.ts new file mode 100644 index 0000000..3fa44bf --- /dev/null +++ b/src/cli/status.ts @@ -0,0 +1,47 @@ +import { readFile } from 'node:fs/promises'; +import type { RuntimeConfig } from '../config.js'; + +async function isReady(url: string): Promise { + try { + return (await fetch(url, { signal: AbortSignal.timeout(2_000) })).ok; + } catch { + return false; + } +} + +async function runningPid(config: RuntimeConfig): Promise { + try { + const pid = Number((await readFile(config.runtimePidPath, 'utf8')).trim()); + if (!Number.isSafeInteger(pid) || pid <= 0) { + return undefined; + } + process.kill(pid, 0); + return pid; + } catch { + return undefined; + } +} + +export async function status(config: RuntimeConfig): Promise { + const pid = await runningPid(config); + if (!pid) { + console.log('Service stopped'); + return false; + } + + const mcpReady = await isReady(`http://${config.host}:${config.port}/healthz`); + let tunnelState = 'disabled'; + if (config.tunnelEnabled) { + try { + const healthUrl = (await readFile(config.tunnelHealthUrlPath, 'utf8')).trim(); + tunnelState = (await isReady(`${healthUrl}/readyz`)) ? 'ready' : 'not ready'; + } catch { + tunnelState = 'not ready'; + } + } + + console.log(`Service running (PID ${pid})`); + console.log(`MCP ${mcpReady ? `ready at ${config.host}:${config.port}` : 'not ready'}`); + console.log(`Tunnel ${tunnelState}`); + return mcpReady && tunnelState !== 'not ready'; +} diff --git a/src/config.ts b/src/config.ts index d4169e4..92bb9b5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,15 @@ export interface AppConfig { readonly reaperIntervalMs: number; } +export interface RuntimeConfig extends AppConfig { + readonly runtimePidPath: string; + readonly tunnelEnabled: boolean; + readonly tunnelClient: string; + readonly tunnelKeyPath: string; + readonly tunnelIdPath: string; + readonly tunnelHealthUrlPath: string; +} + function readPort(value: string | undefined, fallback: number, name: string): number { const port = Number(value ?? fallback); if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { @@ -68,3 +77,19 @@ export function loadAppConfig(environment: NodeJS.ProcessEnv = process.env): App reaperIntervalMs: 60_000, }; } + +export function loadRuntimeConfig(environment: NodeJS.ProcessEnv = process.env): RuntimeConfig { + const config = loadAppConfig(environment); + const tunnelSecretDir = resolvePath( + environment.CHAT2SHELL_SECRET_DIR ?? '~/.secrets/tunnel-client', + ); + return { + ...config, + runtimePidPath: path.join(config.stateDir, 'runtime.pid'), + tunnelEnabled: environment.CHAT2SHELL_ENABLE_TUNNEL !== '0', + tunnelClient: resolvePath(environment.CHAT2SHELL_TUNNEL_CLIENT ?? '~/.local/bin/tunnel-client'), + tunnelKeyPath: path.join(tunnelSecretDir, 'key'), + tunnelIdPath: path.join(tunnelSecretDir, 'tunnel-id'), + tunnelHealthUrlPath: path.join(config.stateDir, 'health.url'), + }; +} diff --git a/src/mcp/compatibility.ts b/src/mcp/compatibility.ts deleted file mode 100644 index a5d05dc..0000000 --- a/src/mcp/compatibility.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { IncomingHttpHeaders } from 'node:http'; - -interface JsonRpcRequest { - readonly id?: unknown; - readonly method?: unknown; -} - -export interface JsonRpcErrorResponse { - readonly jsonrpc: '2.0'; - readonly id: unknown; - readonly error: { - readonly code: number; - readonly message: string; - }; -} - -export function sessionlessDiscoverResponse( - headers: IncomingHttpHeaders, - body: Buffer, -): JsonRpcErrorResponse | undefined { - if (typeof headers['mcp-session-id'] === 'string') { - return undefined; - } - - try { - const request = JSON.parse(body.toString('utf8')) as JsonRpcRequest; - if (request.method !== 'server/discover') { - return undefined; - } - - return { - jsonrpc: '2.0', - id: request.id ?? null, - error: { code: -32601, message: 'Method not found' }, - }; - } catch { - return undefined; - } -} diff --git a/src/mcp/gateway.ts b/src/mcp/gateway.ts index 6965331..f09694a 100644 --- a/src/mcp/gateway.ts +++ b/src/mcp/gateway.ts @@ -2,7 +2,6 @@ import http, { type IncomingMessage, type Server, type ServerResponse } from 'no import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import type { AuthProvider } from '../auth/provider.js'; import type { AppConfig } from '../config.js'; -import { sessionlessDiscoverResponse } from './compatibility.js'; import { type ControlServerDependencies, createControlServer } from './control-server.js'; type ControlServerWithoutPrincipal = Omit; @@ -62,11 +61,6 @@ async function handleRequest( const principal = await dependencies.authProvider.authenticate(request.headers); const body = await readBody(request, config.maxBodyBytes); - const compatibilityResponse = sessionlessDiscoverResponse(request.headers, body); - if (compatibilityResponse) { - sendJson(response, 200, compatibilityResponse); - return; - } let parsedBody: unknown; try { parsedBody = JSON.parse(body.toString('utf8')); @@ -86,6 +80,8 @@ async function handleRequest( }); await mcpServer.connect(transport); try { + // MCP 2026-07-28 clients may probe `server/discover` before initialization. + // SDK 1.x returns -32601 here, which tells them to fall back to legacy `initialize`. await transport.handleRequest(request, response, parsedBody); } finally { await transport.close(); diff --git a/src/mcp/main.ts b/src/mcp/main.ts deleted file mode 100644 index a8e49c3..0000000 --- a/src/mcp/main.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { SingleUserAuthProvider } from '../auth/single-user-provider.js'; -import { BashSessionService } from '../codexpro/bash-sessions.js'; -import { CodexProClientPool } from '../codexpro/client-pool.js'; -import { publicCodexProTools } from '../codexpro/tool-manifest.js'; -import { loadAppConfig } from '../config.js'; -import { SbxDriver } from '../sandbox/sbx-driver.js'; -import { SandboxService } from '../sandbox/service.js'; -import { StateDatabase } from '../state/database.js'; -import { WorkspaceService } from '../workspaces/service.js'; -import { createGateway } from './gateway.js'; - -const config = loadAppConfig(); -const database = new StateDatabase(config.databasePath); -const workspaces = new WorkspaceService({ - database, - workspaceRoot: config.workspaceRoot, - dataRoot: config.dataRoot, - allowedHostRoots: config.allowedHostRoots, -}); -const driver = new SbxDriver({ - binary: config.sbxBinary, - template: config.sandboxTemplate, - sandboxPort: config.sandboxPort, -}); -await driver.assertReady(); -const sandboxes = new SandboxService({ database, workspaces, driver, config }); -await sandboxes.reconcile(); -const codexPro = new CodexProClientPool(sandboxes); -const bashSessions = new BashSessionService(codexPro, (listener) => sandboxes.onDestroy(listener)); -const codexProTools = publicCodexProTools(); -const server = createGateway(config, { - authProvider: new SingleUserAuthProvider(), - controlServer: { sandboxes, workspaces, codexPro, bashSessions, codexProTools }, -}); - -server.listen(config.port, config.host, () => { - console.log(`[chat2shell] listening on http://${config.host}:${config.port}/mcp`); - console.log(`[chat2shell] ${codexProTools.length} CodexPro tools are sandbox-scoped`); -}); - -const reaper = setInterval(() => { - sandboxes.reap().catch((error) => console.error('[chat2shell] reaper failed', error)); -}, config.reaperIntervalMs); -reaper.unref(); - -let shuttingDown = false; - -async function shutdown(): Promise { - if (shuttingDown) { - return; - } - shuttingDown = true; - clearInterval(reaper); - await new Promise((resolve) => { - server.close(() => resolve()); - }); - await codexPro.closeAll(); - database.close(); -} - -function requestShutdown(): void { - void shutdown().then( - () => process.exit(0), - (error: unknown) => { - console.error('[chat2shell] shutdown failed', error); - process.exit(1); - }, - ); -} - -process.on('SIGINT', requestShutdown); -process.on('SIGTERM', requestShutdown); diff --git a/src/runtime/serve.ts b/src/runtime/serve.ts new file mode 100644 index 0000000..308c3a0 --- /dev/null +++ b/src/runtime/serve.ts @@ -0,0 +1,217 @@ +import type { Server } from 'node:http'; +import { type ChildProcess, spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { access, chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { SingleUserAuthProvider } from '../auth/single-user-provider.js'; +import { BashSessionService } from '../codexpro/bash-sessions.js'; +import { CodexProClientPool } from '../codexpro/client-pool.js'; +import { publicCodexProTools } from '../codexpro/tool-manifest.js'; +import { type RuntimeConfig, loadRuntimeConfig } from '../config.js'; +import { createGateway } from '../mcp/gateway.js'; +import { SbxDriver } from '../sandbox/sbx-driver.js'; +import { SandboxService } from '../sandbox/service.js'; +import { StateDatabase } from '../state/database.js'; +import { WorkspaceService } from '../workspaces/service.js'; + +async function assertRuntimeAvailable(config: RuntimeConfig): Promise { + if (!config.tunnelEnabled) { + return; + } + await Promise.all([ + access(config.tunnelClient), + access(config.tunnelKeyPath), + access(config.tunnelIdPath), + ]); +} + +async function claimRuntime(config: RuntimeConfig): Promise { + await mkdir(config.stateDir, { mode: 0o700, recursive: true }); + await chmod(config.stateDir, 0o700); + let existingPid: number | undefined; + try { + existingPid = Number((await readFile(config.runtimePidPath, 'utf8')).trim()); + } catch (error) { + if (!isErrno(error, 'ENOENT')) { + throw error; + } + } + if (Number.isSafeInteger(existingPid) && existingPid && existingPid > 0) { + try { + process.kill(existingPid, 0); + throw new Error(`chat2shell is already running with PID ${existingPid}`); + } catch (error) { + if (!isErrno(error, 'ESRCH')) { + throw error; + } + } + } + await rm(config.tunnelHealthUrlPath, { force: true }); + await writeFile(config.runtimePidPath, `${process.pid}\n`, { mode: 0o600 }); +} + +function isErrno(error: unknown, code: string): boolean { + return ( + error instanceof Error && + 'code' in error && + typeof error.code === 'string' && + error.code === code + ); +} + +async function releaseRuntime(config: RuntimeConfig): Promise { + await Promise.all([ + rm(config.runtimePidPath, { force: true }), + rm(config.tunnelHealthUrlPath, { force: true }), + ]); +} + +async function runRuntime(config: RuntimeConfig): Promise { + const database = new StateDatabase(config.databasePath); + const workspaces = new WorkspaceService({ + database, + workspaceRoot: config.workspaceRoot, + dataRoot: config.dataRoot, + allowedHostRoots: config.allowedHostRoots, + }); + const driver = new SbxDriver({ + binary: config.sbxBinary, + template: config.sandboxTemplate, + sandboxPort: config.sandboxPort, + }); + let server: Server | undefined; + let tunnel: ChildProcess | undefined; + let codexPro: CodexProClientPool | undefined; + let reaper: NodeJS.Timeout | undefined; + + let requestStop!: () => void; + const stopRequested = new Promise((resolve) => { + requestStop = resolve; + }); + + const onSignal = (): void => requestStop(); + + process.once('SIGINT', onSignal); + process.once('SIGTERM', onSignal); + + try { + await driver.assertReady(); + console.log('[chat2shell] reconciling previous sandbox state'); + const sandboxes = new SandboxService({ database, workspaces, driver, config }); + await sandboxes.reconcile(); + + codexPro = new CodexProClientPool(sandboxes); + const bashSessions = new BashSessionService(codexPro, (listener) => + sandboxes.onDestroy(listener), + ); + const codexProTools = publicCodexProTools(); + server = createGateway(config, { + authProvider: new SingleUserAuthProvider(), + controlServer: { sandboxes, workspaces, codexPro, bashSessions, codexProTools }, + }); + await listen(server, config); + console.log(`[chat2shell] MCP ready at http://${config.host}:${config.port}/mcp`); + console.log(`[chat2shell] ${codexProTools.length} CodexPro tools are sandbox-scoped`); + + reaper = setInterval(() => { + sandboxes.reap().catch((error) => console.error('[chat2shell] reaper failed', error)); + }, config.reaperIntervalMs); + reaper.unref(); + + if (!config.tunnelEnabled) { + console.log('[chat2shell] tunnel disabled'); + await stopRequested; + return; + } + + const tunnelId = (await readFile(config.tunnelIdPath, 'utf8')).trim(); + if (!tunnelId) { + throw new Error('Tunnel ID file is empty'); + } + const tunnelProcess = startTunnel(config, tunnelId); + tunnel = tunnelProcess; + console.log('[chat2shell] tunnel client started'); + const tunnelExit = new Promise<{ + kind: 'tunnel'; + code: number | null; + signal: NodeJS.Signals | null; + }>((resolve, reject) => { + tunnelProcess.once('exit', (code, signal) => resolve({ kind: 'tunnel', code, signal })); + tunnelProcess.once('error', reject); + }); + const outcome = await Promise.race([ + stopRequested.then(() => ({ kind: 'stop' as const })), + tunnelExit, + ]); + if (outcome.kind === 'tunnel' && outcome.code !== 0) { + throw new Error( + `tunnel-client exited unexpectedly (${outcome.signal ? `signal ${outcome.signal}` : `code ${String(outcome.code)}`})`, + ); + } + } finally { + process.off('SIGINT', onSignal); + process.off('SIGTERM', onSignal); + if (reaper) { + clearInterval(reaper); + } + await stopChild(tunnel); + if (server) { + await closeServer(server); + } + await codexPro?.closeAll(); + database.close(); + } +} + +export async function serve(config: RuntimeConfig = loadRuntimeConfig()): Promise { + await assertRuntimeAvailable(config); + await claimRuntime(config); + try { + await runRuntime(config); + } finally { + await releaseRuntime(config); + } +} + +async function listen(server: Server, config: RuntimeConfig): Promise { + server.listen(config.port, config.host); + await once(server, 'listening'); +} + +async function closeServer(server: Server): Promise { + if (!server.listening) { + return; + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +async function stopChild(child: ChildProcess | undefined): Promise { + if (!child || child.exitCode !== null || child.signalCode !== null) { + return; + } + child.kill('SIGTERM'); + await once(child, 'exit'); +} + +function startTunnel(config: RuntimeConfig, tunnelId: string): ChildProcess { + return spawn( + config.tunnelClient, + [ + 'run', + '--mcp.server-url', + `http://${config.host}:${config.port}/mcp`, + '--control-plane.api-key', + `file:${config.tunnelKeyPath}`, + '--control-plane.tunnel-id', + tunnelId, + '--health.listen-addr', + '127.0.0.1:0', + '--health.url-file', + config.tunnelHealthUrlPath, + '--log.file', + `${config.stateDir}/tunnel-client.log`, + ], + { stdio: 'inherit' }, + ); +} diff --git a/src/sandbox/sbx-driver.ts b/src/sandbox/sbx-driver.ts index fed7fb7..fd5ac18 100644 --- a/src/sandbox/sbx-driver.ts +++ b/src/sandbox/sbx-driver.ts @@ -62,9 +62,7 @@ export class SbxDriver implements SandboxDriver { `${image.repository}:${image.tag}`.replace(/^docker\.io\/library\//, '') === requested, ); if (!present) { - throw new Error( - `Missing sandbox template ${this.#template}. Run scripts/setup-template.sh first.`, - ); + throw new Error(`Missing sandbox template ${this.#template}. Run chat2shell setup first.`); } } @@ -81,8 +79,6 @@ export class SbxDriver implements SandboxDriver { this.#template, '--publish', String(this.#sandboxPort), - '--deny-network', - 'openrouter.ai', ]; if (workspace.mode === 'clone') { args.push('--clone'); diff --git a/test/e2e/sandbox-lifecycle.test.ts b/test/e2e/sandbox-lifecycle.test.ts index a22bbdf..a7f3cd4 100644 --- a/test/e2e/sandbox-lifecycle.test.ts +++ b/test/e2e/sandbox-lifecycle.test.ts @@ -135,18 +135,6 @@ test('routes full shell and private Docker only into a real microVM', async () = }; expect(created.status).toBe('created'); sandboxId = created.sandbox.id; - const runtimeName = `c2s-${sandboxId.slice(4, 25)}`; - - const policy = spawnSync( - 'sbx', - ['policy', 'check', 'network', '--sandbox', runtimeName, 'openrouter.ai'], - { encoding: 'utf8' }, - ); - expect(policy.status).toBe(1); - expect( - policy.stdout, - 'chat2shell sandboxes must not use the inherited opencodex credential domain', - ).toMatch(/Denied/i); const write = await callTool(url, 4, 'write', { content: 'isolated\n', diff --git a/test/integration/bash-sessions.test.ts b/test/integration/bash-sessions.test.ts index 5ec9820..1936b44 100644 --- a/test/integration/bash-sessions.test.ts +++ b/test/integration/bash-sessions.test.ts @@ -341,7 +341,6 @@ test('waits for completion bytes instead of spinning on partial UTF-8', async () expect(started.structuredContent?.has_more_output).toBe(false); const polled = await sessions.poll('owner', 'sandbox', id, { yieldTimeMs: 2_000 }); - expect(polled.structuredContent?.status).toBe('exited'); expect(polled.structuredContent?.output).toBe('😀'); }); diff --git a/test/integration/cli.test.ts b/test/integration/cli.test.ts new file mode 100644 index 0000000..b62705f --- /dev/null +++ b/test/integration/cli.test.ts @@ -0,0 +1,88 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, expect, test, vi } from 'vitest'; +import { runCli } from '../../src/cli/program.js'; +import { loadAppConfig } from '../../src/config.js'; +import { StateDatabase } from '../../src/state/database.js'; +import { WorkspaceService } from '../../src/workspaces/service.js'; + +const roots: string[] = []; + +afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +async function environment(): Promise<{ hostRoot: string; root: string }> { + const root = await mkdtemp(path.join(os.tmpdir(), 'chat2shell-cli-')); + const hostRoot = path.join(root, 'repositories'); + await mkdir(hostRoot); + roots.push(root); + vi.stubEnv('CHAT2SHELL_DATA_ROOT', path.join(root, 'data')); + vi.stubEnv('CHAT2SHELL_ALLOWED_HOST_ROOTS', hostRoot); + return { hostRoot, root }; +} + +test('executes workspace list and add commands', async () => { + const { hostRoot } = await environment(); + const repository = path.join(hostRoot, 'project'); + await mkdir(repository); + const output: string[] = []; + vi.spyOn(console, 'log').mockImplementation((message) => output.push(String(message))); + + await runCli(['node', 'chat2shell', 'workspace', 'list']); + expect(JSON.parse(output.pop() ?? '')).toEqual([]); + + await runCli(['node', 'chat2shell', 'workspace', 'add', repository, '--mode', 'direct']); + expect(JSON.parse(output.pop() ?? '')).toMatchObject({ mode: 'direct', root: repository }); + + await runCli(['node', 'chat2shell', 'workspace', 'list']); + expect(JSON.parse(output.pop() ?? '')).toEqual([ + expect.objectContaining({ mode: 'direct', root: repository }), + ]); +}); + +test('executes approval list, approve, and reject commands', async () => { + const { hostRoot } = await environment(); + const approvePath = path.join(hostRoot, 'approve'); + const rejectPath = path.join(hostRoot, 'reject'); + await Promise.all([mkdir(approvePath), mkdir(rejectPath)]); + const config = loadAppConfig(); + const database = new StateDatabase(config.databasePath); + const workspaces = new WorkspaceService({ + allowedHostRoots: config.allowedHostRoots, + database, + dataRoot: config.dataRoot, + workspaceRoot: config.workspaceRoot, + }); + const approve = workspaces.requestHost('local-owner', approvePath, 'clone'); + const reject = workspaces.requestHost('local-owner', rejectPath, 'direct'); + database.close(); + const output: string[] = []; + vi.spyOn(console, 'log').mockImplementation((message) => output.push(String(message))); + + await runCli(['node', 'chat2shell', 'approval', 'list']); + expect(JSON.parse(output.pop() ?? '')).toHaveLength(2); + + await runCli(['node', 'chat2shell', 'approval', 'approve', approve.id]); + expect(JSON.parse(output.pop() ?? '')).toMatchObject({ mode: 'clone', root: approvePath }); + + await runCli(['node', 'chat2shell', 'approval', 'reject', reject.id]); + expect(JSON.parse(output.pop() ?? '')).toMatchObject({ id: reject.id, status: 'rejected' }); +}); + +test('rejects unknown commands and management actions', async () => { + await environment(); + + await expect(runCli(['node', 'chat2shell', 'does-not-exist'])).rejects.toThrow( + 'Unknown command: does-not-exist', + ); + await expect(runCli(['node', 'chat2shell', 'workspace', 'remove'])).rejects.toThrow( + 'Unknown workspace action: remove', + ); + await expect(runCli(['node', 'chat2shell', 'approval', 'approve'])).rejects.toThrow( + 'approval approve requires an ID', + ); +}); diff --git a/test/integration/gateway.test.ts b/test/integration/gateway.test.ts index 62caa13..8ad2561 100644 --- a/test/integration/gateway.test.ts +++ b/test/integration/gateway.test.ts @@ -115,6 +115,9 @@ test('serves management tools itself instead of proxying to a host CodexPro', as const port = await listen(gateway); const url = `http://127.0.0.1:${port}/mcp`; + const discovery = await rpc(url, 0, 'server/discover'); + expect(discovery.error).toEqual({ code: -32601, message: 'Method not found' }); + await rpc(url, 1, 'initialize', { capabilities: {}, clientInfo: { name: 'test', version: '1' }, diff --git a/test/integration/serve.test.ts b/test/integration/serve.test.ts new file mode 100644 index 0000000..60ad3c9 --- /dev/null +++ b/test/integration/serve.test.ts @@ -0,0 +1,28 @@ +import { chmod, mkdir, mkdtemp, rm, stat } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { loadRuntimeConfig } from '../../src/config.js'; +import { serve } from '../../src/runtime/serve.js'; + +test('makes an existing state directory owner-only before startup', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'chat2shell-serve-')); + const stateDir = path.join(root, 'state'); + await mkdir(stateDir); + await chmod(stateDir, 0o755); + const config = { + ...loadRuntimeConfig({ + CHAT2SHELL_DATA_ROOT: path.join(root, 'data'), + CHAT2SHELL_ENABLE_TUNNEL: '0', + CHAT2SHELL_STATE_DIR: stateDir, + }), + sbxBinary: path.join(root, 'missing-sbx'), + }; + + try { + await expect(serve(config)).rejects.toThrow(); + expect((await stat(stateDir)).mode & 0o777).toBe(0o700); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/test/integration/status.test.ts b/test/integration/status.test.ts new file mode 100644 index 0000000..4c12b09 --- /dev/null +++ b/test/integration/status.test.ts @@ -0,0 +1,68 @@ +import { once } from 'node:events'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, expect, test, vi } from 'vitest'; +import { status } from '../../src/cli/status.js'; +import { loadRuntimeConfig } from '../../src/config.js'; + +const roots: string[] = []; + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +test('reports a stopped service when no live PID exists', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'chat2shell-status-')); + roots.push(root); + const output: string[] = []; + vi.spyOn(console, 'log').mockImplementation((message) => output.push(String(message))); + + const ready = await status( + loadRuntimeConfig({ + CHAT2SHELL_DATA_ROOT: path.join(root, '.chat2shell'), + CHAT2SHELL_ENABLE_TUNNEL: '0', + }), + ); + + expect(ready).toBe(false); + expect(output).toEqual(['Service stopped']); +}); + +test('checks the running process and MCP health', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'chat2shell-status-')); + roots.push(root); + const server = http.createServer((_request, response) => { + response.writeHead(200).end('{"status":"ok"}'); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected a TCP listener'); + } + + const config = loadRuntimeConfig({ + CHAT2SHELL_DATA_ROOT: path.join(root, '.chat2shell'), + CHAT2SHELL_ENABLE_TUNNEL: '0', + CHAT2SHELL_PORT: String(address.port), + }); + await mkdir(config.stateDir, { recursive: true }); + await writeFile(config.runtimePidPath, `${process.pid}\n`); + const output: string[] = []; + vi.spyOn(console, 'log').mockImplementation((message) => output.push(String(message))); + + try { + expect(await status(config)).toBe(true); + expect(output).toEqual([ + `Service running (PID ${process.pid})`, + `MCP ready at 127.0.0.1:${address.port}`, + 'Tunnel disabled', + ]); + } finally { + server.close(); + await once(server, 'close'); + } +}); diff --git a/test/unit/cli.test.ts b/test/unit/cli.test.ts new file mode 100644 index 0000000..f8e389a --- /dev/null +++ b/test/unit/cli.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from 'vitest'; +import { createCli } from '../../src/cli/program.js'; + +test('exposes only the supported CLI commands', () => { + expect(createCli().commands.map((command) => command.rawName)).toEqual([ + 'setup', + 'serve', + 'status', + 'workspace [path]', + 'approval [id]', + 'help [command]', + ]); +}); diff --git a/test/unit/compatibility.test.ts b/test/unit/compatibility.test.ts deleted file mode 100644 index c178bb4..0000000 --- a/test/unit/compatibility.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { expect, test } from 'vitest'; -import { sessionlessDiscoverResponse } from '../../src/mcp/compatibility.js'; - -test('returns method-not-found for a sessionless server/discover probe', () => { - const response = sessionlessDiscoverResponse( - {}, - Buffer.from('{"jsonrpc":"2.0","id":"probe","method":"server/discover","params":{}}'), - ); - - expect(response).toEqual({ - error: { code: -32601, message: 'Method not found' }, - id: 'probe', - jsonrpc: '2.0', - }); -}); - -test('does not intercept established MCP sessions', () => { - const response = sessionlessDiscoverResponse( - { 'mcp-session-id': 'session-1' }, - Buffer.from('{"jsonrpc":"2.0","id":1,"method":"server/discover"}'), - ); - - expect(response).toBeUndefined(); -}); diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts index cd7e638..9812eb7 100644 --- a/test/unit/config.test.ts +++ b/test/unit/config.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import { loadAppConfig } from '../../src/config.js'; +import { loadAppConfig, loadRuntimeConfig } from '../../src/config.js'; test('uses the complete public lifecycle policy', () => { const config = loadAppConfig({ HOME: '/tmp/chat2shell-config-test' }); @@ -10,3 +10,16 @@ test('uses the complete public lifecycle policy', () => { expect('sandboxCpus' in config).toBe(false); expect('sandboxMemory' in config).toBe(false); }); + +test('uses the documented runtime locations without enabling extra service policy', () => { + const config = loadRuntimeConfig({ + CHAT2SHELL_DATA_ROOT: '/tmp/chat2shell-config-test/.chat2shell', + }); + + expect(config.runtimePidPath).toBe('/tmp/chat2shell-config-test/.chat2shell/state/runtime.pid'); + expect(config.tunnelHealthUrlPath).toBe( + '/tmp/chat2shell-config-test/.chat2shell/state/health.url', + ); + expect(config.tunnelEnabled).toBe(true); + expect('restart' in config).toBe(false); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json deleted file mode 100644 index 36a5ad7..0000000 --- a/tsconfig.build.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "declaration": true - }, - "include": ["src/**/*.ts"] -} diff --git a/tsdown.config.mjs b/tsdown.config.mjs new file mode 100644 index 0000000..044884a --- /dev/null +++ b/tsdown.config.mjs @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + clean: true, + entry: { cli: 'src/cli/main.ts' }, + format: 'esm', + platform: 'node', + sourcemap: true, + target: 'node24', +});