diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e89b0cf..7f44c7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,4 +32,5 @@ jobs: gzip -d "$RUNNER_TEMP/celld-x86_64-unknown-linux-gnu.gz" chmod +x "$RUNNER_TEMP/celld-x86_64-unknown-linux-gnu" echo "CELLD_BIN=$RUNNER_TEMP/celld-x86_64-unknown-linux-gnu" >> "$GITHUB_ENV" + - run: CELLD_PACKAGE_INTEGRATION=1 bun run test:package:prebuilt - run: bun run test:celld diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..572f163 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,71 @@ +name: Publish npm package + +on: + workflow_dispatch: + inputs: + version: + description: Exact package.json version to publish + required: true + type: string + +permissions: + contents: read + +concurrency: + group: npm-publish + cancel-in-progress: false + +jobs: + package: + if: github.repository == 'sidequery/canvas' && github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + - name: Verify release version + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + bun -e 'const p = await Bun.file("package.json").json(); if (p.name !== "@sidequery/canvas" || p.version !== process.env.RELEASE_VERSION) throw new Error("Release input must match @sidequery/canvas package.json version");' + - run: bun install --frozen-lockfile + - run: bun run typecheck + - run: bun run typecheck:cloudflare + - run: bun run test + - run: bun run build:package + - name: Pack once + run: bun pm pack --ignore-scripts --filename "$RUNNER_TEMP/canvas.tgz" + - name: Test release archive including native server persistence + env: + CANVAS_PACKAGE_ARCHIVE: ${{ runner.temp }}/canvas.tgz + CELLD_PACKAGE_INTEGRATION: '1' + run: bun test scripts/package.integration.test.ts --timeout 360000 + - uses: actions/upload-artifact@v4 + with: + name: npm-package + path: ${{ runner.temp }}/canvas.tgz + if-no-files-found: error + retention-days: 7 + + publish: + needs: package + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + id-token: write + steps: + - uses: actions/setup-node@v6 + with: + node-version: '24' + package-manager-cache: false + - uses: actions/download-artifact@v4 + with: + name: npm-package + path: release + # npm CLI performs the OIDC exchange. No NPM_TOKEN or NODE_AUTH_TOKEN. + - run: npm publish ./release/canvas.tgz --access public --ignore-scripts diff --git a/README.md b/README.md index 0ac3f5a..a0e03a3 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,32 @@ Only Herdr pane opening requires Herdr 0.8.2+ and [Terminal Browser](https://github.com/zenbu-labs/terminal-browser). The gallery, source operations, history, typechecking and compilation work without them. +## Install + +After the first registry release, install the CLI with Bun; a source checkout is +not required. The package is currently being prepared and is not published yet. + +```bash +bun add --global @sidequery/canvas +canvas write overview --file overview.canvas.tsx +canvas web --port 4784 +``` + +Open `http://127.0.0.1:4784`. The package includes the local CLI, stdio MCP +server, gallery, and the prebuilt Worker and assets used by the optional native +canvas server. It does not build from source during installation. + +For a stdio MCP client, configure the installed command directly: + +```json +{ + "command": "canvas", + "args": ["mcp", "--dir", "/absolute/path/to/workspace/canvases"] +} +``` + +Agents without MCP can use the CLI for the same artifact operations. + ## Run from source From the checkout: @@ -32,17 +58,7 @@ bun run canvas web --port 4784 Open `http://127.0.0.1:4784`. Your working canvases and UI state are ignored by this repository; reusable checked-in samples belong in `examples/`. -The package is private and is not published to a registry. The CLI has a `canvas` -bin entry for future packaging; `bun run canvas` works directly from this checkout. - -For a stdio MCP client, configure the command `bun` with arguments: - -```json -["run", "/absolute/path/to/canvas/src/cli.ts", "mcp", "--dir", "/absolute/path/to/workspace/canvases"] -``` - -Use your actual checkout path, including `herdr-canvas` if that is its folder name. -Agents without MCP can use the CLI for the same artifact operations. +`bun run canvas` uses the same CLI directly from this checkout. ## Optional Herdr integration @@ -68,11 +84,11 @@ Canvases live at `/canvases/.canvas.tsx` and may import only from `sidequery/canvas` (`herdr/canvas` and `cursor/canvas` remain compatibility aliases). Use the CLI or MCP: ```bash -bun run src/cli.ts list -bun run src/cli.ts write overview --file examples/overview.canvas.tsx -bun run src/cli.ts typecheck overview -bun run src/cli.ts open overview -bun run src/cli.ts mcp +canvas list +canvas write overview --file examples/overview.canvas.tsx +canvas typecheck overview +canvas open overview +canvas mcp ``` `open` typechecks and bundles with Bun, then opens the `herdr.canvas` `canvas` @@ -132,6 +148,20 @@ CLI `open` continues to open a Herdr pane. ## Native canvas servers and SQLite +The optional local native runtime starts in the foreground with `canvas server`. +It downloads a pinned, checksummed `celld` binary on first use and stores its +runtime and SQLite state under the operating system's application-data directory. +Use `canvas server start`, `canvas server stop`, `canvas server status`, and +`canvas server logs` for the opt-in background service. See +[`docs/daemon.md`](docs/daemon.md) for lifecycle and login-service details. + +The native server exposes its gallery at `http://127.0.0.1:4786` and its HTTP MCP +endpoint at `http://127.0.0.1:4786/mcp`. Configure an HTTP MCP client against that +endpoint to create canvases with server source and call `canvas_request`. The +`canvas web` gallery and `canvas mcp` stdio server continue to use workspace files +and the local history database; they do not migrate or synchronize canvases with +the native server. + Hosted Cloudflare canvases can pair the browser `.canvas.tsx` with a native `CanvasServer`. The server is a generated Durable Object class and uses its own SQLite through `ctx.storage.sql` and key/value storage through `ctx.storage.kv` directly. The browser calls it with @@ -328,6 +358,7 @@ or delete action; restoring working source remains an explicit CLI/MCP operation bun run test bun run test:e2e bun run test:mcp-ui +bun run test:package bun run typecheck ``` @@ -340,7 +371,8 @@ GitHub Actions runs the typecheck and unit/service suite with Bun 1.4.0. The root Dockerfile is an integration-test environment, not a production image. `bun run test:mcp-ui` exercises the real canvas in Chromium with an MCP Apps host. Install its browser first with `bun x playwright install chromium`. CI runs this -browser suite as well as the unit/service suite. +browser suite, the unit/service suite, and a clean tarball install that exercises +the installed CLI, gallery, compilation, and stdio MCP server. ## Trust boundary diff --git a/bun.lock b/bun.lock index 2bb53e4..55dd4c5 100644 --- a/bun.lock +++ b/bun.lock @@ -4,24 +4,25 @@ "workspaces": { "": { "dependencies": { - "@better-auth/mcp": "1.7.3", "@better-auth/oauth-provider": "1.7.3", - "@cloudflare/worker-bundler": "0.2.3", "@modelcontextprotocol/ext-apps": "^1.7.5", - "@modelcontextprotocol/sdk": "^1.30.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", - "ajv": "8.20.0", "better-auth": "1.7.3", - "hono": "4.13.7", - "jose": "6.2.12", + "esbuild": "0.28.1", "react": "^19.2.8", "react-dom": "^19.2.8", "typescript": "5.9.3", }, "devDependencies": { + "@better-auth/mcp": "1.7.3", + "@cloudflare/worker-bundler": "0.2.3", "@cloudflare/workers-types": "5.20260907.1", + "@modelcontextprotocol/sdk": "^1.30.0", "@types/bun": "^1.3.14", + "ajv": "8.20.0", + "hono": "4.13.7", + "jose": "6.2.12", "miniflare": "5.20260903.0-alpha", "playwright": "^1.63.0", "wrangler": "4.129.0", @@ -254,7 +255,7 @@ "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], @@ -514,12 +515,12 @@ "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], - "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], } } diff --git a/docs/daemon.md b/docs/daemon.md new file mode 100644 index 0000000..197126c --- /dev/null +++ b/docs/daemon.md @@ -0,0 +1,102 @@ +# Local Canvas server + +The native Canvas server is optional. It runs the packaged Worker and its +SQLite/KV state through a pinned celld runtime on `127.0.0.1`. The ordinary +Canvas CLI, stdio MCP server, local gallery, and artifact commands do not start +or download celld. + +## Foreground server + +Run the server in the current terminal: + +```sh +canvas server +``` + +The default address is `http://127.0.0.1:4786`. `--port` selects another port. +The server keeps its project and native state under the Canvas data directory; +`--state-dir PATH` overrides that project directory for a foreground run. Press +Ctrl-C to request a graceful stop. SIGTERM uses the same shutdown path. + +On first use, Canvas downloads celld 0.4.1 for a supported platform, verifies +the pinned archive and executable SHA-256 values, and installs it in the Canvas +data directory with user-only permissions. This release supports Apple Silicon +macOS and glibc Linux on arm64 or x64. The ordinary Bun commands do not require this native runtime; `canvas server` +reports an explicit error when the native platform is unsupported. + +## Background service + +Use the operating system's user service manager for crash recovery: + +```sh +canvas server start +canvas server status +canvas server logs +canvas server stop +``` + +`start` uses launchd on macOS and `systemd --user` on Linux. It first verifies +the packaged server assets and managed celld executable, then registers the +service and waits for owned-process readiness. A manual start does not enable +the service for future logins. `stop` unloads or stops the current job and waits +for graceful shutdown. It retains the server project and all SQLite/KV data. + +`status` reports the service manager and four separate facts: + +- `installedAtLogin`: the user explicitly enabled future login starts. +- `loaded`: the service definition is currently known to the manager. +- `running`: the manager reports a live main process. +- `ready`: that process published matching PID, port, and state-directory + readiness, and its loopback health endpoint responds successfully. + +The URL appears only when `ready` is true, so an unrelated process on the same +port cannot be reported as this Canvas service. Service identities include a +stable hash of `CANVAS_DATA_HOME`, which prevents commands for one data root +from stopping the service for another. Two roots still cannot listen on the +same port; `start` checks for that conflict before registration. + +`logs` prints the most recent 100 lines from the service log. Select between 1 +and 1000 lines with `canvas server logs --lines N`. Each read is capped at 256 +KiB even when the log is larger. + +## Start at login + +If the server is already running, stop it first. Then start it and opt in to +future login starts: + +```sh +canvas server start --at-login +``` + +Install `@sidequery/canvas` globally before enabling this option, for example +with `bun add --global @sidequery/canvas`. The supervisor definition records +the exact Bun executable and absolute installed `src/cli.ts` path. Do not create +a login service from `bunx` or another transient package cache: cleanup or cache +rotation can remove that recorded path. + +`canvas server stop` stops the current process but preserves the login setting, +so the service starts at the next login. Disable login startup, stop the service, +and remove its installed supervisor definition with: + +```sh +canvas server uninstall +``` + +Run `uninstall` before moving or removing the global package. Enabling login is +never part of package installation, upgrade, or publication. + +## Data and configuration + +Set `CANVAS_DATA_HOME` to choose a data root. Otherwise Canvas uses: + +- macOS: `~/Library/Application Support/sidequery-canvas` +- Linux: `${XDG_DATA_HOME:-~/.local/share}/sidequery-canvas` + +The durable native project is under `server`, the managed runtime is under +`runtimes`, and lifecycle state and logs are under `daemon`. On Linux, +`XDG_CONFIG_HOME` selects the systemd user-unit directory when set. + +Supervisor definitions forward only `CANVAS_DATA_HOME`; they do not copy the +invoking shell's environment or credentials. The native server binds only to +loopback. Its state is local application data and is not uploaded or backed up +automatically. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..75372f8 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,76 @@ +# Releasing @sidequery/canvas + +Canvas requires Bun 1.4 or newer. The bun package includes the CLI, its source +assets used by Bun at runtime, and the prebuilt Worker/assets used by the optional +local celld server. Consumers do not need Wrangler or a source checkout. + +## GitHub Actions releases + +The `Publish npm package` workflow (`.github/workflows/publish.yml`) uses npm +trusted publishing (OIDC). It runs only when manually dispatched on `main` in +`sidequery/canvas`; merging a PR does not publish. Enter the exact `package.json` +version when dispatching. The workflow checks types and source tests, builds one +tarball, exercises that archive in an isolated consumer including native server +persistence, then publishes the same archive from a separate GitHub-hosted job. +Only the publish job can request an OIDC token. Bun handles installation, builds, +and tests; Node 24's npm CLI handles the OIDC publish exchange. No registry token +is stored in GitHub or 1Password for routine releases. + +### One-time npm setup + +The package must first exist on npm before its trusted publisher can be configured. +For the first approved release, use the local validation and publication steps +below with interactive npm authentication (or a temporary bootstrap credential). +Then open `@sidequery/canvas` package settings on npm and add this trusted publisher: + +| Field | Value | +| --- | --- | +| Provider | GitHub Actions | +| Organization | `sidequery` | +| Repository | `canvas` | +| Workflow filename | `publish.yml` | +| Environment | Leave blank | +| Allowed actions | Enable direct `npm publish` | + +After a successful OIDC release, set package publishing access to **Require +two-factor authentication and disallow tokens** and revoke any temporary bootstrap +token. See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/). +Saving the configuration alone does not validate it; the first successful OIDC +publication verifies the connection. Subsequent releases need a new package version. + +## Local validation and first publication + +1. Update `version` in `package.json` and refresh `bun.lock` with `bun install`. +2. Run `bun run typecheck`, `bun run typecheck:cloudflare`, and `bun test src`. +3. Run `bun run test:package`. This builds the Worker and installs the actual + tarball into a separate consumer directory before exercising the CLI, gallery, + and MCP. +4. Run the native package check on a supported platform: + `CELLD_PACKAGE_INTEGRATION=1 bun run test:package`. +5. Run `bun pm pack --destination /tmp` to build a release tarball. Test that exact + archive with `CANVAS_PACKAGE_ARCHIVE=/tmp/sidequery-canvas-VERSION.tgz + CELLD_PACKAGE_INTEGRATION=1 bun test scripts/package.integration.test.ts --timeout 360000`. +6. After release approval and authentication for the `@sidequery` scope, + publish that tarball with `bun publish /tmp/sidequery-canvas-VERSION.tgz --access public`. + +A package build is not a registry publication. CI validates packaging without +publishing. Never enable a login daemon as an install or publish lifecycle script. +The user enables it explicitly through the [daemon commands](daemon.md). + +## celld upgrades + +The managed runtime is pinned in `src/celldRuntime.ts`. For an upgrade, obtain the +archive SHA-256 values from the upstream release, verify each platform archive, +and compute the decompressed executable hashes before updating the manifest. +Do not select `latest` dynamically. The cache is keyed by celld version and target; +existing servers keep their persistent data separately from executable versions. + +Qualify `bun run test:celld` and the installed native package check before changing +the pin. Include cold start, stored SQL/KV across restart, OAuth compatibility in +the runtime suite, and daemon lifecycle on supported service managers. The local +packaged server uses loopback single-user mode; deploying Better Auth remains the +separate configuration described in [Cloudflare setup](cloudflare.md). + +celld v0.4.1 publishes Apple Silicon macOS and glibc Linux arm64/x64 binaries. +Intel macOS and other targets cannot use this managed native server version. +The ordinary Bun CLI, stdio MCP, and local file gallery do not download celld. diff --git a/package.json b/package.json index 0bf8098..30baae6 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,84 @@ { - "name": "canvas", + "name": "@sidequery/canvas", "description": "Sidequery Canvas: React artifacts with a web gallery, version history, CLI, MCP, and optional Herdr integration.", "version": "0.1.0", - "private": true, "license": "MIT", "type": "module", + "sideEffects": false, + "repository": { + "type": "git", + "url": "git+https://github.com/sidequery/canvas.git" + }, + "homepage": "https://github.com/sidequery/canvas#readme", + "bugs": { + "url": "https://github.com/sidequery/canvas/issues" + }, "engines": { "bun": ">=1.4.0" }, + "exports": { + ".": "./src/sdk/index.ts" + }, "bin": { "canvas": "src/cli.ts" }, + "files": [ + "docs/daemon.md", + "docs/releasing.md", + "dist/celld/**", + "dist/cloudflare/**", + "dist/worker-app/**", + "src/actions/open.ts", + "src/auth/client-api.ts", + "src/auth/client.tsx", + "src/auth/server.ts", + "src/gallery/client.tsx", + "src/gallery/server.ts", + "src/gallery/types.ts", + "src/runtime/herdr-canvas-entry.d.ts", + "src/runtime/cloud-runtime.ts", + "src/runtime/gallery-request.ts", + "src/runtime/mcp-app.ts", + "src/runtime/wrapper.tsx", + "src/sdk/charts.tsx", + "src/sdk/forms.tsx", + "src/sdk/hooks.ts", + "src/sdk/index.ts", + "src/sdk/server.ts", + "src/sdk/tokens.ts", + "src/sdk/ui.tsx", + "src/args.ts", + "src/canvasFile.ts", + "src/canvasGuide.ts", + "src/celldDaemon.ts", + "src/celldRuntime.ts", + "src/celldServer.ts", + "src/cli.ts", + "src/compile.ts", + "src/daemon.ts", + "src/diagnostics.ts", + "src/herdr.ts", + "src/history.ts", + "src/historyTypes.ts", + "src/html.ts", + "src/httpTypes.ts", + "src/mcp.ts", + "src/mcpApp.ts", + "src/mcpAppContract.ts", + "src/mcpProtocol.ts", + "src/mcpServer.ts", + "src/mcpTools.ts", + "src/open.ts", + "src/paths.ts", + "src/sandbox.ts", + "src/serve.ts", + "src/service.ts", + "src/typecheck.ts", + "src/viewer.ts" + ], + "publishConfig": { + "access": "public" + }, "scripts": { "canvas": "bun run src/cli.ts", "mcp": "bun run src/cli.ts mcp", @@ -20,6 +88,10 @@ "typecheck": "tsc --noEmit", "dev:cloudflare": "wrangler dev --local --var ENVIRONMENT:local", "prepare:celld": "bun run scripts/prepare-celld.ts", + "build:package": "bun run scripts/build-package.ts", + "prepack": "bun run build:package", + "test:package": "bun run build:package && bun test scripts/package.integration.test.ts --timeout 360000", + "test:package:prebuilt": "bun run scripts/build-package.ts --prebuilt-worker && bun test scripts/package.integration.test.ts --timeout 360000", "dev:celld": "bun run build:cloudflare && bun run prepare:celld && bun run scripts/dev-celld.ts", "build:cloudflare": "wrangler deploy --dry-run --outdir dist/worker-app", "build:cloudflare-compiler": "wrangler deploy --config cloudflare/wrangler.compiler.jsonc --dry-run --outdir ../dist/worker", @@ -30,24 +102,25 @@ "seed:cloudflare": "bun run scripts/seed-cloudflare.ts" }, "dependencies": { - "@better-auth/mcp": "1.7.3", "@better-auth/oauth-provider": "1.7.3", - "@cloudflare/worker-bundler": "0.2.3", "@modelcontextprotocol/ext-apps": "^1.7.5", - "@modelcontextprotocol/sdk": "^1.30.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", - "ajv": "8.20.0", "better-auth": "1.7.3", - "hono": "4.13.7", - "jose": "6.2.12", + "esbuild": "0.28.1", "react": "^19.2.8", "react-dom": "^19.2.8", "typescript": "5.9.3" }, "devDependencies": { + "@better-auth/mcp": "1.7.3", + "@cloudflare/worker-bundler": "0.2.3", "@cloudflare/workers-types": "5.20260907.1", + "@modelcontextprotocol/sdk": "^1.30.0", "@types/bun": "^1.3.14", + "ajv": "8.20.0", + "hono": "4.13.7", + "jose": "6.2.12", "miniflare": "5.20260903.0-alpha", "playwright": "^1.63.0", "wrangler": "4.129.0" diff --git a/scripts/build-package.ts b/scripts/build-package.ts new file mode 100644 index 0000000..3670c16 --- /dev/null +++ b/scripts/build-package.ts @@ -0,0 +1,41 @@ +import { mkdir, rm, stat } from "node:fs/promises"; +import { join } from "node:path"; + +import { prepareCelldConfig } from "./prepare-celld"; + +const root = join(import.meta.dir, ".."); +const dist = join(root, "dist"); + +const usePrebuiltWorker = process.argv.includes("--prebuilt-worker"); +if (!usePrebuiltWorker) { + for (const directory of ["celld", "cloudflare", "worker-app"]) { + await rm(join(dist, directory), { recursive: true, force: true }); + } + + const build = Bun.spawn([process.execPath, "run", "build:cloudflare"], { + cwd: root, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + if (await build.exited !== 0) throw new Error("Cloudflare package build failed"); +} + +const celldDirectory = join(dist, "celld"); +await mkdir(celldDirectory, { recursive: true }); +await prepareCelldConfig(join(root, "wrangler.jsonc"), join(celldDirectory, "wrangler.jsonc"), { + main: "../worker-app/worker.js", + assets: "../cloudflare/assets", +}); + +for (const path of [ + "celld/wrangler.jsonc", + "cloudflare/assets/index.html", + "cloudflare/compiler-files.json", + "worker-app/worker.js", +]) { + const file = await stat(join(dist, path)); + if (!file.isFile() || file.size === 0) throw new Error(`Missing package artifact: dist/${path}`); +} + +console.log("Prepared package artifacts in dist/{celld,cloudflare,worker-app}"); diff --git a/scripts/package.integration.test.ts b/scripts/package.integration.test.ts new file mode 100644 index 0000000..2ecdf09 --- /dev/null +++ b/scripts/package.integration.test.ts @@ -0,0 +1,282 @@ +import { expect, test } from "bun:test"; +import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +const root = join(import.meta.dir, ".."); + +async function execute(command: string[], cwd: string, env: Record) { + const child = Bun.spawn(command, { cwd, env, stdin: "ignore", stdout: "pipe", stderr: "pipe" }); + const stdout = new Response(child.stdout).text(); + const stderr = new Response(child.stderr).text(); + const code = await child.exited; + return { code, stdout: await stdout, stderr: await stderr }; +} + +async function run(command: string[], cwd: string, env: Record) { + const result = await execute(command, cwd, env); + if (result.code !== 0) throw new Error(`${command.join(" ")} exited ${result.code}\n${result.stdout}\n${result.stderr}`); + return result; +} + +function reservePort(): number { + const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + const port = listener.port; + listener.stop(true); + return port; +} + +async function startNativeServer( + canvas: string, + consumer: string, + stateDir: string, + port: number, + env: Record, +) { + const child = Bun.spawn([process.execPath, canvas, "server", "--port", String(port), "--state-dir", stateDir], { + // Match service-manager environments: no interactive Bun/Node PATH entries. + cwd: consumer, env: { ...env, PATH: "/usr/bin:/bin" }, stdin: "ignore", stdout: "pipe", stderr: "pipe", + }); + const errors: string[] = []; + const stderr = (async () => { + if (!(child.stderr instanceof ReadableStream)) return; + for await (const chunk of child.stderr) errors.push(new TextDecoder().decode(chunk)); + })(); + try { + return { child, url: await readyUrl(child, 180_000), errors, stderr }; + } catch (error) { + child.kill(); + await child.exited; + await stderr; + throw new Error(`${error instanceof Error ? error.message : String(error)}\n${errors.join("")}`); + } +} + +async function stopNativeServer(server: Awaited>) { + server.child.kill("SIGINT"); + let watchdog: ReturnType | undefined; + const stopped = await Promise.race([ + server.child.exited.then(() => true), + new Promise(resolve => { watchdog = setTimeout(() => resolve(false), 50_000); }), + ]).finally(() => clearTimeout(watchdog)); + if (!stopped) { + server.child.kill("SIGKILL"); + await server.child.exited; + await server.stderr; + throw new Error(`installed native server did not stop gracefully\n${server.errors.join("")}`); + } + const code = await server.child.exited; + await server.stderr; + if (code !== 0) throw new Error(`installed native server exited ${code}\n${server.errors.join("")}`); +} + +async function readyUrl(child: ReturnType, timeoutMilliseconds = 15_000): Promise { + if (!(child.stdout instanceof ReadableStream)) throw new Error("gallery stdout is unavailable"); + const reader = child.stdout.getReader(); + const decoder = new TextDecoder(); + let output = ""; + let timeout: ReturnType | undefined; + const ready = (async () => { + while (true) { + const chunk = await reader.read(); + if (chunk.done) throw new Error(`gallery exited before listening:\n${output}`); + output += decoder.decode(chunk.value, { stream: true }); + try { + const result = JSON.parse(output) as { ok?: boolean; url?: string }; + if (result.ok && result.url) return result.url; + } catch { /* Wait for the complete, pretty-printed startup object. */ } + } + })(); + try { + return await Promise.race([ + ready, + new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error("server startup timed out")), timeoutMilliseconds); }), + ]); + } finally { + clearTimeout(timeout); + reader.releaseLock(); + } +} + +test("published tarball runs the CLI, gallery, and stdio MCP outside a checkout", async () => { + const temporary = mkdtempSync(join(tmpdir(), "sidequery-canvas-package-")); + const archiveDirectory = join(temporary, "archive"); + const consumer = join(temporary, "consumer"); + const clientDirectory = join(temporary, "mcp-client"); + const home = join(temporary, "home"); + const canvases = join(consumer, "canvases"); + const history = join(consumer, "history.sqlite"); + mkdirSync(archiveDirectory, { recursive: true }); + mkdirSync(consumer, { recursive: true }); + mkdirSync(clientDirectory, { recursive: true }); + mkdirSync(home, { recursive: true }); + const isolatedEnv = { + ...process.env, + HOME: home, + XDG_CACHE_HOME: join(temporary, "cache"), + XDG_DATA_HOME: join(temporary, "data"), + BUN_INSTALL_CACHE_DIR: join(temporary, "bun-cache"), + CANVAS_DATA_HOME: join(temporary, "canvas-data"), + HERDR_PLUGIN_STATE_DIR: join(temporary, "herdr-state"), + }; + let gallery: ReturnType | undefined; + try { + // Release jobs supply the exact archive that will be published. + const archive = process.env.CANVAS_PACKAGE_ARCHIVE + ? resolve(process.env.CANVAS_PACKAGE_ARCHIVE) + : join(archiveDirectory, `sidequery-canvas-${(await Bun.file(join(root, "package.json")).json()).version}.tgz`); + if (!process.env.CANVAS_PACKAGE_ARCHIVE) { + await run([ + process.execPath, "pm", "pack", "--ignore-scripts", "--destination", archiveDirectory, + ], root, isolatedEnv); + } + const listing = await run(["tar", "-tzf", archive], root, isolatedEnv); + expect(listing.stdout).toContain("package/dist/celld/wrangler.jsonc"); + expect(listing.stdout).toContain("package/dist/worker-app/worker.js"); + expect(listing.stdout).toContain("package/dist/cloudflare/assets/index.html"); + expect(listing.stdout).toContain("package/docs/daemon.md"); + expect(listing.stdout).toContain("package/docs/releasing.md"); + expect(listing.stdout).toContain("package/src/cli.ts"); + expect(listing.stdout).not.toMatch(/\.test\.[cm]?[jt]sx?$/m); + expect(listing.stdout).not.toContain("package/scripts/"); + + await Bun.write(join(consumer, "package.json"), JSON.stringify({ + private: true, + type: "module", + dependencies: { + "@sidequery/canvas": `file:${archive}`, + }, + }, null, 2)); + await run([process.execPath, "install", "--ignore-scripts"], consumer, isolatedEnv); + + const source = join(consumer, "package-smoke.canvas.tsx"); + await Bun.write(source, `import { Card, H1, Text } from "sidequery/canvas";\nexport default function PackageSmoke() { return

Package smoke

Installed tarball
; }\n`); + const canvas = join(consumer, "node_modules", ".bin", "canvas"); + const args = ["--dir", canvases, "--history-db", history]; + const written = JSON.parse((await run([canvas, "write", "package-smoke", "--file", source, ...args], consumer, isolatedEnv)).stdout); + expect(written.ok).toBe(true); + const checked = JSON.parse((await run([canvas, "typecheck", "package-smoke", ...args], consumer, isolatedEnv)).stdout); + expect(checked.diagnostics).toEqual([]); + const compiled = JSON.parse((await run([canvas, "compile", "package-smoke", ...args], consumer, isolatedEnv)).stdout); + expect(compiled.ok).toBe(true); + expect(compiled.bytes).toBeGreaterThan(1_000); + + gallery = Bun.spawn([canvas, "web", "--port", "0", ...args], { + cwd: consumer, + env: isolatedEnv, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const url = await readyUrl(gallery); + expect((await fetch(url)).status).toBe(200); + const galleryData = await (await fetch(`${url}/api/gallery`)).json() as { artifacts: { name: string }[] }; + expect(galleryData.artifacts.some(artifact => artifact.name === "package-smoke")).toBe(true); + gallery.kill("SIGTERM"); + expect(await gallery.exited).toBe(0); + gallery = undefined; + + // Keep the official client in a sibling project, so dependency hoisting can + // never hide a missing Canvas runtime dependency. + await Bun.write(join(clientDirectory, "package.json"), JSON.stringify({ + private: true, + type: "module", + dependencies: { "@modelcontextprotocol/sdk": "1.30.0" }, + }, null, 2)); + await run([process.execPath, "install", "--ignore-scripts"], clientDirectory, isolatedEnv); + const mcpSmoke = join(clientDirectory, "mcp-smoke.ts"); + await Bun.write(mcpSmoke, ` +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +const client = new Client({ name: "package-smoke", version: "1" }); +const transport = new StdioClientTransport({ + command: ${JSON.stringify(canvas)}, + args: ["mcp", "--dir", ${JSON.stringify(canvases)}, "--history-db", ${JSON.stringify(history)}], + env: Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string")), + stderr: "pipe", +}); +try { + await client.connect(transport); + const tools = await client.listTools(); + if (!tools.tools.some(tool => tool.name === "canvas_read")) throw new Error("canvas_read tool missing"); + const result = await client.callTool({ name: "canvas_read", arguments: { name: "package-smoke" } }); + if (result.isError || !JSON.stringify(result.content).includes("Package smoke")) throw new Error("installed MCP read failed"); + const opened = await client.callTool({ name: "canvas_open", arguments: { name: "package-smoke" } }); + if (opened.isError || !opened._meta?.canvas) throw new Error("installed MCP preview failed"); +} finally { await client.close(); } +`); + await run([process.execPath, "run", mcpSmoke], clientDirectory, isolatedEnv); + + if (process.env.CELLD_PACKAGE_INTEGRATION === "1") { + const target = process.platform === "darwin" && process.arch === "arm64" + ? "aarch64-apple-darwin" + : process.platform === "linux" && process.arch === "arm64" + ? "aarch64-unknown-linux-gnu" + : process.platform === "linux" && process.arch === "x64" + ? "x86_64-unknown-linux-gnu" + : undefined; + if (!target) throw new Error(`CELLD_PACKAGE_INTEGRATION is unsupported on ${process.platform}/${process.arch}`); + if (process.env.CELLD_BIN) { + const managedBinary = join(isolatedEnv.CANVAS_DATA_HOME, "runtimes", "celld", "0.4.1", target, "celld"); + mkdirSync(dirname(managedBinary), { recursive: true }); + copyFileSync(process.env.CELLD_BIN, managedBinary); + chmodSync(managedBinary, 0o700); + } + + const httpSmoke = join(clientDirectory, "http-smoke.ts"); + await Bun.write(httpSmoke, ` +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +const [origin, mode] = process.argv.slice(2); +const client = new Client({ name: "native-package-smoke", version: "1" }); +try { + await client.connect(new StreamableHTTPClientTransport(new URL(origin + "/mcp"))); + if (mode === "write") { + const written = await client.callTool({ name: "canvas_write", arguments: { + name: "native-counter", + contents: 'import { Text } from "sidequery/canvas"; export default function Counter() { return Native counter; }', + server: 'import { DurableObject } from "cloudflare:workers"; export class CanvasServer extends DurableObject { fetch(request: Request): Response { this.ctx.storage.sql.exec("create table if not exists counter (id integer primary key, value integer not null)"); this.ctx.storage.sql.exec("insert or ignore into counter values (1, 0)"); if (request.method === "POST") this.ctx.storage.sql.exec("update counter set value = value + 1 where id = 1"); return Response.json(this.ctx.storage.sql.exec<{ value: number }>("select value from counter where id = 1").one()); } }', + } }); + if (written.isError) throw new Error("native canvas_write failed: " + JSON.stringify(written.content)); + } + const result = await client.callTool({ name: "canvas_request", arguments: { + name: "native-counter", request: { path: "/counter", method: mode === "write" ? "POST" : "GET", headers: [] }, + } }); + if (result.isError) throw new Error("native canvas_request failed: " + JSON.stringify(result.content)); + const response = result.structuredContent.response; + if (response.status !== 200) throw new Error("native response status " + response.status); + console.log(Buffer.from(response.body, "base64").toString("utf8")); +} finally { await client.close(); } +`); + const stateDir = join(temporary, "native-state"); + const firstPort = reservePort(); + const first = await startNativeServer(canvas, consumer, stateDir, firstPort, isolatedEnv); + try { + const locked = await execute([ + canvas, "server", "--port", String(reservePort()), "--state-dir", stateDir, + ], consumer, isolatedEnv); + expect(locked.code).not.toBe(0); + expect(locked.stderr).toContain("already using"); + const incremented = JSON.parse((await run([ + process.execPath, "run", httpSmoke, first.url, "write", + ], clientDirectory, isolatedEnv)).stdout); + expect(incremented.value).toBe(1); + } finally { + await stopNativeServer(first); + } + + const restarted = await startNativeServer(canvas, consumer, stateDir, reservePort(), isolatedEnv); + try { + const persisted = JSON.parse((await run([ + process.execPath, "run", httpSmoke, restarted.url, "read", + ], clientDirectory, isolatedEnv)).stdout); + expect(persisted.value).toBe(1); + } finally { + await stopNativeServer(restarted); + } + } + } finally { + if (gallery) { gallery.kill(); await gallery.exited; } + rmSync(temporary, { recursive: true, force: true }); + } +}, { timeout: process.env.CELLD_PACKAGE_INTEGRATION === "1" ? 360_000 : 120_000 }); diff --git a/scripts/prepare-celld.ts b/scripts/prepare-celld.ts index c27a008..6e2465e 100644 --- a/scripts/prepare-celld.ts +++ b/scripts/prepare-celld.ts @@ -23,10 +23,17 @@ const supportedKeys = new Set([ export async function prepareCelldConfig( source = resolve(import.meta.dir, "../wrangler.jsonc"), output = resolve(import.meta.dir, "../wrangler.celld.jsonc"), + paths: { main?: string; assets?: string } = {}, ) { const canonical = Bun.JSONC.parse(await Bun.file(source).text()) as Record; const config = Object.fromEntries(Object.entries(canonical).filter(([key]) => supportedKeys.has(key))); - config.main = "dist/worker-app/worker.js"; + config.main = paths.main ?? "dist/worker-app/worker.js"; + if (config.assets && typeof config.assets === "object" && !Array.isArray(config.assets)) { + config.assets = { + ...(config.assets as Record), + directory: paths.assets ?? "./dist/cloudflare/assets", + }; + } config.vars = { ...(config.vars as Record | undefined), ENVIRONMENT: "local" }; await Bun.write(output, `${JSON.stringify(config, null, 2)}\n`); return config; diff --git a/src/celldDaemon.test.ts b/src/celldDaemon.test.ts new file mode 100644 index 0000000..b34f59d --- /dev/null +++ b/src/celldDaemon.test.ts @@ -0,0 +1,242 @@ +import { expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; + +import { + launchdPlist, + ServerDaemonManager, + serverDaemonPaths, + systemdUnit, + writeServerReady, + type CommandResult, + type RunCommand, +} from "./celldDaemon"; +import { tempDir } from "./test/fixtures"; + +const ok = (stdout = ""): CommandResult => ({ exitCode: 0, stdout, stderr: "" }); +const missing = (): CommandResult => ({ exitCode: 1, stdout: "", stderr: "not found" }); + +test("generated supervisor definitions preserve exact paths and only forward Canvas data home", () => { + const root = join(tempDir(), 'space "quote" 100% $cash'); + const home = join(root, "home"); + const paths = serverDaemonPaths({ CANVAS_DATA_HOME: root, SECRET_TOKEN: "must-not-leak" }, "darwin", home); + const input = { + execPath: join(root, "bin", "bun$runtime"), + cliEntry: join(root, "package", "src", "cli.ts"), + port: 4786, + paths, + }; + const plist = launchdPlist(input); + expect(plist).toContain(`${paths.launchdLabel}`); + expect(plist).toContain("space "quote" 100% $cash"); + expect(plist).toContain("CANVAS_DATA_HOME"); + expect(plist).toContain("ExitTimeOut"); + expect(plist).not.toContain("SECRET_TOKEN"); + expect(plist).not.toContain("must-not-leak"); + + const unit = systemdUnit(input); + expect(unit).toContain('Environment="CANVAS_DATA_HOME='); + expect(unit).toContain("100%% $cash"); + expect(unit).toContain("bun$runtime"); + expect(unit).toContain('ExecStart=:"/usr/bin/env" "--" '); + expect(unit).toContain(`WorkingDirectory=${root.replaceAll("%", "%%")}`); + expect(unit).toContain("KillMode=mixed"); + expect(unit).toContain("TimeoutStopSec=55s"); + expect(unit).toContain("StandardOutput=append:"); + expect(unit).not.toContain("SECRET_TOKEN"); +}); + +test.skipIf(process.platform !== "linux" || !Bun.which("systemd-analyze"))("systemd accepts the generated unit with special path characters", async () => { + const root = join(tempDir(), 'space "quote" 100% $cash'); + await mkdir(root, { recursive: true }); + const paths = serverDaemonPaths({ CANVAS_DATA_HOME: root }, "linux", root); + const path = join(root, paths.systemdUnit); + await Bun.write(path, systemdUnit({ execPath: "/bin/true", cliEntry: join(root, "cli.ts"), port: 4786, paths })); + const child = Bun.spawn(["systemd-analyze", "verify", path], { stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited, + ]); + expect({ exitCode, stdout, stderr }).toMatchObject({ exitCode: 0 }); +}); + +test("service identity is stable per data root and separates custom roots", () => { + const home = tempDir(); + const first = serverDaemonPaths({ CANVAS_DATA_HOME: join(home, "one") }, "darwin", home); + const again = serverDaemonPaths({ CANVAS_DATA_HOME: join(home, "one") }, "darwin", home); + const second = serverDaemonPaths({ CANVAS_DATA_HOME: join(home, "two") }, "darwin", home); + expect(first.launchdLabel).toBe(again.launchdLabel); + expect(first.launchdLabel).not.toBe(second.launchdLabel); + expect(first.systemdUnit).not.toBe(second.systemdUnit); + expect(serverDaemonPaths({ XDG_CONFIG_HOME: join(home, "xdg") }, "linux", home).systemdPath).toStartWith(join(home, "xdg")); +}); + +test("launchd lifecycle keeps manual start transient and makes login opt-in reversible", async () => { + const root = tempDir(); + const home = join(root, "home"); + const paths = serverDaemonPaths({ CANVAS_DATA_HOME: join(root, "data") }, "darwin", home); + let loaded = false; + let pid: number | undefined; + const calls: string[] = []; + const runCommand: RunCommand = async (command, args) => { + calls.push([command, ...args].join(" ")); + if (command !== "launchctl") throw new Error(`unexpected ${command}`); + if (args[0] === "print") return loaded ? ok(pid ? `state = running\n\tpid = ${pid}\n` : "state = waiting\n") : missing(); + if (args[0] === "bootstrap") { + loaded = true; + pid = 912; + await writeServerReady(paths.readyPath, { pid, port: 4786, url: "http://127.0.0.1:4786", stateDir: paths.stateDir }); + return ok(); + } + if (args[0] === "bootout") { + loaded = false; + pid = undefined; + return ok(); + } + throw new Error(`unexpected launchctl ${args.join(" ")}`); + }; + const manager = new ServerDaemonManager({ + env: { CANVAS_DATA_HOME: paths.dataRoot }, platform: "darwin", home, uid: 501, + execPath: join(root, "bun"), cliEntry: join(root, "installed", "src", "cli.ts"), + runCommand, healthCheck: async url => url === "http://127.0.0.1:4786", + prepareRuntime: async dataRoot => { expect(dataRoot).toBe(paths.dataRoot); }, + portAvailable: () => true, sleep: async () => {}, + }); + + const manual = await manager.start(); + expect(manual).toMatchObject({ running: true, ready: true, installedAtLogin: false, pid: 912 }); + expect(existsSync(paths.launchdRuntimePath)).toBe(true); + expect(existsSync(paths.launchdLoginPath)).toBe(false); + await expect(manager.start()).rejects.toThrow("already ready"); + + await manager.stop(); + const login = await manager.start({ atLogin: true }); + expect(login.installedAtLogin).toBe(true); + expect(existsSync(paths.launchdLoginPath)).toBe(true); + const uninstalled = await manager.uninstall(); + expect(uninstalled).toMatchObject({ loaded: false, running: false, ready: false, installedAtLogin: false }); + expect(existsSync(paths.launchdLoginPath)).toBe(false); + expect(calls.some(call => call.includes(`bootout gui/501/${paths.launchdLabel}`))).toBe(true); +}); + +test("stop unloads a launchd job even when its process has no PID", async () => { + const root = tempDir(); + let loaded = true; + let bootouts = 0; + const manager = new ServerDaemonManager({ + env: { CANVAS_DATA_HOME: join(root, "data") }, platform: "darwin", home: join(root, "home"), uid: 501, + runCommand: async (_command, args) => { + if (args[0] === "print") return loaded ? ok("state = waiting\n") : missing(); + if (args[0] === "bootout") { loaded = false; bootouts += 1; return ok(); } + throw new Error(`unexpected ${args.join(" ")}`); + }, + prepareRuntime: async () => {}, portAvailable: () => true, sleep: async () => {}, + }); + expect(await manager.status()).toMatchObject({ loaded: true, running: false, detail: "loaded but process is not running" }); + expect(await manager.stop()).toMatchObject({ loaded: false, running: false }); + expect(bootouts).toBe(1); +}); + +test("systemd status parses keyed properties in any order and requires matching readiness PID", async () => { + const root = tempDir(); + const paths = serverDaemonPaths({ CANVAS_DATA_HOME: join(root, "data") }, "linux", join(root, "home")); + await writeServerReady(paths.readyPath, { pid: 88, port: 4786, url: "http://127.0.0.1:4786", stateDir: paths.stateDir }); + await Bun.write(paths.configPath, JSON.stringify({ port: 4786, stateDir: paths.stateDir })); + let healthCalls = 0; + const manager = new ServerDaemonManager({ + env: { CANVAS_DATA_HOME: paths.dataRoot }, platform: "linux", home: join(root, "home"), + runCommand: async (_command, args) => args.includes("is-enabled") + ? ok("enabled\n") + : ok("MainPID=88\nLoadState=loaded\nActiveState=active\n"), + healthCheck: async () => { healthCalls += 1; return true; }, + prepareRuntime: async () => {}, portAvailable: () => true, + }); + await mkdir(manager.paths.daemonDir, { recursive: true }); + expect(await manager.status()).toMatchObject({ manager: "systemd", installedAtLogin: true, loaded: true, running: true, ready: true, pid: 88 }); + expect(healthCalls).toBe(1); + + await writeServerReady(paths.readyPath, { pid: 89, port: 4786, url: "http://127.0.0.1:4786", stateDir: paths.stateDir }); + expect(await manager.status()).toMatchObject({ running: true, ready: false, detail: "running but has not published matching readiness" }); + expect(healthCalls).toBe(1); +}); + +test("systemd manual start, stop, login enable and uninstall use distinct lifecycle actions", async () => { + const root = tempDir(); + const home = join(root, "home"); + const paths = serverDaemonPaths({ CANVAS_DATA_HOME: join(root, "data") }, "linux", home); + let active = false; + let enabled = false; + let pid = 0; + const actions: string[] = []; + const runCommand: RunCommand = async (command, args) => { + expect(command).toBe("systemctl"); + const action = args[1]!; + actions.push(args.slice(1).join(" ")); + if (action === "show") { + const loaded = existsSync(paths.systemdPath) ? "loaded" : "not-found"; + return ok(`ActiveState=${active ? "active" : "inactive"}\nMainPID=${pid}\nLoadState=${loaded}\n`); + } + if (action === "is-enabled") return enabled ? ok("enabled\n") : missing(); + if (action === "daemon-reload") return ok(); + if (action === "start" || action === "enable") { + if (action === "enable") enabled = true; + active = true; + pid = 611; + await writeServerReady(paths.readyPath, { pid, port: 4786, url: "http://127.0.0.1:4786", stateDir: paths.stateDir }); + return ok(); + } + if (action === "stop") { active = false; pid = 0; return ok(); } + if (action === "disable") { enabled = false; return ok(); } + throw new Error(`unexpected systemctl ${args.join(" ")}`); + }; + const manager = new ServerDaemonManager({ + env: { CANVAS_DATA_HOME: paths.dataRoot }, platform: "linux", home, + runCommand, healthCheck: async () => true, prepareRuntime: async () => {}, + portAvailable: () => true, sleep: async () => {}, + }); + + expect(await manager.start()).toMatchObject({ running: true, ready: true, installedAtLogin: false }); + expect(actions).toContain(`start ${paths.systemdUnit}`); + expect(await manager.stop()).toMatchObject({ loaded: true, running: false, ready: false }); + expect(await manager.start({ atLogin: true })).toMatchObject({ running: true, ready: true, installedAtLogin: true }); + expect(actions).toContain(`enable --now ${paths.systemdUnit}`); + expect(await manager.uninstall()).toMatchObject({ loaded: false, running: false, installedAtLogin: false }); + expect(existsSync(paths.systemdPath)).toBe(false); + expect(actions).toContain(`disable ${paths.systemdUnit}`); +}); + +test("startup reports a supervisor cleanup failure instead of claiming it stopped", async () => { + const root = tempDir(); + let loaded = false; + let now = 0; + const manager = new ServerDaemonManager({ + env: { CANVAS_DATA_HOME: join(root, "data") }, platform: "darwin", home: join(root, "home"), uid: 501, + runCommand: async (_command, args) => { + if (args[0] === "print") return loaded ? ok("state = waiting\n") : missing(); + if (args[0] === "bootstrap") { loaded = true; return ok(); } + if (args[0] === "bootout") return { exitCode: 1, stdout: "", stderr: "permission denied" }; + throw new Error(`unexpected ${args.join(" ")}`); + }, + prepareRuntime: async () => {}, portAvailable: () => true, + now: () => now, sleep: async () => { now += 200_000; }, + }); + await expect(manager.start()).rejects.toThrow("Cleanup also failed: launchctl bootout failed: permission denied"); + expect(loaded).toBe(true); +}); + +test("logs returns only a bounded tail", async () => { + const root = tempDir(); + const manager = new ServerDaemonManager({ + env: { CANVAS_DATA_HOME: join(root, "data") }, platform: "darwin", home: join(root, "home"), + runCommand: async () => missing(), prepareRuntime: async () => {}, portAvailable: () => true, + }); + await mkdir(manager.paths.daemonDir, { recursive: true }); + await Bun.write(manager.paths.logPath, Array.from({ length: 2000 }, (_, index) => `${index}:${"x".repeat(200)}\n`).join("")); + const logs = await manager.logs(3); + expect(logs.contents).toBe(`1997:${"x".repeat(200)}\n1998:${"x".repeat(200)}\n1999:${"x".repeat(200)}\n`); + await expect(manager.logs(1001)).rejects.toThrow("between 1 and 1000"); +}); + +test("unsupported supervisors report the required service managers", () => { + expect(() => new ServerDaemonManager({ platform: "win32" })).toThrow("background services require macOS launchd or Linux systemd"); +}); diff --git a/src/celldDaemon.ts b/src/celldDaemon.ts new file mode 100644 index 0000000..99728e9 --- /dev/null +++ b/src/celldDaemon.ts @@ -0,0 +1,462 @@ +import { createHash, randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +import { canvasDataRoot, ensureCelldRuntime } from "./celldRuntime"; +import { CLI_ENTRY, PLUGIN_ROOT } from "./paths"; + +export const SERVER_PORT = 4786; +export const LAUNCHD_LABEL = "com.sidequery.canvas.server"; +export const SYSTEMD_UNIT = "sidequery-canvas-server.service"; + +export type CommandResult = { exitCode: number; stdout: string; stderr: string }; +export type RunCommand = (command: string, args: string[]) => Promise; + +export type ServerReadyState = { + pid: number; + port: number; + url: string; + stateDir: string; + readyAt: string; +}; + +type ServerConfig = { port: number; stateDir: string }; +type SupervisorState = { loaded: boolean; running: boolean; pid?: number }; + +export type ServerDaemonStatus = { + manager: "launchd" | "systemd"; + installedAtLogin: boolean; + loaded: boolean; + running: boolean; + ready: boolean; + pid?: number; + url?: string; + logPath: string; + detail: string; +}; + +export type ServerDaemonDependencies = { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + home?: string; + uid?: number; + execPath?: string; + cliEntry?: string; + runCommand?: RunCommand; + healthCheck?: (url: string) => Promise; + sleep?: (milliseconds: number) => Promise; + now?: () => number; + portAvailable?: (port: number) => boolean; + prepareRuntime?: (dataRoot: string) => Promise; +}; + +export type ServerDaemonPaths = ReturnType; + +export function serverDaemonPaths( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + home = homedir(), +) { + const dataRoot = canvasDataRoot(env, platform, home); + const daemonDir = join(dataRoot, "daemon"); + const serviceSuffix = createHash("sha256").update(dataRoot).digest("hex").slice(0, 12); + const launchdLabel = `${LAUNCHD_LABEL}.${serviceSuffix}`; + const systemdUnit = `sidequery-canvas-server-${serviceSuffix}.service`; + const systemdConfig = platform === "linux" && env.XDG_CONFIG_HOME ? resolve(env.XDG_CONFIG_HOME) : join(home, ".config"); + return { + dataRoot, + daemonDir, + stateDir: join(dataRoot, "server"), + configPath: join(daemonDir, "server.json"), + readyPath: join(daemonDir, "ready.json"), + logPath: join(daemonDir, "server.log"), + launchdLabel, + systemdUnit, + launchdRuntimePath: join(daemonDir, `${launchdLabel}.plist`), + launchdLoginPath: join(home, "Library", "LaunchAgents", `${launchdLabel}.plist`), + systemdPath: join(systemdConfig, "systemd", "user", systemdUnit), + }; +} + +function xml(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +} + +function systemdQuote(value: string): string { + const escaped = value + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("\n", "\\n") + .replaceAll("\r", "\\r") + .replaceAll("\t", "\\t") + .replaceAll("%", "%%"); + return `"${escaped}"`; +} + +function systemdPathValue(value: string): string { + if (/[\r\n]/.test(value)) throw new Error("Canvas data paths containing line breaks are unsupported by systemd"); + return value.replaceAll("\\", "\\\\").replaceAll("%", "%%"); +} + +function serverArguments(input: { execPath: string; cliEntry: string; port: number; paths: ServerDaemonPaths }): string[] { + return [ + input.execPath, + input.cliEntry, + "server", + "--port", + String(input.port), + "--state-dir", + input.paths.stateDir, + "--managed-ready", + input.paths.readyPath, + ]; +} + +export function launchdPlist(input: { execPath: string; cliEntry: string; port: number; paths: ServerDaemonPaths }): string { + const args = serverArguments(input).map(value => ` ${xml(value)}`).join("\n"); + return ` + + + + Label + ${input.paths.launchdLabel} + ProgramArguments + +${args} + + EnvironmentVariables + + CANVAS_DATA_HOME + ${xml(input.paths.dataRoot)} + + RunAtLoad + + KeepAlive + + ExitTimeOut + 55 + ProcessType + Background + WorkingDirectory + ${xml(input.paths.dataRoot)} + StandardOutPath + ${xml(input.paths.logPath)} + StandardErrorPath + ${xml(input.paths.logPath)} + + +`; +} + +export function systemdUnit(input: { execPath: string; cliEntry: string; port: number; paths: ServerDaemonPaths }): string { + // systemd restricts characters in its executable path. env immediately execs + // the absolute Bun path passed as an argument, without a shell or PATH lookup. + // The colon prefix disables systemd dollar expansion for these literal paths. + const command = ["/usr/bin/env", "--", ...serverArguments(input)].map(systemdQuote).join(" "); + return `[Unit] +Description=Sidequery Canvas local server +After=network.target + +[Service] +Type=simple +Environment=${systemdQuote(`CANVAS_DATA_HOME=${input.paths.dataRoot}`)} +WorkingDirectory=${systemdPathValue(input.paths.dataRoot)} +ExecStart=:${command} +Restart=on-failure +RestartSec=2 +KillMode=mixed +TimeoutStopSec=55s +StandardOutput=append:${systemdPathValue(input.paths.logPath)} +StandardError=append:${systemdPathValue(input.paths.logPath)} + +[Install] +WantedBy=default.target +`; +} + +async function defaultRunCommand(command: string, args: string[]): Promise { + const child = Bun.spawn([command, ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { exitCode, stdout, stderr }; +} + +async function defaultHealthCheck(url: string): Promise { + try { + return (await fetch(`${url}/health`, { signal: AbortSignal.timeout(750) })).ok; + } catch { + return false; + } +} + +function commandError(command: string, result: CommandResult): Error { + const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}`; + return new Error(`${command} failed: ${detail}`); +} + +async function atomicJson(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const temporary = `${path}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: "wx" }); + await rename(temporary, path); + } finally { + await rm(temporary, { force: true }); + } +} + +async function readJson(path: string): Promise { + try { + return JSON.parse(await readFile(path, "utf8")) as T; + } catch { + return undefined; + } +} + +export async function writeServerReady(path: string, state: Omit): Promise { + await atomicJson(path, { ...state, readyAt: new Date().toISOString() }); +} + +export async function clearServerReady(path: string, pid = process.pid): Promise { + const current = await readJson(path); + if (current?.pid === pid) await rm(path, { force: true }); +} + +export class ServerDaemonManager { + readonly paths: ServerDaemonPaths; + readonly platform: NodeJS.Platform; + readonly manager: "launchd" | "systemd"; + private readonly uid: number; + private readonly execPath: string; + private readonly cliEntry: string; + private readonly runCommand: RunCommand; + private readonly healthCheck: (url: string) => Promise; + private readonly sleep: (milliseconds: number) => Promise; + private readonly now: () => number; + private readonly portAvailable: (port: number) => boolean; + private readonly prepareRuntime: (dataRoot: string) => Promise; + + constructor(dependencies: ServerDaemonDependencies = {}) { + this.platform = dependencies.platform ?? process.platform; + if (this.platform !== "darwin" && this.platform !== "linux") { + throw new Error(`Canvas server background service is unsupported on ${this.platform}; background services require macOS launchd or Linux systemd.`); + } + const home = dependencies.home ?? homedir(); + this.paths = serverDaemonPaths(dependencies.env, this.platform, home); + this.manager = this.platform === "darwin" ? "launchd" : "systemd"; + this.uid = dependencies.uid ?? process.getuid?.() ?? 0; + this.execPath = resolve(dependencies.execPath ?? process.execPath); + this.cliEntry = resolve(dependencies.cliEntry ?? CLI_ENTRY); + this.runCommand = dependencies.runCommand ?? defaultRunCommand; + this.healthCheck = dependencies.healthCheck ?? defaultHealthCheck; + this.sleep = dependencies.sleep ?? Bun.sleep; + this.now = dependencies.now ?? Date.now; + this.portAvailable = dependencies.portAvailable ?? (port => { + try { + const listener = Bun.listen({ hostname: "127.0.0.1", port, socket: { data() {} } }); + listener.stop(true); + return true; + } catch { + return false; + } + }); + this.prepareRuntime = dependencies.prepareRuntime ?? (async dataRoot => { + if (!existsSync(join(PLUGIN_ROOT, "dist", "celld", "wrangler.jsonc"))) { + throw new Error("Packaged Canvas server assets are missing. From a source checkout, run bun run build:package first."); + } + await ensureCelldRuntime({ dataRoot, notify: message => console.error(message) }); + }); + } + + private launchdDomain(): string { + return `gui/${this.uid}`; + } + + private async supervisorState(): Promise { + if (this.manager === "launchd") { + const result = await this.runCommand("launchctl", ["print", `${this.launchdDomain()}/${this.paths.launchdLabel}`]); + if (result.exitCode !== 0) return { loaded: false, running: false }; + const pid = Number(result.stdout.match(/^\s*pid\s*=\s*(\d+)\s*$/m)?.[1]); + return { loaded: true, running: Number.isInteger(pid) && pid > 0, pid: pid || undefined }; + } + const result = await this.runCommand("systemctl", ["--user", "show", this.paths.systemdUnit, "--property=LoadState", "--property=ActiveState", "--property=MainPID"]); + if (result.exitCode !== 0) return { loaded: false, running: false }; + const properties = Object.fromEntries(result.stdout.trim().split(/\r?\n/).map(line => { + const separator = line.indexOf("="); + return separator < 0 ? [line, ""] : [line.slice(0, separator), line.slice(separator + 1)]; + })); + const pid = Number(properties.MainPID); + const loaded = properties.LoadState !== undefined && properties.LoadState !== "not-found"; + return { loaded, running: properties.ActiveState === "active" && Number.isInteger(pid) && pid > 0, pid: pid || undefined }; + } + + private async installedAtLogin(): Promise { + if (this.manager === "launchd") return existsSync(this.paths.launchdLoginPath); + const result = await this.runCommand("systemctl", ["--user", "is-enabled", this.paths.systemdUnit]); + return result.exitCode === 0 && result.stdout.trim() === "enabled"; + } + + async status(): Promise { + const [supervisor, installed, config, ready] = await Promise.all([ + this.supervisorState(), + this.installedAtLogin(), + readJson(this.paths.configPath), + readJson(this.paths.readyPath), + ]); + const ownsReady = Boolean( + supervisor.running && + supervisor.pid === ready?.pid && + config?.port === ready?.port && + config?.stateDir === ready?.stateDir, + ); + const healthy = ownsReady && ready ? await this.healthCheck(ready.url) : false; + const detail = !supervisor.loaded + ? "stopped" + : !supervisor.running + ? "loaded but process is not running" + : healthy + ? "ready" + : ownsReady + ? "running but health check failed" + : "running but has not published matching readiness"; + return { + manager: this.manager, + installedAtLogin: installed, + loaded: supervisor.loaded, + running: supervisor.running, + ready: healthy, + pid: supervisor.pid, + url: healthy ? ready?.url : undefined, + logPath: this.paths.logPath, + detail, + }; + } + + async start(options: { atLogin?: boolean; port?: number } = {}): Promise { + const port = options.port ?? SERVER_PORT; + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("server start --port must be an integer between 1 and 65535"); + const current = await this.status(); + if (current.running) throw new Error(`Canvas server is already ${current.detail}${current.url ? ` at ${current.url}` : ""}.`); + await this.prepareRuntime(this.paths.dataRoot); + const loaded = await this.supervisorState(); + if (loaded.loaded) await this.stopSupervisor(); + if (!this.portAvailable(port)) throw new Error(`Canvas server port ${port} is already in use.`); + await mkdir(this.paths.daemonDir, { recursive: true, mode: 0o700 }); + await rm(this.paths.readyPath, { force: true }); + const config: ServerConfig = { port, stateDir: this.paths.stateDir }; + await atomicJson(this.paths.configPath, config); + const definition = { execPath: this.execPath, cliEntry: this.cliEntry, port, paths: this.paths }; + + try { + if (this.manager === "launchd") { + const persistent = options.atLogin || current.installedAtLogin; + const path = persistent ? this.paths.launchdLoginPath : this.paths.launchdRuntimePath; + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await writeFile(path, launchdPlist(definition), { mode: 0o600 }); + const result = await this.runCommand("launchctl", ["bootstrap", this.launchdDomain(), path]); + if (result.exitCode !== 0) throw commandError("launchctl bootstrap", result); + } else { + await mkdir(dirname(this.paths.systemdPath), { recursive: true, mode: 0o700 }); + await writeFile(this.paths.systemdPath, systemdUnit(definition), { mode: 0o600 }); + const reload = await this.runCommand("systemctl", ["--user", "daemon-reload"]); + if (reload.exitCode !== 0) throw commandError("systemctl --user daemon-reload", reload); + const action = options.atLogin || current.installedAtLogin + ? ["enable", "--now", this.paths.systemdUnit] + : ["start", this.paths.systemdUnit]; + const result = await this.runCommand("systemctl", ["--user", ...action]); + if (result.exitCode !== 0) throw commandError(`systemctl --user ${action[0]}`, result); + } + } catch (error) { + return await this.failStart(error); + } + + try { + const deadline = this.now() + 190_000; + while (this.now() < deadline) { + const status = await this.status(); + if (status.ready) return status; + await this.sleep(100); + } + throw new Error(`Canvas server did not become ready within 190 seconds. See ${this.paths.logPath}`); + } catch (error) { + return await this.failStart(error); + } + } + + private async failStart(error: unknown): Promise { + let cleanupError: unknown; + try { await this.stopSupervisor(); } + catch (caught) { cleanupError = caught; } + await rm(this.paths.readyPath, { force: true }); + const message = error instanceof Error ? error.message : String(error); + if (cleanupError) { + const cleanup = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + throw new Error(`${message} Cleanup also failed: ${cleanup}. Check \`canvas server status\` and \`canvas server logs\`.`); + } + throw new Error(`${message} The created service was stopped.`); + } + + private async stopSupervisor(): Promise { + const state = await this.supervisorState(); + if (state.loaded) { + const result = this.manager === "launchd" + ? await this.runCommand("launchctl", ["bootout", `${this.launchdDomain()}/${this.paths.launchdLabel}`]) + : await this.runCommand("systemctl", ["--user", "stop", this.paths.systemdUnit]); + if (result.exitCode !== 0) throw commandError(this.manager === "launchd" ? "launchctl bootout" : "systemctl --user stop", result); + } + } + + async stop(): Promise { + await this.stopSupervisor(); + for (let attempt = 0; attempt < 600; attempt += 1) { + const supervisor = await this.supervisorState(); + const status = await this.status(); + if (this.manager === "launchd" ? !supervisor.loaded : !supervisor.running) { + await rm(this.paths.readyPath, { force: true }); + return status; + } + await this.sleep(100); + } + throw new Error(`Canvas server did not stop within 60 seconds. See ${this.paths.logPath}`); + } + + async uninstall(): Promise { + await this.stop(); + if (this.manager === "launchd") { + await rm(this.paths.launchdLoginPath, { force: true }); + await rm(this.paths.launchdRuntimePath, { force: true }); + } else { + const disable = await this.runCommand("systemctl", ["--user", "disable", this.paths.systemdUnit]); + if (disable.exitCode !== 0 && !disable.stderr.includes("does not exist")) throw commandError("systemctl --user disable", disable); + await rm(this.paths.systemdPath, { force: true }); + const reload = await this.runCommand("systemctl", ["--user", "daemon-reload"]); + if (reload.exitCode !== 0) throw commandError("systemctl --user daemon-reload", reload); + } + return await this.status(); + } + + async logs(lines = 100): Promise<{ path: string; contents: string }> { + if (!Number.isInteger(lines) || lines < 1 || lines > 1000) throw new Error("server logs --lines must be an integer between 1 and 1000"); + let size: number; + try { size = (await stat(this.paths.logPath)).size; } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { path: this.paths.logPath, contents: "" }; + throw error; + } + const length = Math.min(size, 256 * 1024); + const handle = await open(this.paths.logPath, "r"); + try { + const buffer = Buffer.alloc(length); + await handle.read(buffer, 0, length, size - length); + const chunks = buffer.toString("utf8").split(/(?<=\n)/); + return { path: this.paths.logPath, contents: chunks.slice(-lines).join("") }; + } finally { + await handle.close(); + } + } +} diff --git a/src/celldRuntime.test.ts b/src/celldRuntime.test.ts new file mode 100644 index 0000000..c8d8de5 --- /dev/null +++ b/src/celldRuntime.test.ts @@ -0,0 +1,70 @@ +import { afterEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { gzipSync } from "node:zlib"; +import { canvasDataRoot, celldArtifact, ensureCelldRuntime } from "./celldRuntime"; + +const directories: string[] = []; +afterEach(async () => { for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true }); }); +async function fixture() { + const dataRoot = await mkdtemp(join(tmpdir(), "canvas-runtime-test-")); + directories.push(dataRoot); + const binary = Buffer.from("#!/bin/sh\nprintf 'celld 0.4.1\\n'\n"); + const compressed = gzipSync(binary); + const hash = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex"); + return { dataRoot, binary, compressed, artifact: { target: "fixture", archiveSha256: hash(compressed), binarySha256: hash(binary) } }; +} + +test("selects only released native platforms and keeps app data outside the checkout", () => { + expect(celldArtifact("darwin", "arm64").target).toBe("aarch64-apple-darwin"); + expect(celldArtifact("linux", "arm64").target).toBe("aarch64-unknown-linux-gnu"); + expect(celldArtifact("linux", "x64").target).toBe("x86_64-unknown-linux-gnu"); + for (const [platform, arch] of [["darwin", "x64"], ["win32", "x64"], ["linux", "riscv64"]]) { + expect(() => celldArtifact(platform, arch)).toThrow("does not support"); + } + expect(canvasDataRoot({}, "darwin", "/users/test")).toBe("/users/test/Library/Application Support/sidequery-canvas"); + expect(canvasDataRoot({}, "linux", "/users/test")).toBe("/users/test/.local/share/sidequery-canvas"); + expect(canvasDataRoot({ XDG_DATA_HOME: "/data" }, "linux", "/users/test")).toBe("/data/sidequery-canvas"); + expect(canvasDataRoot({ CANVAS_DATA_HOME: "/custom" }, "darwin", "/users/test")).toBe("/custom"); +}); + +test("downloads a verified executable once, works offline thereafter, and repairs executable mode", async () => { + const f = await fixture(); + let calls = 0; + const fetcher = (async (url: string | URL | Request) => { calls++; expect(String(url)).toEndWith("/v0.4.1/celld-fixture.gz"); return new Response(f.compressed); }); + const executable = await ensureCelldRuntime({ ...f, fetch: fetcher }); + expect(await readFile(executable)).toEqual(f.binary); + expect((await stat(executable)).mode & 0o777).toBe(0o700); + const child = Bun.spawn([executable], { stdout: "pipe" }); + expect(await new Response(child.stdout).text()).toBe("celld 0.4.1\n"); + expect(await child.exited).toBe(0); + expect(await ensureCelldRuntime({ ...f, fetch: (async () => { throw new Error("offline"); }) })).toBe(executable); + expect(calls).toBe(1); + await writeFile(executable, "changed"); + await expect(ensureCelldRuntime({ ...f, fetch: fetcher })).rejects.toThrow("Cached celld checksum mismatch"); + expect(calls).toBe(1); +}); + +test("rejects HTTP, archive, and decompressed integrity failures before installing", async () => { + const f = await fixture(); + await expect(ensureCelldRuntime({ ...f, fetch: (async () => new Response("unavailable", { status: 503 })) })).rejects.toThrow("HTTP 503"); + await expect(ensureCelldRuntime({ ...f, fetch: (async () => new Response("bad bytes")) })).rejects.toThrow("archive checksum mismatch"); + await expect(ensureCelldRuntime({ ...f, artifact: { ...f.artifact, binarySha256: "wrong" }, fetch: (async () => new Response(f.compressed)) })).rejects.toThrow("executable checksum mismatch"); + expect(await Bun.file(join(f.dataRoot, "runtimes/celld/0.4.1/fixture/celld")).exists()).toBe(false); +}); + +test("concurrent verified installs publish one complete executable", async () => { + const f = await fixture(); + const options = { ...f, fetch: (async () => new Response(f.compressed)) }; + const paths = await Promise.all([ensureCelldRuntime(options), ensureCelldRuntime(options)]); + expect(paths[0]).toBe(paths[1]); + expect(await readFile(paths[0])).toEqual(f.binary); +}); + +test("cancellation does not download or install", async () => { + const f = await fixture(); + const signal = AbortSignal.abort(new Error("cancelled")); + await expect(ensureCelldRuntime({ ...f, signal, fetch: (async () => { throw new Error("must not fetch"); }) })).rejects.toThrow("cancelled"); +}); diff --git a/src/celldRuntime.ts b/src/celldRuntime.ts new file mode 100644 index 0000000..77fbade --- /dev/null +++ b/src/celldRuntime.ts @@ -0,0 +1,67 @@ +import { createHash, randomUUID } from "node:crypto"; +import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { gunzipSync } from "node:zlib"; + +export const CELLD_VERSION = "0.4.1"; +type Artifact = { target: string; archiveSha256: string; binarySha256: string }; +// Archive digests from the GitHub v0.4.1 release API; executable digests computed +// from those verified archives. Pin both so cached executables are checked too. +const artifacts: Record = { + "darwin-arm64": { target: "aarch64-apple-darwin", archiveSha256: "95c689769f66c08fd0d191fbfc731adb4c8340b49678b11979698ecf2e73393d", binarySha256: "12865054bc0438f8e3dcdaf0e38194ff73f5609794fa2352cfa9005f7831f344" }, + "linux-arm64": { target: "aarch64-unknown-linux-gnu", archiveSha256: "5cc2281493a896b2cc7c7b6c46b2188753832d6b028457338c8eea774a1dd9a1", binarySha256: "bf2546c163e925120ab4df2de1bf4b510f84fbb8e51ab85a074eb5b052423148" }, + "linux-x64": { target: "x86_64-unknown-linux-gnu", archiveSha256: "7b42a410e340bca4dbadd08ecb7f8983854aa855aa7467c29ab0e08b8b7f2007", binarySha256: "e499e6d8e1bb04297252bd4df661bc4429293144fe6f20e02a51d12f1e23b161" }, +}; + +export function celldArtifact(platform: string = process.platform, arch: string = process.arch): Artifact { + const artifact = artifacts[`${platform}-${arch}`]; + if (!artifact) throw new Error(`Managed celld ${CELLD_VERSION} does not support ${platform}/${arch}. Available: macOS arm64 and Linux glibc arm64/x64. Other Canvas commands do not require celld.`); + return artifact; +} + +export function canvasDataRoot(env: NodeJS.ProcessEnv = process.env, platform: string = process.platform, home = homedir()): string { + if (env.CANVAS_DATA_HOME) return resolve(env.CANVAS_DATA_HOME); + const base = platform === "darwin" ? join(home, "Library", "Application Support") : env.XDG_DATA_HOME || join(home, ".local", "share"); + return join(base, "sidequery-canvas"); +} + +const sha256 = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex"); +export async function ensureCelldRuntime(options: { + dataRoot: string; + signal?: AbortSignal; + notify?: (message: string) => void; + fetch?: (url: string, init?: RequestInit) => Promise; + artifact?: Artifact; +}): Promise { + const artifact = options.artifact ?? celldArtifact(); + const executable = join(options.dataRoot, "runtimes", "celld", CELLD_VERSION, artifact.target, "celld"); + try { + const bytes = await readFile(executable); + if (sha256(bytes) !== artifact.binarySha256) throw new Error(`Cached celld checksum mismatch: ${executable}. Remove that managed executable and retry.`); + await chmod(executable, 0o700); + return executable; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + options.signal?.throwIfAborted(); + options.notify?.(`Downloading celld ${CELLD_VERSION} for ${artifact.target}…`); + const response = await (options.fetch ?? globalThis.fetch)(`https://github.com/denoland/celld/releases/download/v${CELLD_VERSION}/celld-${artifact.target}.gz`, { + signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(120_000)]) : AbortSignal.timeout(120_000), + }); + if (!response.ok) throw new Error(`celld download failed: HTTP ${response.status}`); + const compressed = new Uint8Array(await response.arrayBuffer()); + if (sha256(compressed) !== artifact.archiveSha256) throw new Error("celld archive checksum mismatch; nothing was installed"); + const binary = gunzipSync(compressed, { maxOutputLength: 128 * 1024 * 1024 }); + if (sha256(binary) !== artifact.binarySha256) throw new Error("celld executable checksum mismatch; nothing was installed"); + options.signal?.throwIfAborted(); + await mkdir(dirname(executable), { recursive: true, mode: 0o700 }); + const temporary = `${executable}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, binary, { mode: 0o700, flag: "wx" }); + await rename(temporary, executable); + } finally { + await rm(temporary, { force: true }); + } + return executable; +} diff --git a/src/celldServer.ts b/src/celldServer.ts new file mode 100644 index 0000000..4ede175 --- /dev/null +++ b/src/celldServer.ts @@ -0,0 +1,91 @@ +import { Database } from "bun:sqlite"; +import { createRequire } from "node:module"; +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { canvasDataRoot, ensureCelldRuntime } from "./celldRuntime"; +import { PLUGIN_ROOT } from "./paths"; + +export async function runCelldServer(options: { port?: number; stateDir?: string; signal: AbortSignal; onReady?: (url: string) => void | Promise }): Promise { + const port = options.port ?? 4786; + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("server --port must be an integer between 1 and 65535"); + const dataRoot = canvasDataRoot(); + const project = resolve(options.stateDir ?? join(dataRoot, "server")); + let template: Record; + try { template = JSON.parse(await readFile(join(PLUGIN_ROOT, "dist/celld/wrangler.jsonc"), "utf8")); } + catch { throw new Error("Packaged Canvas server assets are missing. From a source checkout, run bun run build:package first."); } + await mkdir(project, { recursive: true, mode: 0o700 }); + // A held SQLite write transaction excludes a second launcher and releases on + // process death, so stale PID files cannot prevent recovery after a crash. + const lock = new Database(join(project, "server-lock.sqlite"), { create: true }); + try { lock.exec("begin exclusive"); } + catch { lock.close(); throw new Error(`A Canvas server is already using ${project}`); } + let child: ReturnType | undefined; + let drained: Promise | undefined; + let stopping: Promise | undefined; + const stop = (): Promise => { + if (stopping) return stopping; + if (!child || child.exitCode !== null) return Promise.resolve(); + const running = child; + stopping = (async () => { + running.kill("SIGINT"); + const timeout = setTimeout(() => { if (running.exitCode === null) running.kill("SIGKILL"); }, 45_000); + try { await running.exited; } finally { clearTimeout(timeout); } + })(); + return stopping; + }; + const abort = () => { void stop(); }; + options.signal.addEventListener("abort", abort); + try { + options.signal.throwIfAborted(); + // Check before a first-use download; celld must own this exact listener. + const probe = Bun.listen({ hostname: "127.0.0.1", port, socket: { data() {} } }); + probe.stop(true); + const executable = await ensureCelldRuntime({ dataRoot, signal: options.signal, notify: message => console.error(message) }); + // This directory contains only reproducible package output, never user data. + // Removing it prevents deleted assets from a previous version remaining served. + await rm(join(project, "dist"), { recursive: true, force: true }); + for (const directory of ["worker-app", "cloudflare"]) { + await cp(join(PLUGIN_ROOT, "dist", directory), join(project, "dist", directory), { recursive: true }); + } + // Keep the project path stable across package versions: celld derives local + // node identity from it. Its .celld/dev directory is durable, never replaced. + template.main = "dist/worker-app/worker.js"; + template.assets.directory = "dist/cloudflare/assets"; + await writeFile(join(project, "wrangler.jsonc"), JSON.stringify(template, null, 2) + "\n"); + options.signal.throwIfAborted(); + const require = createRequire(import.meta.url); + // esbuild/bin/esbuild may be a Node-shebang wrapper. A launchd/systemd + // environment need not have Node (or Bun) on PATH; use the installed native + // optional dependency directly, resolved from esbuild even when nested. + const esbuildRequire = createRequire(require.resolve("esbuild/package.json")); + const esbuild = esbuildRequire.resolve(`@esbuild/${process.platform}-${process.arch}/bin/esbuild`); + child = Bun.spawn([executable, "dev", project, "--host", "127.0.0.1", "--port", String(port), "--no-watch", "--logs"], { + cwd: project, + env: { ...process.env, CELLD_ESBUILD: esbuild, CELLD_WORKER_LOADER: "LOADER", CELLD_IDLE_EVICT_S: process.env.CELLD_IDLE_EVICT_S ?? "60", RUST_LOG: process.env.RUST_LOG ?? "warn" }, + stdin: "ignore", stdout: "pipe", stderr: "pipe", + }); + const forward = async (stream: ReadableStream | number | undefined) => { + if (!(stream instanceof ReadableStream)) return; + for await (const chunk of stream) process.stderr.write(chunk); + }; + drained = Promise.all([forward(child.stdout), forward(child.stderr)]); + const origin = `http://127.0.0.1:${port}`; + let ready = false; + const deadline = Date.now() + 45_000; + while (Date.now() < deadline) { + options.signal.throwIfAborted(); + if (child.exitCode !== null) throw new Error(`celld exited during startup (${child.exitCode})`); + try { const response = await fetch(`${origin}/health`, { signal: AbortSignal.timeout(500) }); if (response.ok) { ready = true; break; } } catch {} + await Bun.sleep(100); + } + if (!ready) throw new Error("celld did not become ready within 45 seconds"); + await options.onReady?.(origin); + const code = await child.exited; + if (code !== 0 && !options.signal.aborted) throw new Error(`celld exited (${code})`); + } finally { + options.signal.removeEventListener("abort", abort); + await stop(); + await drained; + lock.close(); + } +} diff --git a/src/cli.test.ts b/src/cli.test.ts index 62c35e7..1cf263a 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -8,6 +8,60 @@ import { CanvasHistory } from "./history"; import { createCanvasServer } from "./serve"; import { handleMcpRequest } from "./mcp"; import { CanvasService } from "./service"; +import { parseArgs } from "./args"; +import { runServerCommand } from "./cli"; + +test("CLI reports the installed package version", async () => { + const manifest = await Bun.file(join(PLUGIN_ROOT, "package.json")).json() as { name: string; version: string }; + const result = await runCli(["--version"]); + expect(result).toEqual({ exitCode: 0, stdout: `${manifest.name} ${manifest.version}\n`, stderr: "" }); +}); + +test("server CLI dispatches lifecycle commands and foreground readiness", async () => { + const calls: string[] = []; + const status = { manager: "launchd" as const, installedAtLogin: false, loaded: true, running: true, ready: true, pid: 7, url: "http://127.0.0.1:4799", logPath: "/tmp/server.log", detail: "ready" }; + const manager = { + start: async (options: { atLogin?: boolean; port?: number }) => { calls.push(`start:${options.atLogin}:${options.port}`); return status; }, + stop: async () => status, + status: async () => status, + logs: async (lines?: number) => { calls.push(`logs:${lines}`); return { path: status.logPath, contents: "last line\n" }; }, + uninstall: async () => status, + }; + let output = ""; + expect(await runServerCommand(parseArgs(["server", "start", "--port", "4799", "--at-login"]), { manager, stdout: value => { output += value; } })).toBe(true); + expect(calls).toContain("start:true:4799"); + expect(JSON.parse(output)).toMatchObject({ ready: true, url: status.url }); + output = ""; + await runServerCommand(parseArgs(["server", "logs", "--lines", "1"]), { manager, stdout: value => { output += value; } }); + expect(calls).toContain("logs:1"); + expect(output).toBe("last line\n"); + + const stateDir = tempDir(); + let observedReady = false; + output = ""; + await runServerCommand(parseArgs(["server", "--port", "4799", "--state-dir", stateDir]), { + signal: new AbortController().signal, + stdout: value => { output += value; }, + runForeground: async options => { + await options.onReady?.("http://127.0.0.1:4799"); + observedReady = true; + }, + }); + expect(observedReady).toBe(true); + expect(JSON.parse(output)).toMatchObject({ ok: true, port: 4799, stateDir }); + await expect(runServerCommand(parseArgs(["server", "start", "--state-dir", stateDir]), { manager })).rejects.toThrow("foreground"); + + const stopped = new AbortController(); + stopped.abort(); + await expect(runServerCommand(parseArgs(["server"]), { + signal: stopped.signal, + runForeground: async () => { throw stopped.signal.reason; }, + })).resolves.toBe(true); + await expect(runServerCommand(parseArgs(["server"]), { + signal: stopped.signal, + runForeground: async () => { throw new Error("shutdown cleanup failed"); }, + })).rejects.toThrow("shutdown cleanup failed"); +}); test("CLI list and typecheck work against a temp canvases dir", async () => { const dir = tempDir(); diff --git a/src/cli.ts b/src/cli.ts index e4763d9..45059d4 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,15 +1,102 @@ #!/usr/bin/env bun -import { resolve } from "node:path"; +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; -import { flagBoolean, flagString, parseArgs } from "./args"; +import { flagBoolean, flagString, parseArgs, type ParsedArgs } from "./args"; import { canvasesDirFrom } from "./canvasFile"; +import { clearServerReady, ServerDaemonManager, writeServerReady } from "./celldDaemon"; +import { runCelldServer } from "./celldServer"; import { writeDaemonState, daemonStatePath } from "./daemon"; import { createHerdrClient, type PanePlacement } from "./herdr"; import { runMcpServer } from "./mcpServer"; import { createCanvasServer } from "./serve"; import { CanvasService } from "./service"; import { historyPath } from "./history"; +import { PLUGIN_ROOT } from "./paths"; + +type ServerManager = Pick; + +export async function runServerCommand(args: ParsedArgs, dependencies: { + manager?: ServerManager; + runForeground?: typeof runCelldServer; + signal?: AbortSignal; + stdout?: (value: string) => void; +} = {}): Promise { + if (args.command !== "server") return false; + const action = args.positionals[0]; + if (args.positionals.length > 1) throw new Error("server accepts at most one subcommand"); + const stdout = dependencies.stdout ?? (value => process.stdout.write(value)); + const numberFlag = (key: string, fallback?: number) => { + const raw = args.flags[key]; + return raw === undefined ? fallback : Number(raw === true ? NaN : raw); + }; + + if (action) { + if (flagString(args.flags, "state-dir") || flagString(args.flags, "managed-ready")) { + throw new Error("--state-dir is available only for foreground `canvas server`"); + } + const manager = dependencies.manager ?? new ServerDaemonManager(); + if (action === "start") { + stdout(`${JSON.stringify(await manager.start({ atLogin: flagBoolean(args.flags, "at-login"), port: numberFlag("port") }), null, 2)}\n`); + } else if (action === "stop") { + stdout(`${JSON.stringify(await manager.stop(), null, 2)}\n`); + } else if (action === "status") { + stdout(`${JSON.stringify(await manager.status(), null, 2)}\n`); + } else if (action === "logs") { + stdout((await manager.logs(numberFlag("lines", 100))).contents); + } else if (action === "uninstall") { + stdout(`${JSON.stringify(await manager.uninstall(), null, 2)}\n`); + } else { + throw new Error(`unknown server subcommand: ${action}`); + } + return true; + } + + if (flagBoolean(args.flags, "at-login")) throw new Error("use `canvas server start --at-login` to enable start at login"); + const port = numberFlag("port"); + const stateDir = flagString(args.flags, "state-dir"); + const readyPath = flagString(args.flags, "managed-ready"); + if (readyPath && !stateDir) throw new Error("internal --managed-ready requires --state-dir"); + const controller = dependencies.signal ? undefined : new AbortController(); + const signal = dependencies.signal ?? controller!.signal; + let readyWrite: Promise | undefined; + const stop = () => controller?.abort(); + if (controller) { + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + } + try { + await (dependencies.runForeground ?? runCelldServer)({ + port, + stateDir, + signal, + onReady: async url => { + stdout(`${JSON.stringify({ ok: true, url, port: Number(new URL(url).port), stateDir: stateDir ? resolve(stateDir) : undefined }, null, 2)}\n`); + if (readyPath) { + readyWrite = writeServerReady(resolve(readyPath), { + pid: process.pid, + port: Number(new URL(url).port), + url, + stateDir: resolve(stateDir!), + }); + await readyWrite; + } + }, + }); + } catch (error) { + const abortError = error === signal.reason || (error instanceof Error && error.name === "AbortError"); + if (!signal.aborted || !abortError) throw error; + } finally { + if (controller) { + process.off("SIGINT", stop); + process.off("SIGTERM", stop); + } + if (readyWrite) await readyWrite; + if (readyPath) await clearServerReady(resolve(readyPath)); + } + return true; +} async function main(): Promise { const args = parseArgs(process.argv.slice(2)); @@ -18,6 +105,14 @@ async function main(): Promise { return; } + if (args.command === "version" || args.command === "--version" || args.command === "-v") { + const manifest = JSON.parse(readFileSync(join(PLUGIN_ROOT, "package.json"), "utf8")) as { name: string; version: string }; + console.log(`${manifest.name} ${manifest.version}`); + return; + } + + if (await runServerCommand(args)) return; + const cwd = process.cwd(); const canvasesDir = resolve(flagString(args.flags, "dir") ?? canvasesDirFrom(cwd)); const historyDb = flagString(args.flags, "history-db"); @@ -176,6 +271,7 @@ function printHelp(): void { console.log(`canvas Usage: + canvas --version canvas list [--dir PATH] canvas write NAME [--file PATH | --stdin] canvas read NAME [--start-line N] [--end-line N] @@ -190,6 +286,9 @@ Usage: canvas show VERSION_ID [--source] canvas open --version VERSION_ID [--event EVENT_ID] [--placement split|tab|zoomed|overlay] canvas restore VERSION_ID + canvas server [--port 4786] [--state-dir PATH] + canvas server start [--port 4786] [--at-login] + canvas server stop | status | logs [--lines 100] | uninstall All commands accept --dir PATH and --history-db PATH. read returns up to 200 lines by default, with a source_hash and next_line. @@ -206,7 +305,9 @@ powered internally by Terminal Browser in app mode. `); } -main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); -}); +if (import.meta.main) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/src/history.ts b/src/history.ts index 4ae7a2d..f153028 100644 --- a/src/history.ts +++ b/src/history.ts @@ -1,6 +1,7 @@ import { Database } from "bun:sqlite"; import { createHash, randomUUID } from "node:crypto"; -import { mkdirSync, readFileSync, readdirSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs"; +import { createRequire } from "node:module"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { PLUGIN_ROOT } from "./paths"; @@ -19,13 +20,28 @@ export function hash(value: string): string { // Include implementation identity because this plugin may be an unversioned local link. export function runtimeIdentity(): string { - const files = ["package.json", "bun.lock", "src/compile.ts", "src/html.ts"]; + const files = ["package.json", "src/compile.ts", "src/html.ts"]; + // Registry tarballs intentionally omit package-manager lockfiles. Include the + // checkout lock when available without making installed-package previews depend on it. + if (existsSync(join(PLUGIN_ROOT, "bun.lock"))) files.push("bun.lock"); for (const dir of ["src/sdk", "src/runtime"]) { for (const name of readdirSync(join(PLUGIN_ROOT, dir)).sort()) { if (!name.includes(".test.")) files.push(`${dir}/${name}`); } } - return JSON.stringify({ bun: Bun.version, plugin: "0.1.0", sdkHash: hash(files.map(file => `${file}\0${readFileSync(join(PLUGIN_ROOT, file), "utf8")}`).join("\0")) }); + const manifestPath = join(PLUGIN_ROOT, "package.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { version: string }; + const requireFromPlugin = createRequire(manifestPath); + const dependencies = Object.fromEntries(["react", "react-dom", "typescript"].map(name => { + const dependency = JSON.parse(readFileSync(requireFromPlugin.resolve(`${name}/package.json`), "utf8")) as { version: string }; + return [name, dependency.version]; + })); + return JSON.stringify({ + bun: Bun.version, + plugin: manifest.version, + dependencies, + sdkHash: hash(files.map(file => `${file}\0${readFileSync(join(PLUGIN_ROOT, file), "utf8")}`).join("\0")), + }); } export type { Version, ServeEvent, HistoryEntry } from "./historyTypes";