diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a1adae013..45889b232d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -362,6 +362,13 @@ jobs: # Keep token auth unset so npm can exchange the GitHub OIDC identity. run: | cd packages/cli + # The tarball embeds the URL and digest of the web runtime archive, so the archive + # must be staged (by "Validate portable editor runtime") before anything is published. + ARCHIVE="build/pascal-web-runtime-$CLI_VERSION.tar.gz" + test -f "$ARCHIVE" + test -f "$ARCHIVE.sha256" + test -f dist/services/pascal-mcp.mjs + jq -e --arg v "$CLI_VERSION" '.version == $v' dist/runtime-source.json if [ "${{ inputs.dry-run }}" = "true" ]; then echo "🏜️ Dry run — would publish @pascal-app/cli@$CLI_VERSION" npm publish --ignore-scripts --dry-run --access public --tag "$NPM_TAG" @@ -427,3 +434,20 @@ jobs: done git push --atomic origin HEAD:main $TAGS + + # The npm package points at this asset, so it is uploaded in the same job, immediately + # after the tag it hangs off exists on the remote. + - name: Upload the CLI web runtime release asset + if: ${{ inputs.dry-run == false && (inputs.package == 'cli' || inputs.package == 'all') }} + working-directory: packages/cli + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="@pascal-app/cli@$CLI_VERSION" + ARCHIVE="build/pascal-web-runtime-$CLI_VERSION.tar.gz" + if ! gh release view "$TAG" >/dev/null 2>&1; then + [ "$NPM_TAG" = "beta" ] && PRERELEASE=--prerelease || PRERELEASE= + gh release create "$TAG" $PRERELEASE --title "$TAG" --notes "The Pascal web editor runtime for \`@pascal-app/cli@$CLI_VERSION\`. The CLI downloads \`$(basename "$ARCHIVE")\` the first time a command starts the editor and verifies it against the digest published inside the npm package. Offline installs can pass the archive directly: \`pascal editor --runtime $(basename "$ARCHIVE")\`." + fi + gh release upload "$TAG" "$ARCHIVE" "$ARCHIVE.sha256" --clobber + echo "🌐 Uploaded $(basename "$ARCHIVE") to $TAG" diff --git a/CHANGELOG.md b/CHANGELOG.md index bda8ab43ce..5921f5458c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - **Official MCP Registry entry** — `io.github.pascalorg/editor` 0.6.1 publishes the hosted Streamable HTTP endpoint, with CI validating the manifest against the live API catalog ([#808](https://github.com/pascalorg/editor/pull/808), [#809](https://github.com/pascalorg/editor/pull/809)) - **Portable `mcp.json`, Cursor manifest, Gemini extension, plate logo** — Codex and Cursor now register the bundled MCP server (the spec reads root `mcp.json`, not `.mcp.json`); `.cursor-plugin/plugin.json` and `gemini-extension.json` add those marketplaces; the MCP Registry entry gains `repository` and `icons`; marketplace logos use the brand mark on its `#171717` plate; `bun run skills:validate` asserts parity across every descriptor ([#829](https://github.com/pascalorg/editor/pull/829)) - **`pascal agent claim` and `pascal agent status`** — an agent can open a prefilled 15-minute human handoff and verify its hosted key without storing or printing it, available in the verified GitHub CLI preview ([#815](https://github.com/pascalorg/editor/pull/815), [#818](https://github.com/pascalorg/editor/pull/818), [#819](https://github.com/pascalorg/editor/pull/819), [#821](https://github.com/pascalorg/editor/pull/821), [#822](https://github.com/pascalorg/editor/pull/822)) +- **Small CLI, downloaded web runtime** — `@pascal-app/cli` now installs from a 0.5 MB npm package instead of 65 MB: the MCP service ships inside it, so `pascal mcp connect` works with no editor process and no download, while the web editor runtime is fetched once per version from its release asset, verified against a SHA-256 digest published in the package, and installed atomically; `--runtime ` covers offline hosts, `HTTPS_PROXY`/`NO_PROXY` are honoured, and an editor started by a pre-split CLI keeps its own MCP child, which must be stopped once by hand after upgrading (#845) ### Fixes diff --git a/README.md b/README.md index b221f66284..eb11be6f6e 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,11 @@ npx @pascal-app/cli editor ``` The CLI starts the editor and an authenticated MCP service in the background, selects -collision-free loopback ports, and keeps projects in `~/.pascal/data/pascal.db`. Configure -an agent to launch `pascal mcp connect`. See [Run Pascal locally](https://editor.pascal.app/docs/developers/local-editor) +collision-free loopback ports, and keeps projects in `~/.pascal/data/pascal.db`. The npm +package holds the CLI and that MCP service; the web editor runtime is downloaded once per +version on the first command that starts the editor and verified against a digest published +inside the package. Configure an agent to launch `pascal mcp connect`, which needs neither +the editor process nor that download. See [Run Pascal locally](https://editor.pascal.app/docs/developers/local-editor) for pnpm/Bun commands, project management, MCP setup, updates, storage paths, and troubleshooting. The npm release is the older runtime described below; use the verified GitHub preview when a task needs the new read-only furniture candidate check. diff --git a/SETUP.md b/SETUP.md index 80c33bc024..7032cdf21a 100644 --- a/SETUP.md +++ b/SETUP.md @@ -58,13 +58,16 @@ background, and open it in the browser without a repository checkout: npx @pascal-app/cli editor ``` -The command starts the editor and its authenticated local MCP service together. Configure -an agent to launch `pascal mcp connect`; for example, run `pascal mcp setup codex`. +The command starts the editor and its authenticated local MCP service together, downloading +the web editor runtime for that CLI version on the first run and verifying it against a +digest published in the npm package. Configure an agent to launch `pascal mcp connect`; for +example, run `pascal mcp setup codex`. That connector needs neither the editor process nor +the runtime download, and `--runtime ` covers an offline host. Use `npx @pascal-app/cli doctor` to check the runtime, storage, editor, and MCP state. Saved scenes live in `~/.pascal/data/pascal.db` independently from installed runtime versions. The CLI retains old runtime versions for rollback and warns after more than three have -accumulated. It also replaces a damaged copy of its bundled runtime on the next start; +accumulated. It also replaces a damaged copy of the installed runtime on the next start; neither operation modifies the data directory. The complete command and storage reference is in [Run Pascal locally](https://editor.pascal.app/docs/developers/local-editor). diff --git a/packages/cli/README.md b/packages/cli/README.md index 698467cace..1567d1c593 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -17,10 +17,10 @@ for `status`, `logs`, `stop`, and future sessions without another setup step. If global installation is unavailable because of local npm permissions, the editor remains running and the CLI shows the equivalent `npx` commands plus the manual install command. -The first run walks through local storage, runtime installation, automatic editor and -MCP port selection, process startup, and both health checks with live terminal feedback. -It then opens `http://pascal.localhost:`. Your projects are stored separately from -the runtime, so updating the CLI does not replace your work. +The first run walks through local storage, the one-time web runtime download, automatic +editor and MCP port selection, process startup, and both health checks with live terminal +feedback. It then opens `http://pascal.localhost:`. Your projects are stored +separately from the runtime, so updating the CLI does not replace your work. ## Why use the CLI? @@ -36,6 +36,9 @@ the runtime, so updating the CLI does not replace your work. - Node.js 22.13 or newer - npm, including when the CLI itself is launched with pnpm or Bun - A browser, unless you pass `--no-open` +- Network access the first time you start the editor, or a local copy of the web runtime + archive (see [The web editor runtime](#the-web-editor-runtime)); `pascal mcp connect` + needs neither The initial supported release is macOS. The GitHub preview below also passed a clean claim-command installation in a Linux arm64 container. This is not an x86_64 or Windows @@ -112,12 +115,57 @@ npx @pascal-app/cli editor --no-open npx @pascal-app/cli editor --foreground --no-open ``` +## The web editor runtime + +The npm package carries the CLI and the MCP service only: about 0.5 MB compressed and +2.5 MB installed. The web editor itself—the Next.js server, its static assets, and the +bundled item library—is published as one archive per CLI version, about 64 MB compressed +and 106 MB on disk. + +Every command that starts the editor (`editor`, `start`, `open`, `resume`, `projects`, +`project open`, `update`) resolves that runtime in this order: + +1. `PASCAL_BUNDLED_RUNTIME_DIR`, an already-extracted runtime directory. +2. `--runtime `, which every one of those commands accepts. +3. The runtime already installed in `~/.pascal/runtime/` for this CLI version. +4. The release asset recorded in the package, streamed into `~/.pascal/tmp` with download + progress in the terminal. + +A downloaded archive is checked against the SHA-256 digest published inside the npm +package before anything is extracted. On a mismatch the CLI deletes the temporary file and +installs nothing, so a corrupted or substituted archive never becomes your runtime. +Concurrent first runs share one download through the runtime install lock. + +An offline or air-gapped machine can take the archive from the release page: + +```bash +# On a connected machine +curl --fail --location --remote-name \ + "https://github.com/pascalorg/editor/releases/download/@pascal-app/cli@/pascal-web-runtime-.tar.gz" + +# On the target machine +pascal editor --runtime ./pascal-web-runtime-.tar.gz +``` + +An archive passed with `--runtime` is digest-verified exactly like a download. A directory +is installed as it is, which is the escape hatch for a runtime you built yourself from this +repository. + +`HTTPS_PROXY` (or `ALL_PROXY`), including a proxy that requires basic authentication, and +`NO_PROXY` are honoured; only `https://` URLs are accepted. When a download fails, the CLI +prints the archive URL, the expected digest, and the `--runtime` command to run after +copying the file across. + +Agent tools need none of this. `pascal mcp connect` starts the MCP service that ships in +the npm package, so an agent can read and write local projects on a machine that has never +downloaded the web runtime. + ## Commands | Command | Purpose | | --- | --- | -| `pascal editor` | Install if needed, ensure the editor is running, and open it. | -| `pascal start` | Ensure the editor is running without opening a browser. | +| `pascal editor [--runtime ]` | Install the web runtime if needed, ensure the editor is running, and open it. | +| `pascal start [--runtime ]` | Ensure the editor is running without opening a browser. | | `pascal stop [--force]` | Stop the managed editor and MCP processes; `--force` is a guarded recovery path. | | `pascal restart` | Restart the editor and MCP service with their current configuration. | | `pascal status [--json]` | Show editor and MCP health, version, PIDs, ports, URL, and runtime metadata. | @@ -125,14 +173,14 @@ npx @pascal-app/cli editor --foreground --no-open | `pascal resume [project]` | Open the latest project, or a selected project. | | `pascal projects [--json]` | List local projects. | | `pascal logs [--follow]` | Read or follow the managed editor log. | -| `pascal update [--version ]` | Health-check and activate the current CLI's bundled runtime or an npm-published target. | +| `pascal update [--version ] [--runtime ]` | Health-check and activate the runtime this CLI publishes, or an npm-published target. | | `pascal doctor [--json]` | Diagnose Node.js, storage, runtime, process, and plugin state. | | `pascal info [--json]` | Print platform, paths, runtime, and plugin context. | | `pascal project list [--json]` | Explicit form of `pascal projects`. | | `pascal project open ` | Explicit form of `pascal open `. | | `pascal agent claim [--no-open] [--json]` | Link an autonomous hosted agent to the person accountable for it. | | `pascal agent status [--json]` | Verify the hosted agent credential and inspect its claim and organization scope. | -| `pascal mcp connect` | Stable local connector for MCP clients; discovers the dynamic managed service. | +| `pascal mcp connect` | Stable local connector for MCP clients; starts the bundled MCP service without the web runtime. | | `pascal mcp status [--json]` | Show managed MCP health. | | `pascal mcp config [--json]` | Print generic MCP client configuration. | | `pascal mcp setup ` | Configure an installed client without overwriting existing entries. | @@ -149,11 +197,13 @@ directory; client configuration never contains that token. ```text ~/.pascal/ - runtime// installed editor runtimes + runtime// installed web editor runtimes data/pascal.db projects and scenes - logs/editor.log detached editor output - run/editor.json managed editor and MCP process identity + logs/editor.log detached editor and MCP output + run/editor.json managed editor process identity + run/mcp.json managed MCP service identity run/mcp-token private local MCP token + tmp/ runtime downloads in progress plugins/ reserved verified-plugin storage pascal.plugins.lock reserved managed-plugin lock ``` @@ -165,17 +215,19 @@ have accumulated. ## Local AI agents -The MCP server starts automatically with `pascal editor`. Add the stable connector to -your client once: +The MCP service ships in the npm package. It starts automatically with `pascal editor`, and +`pascal mcp connect` starts it on its own—no web runtime download, no editor process. Add +the stable connector to your client once: ```bash pascal mcp setup codex pascal mcp setup claude ``` -Or use `pascal mcp config` for JSON-based clients. The connector also starts Pascal -when an agent connects while it is stopped. Ask the agent to read -`pascal://agent-guide`, list or load a scene, edit it, and return the `editorUrl`. +Or use `pascal mcp config` for JSON-based clients. Ask the agent to read +`pascal://agent-guide`, list or load a scene, edit it, and return the `editorUrl`. Those +`editorUrl` values point at the local editor; run `pascal editor` to open one, which is +also when the web runtime is downloaded. ## Hosted autonomous agents diff --git a/packages/cli/scripts/smoke-packed-runtime.ts b/packages/cli/scripts/smoke-packed-runtime.ts index 5b783fa627..9db2d6db73 100644 --- a/packages/cli/scripts/smoke-packed-runtime.ts +++ b/packages/cli/scripts/smoke-packed-runtime.ts @@ -1,5 +1,7 @@ import { spawn } from 'node:child_process' -import { mkdtemp, rm } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { copyFile, mkdtemp, open, readFile, rm, stat } from 'node:fs/promises' import http from 'node:http' import os from 'node:os' import path from 'node:path' @@ -11,10 +13,17 @@ const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url const smokeRoot = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-smoke-')) let tarballPath: string | null = null let smokeExecutable: string | null = null +let mcpOnlyExecutable: string | null = null const defaultPortBlocker = http.createServer((_request, response) => { response.setHeader('content-type', 'application/json') response.end(JSON.stringify({ status: 'ok', app: 'foreign' })) }) +/** MCP-only mode is verified in its own home so no web runtime can be installed there. */ +const mcpOnlyEnvironment = { + ...process.env, + PASCAL_HOME: path.join(smokeRoot, 'home-mcp-only'), + PASCAL_NO_OPEN: '1', +} const smokeEnvironment = { ...process.env, PASCAL_HOME: path.join(smokeRoot, 'home'), @@ -31,22 +40,141 @@ try { if (!artifact) throw new Error('npm pack did not return an artifact') tarballPath = path.join(packageDirectory, artifact.filename) enforceArtifactBudget(artifact) + const runtimeArchive = await verifyStagedWebRuntime() const installDirectory = path.join(smokeRoot, 'install') await run('npm', ['install', '--ignore-scripts', '--prefix', installDirectory, tarballPath]) - smokeExecutable = path.join(installDirectory, 'node_modules/@pascal-app/cli/dist/bin/pascal.js') + const executable = path.join(installDirectory, 'node_modules/@pascal-app/cli/dist/bin/pascal.js') + + mcpOnlyExecutable = executable + await checkMcpWithoutWebRuntime(executable) + mcpOnlyExecutable = null + + smokeExecutable = executable + await checkTamperedArchiveIsRejected(executable, runtimeArchive.file) + await checkEditorFromLocalArchive(executable, runtimeArchive.file) + smokeExecutable = null + + console.log( + `Packed CLI smoke passed (${formatMb(artifact.size)} MB compressed, ${formatMb(artifact.unpackedSize)} MB unpacked, ${artifact.entryCount} files).`, + ) + console.log( + `Web runtime archive ${path.basename(runtimeArchive.file)} (${formatMb(runtimeArchive.size)} MB) verified against ${runtimeArchive.url}`, + ) +} finally { + await close(defaultPortBlocker) + for (const [command, environment] of [ + [smokeExecutable, smokeEnvironment], + [mcpOnlyExecutable, mcpOnlyEnvironment], + ] as Array<[string | null, NodeJS.ProcessEnv]>) { + if (!command) continue + await run( + process.execPath, + [command, 'stop', '--force', '--json'], + undefined, + environment, + ).catch(() => undefined) + } + if (tarballPath) await rm(tarballPath, { force: true }) + await rm(smokeRoot, { recursive: true, force: true }) +} + +/** + * Phase 1: agent tools must work on a machine that has never downloaded the web runtime. + */ +async function checkMcpWithoutWebRuntime(executable: string): Promise { + const client = new Client({ name: 'pascal-cli-smoke-mcp-only', version: '0.0.0' }) + const transport = new StdioClientTransport({ + command: process.execPath, + args: [executable, 'mcp', 'connect'], + env: mcpOnlyEnvironment as Record, + stderr: 'pipe', + }) + try { + await client.connect(transport) + const tools = await client.listTools() + if (!tools.tools.some((tool) => tool.name === 'save_scene')) { + throw new Error('MCP-only mode did not expose save_scene') + } + const saved = await client.callTool({ + name: 'save_scene', + arguments: { id: 'mcp-only-project', name: 'MCP only project' }, + }) + if (saved.isError) throw new Error(`MCP-only save_scene failed: ${JSON.stringify(saved)}`) + const listed = await client.callTool({ name: 'list_scenes', arguments: {} }) + if (listed.isError || !JSON.stringify(listed).includes('mcp-only-project')) { + throw new Error(`MCP-only list_scenes failed: ${JSON.stringify(listed)}`) + } + console.log( + `MCP-only mode exposed ${tools.tools.length} tools and stored a scene with no web runtime installed.`, + ) + } finally { + await client.close() + } + const status = JSON.parse( + (await run(process.execPath, [executable, 'status', '--json'], undefined, mcpOnlyEnvironment)) + .stdout, + ) as { installed: boolean; running: boolean; runtime: unknown; mcp: { healthy: boolean } } + if (status.installed || status.runtime !== null || status.running) { + throw new Error('MCP-only mode installed or started the web runtime') + } + if (!status.mcp.healthy) throw new Error('the managed MCP service is not healthy on its own') + const stopped = JSON.parse( + (await run(process.execPath, [executable, 'stop', '--json'], undefined, mcpOnlyEnvironment)) + .stdout, + ) as { stopped: boolean } + if (!stopped.stopped) throw new Error('stop did not report the MCP-only service as stopped') +} + +/** Phase 2: a modified archive must never reach the runtime directory. */ +async function checkTamperedArchiveIsRejected( + executable: string, + archiveFile: string, +): Promise { + const tampered = path.join(smokeRoot, 'tampered-web-runtime.tar.gz') + await copyFile(archiveFile, tampered) + const handle = await open(tampered, 'r+') + try { + const offset = Math.floor((await handle.stat()).size / 2) + const byte = Buffer.alloc(1) + await handle.read(byte, 0, 1, offset) + byte[0] = ((byte[0] ?? 0) ^ 0xff) & 0xff + await handle.write(byte, 0, 1, offset) + } finally { + await handle.close() + } + const failure = await runExpectingFailure( + process.execPath, + [executable, 'editor', '--no-open', '--json', '--runtime', tampered], + smokeEnvironment, + ) + const reported = JSON.parse(failure.stderr) as { error: string; message: string } + if (reported.error !== 'runtime_digest_mismatch') { + throw new Error(`a tampered archive was not rejected: ${failure.stderr}`) + } + await stat(tampered) + const status = JSON.parse( + (await run(process.execPath, [executable, 'status', '--json'], undefined, smokeEnvironment)) + .stdout, + ) as { installed: boolean } + if (status.installed) throw new Error('a tampered archive was installed') + console.log(`Tampered archive rejected: ${reported.message.split('\n')[0]}`) +} +/** Phase 3: the offline install path, then the full editor and MCP flow over that runtime. */ +async function checkEditorFromLocalArchive(executable: string, archiveFile: string): Promise { const started = JSON.parse( ( await run( process.execPath, - [smokeExecutable, 'editor', '--no-open', '--json'], + [executable, 'editor', '--no-open', '--json', '--runtime', archiveFile], undefined, smokeEnvironment, ) ).stdout, - ) as { pid: number; port: number; url: string } + ) as { pid: number; port: number; url: string; mcp: { port: number } } if (started.port === 3000) throw new Error('editor reused the occupied default port') + if (!started.mcp?.port) throw new Error('the editor did not report a managed MCP port') const rootResponse = await fetch(`http://127.0.0.1:${started.port}/`) if (!rootResponse.ok) throw new Error(`editor root returned ${rootResponse.status}`) const scenesResponse = await fetch(`${started.url}/scenes`) @@ -55,7 +183,7 @@ try { ( await run( process.execPath, - [smokeExecutable, 'editor', '--no-open', '--port', '0', '--json'], + [executable, 'editor', '--no-open', '--port', '0', '--json'], undefined, smokeEnvironment, ) @@ -70,7 +198,7 @@ try { } const humanStart = await run( process.execPath, - [smokeExecutable, 'editor', '--no-open'], + [executable, 'editor', '--no-open'], undefined, smokeEnvironment, ) @@ -82,13 +210,13 @@ try { } await run( process.execPath, - [smokeExecutable, 'project', 'list', '--json'], + [executable, 'project', 'list', '--json'], undefined, smokeEnvironment, ) const mcpTransport = new StdioClientTransport({ command: process.execPath, - args: [smokeExecutable, 'mcp', 'connect'], + args: [executable, 'mcp', 'connect'], env: smokeEnvironment as Record, stderr: 'pipe', }) @@ -111,7 +239,7 @@ try { ( await run( process.execPath, - [smokeExecutable, 'resume', 'Smoke project', '--json'], + [executable, 'resume', 'Smoke project', '--json'], undefined, smokeEnvironment, ) @@ -120,25 +248,57 @@ try { if (resumed.project.id !== 'smoke-project' || !resumed.url.endsWith('/scene/smoke-project')) { throw new Error('CLI project resume did not resolve the MCP-saved project') } - await run(process.execPath, [smokeExecutable, 'doctor', '--json'], undefined, smokeEnvironment) - await run(process.execPath, [smokeExecutable, 'stop', '--json'], undefined, smokeEnvironment) - smokeExecutable = null - console.log( - `Packed runtime smoke passed (${formatMb(artifact.size)} MB compressed, ${formatMb(artifact.unpackedSize)} MB unpacked, ${artifact.entryCount} files).`, + `Editor installed from ${path.basename(archiveFile)} on port ${started.port}, MCP on port ${started.mcp.port}, and a scene round-tripped between MCP and the CLI.`, ) -} finally { - await close(defaultPortBlocker) - if (smokeExecutable) { - await run( - process.execPath, - [smokeExecutable, 'stop', '--force', '--json'], - undefined, - smokeEnvironment, - ).catch(() => undefined) + await run(process.execPath, [executable, 'doctor', '--json'], undefined, smokeEnvironment) + await run(process.execPath, [executable, 'stop', '--json'], undefined, smokeEnvironment) +} + +async function verifyStagedWebRuntime(): Promise<{ file: string; size: number; url: string }> { + const source = JSON.parse( + await readFile(path.join(packageDirectory, 'dist/runtime-source.json'), 'utf8'), + ) as { version: string; url: string; sha256: string; size: number } + const packageVersion = ( + JSON.parse(await readFile(path.join(packageDirectory, 'package.json'), 'utf8')) as { + version: string + } + ).version + if (source.version !== packageVersion) { + throw new Error(`dist/runtime-source.json targets ${source.version}, not ${packageVersion}`) } - if (tarballPath) await rm(tarballPath, { force: true }) - await rm(smokeRoot, { recursive: true, force: true }) + const archiveName = `pascal-web-runtime-${packageVersion}.tar.gz` + const expectedUrl = `https://github.com/pascalorg/editor/releases/download/@pascal-app/cli@${packageVersion}/${archiveName}` + if (source.url !== expectedUrl) { + throw new Error(`dist/runtime-source.json points at ${source.url}, not ${expectedUrl}`) + } + const file = path.join(packageDirectory, 'build', archiveName) + const { size } = await stat(file) + if (size !== source.size) { + throw new Error(`${archiveName} is ${size} bytes; runtime-source.json records ${source.size}`) + } + const maximumArchiveSize = 70 * 1024 * 1024 + if (size > maximumArchiveSize) { + throw new Error( + `the web runtime archive exceeds its release budget: ${formatMb(size)} MB > ${formatMb(maximumArchiveSize)} MB`, + ) + } + const digestFile = `${file}.sha256` + const recordedDigest = (await readFile(digestFile, 'utf8')).trim().split(/\s+/)[0] + if (recordedDigest !== source.sha256) { + throw new Error(`${digestFile} does not match dist/runtime-source.json`) + } + const hashed = await sha256(file) + if (hashed !== source.sha256) { + throw new Error(`${archiveName} hashes to ${hashed}, not the published ${source.sha256}`) + } + return { file, size, url: source.url } +} + +async function sha256(filePath: string): Promise { + const hash = createHash('sha256') + for await (const chunk of createReadStream(filePath)) hash.update(chunk as Buffer) + return hash.digest('hex') } async function listen(server: http.Server): Promise { @@ -164,14 +324,18 @@ interface PackedArtifact { entryCount: number } +/** + * The npm package carries the CLI and the MCP service only. The web runtime rides a GitHub + * release asset, so both budgets are enforced separately. + */ function enforceArtifactBudget(artifact: { size: number unpackedSize: number entryCount: number }): void { - const maximumSize = 75 * 1024 * 1024 - const maximumUnpackedSize = 115 * 1024 * 1024 - const maximumEntryCount = 3_200 + const maximumSize = 3 * 1024 * 1024 + const maximumUnpackedSize = 10 * 1024 * 1024 + const maximumEntryCount = 250 if ( artifact.size > maximumSize || artifact.unpackedSize > maximumUnpackedSize || @@ -189,6 +353,31 @@ async function run( cwd?: string, env: NodeJS.ProcessEnv = process.env, ): Promise<{ stdout: string; stderr: string }> { + const result = await capture(command, args, cwd, env) + if (result.exitCode !== 0) { + throw new Error(`${command} ${args.join(' ')} failed (${result.exitCode}): ${result.stderr}`) + } + return result +} + +async function runExpectingFailure( + command: string, + args: string[], + env: NodeJS.ProcessEnv, +): Promise<{ stdout: string; stderr: string }> { + const result = await capture(command, args, undefined, env) + if (result.exitCode === 0) { + throw new Error(`${command} ${args.join(' ')} succeeded but should have failed`) + } + return result +} + +async function capture( + command: string, + args: string[], + cwd?: string, + env: NodeJS.ProcessEnv = process.env, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { const executable = process.platform === 'win32' && command === 'npm' ? 'npm.cmd' : command const child = spawn(executable, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }) const stdout: Buffer[] = [] @@ -199,14 +388,11 @@ async function run( child.once('error', reject) child.once('exit', (code) => resolve(code ?? 1)) }) - const result = { + return { + exitCode, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'), } - if (exitCode !== 0) { - throw new Error(`${command} ${args.join(' ')} failed (${exitCode}): ${result.stderr}`) - } - return result } function formatMb(bytes: number): string { diff --git a/packages/cli/scripts/stage-runtime.ts b/packages/cli/scripts/stage-runtime.ts index 7eadb47f45..c973558fdd 100644 --- a/packages/cli/scripts/stage-runtime.ts +++ b/packages/cli/scripts/stage-runtime.ts @@ -12,13 +12,21 @@ import { } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { fileSha256 } from '../src/runtime-download.js' +import { createRuntimeArchive } from '../src/tar.js' const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const repositoryRoot = path.resolve(packageDirectory, '../..') const appDirectory = path.join(repositoryRoot, 'apps/editor') const standaloneDirectory = path.join(appDirectory, '.next/standalone') const standaloneAppDirectory = path.join(standaloneDirectory, 'apps/editor') -const outputDirectory = path.join(packageDirectory, 'dist/runtime') +/** + * The web runtime is a release asset, not part of the npm package: it is staged and archived + * under `build/`, while `dist/` only gains the MCP service and the digest of that archive. + */ +const buildDirectory = path.join(packageDirectory, 'build') +const outputDirectory = path.join(buildDirectory, 'runtime') +const releaseAssetBaseUrl = 'https://github.com/pascalorg/editor/releases/download' /** * `next build` copies its tracing root into `.next/standalone`, so the portable runtime @@ -52,7 +60,15 @@ const packageJson = JSON.parse( version: string } +const archiveName = `pascal-web-runtime-${packageJson.version}.tar.gz` +const archiveFile = path.join(buildDirectory, archiveName) +const assetUrl = `${releaseAssetBaseUrl}/@pascal-app/cli@${packageJson.version}/${archiveName}` + await chmod(path.join(packageDirectory, 'dist/bin/pascal.js'), 0o755) +await bundleMcpServer( + path.join(packageDirectory, 'dist/services/pascal-mcp.mjs'), + packageJson.version, +) await assertFile(path.join(standaloneAppDirectory, 'server.js')) await rm(outputDirectory, { recursive: true, force: true }) await mkdir(path.dirname(outputDirectory), { recursive: true }) @@ -67,8 +83,6 @@ await cp( path.join(outputDirectory, 'apps/editor/.next/static'), { recursive: true, force: true }, ) -await bundleMcpServer(outputDirectory, packageJson.version) - await rm(path.join(outputDirectory, 'apps/editor/vendor'), { recursive: true, force: true }) await removeUnusedSharp(outputDirectory) await flattenBunNodeModules(outputDirectory) @@ -83,23 +97,32 @@ if (nativeFiles.length > 0) { await writeFile( path.join(outputDirectory, 'runtime-manifest.json'), `${JSON.stringify( - { - schemaVersion: 1, - version: packageJson.version, - entrypoint: 'apps/editor/server.js', - mcpEntrypoint: 'services/pascal-mcp.mjs', - healthPath: '/api/health', - mcpHealthPath: '/health', - }, + { schemaVersion: 2, version: packageJson.version, entrypoint: 'apps/editor/server.js' }, null, 2, )}\n`, ) -console.log(`Staged Pascal editor runtime ${packageJson.version} at ${outputDirectory}`) +const archive = await createRuntimeArchive(outputDirectory, archiveFile) +const sha256 = await fileSha256(archiveFile) +await writeFile(`${archiveFile}.sha256`, `${sha256} ${archiveName}\n`) +await writeFile( + path.join(packageDirectory, 'dist/runtime-source.json'), + `${JSON.stringify( + { version: packageJson.version, url: assetUrl, sha256, size: archive.size }, + null, + 2, + )}\n`, +) + +console.log(`Staged Pascal web runtime ${packageJson.version} at ${outputDirectory}`) +console.log( + `Archived ${archive.entryCount} entries to ${archiveFile} (${formatMegabytes(archive.size)} MB)`, +) +console.log(`Digest ${sha256}`) +console.log(`Release asset ${assetUrl}`) -async function bundleMcpServer(runtimeDirectory: string, version: string): Promise { - const output = path.join(runtimeDirectory, 'services/pascal-mcp.mjs') +async function bundleMcpServer(output: string, version: string): Promise { await mkdir(path.dirname(output), { recursive: true }) const child = spawn( process.execPath, diff --git a/packages/cli/src/bin/pascal.ts b/packages/cli/src/bin/pascal.ts index 9cc24f472d..9cf8e4dbfe 100755 --- a/packages/cli/src/bin/pascal.ts +++ b/packages/cli/src/bin/pascal.ts @@ -18,9 +18,10 @@ import { import { CliError, toCliError } from '../errors.js' import { readJsonFile } from '../json-files.js' import { connectManagedMcp } from '../mcp-connector.js' +import { getMcpServiceStatus } from '../mcp-service.js' import { resolvePascalPaths } from '../paths.js' import { listLocalProjects, projectUrl, resolveLocalProject } from '../projects.js' -import { installBundledRuntime } from '../runtime.js' +import { ensureWebRuntime } from '../runtime-download.js' import { TerminalProgress } from '../terminal-progress.js' import { version } from '../version.js' @@ -38,8 +39,8 @@ ENABLE THE SHORT GLOBAL COMMAND: pascal USAGE: - pascal editor [--foreground] [--no-open] [--port ] - pascal start [--foreground] [--port ] + pascal editor [--foreground] [--no-open] [--port ] [--runtime ] + pascal start [--foreground] [--port ] [--runtime ] pascal stop | restart | status pascal open [project] pascal resume [project] @@ -56,12 +57,21 @@ USAGE: pascal mcp connect | status | config | setup pascal plugin list [--json] +THE WEB EDITOR RUNTIME: + The npm package holds the CLI and the MCP service. The web editor runtime is + downloaded once per version into ~/.pascal/runtime the first time a command + starts the editor, and verified against a digest published with this CLI. + Offline: pass --runtime . "pascal mcp connect" needs no + download at all. + Documentation: https://editor.pascal.app/docs/developers/local-editor ` const MCP_HELP = `Pascal MCP — connect AI agents to local projects -The authenticated MCP service starts and stops with the Pascal editor. +The authenticated MCP service ships inside this package. It starts on demand and +needs neither the web editor nor its downloaded runtime, so agents can read and +write local projects on a machine that never runs the editor. USAGE: pascal mcp status [--json] Check the managed MCP service @@ -142,7 +152,7 @@ async function main(): Promise { case 'mcp': return runMcp(args) case '_install-runtime': - return output(true, await installBundledRuntime(paths, undefined, { activate: false }), '') + return output(true, (await ensureWebRuntime({ paths, activate: false })).runtime, '') default: throw new CliError('unknown_command', `Unknown command: ${command}`, { command }, 2) } @@ -157,6 +167,7 @@ async function runStart(args: string[], shouldOpen: boolean): Promise { open: { type: 'boolean', default: shouldOpen }, 'no-open': { type: 'boolean', default: false }, port: { type: 'string' }, + runtime: { type: 'string' }, json: { type: 'boolean', default: false }, help: { type: 'boolean', short: 'h', default: false }, }, @@ -171,7 +182,8 @@ async function runStart(args: string[], shouldOpen: boolean): Promise { paths, port, foreground: values.foreground, - onProgress: progress ? (event) => reportStartProgress(progress, event) : undefined, + runtimeSource: values.runtime, + onProgress: progress ? createStartProgressReporter(progress) : undefined, }) } catch (error) { progress?.stop() @@ -197,12 +209,12 @@ async function runStart(args: string[], shouldOpen: boolean): Promise { const commandPrefix = useShortCommand ? 'pascal' : 'npx @pascal-app/cli' output( values.json, - { ...result.state, alreadyRunning: result.alreadyRunning }, + { ...result.state, mcp: result.mcp, alreadyRunning: result.alreadyRunning }, [ result.alreadyRunning ? `Pascal is already running at ${result.state.url}` : `Pascal is ready at ${result.state.url}`, - `MCP is ready on port ${result.state.mcp?.port}`, + `MCP is ready on port ${result.mcp.port}`, `Projects stay in ${paths.data}`, '', `Manage it with ${useShortCommand ? 'pascal' : 'npx'}:`, @@ -232,11 +244,54 @@ async function runStart(args: string[], shouldOpen: boolean): Promise { } } +/** + * Download progress arrives far more often than a non-TTY log should print, so percentages + * are reported per whole percent on a terminal and per tenth otherwise. + */ +function createStartProgressReporter( + progress: TerminalProgress, +): (event: EditorStartProgress) => void { + const perPercent = Boolean(process.stderr.isTTY) + let lastReportedStep = -1 + return (event) => { + if (event.step !== 'runtime-downloading') return reportStartProgress(progress, event) + if (event.received === 0) { + lastReportedStep = -1 + progress.start(`Downloading the editor runtime from ${event.url}`) + return + } + const percent = event.total + ? Math.min(100, Math.floor((event.received / event.total) * 100)) + : 0 + const step = perPercent ? percent : Math.floor(percent / 10) + if (step === lastReportedStep) return + lastReportedStep = step + progress.update( + event.total + ? `Downloading the editor runtime ${percent}% (${formatMegabytes(event.received)} of ${formatMegabytes(event.total)})` + : `Downloading the editor runtime (${formatMegabytes(event.received)})`, + ) + } +} + +function formatMegabytes(bytes: number): string { + return `${(bytes / 1024 / 1024).toFixed(1)} MB` +} + function reportStartProgress(progress: TerminalProgress, event: EditorStartProgress): void { switch (event.step) { case 'storage-ready': progress.succeed(`Local data directory ready at ${event.dataDirectory}`) return + case 'runtime-downloading': + progress.update('Downloading the editor runtime') + return + case 'runtime-verifying': + progress.update('Verifying the editor runtime digest') + return + case 'runtime-extracting': + progress.update('Extracting the editor runtime') + return case 'runtime-installing': progress.start('Installing the editor runtime') return @@ -271,6 +326,12 @@ function reportStartProgress(progress: TerminalProgress, event: EditorStartProgr case 'mcp-health-checking': progress.update('Checking that MCP is ready') return + case 'mcp-ready': + progress.succeed(`MCP is ready on port ${event.port}`) + return + case 'mcp-already-running': + progress.succeed(`MCP is already running on port ${event.port}`) + return case 'ready': progress.succeed('Pascal Editor and MCP are ready') return @@ -300,20 +361,20 @@ async function runRestart(args: string[]): Promise { async function runStatus(args: string[]): Promise { const json = booleanOption(args, 'json') - const status = await getEditorStatus(paths) + const [status, mcp] = await Promise.all([getEditorStatus(paths), getMcpServiceStatus(paths)]) output( json, - status, + { ...status, mcp }, status.healthy ? [ `Pascal ${status.state?.version} is running at ${status.state?.url}`, - `MCP is ready on port ${status.state?.mcp?.port}`, + mcp.healthy ? `MCP is ready on port ${mcp.state?.port}` : 'MCP is stopped.', ].join('\n') : status.running ? 'Pascal has a running but unhealthy process.' : status.installed ? `Pascal ${status.runtime?.version} is installed and stopped.` - : 'Pascal is not installed.', + : 'The Pascal web runtime is not installed yet.', ) if (status.running && !status.healthy) process.exitCode = 1 } @@ -323,13 +384,13 @@ async function runOpen(args: string[]): Promise { args, strict: true, allowPositionals: true, - options: { json: { type: 'boolean', default: false } }, + options: { json: { type: 'boolean', default: false }, runtime: { type: 'string' } }, }) if (positionals.length > 1) { throw new CliError('invalid_option', 'Use "pascal open [project]".', undefined, 2) } if (positionals[0]) return runProjectOpen(args, false) - const status = await ensureRunningEditor() + const status = await ensureRunningEditor(values.runtime) openBrowser(status.state.url) output(values.json, { url: status.state.url }, status.state.url) } @@ -377,9 +438,9 @@ async function runInfo(args: string[]): Promise { `CLI: ${version}`, `Node: ${info.cli.node}`, `Home: ${paths.root}`, - `Runtime: ${info.editor.runtime?.version ?? 'not installed'}`, + `Web runtime: ${info.editor.runtime?.version ?? 'not installed'}`, `Editor: ${info.editor.healthy ? info.editor.state?.url : 'stopped'}`, - `MCP: ${info.editor.components.mcp.healthy ? `ready on port ${info.editor.state?.mcp?.port}` : 'stopped'}`, + `MCP: ${info.mcp.healthy ? `ready on port ${info.mcp.state?.port}` : 'stopped'}`, `Plugins: ${info.plugins.length}`, ].join('\n'), ) @@ -389,7 +450,11 @@ async function runUpdate(args: string[]): Promise { const { values } = parseArgs({ args, strict: true, - options: { version: { type: 'string' }, json: { type: 'boolean', default: false } }, + options: { + version: { type: 'string' }, + runtime: { type: 'string' }, + json: { type: 'boolean', default: false }, + }, }) const target = values.version ?? 'latest' if (!isAllowedUpdateVersion(target)) { @@ -402,7 +467,8 @@ async function runUpdate(args: string[]): Promise { } let candidate if (target === version) { - candidate = await installBundledRuntime(paths, undefined, { activate: false }) + candidate = (await ensureWebRuntime({ paths, runtimeSource: values.runtime, activate: false })) + .runtime } else { const spec = `@pascal-app/cli@${target}` const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm' @@ -457,11 +523,15 @@ async function runUpdate(args: string[]): Promise { async function runProject(args: string[]): Promise { const [subcommand, ...rest] = args if (subcommand === 'list') { - const json = booleanOption(rest, 'json') - const status = await ensureRunningEditor() + const { values } = parseArgs({ + args: rest, + strict: true, + options: { json: { type: 'boolean', default: false }, runtime: { type: 'string' } }, + }) + const status = await ensureRunningEditor(values.runtime) const projects = await listLocalProjects(status.state) output( - json, + values.json, { projects }, projects.length ? projects @@ -493,7 +563,7 @@ async function runProjectOpen(args: string[], latestWhenMissing: boolean): Promi args, strict: true, allowPositionals: true, - options: { json: { type: 'boolean', default: false } }, + options: { json: { type: 'boolean', default: false }, runtime: { type: 'string' } }, }) if (positionals.length > 1 || (!latestWhenMissing && positionals.length !== 1)) { throw new CliError( @@ -503,7 +573,7 @@ async function runProjectOpen(args: string[], latestWhenMissing: boolean): Promi 2, ) } - const status = await ensureRunningEditor() + const status = await ensureRunningEditor(values.runtime) const projects = await listLocalProjects(status.state) const project = resolveLocalProject(projects, positionals[0]) const url = projectUrl(status.state, project) @@ -522,11 +592,11 @@ async function runMcp(args: string[]): Promise { } if (subcommand === 'status') { const json = booleanOption(rest, 'json') - const status = await getEditorStatus(paths) + const status = await getMcpServiceStatus(paths) const result = { - running: status.components.mcp.running, - healthy: status.components.mcp.healthy, - port: status.state?.mcp?.port ?? null, + running: status.running, + healthy: status.healthy, + port: status.state?.port ?? null, } output( json, @@ -535,7 +605,7 @@ async function runMcp(args: string[]): Promise { ? `Pascal MCP is ready on port ${result.port}.` : result.running ? 'Pascal MCP is running but unhealthy.' - : 'Pascal MCP is stopped.', + : 'Pascal MCP is stopped. It starts when an MCP client runs "pascal mcp connect".', ) if (result.running && !result.healthy) process.exitCode = 1 return @@ -684,10 +754,20 @@ async function runPlugin(args: string[]): Promise { ) } -async function ensureRunningEditor() { +async function ensureRunningEditor(runtimeSource?: string) { const status = await getEditorStatus(paths) if (status.healthy && status.state) return { ...status, state: status.state } - const started = await startEditor({ paths }) + const progress = process.stderr.isTTY ? new TerminalProgress() : undefined + let started: Awaited> + try { + started = await startEditor({ + paths, + runtimeSource, + onProgress: progress ? createStartProgressReporter(progress) : undefined, + }) + } finally { + progress?.stop() + } return { ...(await getEditorStatus(paths)), state: started.state, diff --git a/packages/cli/src/diagnostics.ts b/packages/cli/src/diagnostics.ts index b499341b1a..7b91ffdb7d 100644 --- a/packages/cli/src/diagnostics.ts +++ b/packages/cli/src/diagnostics.ts @@ -2,6 +2,7 @@ import { constants } from 'node:fs' import { access, readdir, stat } from 'node:fs/promises' import { ensurePascalDirectories, getEditorStatus } from './editor-process.js' import { readJsonFile } from './json-files.js' +import { getMcpServiceStatus } from './mcp-service.js' import type { PascalPaths } from './paths.js' export interface DiagnosticCheck { @@ -46,39 +47,31 @@ export async function runDoctor(paths: PascalPaths): Promise }) } try { - const status = await getEditorStatus(paths) + const [status, mcp] = await Promise.all([getEditorStatus(paths), getMcpServiceStatus(paths)]) checks.push({ id: 'runtime', status: status.installed ? 'pass' : 'warn', message: status.runtime - ? `Installed runtime ${status.runtime.version}` - : 'No runtime installed yet.', + ? `Installed web runtime ${status.runtime.version}` + : 'No web runtime installed yet. It downloads when the editor first starts.', }) checks.push({ id: 'editor', - status: status.components.editor.healthy - ? 'pass' - : status.components.editor.running - ? 'fail' - : 'warn', - message: status.components.editor.healthy + status: status.healthy ? 'pass' : status.running ? 'fail' : 'warn', + message: status.healthy ? `Healthy at ${status.state?.url}` - : status.components.editor.running + : status.running ? 'A recorded editor process is running but unhealthy.' : 'The editor is stopped.', }) checks.push({ id: 'mcp', - status: status.components.mcp.healthy - ? 'pass' - : status.components.mcp.running - ? 'fail' - : 'warn', - message: status.components.mcp.healthy - ? `MCP is healthy on loopback port ${status.state?.mcp?.port}.` - : status.components.mcp.running + status: mcp.healthy ? 'pass' : mcp.running ? 'fail' : 'warn', + message: mcp.healthy + ? `MCP is healthy on loopback port ${mcp.state?.port}.` + : mcp.running ? 'The managed MCP process is running but unhealthy.' - : 'MCP is stopped with the editor.', + : 'MCP is stopped. "pascal mcp connect" starts it on demand.', }) const runtimeVersions = (await readdir(paths.runtime, { withFileTypes: true })) .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) @@ -123,14 +116,16 @@ function errorMessage(error: unknown): string { export async function collectInfo(paths: PascalPaths) { await ensurePascalDirectories(paths) - const [status, runtimeVersions, pluginLock] = await Promise.all([ + const [status, mcp, runtimeVersions, pluginLock] = await Promise.all([ getEditorStatus(paths), + getMcpServiceStatus(paths), readdir(paths.runtime).catch(() => [] as string[]), readJsonFile<{ schemaVersion?: number; plugins?: unknown[] }>(paths.pluginLock), ]) return { cli: { node: process.versions.node, platform: process.platform, arch: process.arch }, editor: status, + mcp, paths, runtimes: runtimeVersions.filter((entry) => !entry.startsWith('.')).sort(), plugins: pluginLock?.plugins ?? [], diff --git a/packages/cli/src/editor-process.ts b/packages/cli/src/editor-process.ts index 8017105310..c04e27149b 100644 --- a/packages/cli/src/editor-process.ts +++ b/packages/cli/src/editor-process.ts @@ -1,20 +1,33 @@ -import { type ChildProcess, execFile, spawn } from 'node:child_process' -import { randomBytes, randomUUID } from 'node:crypto' +import { type ChildProcess, spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' import { closeSync, openSync } from 'node:fs' -import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' -import net from 'node:net' +import { mkdir, open, rename, rm, stat } from 'node:fs/promises' import path from 'node:path' import { CliError } from './errors.js' import { withFileLock } from './file-lock.js' import { readJsonFile, writeJsonFile } from './json-files.js' +import { + ensureMcpService, + type McpServiceState, + type McpStartProgress, + stopMcpService, +} from './mcp-service.js' import type { PascalPaths } from './paths.js' +import { + errorMessage, + findAvailablePort, + isProcessRunning, + processCommand, + terminateProcess, + waitForSpawn, +} from './process-control.js' import { type ActiveRuntime, activateRuntime, - installBundledRuntime, readActiveRuntime, readRuntimeManifest, } from './runtime.js' +import { ensureWebRuntime, type RuntimeProvisionProgress } from './runtime-download.js' export interface EditorState { schemaVersion: 1 @@ -26,14 +39,6 @@ export interface EditorState { instanceId: string runtimeDirectory: string startedAt: string - mcp?: McpState -} - -export interface McpState { - pid: number - port: number - host: '127.0.0.1' - url: string } export interface EditorStatus { @@ -42,35 +47,31 @@ export interface EditorStatus { healthy: boolean state: EditorState | null runtime: ActiveRuntime | null - components: { - editor: { running: boolean; healthy: boolean } - mcp: { running: boolean; healthy: boolean } - } } export interface StartEditorOptions { paths: PascalPaths port?: number foreground?: boolean - sourceDirectory?: string + /** A web-runtime directory or `.tar.gz` archive to install instead of downloading one. */ + runtimeSource?: string onProgress?: (event: EditorStartProgress) => void } export type EditorStartProgress = | { step: 'storage-ready'; dataDirectory: string } - | { step: 'runtime-installing' } | { step: 'runtime-ready'; version: string; installed: boolean } | { step: 'port-ready'; port: number; preferredPort: number } | { step: 'process-starting'; port: number } | { step: 'health-checking'; port: number } - | { step: 'mcp-port-ready'; port: number } - | { step: 'mcp-starting'; port: number } - | { step: 'mcp-health-checking'; port: number } | { step: 'ready'; port: number } | { step: 'already-running'; port: number } + | RuntimeProvisionProgress + | McpStartProgress export interface StartEditorResult { state: EditorState + mcp: McpServiceState alreadyRunning: boolean child?: ChildProcess } @@ -86,8 +87,8 @@ export interface RuntimeActivationResult { export async function ensurePascalDirectories(paths: PascalPaths): Promise { await Promise.all( - [paths.root, paths.runtime, paths.data, paths.plugins, paths.run, paths.logs].map((directory) => - mkdir(directory, { recursive: true, mode: 0o700 }), + [paths.root, paths.runtime, paths.data, paths.plugins, paths.run, paths.logs, paths.tmp].map( + (directory) => mkdir(directory, { recursive: true, mode: 0o700 }), ), ) } @@ -98,34 +99,15 @@ export async function getEditorStatus(paths: PascalPaths): Promise readJsonFile(paths.state), ]) if (state?.schemaVersion !== 1 || typeof state.pid !== 'number') { - return { - installed: Boolean(runtime), - running: false, - healthy: false, - state: null, - runtime, - components: { - editor: { running: false, healthy: false }, - mcp: { running: false, healthy: false }, - }, - } + return { installed: Boolean(runtime), running: false, healthy: false, state: null, runtime } } - const editorRunning = isProcessRunning(state.pid) - const mcpRunning = Boolean(state.mcp && isProcessRunning(state.mcp.pid)) - const [editorHealthy, mcpHealthy] = await Promise.all([ - editorRunning ? checkHealth(state) : false, - mcpRunning ? checkMcpHealth(paths, state) : false, - ]) + const running = isProcessRunning(state.pid) return { installed: Boolean(runtime), - running: editorRunning || mcpRunning, - healthy: editorHealthy && mcpHealthy, + running, + healthy: running ? await checkHealth(state) : false, state, runtime, - components: { - editor: { running: editorRunning, healthy: editorHealthy }, - mcp: { running: mcpRunning, healthy: mcpHealthy }, - }, } } @@ -136,7 +118,6 @@ export async function startEditor(options: StartEditorOptions): Promise { await ensurePascalDirectories(options.paths) options.onProgress?.({ step: 'storage-ready', dataDirectory: options.paths.data }) - let installedRuntime = false let currentStatus: EditorStatus try { currentStatus = await getEditorStatus(options.paths) @@ -144,32 +125,35 @@ async function startEditorUnlocked(options: StartEditorOptions): Promise { - return withEditorLifecycleLock(paths, () => stopEditorUnlocked(paths, options)) + const editorStopped = await withEditorLifecycleLock(paths, () => + stopEditorUnlocked(paths, options), + ) + const mcpStopped = await stopMcpService(paths, options) + return editorStopped || mcpStopped } async function stopEditorUnlocked( @@ -293,34 +244,23 @@ async function stopEditorUnlocked( options: StopEditorOptions = {}, ): Promise { const state = await readJsonFile(paths.state) - const editorRunning = Boolean(state && isProcessRunning(state.pid)) - const mcpRunning = Boolean(state?.mcp && isProcessRunning(state.mcp.pid)) - if (!state || (!editorRunning && !mcpRunning)) { + if (!state || !isProcessRunning(state.pid)) { await rm(paths.state, { force: true }) - await rm(paths.mcpToken, { force: true }) return false } - const [editorHealthy, mcpHealthy] = await Promise.all([ - editorRunning ? checkHealth(state) : true, - mcpRunning ? checkMcpHealth(paths, state) : true, - ]) - const editorIdentified = - editorHealthy || - (options.force && editorRunning && (await matchesRecordedEditorProcess(paths, state))) - const mcpIdentified = - mcpHealthy || (options.force && mcpRunning && (await matchesRecordedMcpProcess(paths, state))) - if (!editorIdentified || !mcpIdentified) { + const identified = + (await checkHealth(state)) || + (options.force && (await matchesRecordedEditorProcess(paths, state))) + if (!identified) { throw new CliError( 'state_conflict', options.force ? 'Refusing to stop a process whose health identity and operating-system command do not match the recorded Pascal runtime.' - : 'A Pascal process identity is unavailable. Inspect "pascal status --json", then use "pascal stop --force" only if the recorded commands are trusted.', + : 'The Pascal editor identity is unavailable. Inspect "pascal status --json", then use "pascal stop --force" only if the recorded command is trusted.', ) } - if (mcpRunning && state.mcp) await terminateProcess(state.mcp.pid) - if (editorRunning) await terminateProcess(state.pid) + await terminateProcess(state.pid) await rm(paths.state, { force: true }) - await rm(paths.mcpToken, { force: true }) return true } @@ -348,28 +288,21 @@ export async function activateEditorRuntime( let previousStatus: EditorStatus if (previousRuntimeWasInvalid) { const state = await readJsonFile(paths.state) - const editorRunning = Boolean(state && isProcessRunning(state.pid)) - const mcpRunning = Boolean(state?.mcp && isProcessRunning(state.mcp.pid)) - const editorHealthy = Boolean(state && editorRunning && (await checkHealth(state))) - const mcpHealthy = Boolean(state && mcpRunning && (await checkMcpHealth(paths, state))) + const running = Boolean(state && isProcessRunning(state.pid)) previousStatus = { installed: false, - running: editorRunning || mcpRunning, - healthy: editorHealthy && mcpHealthy, + running, + healthy: Boolean(state && running && (await checkHealth(state))), state: state ?? null, runtime: null, - components: { - editor: { running: editorRunning, healthy: editorHealthy }, - mcp: { running: mcpRunning, healthy: mcpHealthy }, - }, } } else { previousStatus = await getEditorStatus(paths) } - if (previousStatus.running && !statusComponentsAreIdentified(previousStatus)) { + if (previousStatus.running && !previousStatus.healthy) { throw new CliError( 'state_conflict', - 'A recorded Pascal process is running but its identity could not be verified. Recover or stop it before updating.', + 'A recorded Pascal editor process is running but its identity could not be verified. Recover or stop it before updating.', ) } if ( @@ -386,11 +319,15 @@ export async function activateEditorRuntime( try { await activateRuntime(paths, candidate.version, candidate.directory) await startEditorUnlocked({ paths, port: previousPort }) - if (!wasRunning) await stopEditorUnlocked(paths) + if (!wasRunning) { + await stopEditorUnlocked(paths) + await stopMcpService(paths) + } return { runtime: candidate, restarted: wasRunning } } catch (error) { try { await stopEditorUnlocked(paths, { force: true }) + await stopMcpService(paths, { force: true }) } catch {} let rollbackError: unknown if (previousRuntime) { @@ -478,7 +415,7 @@ export async function followLog(filePath: string): Promise { } } -export async function checkHealth(state: EditorState): Promise { +async function checkHealth(state: EditorState): Promise { return (await probeHealth(state)) === 'healthy' } @@ -529,141 +466,6 @@ export async function waitForHealth(state: EditorState, timeoutMs: number): Prom throw new CliError('health_timeout', `Pascal did not become healthy within ${timeoutMs}ms.`) } -export async function checkMcpHealth(paths: PascalPaths, state: EditorState): Promise { - return (await probeMcpHealth(paths, state)) === 'healthy' -} - -async function probeMcpHealth( - paths: PascalPaths, - state: EditorState, -): Promise<'healthy' | 'foreign' | 'unreachable'> { - if (!state.mcp) return 'unreachable' - let token: string - try { - token = (await readFile(paths.mcpToken, 'utf8')).trim() - } catch { - return 'unreachable' - } - if (!token) return 'unreachable' - try { - const response = await fetch(`http://127.0.0.1:${state.mcp.port}/health`, { - headers: { authorization: `Bearer ${token}` }, - signal: AbortSignal.timeout(1_000), - }) - if (!response.ok) return 'foreign' - const body = (await response.json()) as { - status?: string - app?: string - version?: string - instanceId?: string - } - return body.status === 'ok' && - body.app === 'mcp' && - body.version === state.version && - body.instanceId === state.instanceId - ? 'healthy' - : 'foreign' - } catch { - return 'unreachable' - } -} - -async function waitForMcpHealth( - paths: PascalPaths, - state: EditorState, - timeoutMs: number, -): Promise { - if (!state.mcp) throw new CliError('start_failed', 'Pascal MCP state was not created.') - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - const health = await probeMcpHealth(paths, state) - if (health === 'healthy') return - if (health === 'foreign') { - throw new CliError( - 'port_conflict', - `Port ${state.mcp.port} is responding as another application. Run Pascal again to choose another port.`, - ) - } - if (!isProcessRunning(state.mcp.pid)) { - throw new CliError('start_failed', 'Pascal MCP exited before becoming healthy.') - } - await new Promise((resolve) => setTimeout(resolve, 200)) - } - throw new CliError('health_timeout', `Pascal MCP did not become healthy within ${timeoutMs}ms.`) -} - -export function isProcessRunning(pid: number): boolean { - if (!Number.isSafeInteger(pid) || pid <= 0) return false - try { - process.kill(pid, 0) - return true - } catch (error) { - return (error as NodeJS.ErrnoException).code === 'EPERM' - } -} - -async function findAvailablePort(preferredPort: number): Promise { - if (!Number.isInteger(preferredPort) || preferredPort < 0 || preferredPort > 65_535) { - throw new CliError('invalid_port', `Invalid port: ${preferredPort}`) - } - if (preferredPort === 0) return probePort(0) - if (!(await isPortAcceptingConnections(preferredPort))) { - try { - return await probePort(preferredPort) - } catch {} - } - return probePort(0) -} - -async function isPortAcceptingConnections(port: number): Promise { - return new Promise((resolve) => { - const socket = net.connect({ host: '127.0.0.1', port }) - let settled = false - const finish = (result: boolean) => { - if (settled) return - settled = true - socket.destroy() - resolve(result) - } - socket.setTimeout(250) - socket.once('connect', () => finish(true)) - socket.once('timeout', () => finish(false)) - socket.once('error', () => finish(false)) - }) -} - -async function probePort(port: number): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer() - server.unref() - server.once('error', reject) - server.listen({ host: '127.0.0.1', port }, () => { - const address = server.address() - const resolvedPort = typeof address === 'object' && address ? address.port : port - server.close((error) => (error ? reject(error) : resolve(resolvedPort))) - }) - }) -} - -async function terminateProcess(pid: number): Promise { - try { - process.kill(pid, 'SIGTERM') - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ESRCH') return - throw error - } - const deadline = Date.now() + 10_000 - while (Date.now() < deadline) { - if (!isProcessRunning(pid)) return - await new Promise((resolve) => setTimeout(resolve, 100)) - } - try { - process.kill(pid, 'SIGKILL') - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error - } -} - async function withEditorLifecycleLock( paths: PascalPaths, action: () => Promise, @@ -676,15 +478,6 @@ async function withEditorLifecycleLock( ) } -async function waitForSpawn(child: ChildProcess, binary: string): Promise { - await new Promise((resolve, reject) => { - child.once('spawn', resolve) - child.once('error', reject) - }).catch((error) => { - throw new CliError('start_failed', `Unable to launch ${binary}: ${errorMessage(error)}`) - }) -} - async function matchesRecordedEditorProcess( paths: PascalPaths, state: EditorState, @@ -703,28 +496,6 @@ async function matchesRecordedEditorProcess( return command.includes(expectedEntrypoint) } -async function matchesRecordedMcpProcess(paths: PascalPaths, state: EditorState): Promise { - if (process.platform === 'win32' || !state.mcp) return false - const runtimeDirectory = path.resolve(state.runtimeDirectory) - if (!runtimeDirectory.startsWith(`${path.resolve(paths.runtime)}${path.sep}`)) return false - let expectedEntrypoint: string - try { - const manifest = await readRuntimeManifest(runtimeDirectory) - expectedEntrypoint = path.resolve(runtimeDirectory, manifest.mcpEntrypoint) - } catch { - expectedEntrypoint = path.join(runtimeDirectory, 'services/pascal-mcp.mjs') - } - return (await processCommand(state.mcp.pid)).includes(expectedEntrypoint) -} - -async function processCommand(pid: number): Promise { - return new Promise((resolve) => { - execFile('ps', ['-ww', '-p', String(pid), '-o', 'command='], (error, stdout) => { - resolve(error ? '' : stdout.trim()) - }) - }) -} - async function rotateEditorLog(filePath: string): Promise { try { if ((await stat(filePath)).size <= 10 * 1024 * 1024) return @@ -735,14 +506,3 @@ async function rotateEditorLog(filePath: string): Promise { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } } - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - -function statusComponentsAreIdentified(status: EditorStatus): boolean { - return ( - (!status.components.editor.running || status.components.editor.healthy) && - (!status.components.mcp.running || status.components.mcp.healthy) - ) -} diff --git a/packages/cli/src/file-lock.ts b/packages/cli/src/file-lock.ts index ffb0ddea23..75f4f1df5a 100644 --- a/packages/cli/src/file-lock.ts +++ b/packages/cli/src/file-lock.ts @@ -19,10 +19,11 @@ export async function withFileLock( code: string, message: string, action: () => Promise, + options: { timeoutMs?: number } = {}, ): Promise { await mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 }) const token = randomUUID() - const deadline = Date.now() + DEFAULT_TIMEOUT_MS + const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS) while (!(await tryAcquire(lockPath, token))) { if (await reclaimStaleLock(lockPath)) continue diff --git a/packages/cli/src/http-download.ts b/packages/cli/src/http-download.ts new file mode 100644 index 0000000000..9dd7d5a340 --- /dev/null +++ b/packages/cli/src/http-download.ts @@ -0,0 +1,235 @@ +import { createWriteStream } from 'node:fs' +import http from 'node:http' +import https from 'node:https' +import type { Socket } from 'node:net' +import tls from 'node:tls' +import { CliError } from './errors.js' +import { version } from './version.js' + +const DEFAULT_TIMEOUT_MS = 60_000 +const MAX_REDIRECTS = 5 +const PROGRESS_INTERVAL_MS = 200 + +export interface DownloadProgress { + received: number + total: number | null +} + +export interface DownloadOptions { + environment?: NodeJS.ProcessEnv + onProgress?: (progress: DownloadProgress) => void + timeoutMs?: number +} + +/** + * Streams an HTTPS URL to disk without adding a dependency. Node's built-in `fetch` only + * honours `HTTPS_PROXY` when the process was started with `--use-env-proxy`, which a + * published CLI cannot retrofit onto its own entrypoint, so the proxy tunnel is explicit. + */ +export async function downloadToFile( + url: string, + destination: string, + options: DownloadOptions = {}, +): Promise { + const environment = options.environment ?? process.env + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + let target = parseHttpsUrl(url) + for (let redirect = 0; ; redirect += 1) { + const response = await requestOnce(target, environment, timeoutMs) + const status = response.statusCode ?? 0 + if (status >= 300 && status < 400 && response.headers.location) { + response.resume() + if (redirect >= MAX_REDIRECTS) { + throw new CliError('download_failed', `${url} redirected more than ${MAX_REDIRECTS} times.`) + } + target = parseHttpsUrl(new URL(response.headers.location, target).toString()) + continue + } + if (status !== 200) { + response.resume() + throw new CliError('download_failed', `${target.href} returned HTTP ${status}.`) + } + return writeResponse(response, destination, options.onProgress) + } +} + +export function resolveProxyUrl(target: URL, environment: NodeJS.ProcessEnv): string | null { + if (isProxyBypassed(target.hostname, environment.NO_PROXY ?? environment.no_proxy)) return null + const configured = + environment.HTTPS_PROXY ?? + environment.https_proxy ?? + environment.ALL_PROXY ?? + environment.all_proxy + return configured?.trim() ? configured.trim() : null +} + +export function isProxyBypassed(hostname: string, noProxy: string | undefined): boolean { + if (!noProxy?.trim()) return false + const host = hostname.toLowerCase().replace(/^\[|\]$/g, '') + for (const raw of noProxy.split(/[,\s]+/)) { + const entry = raw.trim().toLowerCase() + if (!entry) continue + if (entry === '*') return true + const pattern = entry.replace(/^\*/, '').replace(/^\./, '').replace(/:\d+$/, '') + if (!pattern) continue + if (host === pattern || host.endsWith(`.${pattern}`)) return true + } + return false +} + +function parseHttpsUrl(value: string): URL { + let url: URL + try { + url = new URL(value) + } catch { + throw new CliError('download_failed', `Invalid download URL: ${value}`) + } + if (url.protocol !== 'https:') { + throw new CliError('download_failed', `Only https downloads are supported: ${value}`) + } + return url +} + +async function requestOnce( + target: URL, + environment: NodeJS.ProcessEnv, + timeoutMs: number, +): Promise { + const proxy = resolveProxyUrl(target, environment) + const agent = proxy + ? new TunnelAgent( + await openProxyTunnel(parseProxyUrl(proxy), target, timeoutMs), + target.hostname, + ) + : undefined + const request = https.request({ + hostname: target.hostname, + port: target.port || 443, + path: `${target.pathname}${target.search}`, + method: 'GET', + headers: { + accept: 'application/octet-stream, */*', + 'accept-encoding': 'identity', + 'user-agent': `pascal-cli/${version}`, + }, + ...(agent ? { agent } : {}), + }) + request.setTimeout(timeoutMs, () => + request.destroy(new Error(`no response from ${target.host} within ${timeoutMs}ms`)), + ) + request.end() + return new Promise((resolve, reject) => { + request.once('response', resolve) + request.once('error', (error) => + reject(new CliError('download_failed', `Unable to reach ${target.href}: ${error.message}`)), + ) + }) +} + +async function writeResponse( + response: http.IncomingMessage, + destination: string, + onProgress: ((progress: DownloadProgress) => void) | undefined, +): Promise { + const declared = Number(response.headers['content-length']) + const total = Number.isFinite(declared) && declared > 0 ? declared : null + const file = createWriteStream(destination, { mode: 0o600 }) + let received = 0 + let lastReport = 0 + await new Promise((resolve, reject) => { + const fail = (error: Error) => { + response.destroy() + file.destroy() + reject(error) + } + response.on('data', (chunk: Buffer) => { + received += chunk.byteLength + if (!file.write(chunk)) response.pause() + const now = Date.now() + if (onProgress && now - lastReport >= PROGRESS_INTERVAL_MS) { + lastReport = now + onProgress({ received, total }) + } + }) + file.on('drain', () => response.resume()) + response.once('error', fail) + file.once('error', fail) + response.once('end', () => file.end(resolve)) + }) + onProgress?.({ received, total }) + return received +} + +function parseProxyUrl(value: string): URL { + const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `http://${value}` + let proxy: URL + try { + proxy = new URL(candidate) + } catch { + throw new CliError('download_failed', `Invalid proxy URL: ${value}`) + } + if (proxy.protocol !== 'http:' && proxy.protocol !== 'https:') { + throw new CliError('download_failed', `Unsupported proxy protocol: ${proxy.protocol}`) + } + return proxy +} + +async function openProxyTunnel(proxy: URL, target: URL, timeoutMs: number): Promise { + const authority = `${target.hostname}:${target.port || 443}` + const headers: Record = { host: authority } + if (proxy.username) { + const credentials = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}` + headers['proxy-authorization'] = `Basic ${Buffer.from(credentials).toString('base64')}` + } + const requestFn = proxy.protocol === 'https:' ? https.request : http.request + const request = requestFn({ + host: proxy.hostname, + port: proxy.port || (proxy.protocol === 'https:' ? 443 : 80), + method: 'CONNECT', + path: authority, + headers, + }) + request.setTimeout(timeoutMs, () => + request.destroy(new Error(`proxy ${proxy.host} did not answer CONNECT within ${timeoutMs}ms`)), + ) + request.end() + return new Promise((resolve, reject) => { + request.once('connect', (response, socket) => { + if (response.statusCode !== 200) { + socket.destroy() + reject( + new CliError( + 'download_failed', + `Proxy ${proxy.host} refused CONNECT ${authority} with HTTP ${response.statusCode}.`, + ), + ) + return + } + resolve(socket) + }) + request.once('error', (error) => + reject( + new CliError('download_failed', `Unable to reach proxy ${proxy.host}: ${error.message}`), + ), + ) + }) +} + +class TunnelAgent extends https.Agent { + private readonly tunnel: Socket + private readonly servername: string + + constructor(tunnel: Socket, servername: string) { + super({ keepAlive: false, maxSockets: 1 }) + this.tunnel = tunnel + this.servername = servername + } + + override createConnection(): tls.TLSSocket { + return tls.connect({ + socket: this.tunnel, + servername: this.servername, + ALPNProtocols: ['http/1.1'], + }) + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6134f0db67..ba86bb163b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,7 +5,6 @@ export { type EditorStatus, ensurePascalDirectories, getEditorStatus, - type McpState, type RuntimeActivationResult, restartEditor, type StopEditorOptions, @@ -13,6 +12,13 @@ export { stopEditor, } from './editor-process.js' export { CliError } from './errors.js' +export { + ensureMcpService, + getMcpServiceStatus, + type McpServiceState, + type McpServiceStatus, + stopMcpService, +} from './mcp-service.js' export { type PascalPaths, resolvePascalPaths } from './paths.js' export { type ActiveRuntime, @@ -21,4 +27,10 @@ export { readActiveRuntime, readRuntimeManifest, } from './runtime.js' +export { + ensureWebRuntime, + type RuntimeSource, + readRuntimeSource, + verifyArchiveDigest, +} from './runtime-download.js' export { version } from './version.js' diff --git a/packages/cli/src/mcp-connector.ts b/packages/cli/src/mcp-connector.ts index 21688e0d70..1ff5d3fed4 100644 --- a/packages/cli/src/mcp-connector.ts +++ b/packages/cli/src/mcp-connector.ts @@ -2,24 +2,19 @@ import { readFile } from 'node:fs/promises' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js' -import { getEditorStatus, startEditor } from './editor-process.js' import { CliError } from './errors.js' +import { ensureMcpService } from './mcp-service.js' import type { PascalPaths } from './paths.js' +/** + * Bridges stdio to the managed MCP service. The service ships with the CLI, so this never + * starts the web editor and never needs the downloaded web runtime. + */ export async function connectManagedMcp(paths: PascalPaths): Promise { - let status = await getEditorStatus(paths) - if (!status.healthy) { - await startEditor({ paths }) - status = await getEditorStatus(paths) - } - if (!(status.healthy && status.state?.mcp)) { - throw new CliError('mcp_unavailable', 'Pascal MCP is not healthy. Run "pascal doctor".') - } - - const token = (await readFile(paths.mcpToken, 'utf8')).trim() - if (!token) throw new CliError('mcp_unavailable', 'Pascal MCP credentials are missing.') + const { state } = await ensureMcpService({ paths }) + const token = await readMcpToken(paths) - const remote = new StreamableHTTPClientTransport(new URL(status.state.mcp.url), { + const remote = new StreamableHTTPClientTransport(new URL(state.url), { requestInit: { headers: { authorization: `Bearer ${token}` } }, }) const stdio = new StdioServerTransport() @@ -42,6 +37,15 @@ export async function connectManagedMcp(paths: PascalPaths): Promise { await stdio.start() } +async function readMcpToken(paths: PascalPaths): Promise { + let token = '' + try { + token = (await readFile(paths.mcpToken, 'utf8')).trim() + } catch {} + if (!token) throw new CliError('mcp_unavailable', 'Pascal MCP credentials are missing.') + return token +} + function applyProtocolVersion( transport: StreamableHTTPClientTransport, message: JSONRPCMessage, diff --git a/packages/cli/src/mcp-service.test.ts b/packages/cli/src/mcp-service.test.ts new file mode 100644 index 0000000000..2f2c56c351 --- /dev/null +++ b/packages/cli/src/mcp-service.test.ts @@ -0,0 +1,165 @@ +import { afterAll, afterEach, describe, expect, test } from 'bun:test' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { + ensureMcpService, + getMcpServiceStatus, + type McpServiceState, + stopMcpService, +} from './mcp-service.js' +import { type PascalPaths, resolvePascalPaths } from './paths.js' +import { readActiveRuntime } from './runtime.js' +import { writeFakeMcpService } from './test-support/fake-mcp-service.js' + +const roots: string[] = [] +const started: PascalPaths[] = [] +/** The MCP service ships with the CLI; the tests inject a stand-in for the bundled bundle. */ +const serviceRoot = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-mcp-service-')) +process.env.PASCAL_MCP_SERVICE_PATH = await writeFakeMcpService(serviceRoot) + +afterEach(async () => { + for (const paths of started.splice(0)) { + await stopMcpService(paths, { force: true }).catch(() => undefined) + } + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +afterAll(() => rm(serviceRoot, { recursive: true, force: true })) + +describe('managed MCP service', () => { + test('starts on demand without a web runtime installed', async () => { + const paths = await temporaryPaths() + + const result = await ensureMcpService({ paths }) + + expect(result.alreadyRunning).toBe(false) + expect(result.state.editorOrigin).toBeNull() + expect(result.state.host).toBe('127.0.0.1') + expect(result.state.url).toBe(`http://127.0.0.1:${result.state.port}/mcp`) + expect(await readActiveRuntime(paths)).toBeNull() + expect(paths.mcpState.endsWith(path.join('run', 'mcp.json'))).toBe(true) + const status = await getMcpServiceStatus(paths) + expect(status).toMatchObject({ running: true, healthy: true }) + expect(status.state?.pid).toBe(result.state.pid) + expect((await stat(paths.mcpToken)).mode & 0o077).toBe(0) + }) + + test('reuses a healthy service instead of starting a second one', async () => { + const paths = await temporaryPaths() + const first = await ensureMcpService({ paths }) + + const second = await ensureMcpService({ paths }) + + expect(second.alreadyRunning).toBe(true) + expect(second.state.pid).toBe(first.state.pid) + expect(second.state.instanceId).toBe(first.state.instanceId) + }) + + test('serializes concurrent starts into one service', async () => { + const paths = await temporaryPaths() + + const [first, second] = await Promise.all([ + ensureMcpService({ paths }), + ensureMcpService({ paths }), + ]) + + expect(first.state.pid).toBe(second.state.pid) + expect([first.alreadyRunning, second.alreadyRunning].sort()).toEqual([false, true]) + }) + + test('keeps the recorded editor origin when the caller does not run the editor', async () => { + const paths = await temporaryPaths() + const editorOrigin = 'http://pascal.localhost:41234' + const first = await ensureMcpService({ paths, editorOrigin }) + + const connected = await ensureMcpService({ paths }) + + expect(connected.alreadyRunning).toBe(true) + expect(connected.state.pid).toBe(first.state.pid) + expect(connected.state.editorOrigin).toBe(editorOrigin) + expect(await reportedEditorOrigin(paths, connected.state)).toBe(editorOrigin) + }) + + test('restarts with the new origin when the editor moves to another port', async () => { + const paths = await temporaryPaths() + const first = await ensureMcpService({ paths, editorOrigin: 'http://pascal.localhost:41234' }) + + const moved = await ensureMcpService({ paths, editorOrigin: 'http://pascal.localhost:41235' }) + + expect(moved.alreadyRunning).toBe(false) + expect(moved.state.pid).not.toBe(first.state.pid) + expect(moved.state.editorOrigin).toBe('http://pascal.localhost:41235') + expect(await reportedEditorOrigin(paths, moved.state)).toBe('http://pascal.localhost:41235') + }) + + test('stops the service once and clears its state and token', async () => { + const paths = await temporaryPaths() + await ensureMcpService({ paths }) + + expect(await stopMcpService(paths)).toBe(true) + expect(await stopMcpService(paths)).toBe(false) + expect(await getMcpServiceStatus(paths)).toEqual({ + running: false, + healthy: false, + state: null, + }) + expect(await exists(paths.mcpState)).toBe(false) + expect(await exists(paths.mcpToken)).toBe(false) + }) + + test('refuses to stop a recorded process that is not the MCP service', async () => { + const paths = await temporaryPaths(false) + await writeFile( + paths.mcpState, + JSON.stringify({ + schemaVersion: 1, + pid: process.pid, + port: 1, + host: '127.0.0.1', + url: 'http://127.0.0.1:1/mcp', + version: '0.0.0', + instanceId: 'not-the-service', + servicePath: path.join(serviceRoot, 'pascal-mcp.mjs'), + editorOrigin: null, + startedAt: new Date().toISOString(), + }), + ) + + await expect(stopMcpService(paths)).rejects.toMatchObject({ code: 'state_conflict' }) + await expect(stopMcpService(paths, { force: true })).rejects.toMatchObject({ + code: 'state_conflict', + }) + await rm(paths.mcpState, { force: true }) + }) +}) + +async function temporaryPaths(tracked = true): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-mcp-test-')) + roots.push(root) + const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }) + await mkdir(paths.run, { recursive: true, mode: 0o700 }) + if (tracked) started.push(paths) + return paths +} + +/** The stand-in service echoes the origin it was started with, proving the restart repointed it. */ +async function reportedEditorOrigin( + paths: PascalPaths, + state: McpServiceState, +): Promise { + const token = (await readFile(paths.mcpToken, 'utf8')).trim() + const response = await fetch(`http://127.0.0.1:${state.port}/health`, { + headers: { authorization: `Bearer ${token}` }, + }) + return ((await response.json()) as { editorOrigin: string | null }).editorOrigin +} + +async function exists(file: string): Promise { + try { + await stat(file) + return true + } catch { + return false + } +} diff --git a/packages/cli/src/mcp-service.ts b/packages/cli/src/mcp-service.ts new file mode 100644 index 0000000000..af633b8bf8 --- /dev/null +++ b/packages/cli/src/mcp-service.ts @@ -0,0 +1,292 @@ +import type { ChildProcess } from 'node:child_process' +import { spawn } from 'node:child_process' +import { randomBytes, randomUUID } from 'node:crypto' +import { closeSync, openSync } from 'node:fs' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { CliError } from './errors.js' +import { withFileLock } from './file-lock.js' +import { readJsonFile, writeJsonFile } from './json-files.js' +import type { PascalPaths } from './paths.js' +import { + findAvailablePort, + isProcessRunning, + processCommand, + terminateProcess, + waitForSpawn, +} from './process-control.js' +import { version } from './version.js' + +export interface McpServiceState { + schemaVersion: 1 + pid: number + port: number + host: '127.0.0.1' + url: string + version: string + instanceId: string + servicePath: string + editorOrigin: string | null + startedAt: string +} + +export interface McpServiceStatus { + running: boolean + healthy: boolean + state: McpServiceState | null +} + +export type McpStartProgress = + | { step: 'mcp-port-ready'; port: number } + | { step: 'mcp-starting'; port: number } + | { step: 'mcp-health-checking'; port: number } + | { step: 'mcp-ready'; port: number } + | { step: 'mcp-already-running'; port: number } + +export interface EnsureMcpServiceOptions { + paths: PascalPaths + /** + * The editor origin the MCP service should format `editorUrl` values against. Omit it when + * the caller does not run the web editor: a recorded origin is then kept as it is. + */ + editorOrigin?: string + foreground?: boolean + onProgress?: (event: McpStartProgress) => void +} + +export interface McpServiceResult { + state: McpServiceState + alreadyRunning: boolean + child?: ChildProcess +} + +/** + * The MCP service is bundled with the CLI itself, not with the downloaded web runtime, so + * agent tools work before (and without) any editor runtime being installed. + */ +export function resolveMcpServicePath(environment: NodeJS.ProcessEnv = process.env): string { + if (environment.PASCAL_MCP_SERVICE_PATH) { + return path.resolve(environment.PASCAL_MCP_SERVICE_PATH) + } + const moduleDirectory = path.dirname(fileURLToPath(import.meta.url)) + return path.basename(moduleDirectory) === 'dist' + ? path.join(moduleDirectory, 'services/pascal-mcp.mjs') + : path.resolve(moduleDirectory, '../dist/services/pascal-mcp.mjs') +} + +export async function getMcpServiceStatus(paths: PascalPaths): Promise { + const state = await readJsonFile(paths.mcpState) + if (state?.schemaVersion !== 1 || typeof state.pid !== 'number') { + return { running: false, healthy: false, state: null } + } + const running = isProcessRunning(state.pid) + return { running, healthy: running ? await checkMcpHealth(paths, state) : false, state } +} + +export async function ensureMcpService( + options: EnsureMcpServiceOptions, +): Promise { + return withMcpLifecycleLock(options.paths, () => ensureMcpServiceUnlocked(options)) +} + +export async function stopMcpService( + paths: PascalPaths, + options: { force?: boolean } = {}, +): Promise { + return withMcpLifecycleLock(paths, () => stopMcpServiceUnlocked(paths, options)) +} + +async function ensureMcpServiceUnlocked( + options: EnsureMcpServiceOptions, +): Promise { + const { paths } = options + const status = await getMcpServiceStatus(paths) + if (status.healthy && status.state) { + const originMatches = + options.editorOrigin === undefined || options.editorOrigin === status.state.editorOrigin + if (originMatches) { + options.onProgress?.({ step: 'mcp-already-running', port: status.state.port }) + return { state: status.state, alreadyRunning: true } + } + } + if (status.running && status.state) { + if (!(status.healthy || (await matchesRecordedMcpProcess(status.state)))) { + throw new CliError( + 'state_conflict', + 'A recorded Pascal MCP process is running but its identity could not be verified. Inspect "pascal mcp status --json", then use "pascal stop --force" only if the recorded command is trusted.', + ) + } + await terminateProcess(status.state.pid) + } + await rm(paths.mcpState, { force: true }) + await rm(paths.mcpToken, { force: true }) + + const servicePath = resolveMcpServicePath() + const port = await findAvailablePort(0) + const instanceId = randomUUID() + const token = randomBytes(32).toString('base64url') + const state: McpServiceState = { + schemaVersion: 1, + pid: 0, + port, + host: '127.0.0.1', + url: `http://127.0.0.1:${port}/mcp`, + version, + instanceId, + servicePath, + editorOrigin: options.editorOrigin ?? null, + startedAt: new Date().toISOString(), + } + options.onProgress?.({ step: 'mcp-port-ready', port }) + await Promise.all( + [paths.run, paths.logs, paths.data].map((directory) => + mkdir(directory, { recursive: true, mode: 0o700 }), + ), + ) + await writeFile(paths.mcpToken, `${token}\n`, { mode: 0o600 }) + + const environment: NodeJS.ProcessEnv = { + ...process.env, + NODE_ENV: 'production', + PASCAL_DATA_DIR: paths.data, + PASCAL_INSTANCE_ID: instanceId, + PASCAL_RUNTIME_VERSION: version, + PASCAL_MCP_HTTP_TOKEN: token, + ...(state.editorOrigin ? { PASCAL_EDITOR_ORIGIN: state.editorOrigin } : {}), + } + const nodeBinary = process.env.PASCAL_NODE_BINARY || 'node' + const logDescriptor = options.foreground ? undefined : openSync(paths.editorLog, 'a', 0o600) + options.onProgress?.({ step: 'mcp-starting', port }) + const child = spawn( + nodeBinary, + [servicePath, '--http', '--host', state.host, '--port', String(port)], + { + cwd: path.dirname(servicePath), + env: environment, + detached: !options.foreground, + stdio: options.foreground + ? ['ignore', 'inherit', 'inherit'] + : ['ignore', logDescriptor!, logDescriptor!], + }, + ) + if (logDescriptor !== undefined) closeSync(logDescriptor) + try { + await waitForSpawn(child, nodeBinary) + if (!child.pid) throw new CliError('start_failed', 'The Pascal MCP process did not start.') + state.pid = child.pid + await writeJsonFile(paths.mcpState, state) + if (!options.foreground) child.unref() + options.onProgress?.({ step: 'mcp-health-checking', port }) + await waitForMcpHealth(paths, state, 20_000) + options.onProgress?.({ step: 'mcp-ready', port }) + } catch (error) { + if (child.pid) await terminateProcess(child.pid) + await rm(paths.mcpState, { force: true }) + await rm(paths.mcpToken, { force: true }) + throw error + } + return { state, alreadyRunning: false, child: options.foreground ? child : undefined } +} + +async function stopMcpServiceUnlocked( + paths: PascalPaths, + options: { force?: boolean }, +): Promise { + const status = await getMcpServiceStatus(paths) + if (!status.state || !status.running) { + await rm(paths.mcpState, { force: true }) + await rm(paths.mcpToken, { force: true }) + return false + } + if (!(status.healthy || (options.force && (await matchesRecordedMcpProcess(status.state))))) { + throw new CliError( + 'state_conflict', + options.force + ? 'Refusing to stop a process whose health identity and operating-system command do not match the recorded Pascal MCP service.' + : 'The Pascal MCP identity is unavailable. Inspect "pascal mcp status --json", then use "pascal stop --force" only if the recorded command is trusted.', + ) + } + await terminateProcess(status.state.pid) + await rm(paths.mcpState, { force: true }) + await rm(paths.mcpToken, { force: true }) + return true +} + +export async function checkMcpHealth(paths: PascalPaths, state: McpServiceState): Promise { + return (await probeMcpHealth(paths, state)) === 'healthy' +} + +async function probeMcpHealth( + paths: PascalPaths, + state: McpServiceState, +): Promise<'healthy' | 'foreign' | 'unreachable'> { + let token: string + try { + token = (await readFile(paths.mcpToken, 'utf8')).trim() + } catch { + return 'unreachable' + } + if (!token) return 'unreachable' + try { + const response = await fetch(`http://127.0.0.1:${state.port}/health`, { + headers: { authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(1_000), + }) + if (!response.ok) return 'foreign' + const body = (await response.json()) as { + status?: string + app?: string + version?: string + instanceId?: string + } + return body.status === 'ok' && + body.app === 'mcp' && + body.version === state.version && + body.instanceId === state.instanceId + ? 'healthy' + : 'foreign' + } catch { + return 'unreachable' + } +} + +async function waitForMcpHealth( + paths: PascalPaths, + state: McpServiceState, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const health = await probeMcpHealth(paths, state) + if (health === 'healthy') return + if (health === 'foreign') { + throw new CliError( + 'port_conflict', + `Port ${state.port} is responding as another application. Run the command again to choose another port.`, + ) + } + if (!isProcessRunning(state.pid)) { + throw new CliError('start_failed', 'Pascal MCP exited before becoming healthy.') + } + await new Promise((resolve) => setTimeout(resolve, 200)) + } + throw new CliError('health_timeout', `Pascal MCP did not become healthy within ${timeoutMs}ms.`) +} + +async function matchesRecordedMcpProcess(state: McpServiceState): Promise { + if (process.platform === 'win32') return false + const servicePath = path.resolve(state.servicePath) + if (path.basename(servicePath) !== 'pascal-mcp.mjs') return false + return (await processCommand(state.pid)).includes(servicePath) +} + +async function withMcpLifecycleLock(paths: PascalPaths, action: () => Promise): Promise { + return withFileLock( + path.join(paths.run, 'mcp-lifecycle.lock'), + 'mcp_locked', + 'Another Pascal MCP lifecycle operation is active.', + action, + { timeoutMs: 30_000 }, + ) +} diff --git a/packages/cli/src/paths.ts b/packages/cli/src/paths.ts index e07529fc95..f352dd83af 100644 --- a/packages/cli/src/paths.ts +++ b/packages/cli/src/paths.ts @@ -8,7 +8,9 @@ export interface PascalPaths { plugins: string run: string logs: string + tmp: string state: string + mcpState: string currentRuntime: string pluginLock: string database: string @@ -25,7 +27,9 @@ export function resolvePascalPaths(environment: NodeJS.ProcessEnv = process.env) plugins: path.join(root, 'plugins'), run: path.join(root, 'run'), logs: path.join(root, 'logs'), + tmp: path.join(root, 'tmp'), state: path.join(root, 'run/editor.json'), + mcpState: path.join(root, 'run/mcp.json'), currentRuntime: path.join(root, 'run/current-runtime.json'), pluginLock: path.join(root, 'pascal.plugins.lock'), database: path.join(root, 'data/pascal.db'), diff --git a/packages/cli/src/process-control.ts b/packages/cli/src/process-control.ts new file mode 100644 index 0000000000..f19027f48d --- /dev/null +++ b/packages/cli/src/process-control.ts @@ -0,0 +1,96 @@ +import { type ChildProcess, execFile } from 'node:child_process' +import net from 'node:net' +import { CliError } from './errors.js' + +export function isProcessRunning(pid: number): boolean { + if (!Number.isSafeInteger(pid) || pid <= 0) return false + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} + +export async function terminateProcess(pid: number): Promise { + try { + process.kill(pid, 'SIGTERM') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return + throw error + } + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + if (!isProcessRunning(pid)) return + await new Promise((resolve) => setTimeout(resolve, 100)) + } + try { + process.kill(pid, 'SIGKILL') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error + } +} + +export async function findAvailablePort(preferredPort: number): Promise { + if (!Number.isInteger(preferredPort) || preferredPort < 0 || preferredPort > 65_535) { + throw new CliError('invalid_port', `Invalid port: ${preferredPort}`) + } + if (preferredPort === 0) return probePort(0) + if (!(await isPortAcceptingConnections(preferredPort))) { + try { + return await probePort(preferredPort) + } catch {} + } + return probePort(0) +} + +async function isPortAcceptingConnections(port: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ host: '127.0.0.1', port }) + let settled = false + const finish = (result: boolean) => { + if (settled) return + settled = true + socket.destroy() + resolve(result) + } + socket.setTimeout(250) + socket.once('connect', () => finish(true)) + socket.once('timeout', () => finish(false)) + socket.once('error', () => finish(false)) + }) +} + +async function probePort(port: number): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.unref() + server.once('error', reject) + server.listen({ host: '127.0.0.1', port }, () => { + const address = server.address() + const resolvedPort = typeof address === 'object' && address ? address.port : port + server.close((error) => (error ? reject(error) : resolve(resolvedPort))) + }) + }) +} + +export async function processCommand(pid: number): Promise { + return new Promise((resolve) => { + execFile('ps', ['-ww', '-p', String(pid), '-o', 'command='], (error, stdout) => { + resolve(error ? '' : stdout.trim()) + }) + }) +} + +export async function waitForSpawn(child: ChildProcess, binary: string): Promise { + await new Promise((resolve, reject) => { + child.once('spawn', resolve) + child.once('error', reject) + }).catch((error) => { + throw new CliError('start_failed', `Unable to launch ${binary}: ${errorMessage(error)}`) + }) +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/cli/src/runtime-download.test.ts b/packages/cli/src/runtime-download.test.ts new file mode 100644 index 0000000000..decae30cd9 --- /dev/null +++ b/packages/cli/src/runtime-download.test.ts @@ -0,0 +1,339 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { copyFile, mkdir, mkdtemp, open, rm, stat, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { isProxyBypassed, resolveProxyUrl } from './http-download.js' +import { resolvePascalPaths } from './paths.js' +import { findInstalledRuntime, installBundledRuntime, readActiveRuntime } from './runtime.js' +import { + ensureWebRuntime, + fileSha256, + type RuntimeSource, + readRuntimeSource, + verifyArchiveDigest, +} from './runtime-download.js' +import { createRuntimeArchive } from './tar.js' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('archive digest verification', () => { + test('accepts a matching digest whatever case it is written in', async () => { + const fixture = await createFixture() + + await verifyArchiveDigest(fixture.archiveFile, fixture.source.sha256) + await verifyArchiveDigest(fixture.archiveFile, fixture.source.sha256.toUpperCase()) + }) + + test('rejects a changed archive and deletes it when asked to', async () => { + const fixture = await createFixture() + const tampered = await tamper(fixture.archiveFile, path.join(fixture.root, 'tampered.tar.gz')) + + await expect( + verifyArchiveDigest(tampered, fixture.source.sha256, { deleteOnMismatch: true }), + ).rejects.toMatchObject({ + code: 'runtime_digest_mismatch', + message: expect.stringContaining(fixture.source.sha256), + }) + expect(await exists(tampered)).toBe(false) + }) + + test('keeps a caller-supplied archive that fails verification', async () => { + const fixture = await createFixture() + const tampered = await tamper(fixture.archiveFile, path.join(fixture.root, 'tampered.tar.gz')) + + await expect(verifyArchiveDigest(tampered, fixture.source.sha256)).rejects.toMatchObject({ + code: 'runtime_digest_mismatch', + }) + expect(await exists(tampered)).toBe(true) + }) +}) + +describe('published runtime source', () => { + test('reads the archive URL and digest committed with the CLI', async () => { + const fixture = await createFixture() + + expect(await readRuntimeSource(fixture.sourceFile)).toEqual(fixture.source) + }) + + test.each([ + ['is missing', null], + ['is not JSON', '{not-json'], + ['omits the digest', { version: '1.2.3', url: 'https://example.com/a.tar.gz', size: 10 }], + [ + 'carries a truncated digest', + { version: '1.2.3', url: 'https://example.com/a.tar.gz', sha256: 'abc123', size: 10 }, + ], + [ + 'points at a plain-http URL', + { version: '1.2.3', url: 'http://example.com/a.tar.gz', sha256: 'a'.repeat(64), size: 10 }, + ], + [ + 'declares an empty archive', + { version: '1.2.3', url: 'https://example.com/a.tar.gz', sha256: 'a'.repeat(64), size: 0 }, + ], + [ + 'carries a path-like version', + { + version: '../escape', + url: 'https://example.com/a.tar.gz', + sha256: 'a'.repeat(64), + size: 10, + }, + ], + ])('refuses a runtime source that %s', async (_label, content) => { + const root = await temporaryRoot() + const sourceFile = path.join(root, 'runtime-source.json') + if (content !== null) { + await writeFile(sourceFile, typeof content === 'string' ? content : JSON.stringify(content)) + } + + await expect(readRuntimeSource(sourceFile)).rejects.toMatchObject({ + code: 'invalid_runtime_source', + message: expect.stringContaining('--runtime'), + }) + }) +}) + +describe('web runtime resolution order', () => { + test('prefers an explicit runtime directory over the environment override', async () => { + const fixture = await createFixture() + const other = await fakeRuntimeDirectory(fixture.root, '9.9.9') + + const result = await ensureWebRuntime({ + paths: fixture.paths, + runtimeSource: fixture.sourceDirectory, + sourceFile: fixture.sourceFile, + environment: { PASCAL_BUNDLED_RUNTIME_DIR: other }, + }) + + expect(result.runtime.version).toBe('1.2.3') + expect((await readActiveRuntime(fixture.paths))?.version).toBe('1.2.3') + expect(await findInstalledRuntime(fixture.paths, '9.9.9')).toBeNull() + }) + + test('falls back to PASCAL_BUNDLED_RUNTIME_DIR when no flag is passed', async () => { + const fixture = await createFixture() + + const result = await ensureWebRuntime({ + paths: fixture.paths, + sourceFile: fixture.sourceFile, + environment: { PASCAL_BUNDLED_RUNTIME_DIR: fixture.sourceDirectory }, + }) + + expect(result).toMatchObject({ installed: true, runtime: { version: '1.2.3' } }) + }) + + test('installs a local archive that matches the published digest', async () => { + const fixture = await createFixture() + + const result = await ensureWebRuntime({ + paths: fixture.paths, + runtimeSource: fixture.archiveFile, + sourceFile: fixture.sourceFile, + environment: {}, + }) + + expect(result.runtime.directory).toBe(path.join(fixture.paths.runtime, '1.2.3')) + expect(await exists(path.join(result.runtime.directory, 'apps/editor/server.js'))).toBe(true) + }) + + test('installs nothing when a local archive fails verification', async () => { + const fixture = await createFixture() + const tampered = await tamper(fixture.archiveFile, path.join(fixture.root, 'tampered.tar.gz')) + + await expect( + ensureWebRuntime({ + paths: fixture.paths, + runtimeSource: tampered, + sourceFile: fixture.sourceFile, + environment: {}, + }), + ).rejects.toMatchObject({ code: 'runtime_digest_mismatch' }) + expect(await findInstalledRuntime(fixture.paths, '1.2.3')).toBeNull() + expect(await exists(tampered)).toBe(true) + }) + + test('reports a missing runtime path instead of reaching for the network', async () => { + const fixture = await createFixture() + + await expect( + ensureWebRuntime({ + paths: fixture.paths, + runtimeSource: path.join(fixture.root, 'absent.tar.gz'), + sourceFile: fixture.sourceFile, + environment: {}, + }), + ).rejects.toMatchObject({ code: 'runtime_source_missing' }) + }) + + test('reuses the installed runtime for this version without downloading', async () => { + const fixture = await createFixture() + await installBundledRuntime(fixture.paths, fixture.sourceDirectory, { activate: false }) + + const result = await ensureWebRuntime({ + paths: fixture.paths, + sourceFile: fixture.sourceFile, + environment: {}, + }) + + expect(result).toEqual({ + installed: false, + runtime: { + schemaVersion: 1, + version: '1.2.3', + directory: path.join(fixture.paths.runtime, '1.2.3'), + }, + }) + expect((await readActiveRuntime(fixture.paths))?.version).toBe('1.2.3') + }) + + test('installs without activating so an update can health-check first', async () => { + const fixture = await createFixture() + + const result = await ensureWebRuntime({ + paths: fixture.paths, + runtimeSource: fixture.sourceDirectory, + sourceFile: fixture.sourceFile, + activate: false, + environment: {}, + }) + + expect(result.runtime.version).toBe('1.2.3') + expect(await readActiveRuntime(fixture.paths)).toBeNull() + expect(await findInstalledRuntime(fixture.paths, '1.2.3')).not.toBeNull() + }) + + test('names the archive, the digest and the offline escape hatch when the download fails', async () => { + const fixture = await createFixture() + + const failure = await ensureWebRuntime({ + paths: fixture.paths, + sourceFile: fixture.sourceFile, + environment: {}, + }).catch((error: unknown) => error) + + expect(failure).toMatchObject({ code: 'runtime_download_failed' }) + const message = (failure as Error).message + expect(message).toContain(fixture.source.url) + expect(message).toContain(fixture.source.sha256) + expect(message).toContain('pascal editor --runtime') + expect(message).toContain('HTTPS_PROXY') + expect(await findInstalledRuntime(fixture.paths, '1.2.3')).toBeNull() + }) +}) + +describe('proxy configuration', () => { + test('prefers HTTPS_PROXY and trims the configured value', () => { + const target = new URL('https://github.com/pascalorg/editor') + + expect(resolveProxyUrl(target, { HTTPS_PROXY: ' http://proxy:3128 ' })).toBe( + 'http://proxy:3128', + ) + expect(resolveProxyUrl(target, { ALL_PROXY: 'http://all:3128' })).toBe('http://all:3128') + expect(resolveProxyUrl(target, { HTTPS_PROXY: ' ' })).toBeNull() + expect(resolveProxyUrl(target, {})).toBeNull() + }) + + test('honours NO_PROXY for the download host', () => { + const target = new URL('https://github.com/pascalorg/editor') + + expect(resolveProxyUrl(target, { HTTPS_PROXY: 'http://proxy:3128', NO_PROXY: '*' })).toBeNull() + expect( + resolveProxyUrl(target, { HTTPS_PROXY: 'http://proxy:3128', no_proxy: 'github.com' }), + ).toBeNull() + expect( + resolveProxyUrl(target, { HTTPS_PROXY: 'http://proxy:3128', NO_PROXY: 'example.com' }), + ).toBe('http://proxy:3128') + }) + + test('matches NO_PROXY entries by suffix and ignores ports', () => { + expect(isProxyBypassed('release-assets.githubusercontent.com', '.githubusercontent.com')).toBe( + true, + ) + expect(isProxyBypassed('github.com', 'github.com:443')).toBe(true) + expect(isProxyBypassed('github.com', '*.github.com')).toBe(true) + expect(isProxyBypassed('notgithub.com', 'github.com')).toBe(false) + expect(isProxyBypassed('github.com', '')).toBe(false) + expect(isProxyBypassed('github.com', undefined)).toBe(false) + }) +}) + +interface Fixture { + root: string + paths: ReturnType + sourceDirectory: string + archiveFile: string + sourceFile: string + source: RuntimeSource +} + +/** An unroutable port keeps every download test offline; the connection is refused at once. */ +const UNREACHABLE_HOST = 'https://127.0.0.1:1' + +async function createFixture(version = '1.2.3'): Promise { + const root = await temporaryRoot() + const sourceDirectory = await fakeRuntimeDirectory(root, version) + const archiveFile = path.join(root, `pascal-web-runtime-${version}.tar.gz`) + await createRuntimeArchive(sourceDirectory, archiveFile) + const source: RuntimeSource = { + version, + url: `${UNREACHABLE_HOST}/pascal-web-runtime-${version}.tar.gz`, + sha256: await fileSha256(archiveFile), + size: (await stat(archiveFile)).size, + } + const sourceFile = path.join(root, 'runtime-source.json') + await writeFile(sourceFile, JSON.stringify(source)) + return { + root, + paths: resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }), + sourceDirectory, + archiveFile, + sourceFile, + source, + } +} + +async function temporaryRoot(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-download-test-')) + roots.push(root) + return root +} + +async function fakeRuntimeDirectory(root: string, version: string): Promise { + const runtime = path.join(root, `source-${version}`) + await mkdir(path.join(runtime, 'apps/editor'), { recursive: true }) + await writeFile( + path.join(runtime, 'runtime-manifest.json'), + JSON.stringify({ schemaVersion: 2, version, entrypoint: 'apps/editor/server.js' }), + ) + await writeFile(path.join(runtime, 'apps/editor/server.js'), `// pascal ${version}\n`) + return runtime +} + +async function tamper(archiveFile: string, destination: string): Promise { + await copyFile(archiveFile, destination) + const handle = await open(destination, 'r+') + try { + const offset = Math.floor((await handle.stat()).size / 2) + const byte = Buffer.alloc(1) + await handle.read(byte, 0, 1, offset) + byte[0] = ((byte[0] ?? 0) ^ 0xff) & 0xff + await handle.write(byte, 0, 1, offset) + } finally { + await handle.close() + } + return destination +} + +async function exists(file: string): Promise { + try { + await stat(file) + return true + } catch { + return false + } +} diff --git a/packages/cli/src/runtime-download.ts b/packages/cli/src/runtime-download.ts new file mode 100644 index 0000000000..8b54b50b0d --- /dev/null +++ b/packages/cli/src/runtime-download.ts @@ -0,0 +1,256 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { CliError } from './errors.js' +import { downloadToFile } from './http-download.js' +import { readJsonFile } from './json-files.js' +import type { PascalPaths } from './paths.js' +import { + type ActiveRuntime, + activateRuntime, + findInstalledRuntime, + installBundledRuntime, + installRuntimeDirectory, + withRuntimeInstallLock, +} from './runtime.js' +import { extractTarGzip } from './tar.js' + +/** A peer process may be downloading the same archive; wait for it instead of duplicating it. */ +const DOWNLOAD_LOCK_TIMEOUT_MS = 20 * 60_000 + +export interface RuntimeSource { + version: string + url: string + sha256: string + size: number +} + +export interface WebRuntimeResult { + runtime: ActiveRuntime + installed: boolean +} + +export type RuntimeProvisionProgress = + | { step: 'runtime-downloading'; url: string; received: number; total: number | null } + | { step: 'runtime-verifying' } + | { step: 'runtime-extracting' } + | { step: 'runtime-installing' } + +export interface EnsureWebRuntimeOptions { + paths: PascalPaths + /** A directory or `.tar.gz` archive from `--runtime`; archives are digest-verified. */ + runtimeSource?: string + /** `false` installs the runtime without pointing the active runtime at it (used by updates). */ + activate?: boolean + environment?: NodeJS.ProcessEnv + sourceFile?: string + onProgress?: (event: RuntimeProvisionProgress) => void +} + +/** + * Resolves the web runtime for the commands that start the Next server. The npm package + * ships the CLI and the MCP service only; the runtime is downloaded once per version and + * verified against the digest committed in `dist/runtime-source.json`. + */ +export async function ensureWebRuntime( + options: EnsureWebRuntimeOptions, +): Promise { + const { paths } = options + const environment = options.environment ?? process.env + const override = options.runtimeSource ?? environment.PASCAL_BUNDLED_RUNTIME_DIR + if (override) return installOverride(paths, override, options) + + const source = await readRuntimeSource(options.sourceFile) + const existing = await findInstalledRuntime(paths, source.version) + if (existing) return { runtime: await useInstalled(paths, existing, options), installed: false } + return withRuntimeInstallLock( + paths, + async () => { + const peerInstalled = await findInstalledRuntime(paths, source.version) + if (peerInstalled) { + return { runtime: await useInstalled(paths, peerInstalled, options), installed: false } + } + return withWorkDirectory(paths, async (workDirectory) => { + const archiveFile = path.join(workDirectory, `pascal-web-runtime-${source.version}.tar.gz`) + await download(source, archiveFile, options) + options.onProgress?.({ step: 'runtime-verifying' }) + await verifyArchiveDigest(archiveFile, source.sha256, { deleteOnMismatch: true }) + return { + runtime: await extractAndInstall(archiveFile, workDirectory, options, (directory) => + installRuntimeDirectory(paths, directory, { activate: options.activate }), + ), + installed: true, + } + }) + }, + { timeoutMs: DOWNLOAD_LOCK_TIMEOUT_MS }, + ) +} + +export function resolveRuntimeSourceFile(): string { + const moduleDirectory = path.dirname(fileURLToPath(import.meta.url)) + return path.basename(moduleDirectory) === 'dist' + ? path.join(moduleDirectory, 'runtime-source.json') + : path.resolve(moduleDirectory, '../dist/runtime-source.json') +} + +export async function readRuntimeSource(sourceFile?: string): Promise { + const file = sourceFile ?? resolveRuntimeSourceFile() + let source: RuntimeSource | null + try { + source = await readJsonFile(file) + } catch { + source = null + } + if ( + !source || + typeof source.version !== 'string' || + !/^[0-9A-Za-z][0-9A-Za-z._-]*$/.test(source.version) || + typeof source.url !== 'string' || + !source.url.startsWith('https://') || + typeof source.sha256 !== 'string' || + !/^[0-9a-f]{64}$/.test(source.sha256) || + !Number.isSafeInteger(source.size) || + source.size <= 0 + ) { + throw new CliError( + 'invalid_runtime_source', + `This CLI cannot resolve the Pascal web runtime it was published with (${file}). Reinstall @pascal-app/cli, or pass "--runtime ".`, + ) + } + return { version: source.version, url: source.url, sha256: source.sha256, size: source.size } +} + +export async function fileSha256(filePath: string): Promise { + const hash = createHash('sha256') + for await (const chunk of createReadStream(filePath)) hash.update(chunk as Buffer) + return hash.digest('hex') +} + +export async function verifyArchiveDigest( + archiveFile: string, + expectedSha256: string, + options: { deleteOnMismatch?: boolean } = {}, +): Promise { + const actual = await fileSha256(archiveFile) + if (actual === expectedSha256.toLowerCase()) return + if (options.deleteOnMismatch) await rm(archiveFile, { force: true }) + throw new CliError( + 'runtime_digest_mismatch', + [ + 'The Pascal web runtime archive does not match the digest published with this CLI.', + ` archive: ${archiveFile}`, + ` expected: ${expectedSha256}`, + ` actual: ${actual}`, + 'The archive was not installed. Download it again from the Pascal release page.', + ].join('\n'), + ) +} + +async function installOverride( + paths: PascalPaths, + override: string, + options: EnsureWebRuntimeOptions, +): Promise { + const resolved = path.resolve(override) + let info: Awaited> + try { + info = await stat(resolved) + } catch { + throw new CliError('runtime_source_missing', `No Pascal web runtime exists at ${resolved}.`) + } + if (info.isDirectory()) { + options.onProgress?.({ step: 'runtime-installing' }) + return { + runtime: await installBundledRuntime(paths, resolved, { activate: options.activate }), + installed: true, + } + } + const source = await readRuntimeSource(options.sourceFile) + options.onProgress?.({ step: 'runtime-verifying' }) + await verifyArchiveDigest(resolved, source.sha256) + return { + runtime: await withWorkDirectory(paths, (workDirectory) => + extractAndInstall(resolved, workDirectory, options, (directory) => + installBundledRuntime(paths, directory, { activate: options.activate }), + ), + ), + installed: true, + } +} + +async function useInstalled( + paths: PascalPaths, + runtime: ActiveRuntime, + options: EnsureWebRuntimeOptions, +): Promise { + return options.activate === false + ? runtime + : activateRuntime(paths, runtime.version, runtime.directory) +} + +async function extractAndInstall( + archiveFile: string, + workDirectory: string, + options: EnsureWebRuntimeOptions, + install: (directory: string) => Promise, +): Promise { + const extracted = path.join(workDirectory, 'runtime') + options.onProgress?.({ step: 'runtime-extracting' }) + await extractTarGzip(archiveFile, extracted) + options.onProgress?.({ step: 'runtime-installing' }) + return install(extracted) +} + +async function download( + source: RuntimeSource, + archiveFile: string, + options: EnsureWebRuntimeOptions, +): Promise { + options.onProgress?.({ step: 'runtime-downloading', url: source.url, received: 0, total: null }) + try { + await downloadToFile(source.url, archiveFile, { + environment: options.environment ?? process.env, + onProgress: ({ received, total }) => + options.onProgress?.({ + step: 'runtime-downloading', + url: source.url, + received, + total: total ?? source.size, + }), + }) + } catch (error) { + await rm(archiveFile, { force: true }) + throw new CliError( + 'runtime_download_failed', + [ + `Unable to download the Pascal web runtime ${source.version}.`, + ` archive: ${source.url}`, + ` sha256: ${source.sha256}`, + ` reason: ${error instanceof Error ? error.message : String(error)}`, + 'Download that archive on a connected machine, copy it over, then run:', + ` pascal editor --runtime /path/to/pascal-web-runtime-${source.version}.tar.gz`, + 'HTTPS_PROXY and NO_PROXY are honoured. "pascal mcp connect" needs no web runtime.', + ].join('\n'), + ) + } +} + +/** + * Downloads and extraction stay out of `runtime/`: `installRuntimeDirectory` deletes every + * `.install-*` directory there before it copies, which would race a partial extraction. + */ +async function withWorkDirectory( + paths: PascalPaths, + action: (directory: string) => Promise, +): Promise { + await mkdir(paths.tmp, { recursive: true, mode: 0o700 }) + const workDirectory = await mkdtemp(path.join(paths.tmp, 'runtime-')) + try { + return await action(workDirectory) + } finally { + await rm(workDirectory, { recursive: true, force: true }) + } +} diff --git a/packages/cli/src/runtime.test.ts b/packages/cli/src/runtime.test.ts index 2fe7c21214..4ab219ae8c 100644 --- a/packages/cli/src/runtime.test.ts +++ b/packages/cli/src/runtime.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'bun:test' +import { afterAll, afterEach, describe, expect, test } from 'bun:test' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import http from 'node:http' import os from 'node:os' @@ -10,15 +10,22 @@ import { stopEditor, waitForHealth, } from './editor-process.js' +import { getMcpServiceStatus } from './mcp-service.js' import { resolvePascalPaths } from './paths.js' import { installBundledRuntime, readActiveRuntime } from './runtime.js' +import { writeFakeMcpService } from './test-support/fake-mcp-service.js' const roots: string[] = [] +/** The MCP service ships with the CLI, so it is injected instead of staged in the runtime. */ +const serviceRoot = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-test-service-')) +process.env.PASCAL_MCP_SERVICE_PATH = await writeFakeMcpService(serviceRoot) afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) +afterAll(() => rm(serviceRoot, { recursive: true, force: true })) + describe('managed runtime', () => { test('installs a bundled runtime outside the package-runner cache', async () => { const root = await temporaryRoot() @@ -39,10 +46,10 @@ describe('managed runtime', () => { await mkdir(paths.data, { recursive: true }) await writeFile(paths.database, 'persistent') - const started = await startEditor({ paths, sourceDirectory: source }) + const started = await startEditor({ paths, runtimeSource: source }) expect(started.alreadyRunning).toBe(false) expect((await getEditorStatus(paths)).healthy).toBe(true) - expect((await startEditor({ paths, sourceDirectory: source })).alreadyRunning).toBe(true) + expect((await startEditor({ paths, runtimeSource: source })).alreadyRunning).toBe(true) expect(await stopEditor(paths)).toBe(true) expect((await getEditorStatus(paths)).running).toBe(false) @@ -55,8 +62,8 @@ describe('managed runtime', () => { const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }) const [first, second] = await Promise.all([ - startEditor({ paths, port: 0, sourceDirectory: source }), - startEditor({ paths, port: 0, sourceDirectory: source }), + startEditor({ paths, port: 0, runtimeSource: source }), + startEditor({ paths, port: 0, runtimeSource: source }), ]) expect(first.state.pid).toBe(second.state.pid) @@ -80,7 +87,7 @@ describe('managed runtime', () => { const started = await startEditor({ paths, port: address.port, - sourceDirectory: source, + runtimeSource: source, }) expect(started.state.port).not.toBe(address.port) @@ -153,7 +160,7 @@ describe('managed runtime', () => { const active = await installBundledRuntime(paths, source) await rm(path.join(active.directory, 'apps/editor/server.js')) - const started = await startEditor({ paths, port: 0, sourceDirectory: source }) + const started = await startEditor({ paths, port: 0, runtimeSource: source }) expect(started.state.version).toBe('1.2.3') expect((await getEditorStatus(paths)).healthy).toBe(true) @@ -168,7 +175,7 @@ describe('managed runtime', () => { const active = await installBundledRuntime(paths, source) await writeFile(path.join(active.directory, 'runtime-manifest.json'), '{not-json') - const started = await startEditor({ paths, port: 0, sourceDirectory: source }) + const started = await startEditor({ paths, port: 0, runtimeSource: source }) expect(started.state.version).toBe('1.2.3') expect((await getEditorStatus(paths)).healthy).toBe(true) @@ -182,7 +189,7 @@ describe('managed runtime', () => { await mkdir(paths.run, { recursive: true }) await writeFile(paths.currentRuntime, '{not-json') - const started = await startEditor({ paths, port: 0, sourceDirectory: source }) + const started = await startEditor({ paths, port: 0, runtimeSource: source }) expect(started.state.version).toBe('1.2.3') expect((await getEditorStatus(paths)).healthy).toBe(true) @@ -206,7 +213,7 @@ describe('managed runtime', () => { const root = await temporaryRoot() const source = await fakeRuntime(root, '1.2.3') const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }) - const started = await startEditor({ paths, port: 0, sourceDirectory: source }) + const started = await startEditor({ paths, port: 0, runtimeSource: source }) await writeFile( paths.state, `${JSON.stringify({ ...started.state, instanceId: 'no-longer-healthy' }, null, 2)}\n`, @@ -220,7 +227,7 @@ describe('managed runtime', () => { const root = await temporaryRoot() const source = await fakeRuntime(root, '1.2.3') const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }) - const started = await startEditor({ paths, port: 0, sourceDirectory: source }) + const started = await startEditor({ paths, port: 0, runtimeSource: source }) await writeFile(path.join(started.state.runtimeDirectory, 'runtime-manifest.json'), '{not-json') await writeFile( paths.state, @@ -235,7 +242,7 @@ describe('managed runtime', () => { const firstSource = await fakeRuntime(root, '1.2.3') const brokenSource = await fakeRuntime(root, '2.0.0', false) const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }) - await startEditor({ paths, port: 0, sourceDirectory: firstSource }) + await startEditor({ paths, port: 0, runtimeSource: firstSource }) const candidate = await installBundledRuntime(paths, brokenSource, { activate: false }) await expect(activateEditorRuntime(paths, candidate)).rejects.toMatchObject({ @@ -246,25 +253,24 @@ describe('managed runtime', () => { await stopEditor(paths) }) - test('upgrades a running editor state that predates managed MCP', async () => { + test('restarts the editor and repoints MCP when a new runtime is activated', async () => { const root = await temporaryRoot() const firstSource = await fakeRuntime(root, '1.2.3') const secondSource = await fakeRuntime(root, '2.0.0') const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }) - const started = await startEditor({ paths, sourceDirectory: firstSource }) - const oldMcpPid = started.state.mcp?.pid - if (!oldMcpPid) throw new Error('test MCP did not start') - process.kill(oldMcpPid, 'SIGTERM') - await waitUntilStopped(oldMcpPid) - const legacyState = { ...started.state, mcp: undefined } - await writeFile(paths.state, `${JSON.stringify(legacyState, null, 2)}\n`) - await rm(paths.mcpToken, { force: true }) + const started = await startEditor({ paths, runtimeSource: firstSource }) const candidate = await installBundledRuntime(paths, secondSource, { activate: false }) const result = await activateEditorRuntime(paths, candidate) expect(result.restarted).toBe(true) - expect((await getEditorStatus(paths)).healthy).toBe(true) + const status = await getEditorStatus(paths) + expect(status.healthy).toBe(true) + expect(status.state?.version).toBe('2.0.0') + expect(status.state?.pid).not.toBe(started.state.pid) + const mcp = await getMcpServiceStatus(paths) + expect(mcp.healthy).toBe(true) + expect(mcp.state?.editorOrigin).toBe(status.state?.url ?? '') await stopEditor(paths) }) @@ -274,7 +280,7 @@ describe('managed runtime', () => { const secondSource = await fakeRuntime(root, '2.0.0') const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }) await installBundledRuntime(paths, firstSource) - const seeded = await startEditor({ paths, port: 0, sourceDirectory: firstSource }) + const seeded = await startEditor({ paths, port: 0, runtimeSource: firstSource }) await stopEditor(paths) await writeFile(paths.state, `${JSON.stringify(seeded.state, null, 2)}\n`) const candidate = await installBundledRuntime(paths, secondSource, { activate: false }) @@ -293,35 +299,13 @@ async function temporaryRoot(): Promise { return root } -async function waitUntilStopped(pid: number): Promise { - const deadline = Date.now() + 2_000 - while (Date.now() < deadline) { - try { - process.kill(pid, 0) - } catch { - return - } - await Bun.sleep(20) - } - throw new Error(`process ${pid} did not stop`) -} - async function fakeRuntime(root: string, version: string, healthy = true): Promise { const runtime = path.join(root, `source-${version}`) const app = path.join(runtime, 'apps/editor') - const services = path.join(runtime, 'services') await mkdir(app, { recursive: true }) - await mkdir(services, { recursive: true }) await writeFile( path.join(runtime, 'runtime-manifest.json'), - JSON.stringify({ - schemaVersion: 1, - version, - entrypoint: 'apps/editor/server.js', - mcpEntrypoint: 'services/pascal-mcp.mjs', - healthPath: '/api/health', - mcpHealthPath: '/health', - }), + JSON.stringify({ schemaVersion: 2, version, entrypoint: 'apps/editor/server.js' }), ) await writeFile( path.join(app, 'server.js'), @@ -346,31 +330,5 @@ process.on('SIGTERM', () => server.close(() => process.exit(0))) ` : 'process.exit(1)\n', ) - await writeFile( - path.join(services, 'pascal-mcp.mjs'), - `import http from 'node:http' -const token = process.env.PASCAL_MCP_HTTP_TOKEN -const server = http.createServer((request, response) => { - if (request.headers.authorization !== \`Bearer \${token}\`) { - response.writeHead(401).end() - return - } - response.setHeader('content-type', 'application/json') - if (request.url === '/health') { - response.end(JSON.stringify({ - status: 'ok', - app: 'mcp', - version: process.env.PASCAL_RUNTIME_VERSION, - instanceId: process.env.PASCAL_INSTANCE_ID, - })) - return - } - response.writeHead(404).end('{}') -}) -const portIndex = process.argv.indexOf('--port') -server.listen(Number(process.argv[portIndex + 1]), '127.0.0.1') -process.on('SIGTERM', () => server.close(() => process.exit(0))) -`, - ) return runtime } diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index 004d48ad5e..495a726713 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -1,18 +1,14 @@ import { cp, mkdir, readdir, rename, rm, stat } from 'node:fs/promises' import path from 'node:path' -import { fileURLToPath } from 'node:url' import { CliError } from './errors.js' import { withFileLock } from './file-lock.js' import { readJsonFile, writeJsonFile } from './json-files.js' import type { PascalPaths } from './paths.js' export interface RuntimeManifest { - schemaVersion: 1 + schemaVersion: 2 version: string entrypoint: string - mcpEntrypoint: string - healthPath: string - mcpHealthPath: string } export interface ActiveRuntime { @@ -21,18 +17,6 @@ export interface ActiveRuntime { directory: string } -export function resolveBundledRuntimeDirectory( - environment: NodeJS.ProcessEnv = process.env, -): string { - if (environment.PASCAL_BUNDLED_RUNTIME_DIR) { - return path.resolve(environment.PASCAL_BUNDLED_RUNTIME_DIR) - } - const moduleDirectory = path.dirname(fileURLToPath(import.meta.url)) - return path.basename(moduleDirectory) === 'dist' - ? path.join(moduleDirectory, 'runtime') - : path.resolve(moduleDirectory, '../dist/runtime') -} - export async function readRuntimeManifest(directory: string): Promise { let manifest: RuntimeManifest | null try { @@ -41,12 +25,9 @@ export async function readRuntimeManifest(directory: string): Promise( + paths: PascalPaths, + action: () => Promise, + options: { timeoutMs?: number } = {}, +): Promise { + return withFileLock( + path.join(paths.run, 'runtime-install.lock'), + 'install_locked', + 'Another Pascal runtime installation is active.', + action, + options, + ) +} + export async function installBundledRuntime( paths: PascalPaths, - sourceDirectory = resolveBundledRuntimeDirectory(), + sourceDirectory: string, + options: { activate?: boolean } = {}, +): Promise { + return withRuntimeInstallLock(paths, () => + installRuntimeDirectory(paths, sourceDirectory, options), + ) +} + +/** Requires `withRuntimeInstallLock`; call `installBundledRuntime` when no lock is held. */ +export async function installRuntimeDirectory( + paths: PascalPaths, + sourceDirectory: string, options: { activate?: boolean } = {}, ): Promise { const sourceManifest = await readRuntimeManifest(sourceDirectory) const targetDirectory = path.join(paths.runtime, sourceManifest.version) await mkdir(paths.runtime, { recursive: true, mode: 0o700 }) - - return withFileLock( - path.join(paths.run, 'runtime-install.lock'), - 'install_locked', - 'Another Pascal runtime installation is active.', - async () => { - await removeAbandonedInstallDirectories(paths.runtime) - const installed = await readInstalledManifest(targetDirectory) - if ( - installed?.version === sourceManifest.version && - (await isRuntimeValid(targetDirectory, sourceManifest.version)) - ) { - return options.activate === false - ? runtimeRecord(sourceManifest.version, targetDirectory) - : activateRuntime(paths, sourceManifest.version, targetDirectory) - } - const temporaryDirectory = path.join( - paths.runtime, - `.install-${sourceManifest.version}-${process.pid}`, - ) - await rm(temporaryDirectory, { recursive: true, force: true }) - await cp(sourceDirectory, temporaryDirectory, { recursive: true, dereference: false }) - await readRuntimeManifest(temporaryDirectory) - await rm(targetDirectory, { recursive: true, force: true }) - await rename(temporaryDirectory, targetDirectory) - return options.activate === false - ? runtimeRecord(sourceManifest.version, targetDirectory) - : activateRuntime(paths, sourceManifest.version, targetDirectory) - }, + await removeAbandonedInstallDirectories(paths.runtime) + const installed = await readInstalledManifest(targetDirectory) + if ( + installed?.version === sourceManifest.version && + (await isRuntimeValid(targetDirectory, sourceManifest.version)) + ) { + return options.activate === false + ? runtimeRecord(sourceManifest.version, targetDirectory) + : activateRuntime(paths, sourceManifest.version, targetDirectory) + } + const temporaryDirectory = path.join( + paths.runtime, + `.install-${sourceManifest.version}-${process.pid}`, ) + await rm(temporaryDirectory, { recursive: true, force: true }) + await cp(sourceDirectory, temporaryDirectory, { recursive: true, dereference: false }) + await readRuntimeManifest(temporaryDirectory) + await rm(targetDirectory, { recursive: true, force: true }) + await rename(temporaryDirectory, targetDirectory) + return options.activate === false + ? runtimeRecord(sourceManifest.version, targetDirectory) + : activateRuntime(paths, sourceManifest.version, targetDirectory) } export async function readActiveRuntime(paths: PascalPaths): Promise { @@ -157,6 +154,14 @@ export async function activateRuntime( return active } +export async function findInstalledRuntime( + paths: PascalPaths, + version: string, +): Promise { + const directory = path.join(paths.runtime, version) + return (await isRuntimeValid(directory, version)) ? runtimeRecord(version, directory) : null +} + function runtimeRecord(version: string, directory: string): ActiveRuntime { return { schemaVersion: 1, version, directory } } diff --git a/packages/cli/src/tar.test.ts b/packages/cli/src/tar.test.ts new file mode 100644 index 0000000000..566c2ec12c --- /dev/null +++ b/packages/cli/src/tar.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { createHash } from 'node:crypto' +import { createWriteStream } from 'node:fs' +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + stat, + symlink, + utimes, + writeFile, +} from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { Readable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { createGzip } from 'node:zlib' +import { createRuntimeArchive, extractTarGzip, tarHeaderBlock } from './tar.js' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('runtime archive', () => { + test('writes the same bytes for the same tree regardless of timestamps', async () => { + const root = await temporaryRoot() + const source = path.join(root, 'runtime') + await mkdir(path.join(source, 'apps/editor/.next'), { recursive: true }) + await writeFile(path.join(source, 'runtime-manifest.json'), '{"schemaVersion":2}') + await writeFile(path.join(source, 'apps/editor/server.js'), 'console.log(1)\n') + await writeFile(path.join(source, 'apps/editor/.next/build.txt'), 'build\n') + /** Longer than the 100-byte ustar name field, so the archive needs a long-name entry. */ + const deep = path.join(source, 'apps/editor', 'a'.repeat(60), 'b'.repeat(60)) + await mkdir(deep, { recursive: true }) + await writeFile(path.join(deep, 'long-path.txt'), 'long\n') + const executable = path.join(source, 'apps/editor/run.sh') + await writeFile(executable, '#!/bin/sh\n') + await chmod(executable, 0o755) + + const first = path.join(root, 'first.tar.gz') + const firstResult = await createRuntimeArchive(source, first) + await utimes(path.join(source, 'apps/editor/server.js'), new Date(0), new Date(0)) + const second = path.join(root, 'second.tar.gz') + const secondResult = await createRuntimeArchive(source, second) + + expect(firstResult.entryCount).toBe(secondResult.entryCount) + expect(await sha256(first)).toBe(await sha256(second)) + + const target = path.join(root, 'extracted') + await extractTarGzip(first, target) + expect(await readFile(path.join(target, 'apps/editor/server.js'), 'utf8')).toBe( + 'console.log(1)\n', + ) + expect( + await readFile(path.join(target, deep.slice(source.length + 1), 'long-path.txt'), 'utf8'), + ).toBe('long\n') + expect((await stat(path.join(target, 'apps/editor/run.sh'))).mode & 0o111).not.toBe(0) + }) + + test('refuses to archive a symbolic link', async () => { + const root = await temporaryRoot() + const source = path.join(root, 'runtime') + await mkdir(source, { recursive: true }) + await writeFile(path.join(source, 'real.txt'), 'real\n') + await symlink('real.txt', path.join(source, 'link.txt')) + + await expect(createRuntimeArchive(source, path.join(root, 'out.tar.gz'))).rejects.toMatchObject( + { + code: 'archive_failed', + }, + ) + }) +}) + +describe('runtime archive extraction safety', () => { + test.each([ + ['a parent traversal', '../escaped.txt'], + ['a nested parent traversal', 'apps/../../escaped.txt'], + ['an absolute path', '/tmp/pascal-escaped.txt'], + ['a Windows drive path', 'C:/pascal-escaped.txt'], + ])('rejects %s', async (_label, name) => { + const root = await temporaryRoot() + const archive = path.join(root, 'malicious.tar.gz') + await writeArchive(archive, fileEntry(name, 'escaped\n')) + + await expect(extractTarGzip(archive, path.join(root, 'target'))).rejects.toMatchObject({ + code: 'invalid_runtime_archive', + }) + expect(await exists(path.join(root, 'escaped.txt'))).toBe(false) + expect(await exists('/tmp/pascal-escaped.txt')).toBe(false) + }) + + test('rejects a symbolic-link entry that would point out of the target', async () => { + const root = await temporaryRoot() + const archive = path.join(root, 'symlink.tar.gz') + await writeArchive(archive, [ + tarHeaderBlock({ name: 'apps/editor/escape', size: 0, mode: 0o777, typeflag: '2' }), + ]) + + await expect(extractTarGzip(archive, path.join(root, 'target'))).rejects.toMatchObject({ + code: 'invalid_runtime_archive', + }) + expect(await exists(path.join(root, 'target/apps/editor/escape'))).toBe(false) + }) + + test('rejects a hard-link entry', async () => { + const root = await temporaryRoot() + const archive = path.join(root, 'hardlink.tar.gz') + await writeArchive(archive, [ + tarHeaderBlock({ name: 'apps/editor/linked', size: 0, mode: 0o644, typeflag: '1' }), + ]) + + await expect(extractTarGzip(archive, path.join(root, 'target'))).rejects.toMatchObject({ + code: 'invalid_runtime_archive', + }) + }) + + test('rejects a header whose checksum was rewritten', async () => { + const root = await temporaryRoot() + const archive = path.join(root, 'tampered.tar.gz') + const [header, ...rest] = fileEntry('apps/editor/server.js', 'console.log(1)\n') + if (!header) throw new Error('the test archive has no header block') + const rewritten = Buffer.from(header) + rewritten.write('X', 0, 1, 'ascii') + await writeArchive(archive, [rewritten, ...rest]) + + await expect(extractTarGzip(archive, path.join(root, 'target'))).rejects.toMatchObject({ + code: 'invalid_runtime_archive', + }) + }) +}) + +async function temporaryRoot(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-tar-test-')) + roots.push(root) + return root +} + +function fileEntry(name: string, body: string): Buffer[] { + const data = Buffer.from(body, 'utf8') + const padded = Buffer.alloc(Math.ceil(data.byteLength / 512) * 512) + data.copy(padded) + return [tarHeaderBlock({ name, size: data.byteLength, mode: 0o644, typeflag: '0' }), padded] +} + +async function writeArchive(file: string, blocks: Buffer[]): Promise { + await pipeline( + Readable.from([Buffer.concat([...blocks, Buffer.alloc(1024)])]), + createGzip(), + createWriteStream(file), + ) +} + +async function sha256(file: string): Promise { + return createHash('sha256') + .update(await readFile(file)) + .digest('hex') +} + +async function exists(file: string): Promise { + try { + await stat(file) + return true + } catch { + return false + } +} diff --git a/packages/cli/src/tar.ts b/packages/cli/src/tar.ts new file mode 100644 index 0000000000..470a4213ea --- /dev/null +++ b/packages/cli/src/tar.ts @@ -0,0 +1,304 @@ +import { createReadStream, createWriteStream } from 'node:fs' +import { mkdir, readdir, rm, stat } from 'node:fs/promises' +import path from 'node:path' +import { Readable, type Writable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { createGunzip, createGzip } from 'node:zlib' +import { CliError } from './errors.js' + +const BLOCK_SIZE = 512 +const LONG_NAME_ENTRY = '././@LongLink' + +export interface TarHeaderFields { + name: string + size: number + mode: number + typeflag: string +} + +export interface RuntimeArchiveResult { + size: number + entryCount: number +} + +interface ArchiveEntry { + relative: string + absolute: string + directory: boolean + size: number + mode: number +} + +/** + * Writes a byte-for-byte reproducible tar.gz: entries sorted by path, zero mtime, zero + * uid/gid, empty owner names and normalized modes. Two runs over the same tree therefore + * produce the same SHA-256, which is what `dist/runtime-source.json` pins. + */ +export async function createRuntimeArchive( + sourceDirectory: string, + destinationFile: string, +): Promise { + const root = path.resolve(sourceDirectory) + const destination = path.resolve(destinationFile) + const entries = await collectEntries(root) + await mkdir(path.dirname(destination), { recursive: true }) + await rm(destination, { force: true }) + await pipeline( + Readable.from(archiveBlocks(entries), { objectMode: false }), + createGzip({ level: 9 }), + createWriteStream(destination), + ) + return { size: (await stat(destination)).size, entryCount: entries.length } +} + +export async function extractTarGzip(archiveFile: string, targetDirectory: string): Promise { + const root = path.resolve(targetDirectory) + await mkdir(root, { recursive: true, mode: 0o700 }) + const source = createReadStream(archiveFile) + const gunzip = createGunzip() + source.on('error', (error) => gunzip.destroy(error)) + const reader = new BlockReader(source.pipe(gunzip)) + let pendingLongName: string | null = null + try { + for (;;) { + const header = await reader.read(BLOCK_SIZE) + if (!header || isZeroBlock(header)) break + verifyChecksum(header) + const typeflag = String.fromCharCode(header[156] ?? 0) + const size = readOctal(header, 124, 12) + const mode = readOctal(header, 100, 8) + if (typeflag === 'L') { + const data = await reader.read(paddedSize(size)) + if (!data) throw invalidArchive('a long-name entry is truncated') + pendingLongName = data.subarray(0, size).toString('utf8').replace(/\0+$/, '') + continue + } + const name = pendingLongName ?? readHeaderString(header, 0, 100) + pendingLongName = null + if (typeflag !== '0' && typeflag !== '\0' && typeflag !== '5') { + throw invalidArchive(`entry ${JSON.stringify(name)} uses unsupported type "${typeflag}"`) + } + const destination = resolveEntryPath(root, name) + if (typeflag === '5') { + await mkdir(destination, { recursive: true, mode: 0o755 }) + continue + } + await mkdir(path.dirname(destination), { recursive: true, mode: 0o755 }) + await reader.writeTo( + createWriteStream(destination, { mode: (mode & 0o111) !== 0 ? 0o755 : 0o644 }), + size, + ) + const padding = paddedSize(size) - size + if (padding > 0 && !(await reader.read(padding))) { + throw invalidArchive(`entry ${JSON.stringify(name)} is truncated`) + } + } + } finally { + gunzip.destroy() + source.destroy() + } +} + +export function tarHeaderBlock(fields: TarHeaderFields): Buffer { + const block = Buffer.alloc(BLOCK_SIZE) + Buffer.from(fields.name, 'utf8').subarray(0, 100).copy(block, 0) + writeOctal(block, fields.mode & 0o7777, 100, 8) + writeOctal(block, 0, 108, 8) + writeOctal(block, 0, 116, 8) + writeOctal(block, fields.size, 124, 12) + writeOctal(block, 0, 136, 12) + block.write(fields.typeflag, 156, 1, 'ascii') + block.write('ustar\0', 257, 6, 'ascii') + block.write('00', 263, 2, 'ascii') + block.fill(0x20, 148, 156) + let checksum = 0 + for (const byte of block) checksum += byte + block.write(`${checksum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'ascii') + return block +} + +async function* archiveBlocks(entries: ArchiveEntry[]): AsyncGenerator { + for (const entry of entries) { + const name = entry.directory ? `${entry.relative}/` : entry.relative + const nameBytes = Buffer.from(name, 'utf8') + if (nameBytes.byteLength > 100) { + yield tarHeaderBlock({ + name: LONG_NAME_ENTRY, + size: nameBytes.byteLength + 1, + mode: 0o644, + typeflag: 'L', + }) + const data = Buffer.concat([nameBytes, Buffer.of(0)]) + yield data + yield* paddingBlocks(data.byteLength) + } + yield tarHeaderBlock({ + name, + size: entry.directory ? 0 : entry.size, + mode: entry.mode, + typeflag: entry.directory ? '5' : '0', + }) + if (entry.directory) continue + let written = 0 + for await (const chunk of createReadStream(entry.absolute)) { + const buffer = chunk as Buffer + written += buffer.byteLength + yield buffer + } + if (written !== entry.size) { + throw new CliError( + 'archive_failed', + `${entry.relative} changed size while the archive was being written.`, + ) + } + yield* paddingBlocks(written) + } + yield Buffer.alloc(2 * BLOCK_SIZE) +} + +function* paddingBlocks(size: number): Generator { + const padding = (BLOCK_SIZE - (size % BLOCK_SIZE)) % BLOCK_SIZE + if (padding > 0) yield Buffer.alloc(padding) +} + +async function collectEntries(root: string): Promise { + const entries: ArchiveEntry[] = [] + const walk = async (directory: string, prefix: string): Promise => { + for (const child of await readdir(directory, { withFileTypes: true })) { + const absolute = path.join(directory, child.name) + const relative = prefix ? `${prefix}/${child.name}` : child.name + if (child.isSymbolicLink()) { + throw new CliError('archive_failed', `Cannot archive the symbolic link ${relative}.`) + } + if (child.isDirectory()) { + entries.push({ relative, absolute, directory: true, size: 0, mode: 0o755 }) + await walk(absolute, relative) + continue + } + if (!child.isFile()) { + throw new CliError('archive_failed', `Cannot archive the special file ${relative}.`) + } + const info = await stat(absolute) + entries.push({ + relative, + absolute, + directory: false, + size: info.size, + mode: (info.mode & 0o111) !== 0 ? 0o755 : 0o644, + }) + } + } + await walk(root, '') + return entries.sort((left, right) => + Buffer.compare(Buffer.from(left.relative, 'utf8'), Buffer.from(right.relative, 'utf8')), + ) +} + +class BlockReader { + private readonly iterator: AsyncIterator + private pending: Buffer = Buffer.alloc(0) + + constructor(stream: Readable) { + this.iterator = stream[Symbol.asyncIterator]() as AsyncIterator + } + + async read(size: number): Promise { + if (size === 0) return Buffer.alloc(0) + while (this.pending.byteLength < size) { + const next = await this.iterator.next() + if (next.done) break + this.pending = + this.pending.byteLength === 0 + ? Buffer.from(next.value) + : Buffer.concat([this.pending, next.value]) + } + if (this.pending.byteLength < size) return null + const result = this.pending.subarray(0, size) + this.pending = this.pending.subarray(size) + return result + } + + async writeTo(target: Writable, size: number): Promise { + let remaining = size + try { + while (remaining > 0) { + const chunk = await this.read(Math.min(remaining, 1024 * 1024)) + if (!chunk) throw invalidArchive('an entry ends before its recorded size') + remaining -= chunk.byteLength + if (!target.write(chunk)) { + await new Promise((resolve, reject) => { + target.once('drain', resolve) + target.once('error', reject) + }) + } + } + } catch (error) { + target.destroy() + throw error + } + await new Promise((resolve, reject) => { + target.once('error', reject) + target.end(resolve) + }) + } +} + +function resolveEntryPath(root: string, name: string): string { + const normalized = name.replace(/\/+$/, '') + const segments = normalized.split('/') + if ( + !normalized || + normalized.includes('\0') || + normalized.startsWith('/') || + path.isAbsolute(normalized) || + /^[A-Za-z]:/.test(normalized) || + segments.some((segment) => segment === '..' || segment === '') + ) { + throw invalidArchive(`entry ${JSON.stringify(name)} is not a safe relative path`) + } + const destination = path.resolve(root, ...segments) + if (destination !== root && !destination.startsWith(`${root}${path.sep}`)) { + throw invalidArchive(`entry ${JSON.stringify(name)} escapes the extraction directory`) + } + return destination +} + +function verifyChecksum(header: Buffer): void { + const expected = readOctal(header, 148, 8) + let checksum = 0 + for (let index = 0; index < BLOCK_SIZE; index += 1) { + checksum += index >= 148 && index < 156 ? 0x20 : (header[index] ?? 0) + } + if (checksum !== expected) throw invalidArchive('an entry header checksum does not match') +} + +function isZeroBlock(block: Buffer): boolean { + return block.every((byte) => byte === 0) +} + +function paddedSize(size: number): number { + return size + ((BLOCK_SIZE - (size % BLOCK_SIZE)) % BLOCK_SIZE) +} + +function readHeaderString(block: Buffer, offset: number, length: number): string { + const field = block.subarray(offset, offset + length) + const end = field.indexOf(0) + return field.subarray(0, end === -1 ? field.byteLength : end).toString('utf8') +} + +function readOctal(block: Buffer, offset: number, length: number): number { + const text = readHeaderString(block, offset, length).trim() + if (!/^[0-7]*$/.test(text)) throw invalidArchive('an entry header field is not octal') + return text ? Number.parseInt(text, 8) : 0 +} + +function writeOctal(block: Buffer, value: number, offset: number, length: number): void { + block.write(`${value.toString(8).padStart(length - 1, '0')}\0`, offset, length, 'ascii') +} + +function invalidArchive(reason: string): CliError { + return new CliError( + 'invalid_runtime_archive', + `The Pascal web runtime archive is invalid: ${reason}.`, + ) +} diff --git a/packages/cli/src/test-support/fake-mcp-service.ts b/packages/cli/src/test-support/fake-mcp-service.ts new file mode 100644 index 0000000000..7dd70fed55 --- /dev/null +++ b/packages/cli/src/test-support/fake-mcp-service.ts @@ -0,0 +1,47 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import path from 'node:path' + +/** + * A stand-in for the bundled `services/pascal-mcp.mjs`: it answers the authenticated health + * probe the CLI uses to identify its own MCP process, and records the environment it was + * started with so tests can assert the editor origin handed to it. + */ +const FAKE_MCP_SERVICE = `import { writeFileSync } from 'node:fs' +import http from 'node:http' +const token = process.env.PASCAL_MCP_HTTP_TOKEN +const server = http.createServer((request, response) => { + if (request.headers.authorization !== \`Bearer \${token}\`) { + response.writeHead(401).end() + return + } + response.setHeader('content-type', 'application/json') + if (request.url === '/health') { + response.end(JSON.stringify({ + status: 'ok', + app: 'mcp', + version: process.env.PASCAL_RUNTIME_VERSION, + instanceId: process.env.PASCAL_INSTANCE_ID, + editorOrigin: process.env.PASCAL_EDITOR_ORIGIN ?? null, + })) + return + } + response.writeHead(404).end('{}') +}) +const portIndex = process.argv.indexOf('--port') +server.listen(Number(process.argv[portIndex + 1]), '127.0.0.1') +if (process.env.PASCAL_MCP_TEST_RECORD) { + writeFileSync(process.env.PASCAL_MCP_TEST_RECORD, JSON.stringify({ + pid: process.pid, + editorOrigin: process.env.PASCAL_EDITOR_ORIGIN ?? null, + dataDirectory: process.env.PASCAL_DATA_DIR, + })) +} +process.on('SIGTERM', () => server.close(() => process.exit(0))) +` + +export async function writeFakeMcpService(directory: string): Promise { + await mkdir(directory, { recursive: true }) + const servicePath = path.join(directory, 'pascal-mcp.mjs') + await writeFile(servicePath, FAKE_MCP_SERVICE) + return servicePath +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 8a0ae091f9..2cb2be14e6 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -9,5 +9,5 @@ "types": ["node"] }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "scripts"] + "exclude": ["node_modules", "dist", "**/*.test.ts", "src/test-support", "scripts"] } diff --git a/skills/furniture-fit/references/setup.md b/skills/furniture-fit/references/setup.md index fecfbba343..89808e03d0 100644 --- a/skills/furniture-fit/references/setup.md +++ b/skills/furniture-fit/references/setup.md @@ -6,6 +6,8 @@ Source and public-documentation review date: 2026-09-10. Native task results are Use the local path when the project should remain on the machine: +CLI builds from this repository keep the MCP service inside the npm package and download the roughly 64 MB web editor runtime only when a command starts the editor, so `pascal mcp connect` needs no runtime download: an agent-only host can list, load, and save local scenes without one. `@pascal-app/cli@1.0.0-beta.1` bundles that runtime in the npm package instead, and its connector also starts the local editor. Run `pascal editor` when a person needs the visual editor, and add `--runtime ` when the host has no network access. + ### Verified GitHub preview The npm `beta` tag currently resolves to `@pascal-app/cli@1.0.0-beta.1`, an older runtime that may not expose `check_collisions.candidate` or the hosted agent claim/status commands. For the candidate and hosted-agent paths verified with this skill, install the GitHub prerelease built from public commit `5dabbc3b56109c9f79dc8a378443a4c520d9ee0a`: diff --git a/skills/pascal-3d/references/setup.md b/skills/pascal-3d/references/setup.md index 539c43bc8a..bfd2f0c233 100644 --- a/skills/pascal-3d/references/setup.md +++ b/skills/pascal-3d/references/setup.md @@ -15,6 +15,8 @@ npm install --global @pascal-app/cli@beta pascal editor --no-open ``` +CLI builds from this repository keep the MCP service inside the npm package and download the roughly 64 MB web editor runtime only when a command starts the editor, so `pascal mcp connect` needs no runtime download: an agent-only host can list, load, and save local scenes without one. `@pascal-app/cli@1.0.0-beta.1` bundles that runtime in the npm package instead, and its connector also starts the local editor. Run `pascal editor` when a person needs the visual editor, and add `--runtime ` when the host has no network access. + ### Verified GitHub preview The npm `beta` tag currently resolves to `@pascal-app/cli@1.0.0-beta.1`, an older runtime that may not expose `check_collisions.candidate` or the hosted agent claim/status commands. For the candidate and hosted-agent paths verified with this skill, install the GitHub prerelease built from public commit `5dabbc3b56109c9f79dc8a378443a4c520d9ee0a`: