diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 2c8e7d34..308273d7 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -72,10 +72,14 @@ verbatim path — it fails with "The system cannot find the path specified." ## Spawning External Binaries Any time Dormouse spawns an external/user-installed binary — `dor ab` driving -`agent-browser`, the agent-browser host running tab/eval/screenshot commands, dev -harnesses launching `pnpm`/`agent-browser` — it goes through **`cross-spawn`**, -never raw `node:child_process` `spawn`. This is mandatory for correctness on -Windows, where two distinct failures bite a naive spawn: +`agent-browser`, the agent-browser host running tab/eval/screenshot commands — it +goes through **`spawnAndCapture` from the `dor-lib-common` package**, never raw +`node:child_process` `spawn`. That helper is the single home for the hard-won +Windows recipe, shared by `dor` and the `lib` host (which otherwise have no common +code); both packages depend on `dor-lib-common`. It owns three concerns: + +**1. cross-spawn, not raw spawn.** Two distinct failures bite a naive spawn on +Windows: - **ENOENT on a bare name.** Node's `spawn` does not consult `PATHEXT`, so a bare `agent-browser` never resolves the `agent-browser.cmd` PATH shim that npm/vfox @@ -87,29 +91,30 @@ Windows, where two distinct failures bite a naive spawn: `cross-spawn` resolves the command via `PATH`/`PATHEXT` and routes `.cmd`/`.bat` through `cmd.exe` with correct argument escaping, and is a transparent passthrough -on POSIX. Use it with the same `(command, args, options)` signature as -`child_process.spawn`; it is bundled into `dist/dor.js` and the sidecar `.cjs` -by esbuild. - -Caveat: a literal `%VAR%` inside an argument can still be expanded by `cmd.exe` -when it passes through a `.cmd` shim — an unavoidable Windows batch limitation, not -something `cross-spawn` (or any wrapper) can fully prevent. Our forwarded -arguments (URLs, selectors, and the host's hardcoded `eval` scripts) contain no -`%VAR%` patterns, so this does not arise in practice. - -### Resolve on `exit`, not `close` - -When buffering a spawned command's output, resolve on the child's **`exit`** -event, not `close`. `agent-browser open` launches a long-lived per-session daemon -that on Windows inherits the parent's stdout/stderr pipes; those pipes never reach -EOF while the daemon lives, so `close` (which waits for stdio to drain) never -fires and the spawn hangs forever. `exit` fires when the foreground process ends -regardless of the lingering pipe. The two spawn helpers -(`dor/src/commands/agent-browser.ts`, `lib/src/host/agent-browser-host.ts`) wait -for `close` but fall back to `exit` after a short grace (`CLOSE_GRACE_MS`), so a -normal command's full output still flushes while the daemon case can't hang. -(POSIX dodges this because the daemon double-forks and detaches from the inherited -fds, closing the pipe — which is why this never surfaced on macOS.) +on POSIX. Caveat: a literal `%VAR%` inside an argument can still be expanded by +`cmd.exe` when it passes through a `.cmd` shim — an unavoidable Windows batch +limitation. Our forwarded arguments (URLs, selectors, the host's hardcoded `eval` +scripts) contain no `%VAR%` patterns, so this does not arise in practice. + +**2. `windowsHide`.** cross-spawn runs `.cmd` shims through `cmd.exe`; without +`windowsHide` each spawn flashes a console window that steals focus — and the +panel's screenshot loop spawns one per stream-frame pulse, so a live page would +flicker windows several times a second. + +**3. Resolve on `exit`, not `close`, with an exit-time snapshot.** `agent-browser +open` launches a long-lived per-session daemon that on Windows inherits the +parent's stdout/stderr pipes; those pipes never reach EOF while the daemon lives, +so `close` (which waits for stdio to drain) never fires and a `close`-only wait +hangs forever. `spawnAndCapture` waits for `close` but falls back to `exit` after +a short grace, and resolves the grace path with the output snapshotted at `exit` +so the daemon's post-command scribbles don't leak into the result. (POSIX dodges +the whole thing because the daemon double-forks and detaches from the inherited +fds, closing the pipe — which is why none of this surfaced on macOS.) + +Resolution: `dor-lib-common`'s package `exports` point at its built `dist` (clean, +Node-type-free `.d.ts` for `dor`'s `tsc`, which deliberately avoids `@types/node`); +every esbuild/Vite consumer (`dist/dor.js`, the sidecar `.cjs`, vscode-ext) inlines +it. `dor`'s `prebuild` builds `dor-lib-common` first so its `.d.ts` exists. ## Host Plumbing diff --git a/dor-lib-common/package.json b/dor-lib-common/package.json new file mode 100644 index 00000000..ac1505fc --- /dev/null +++ b/dor-lib-common/package.json @@ -0,0 +1,24 @@ +{ + "name": "dor-lib-common", + "version": "0.0.0", + "license": "FSL-1.1-MIT", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "pnpm run build && node --test test/*.test.mjs" + }, + "dependencies": { + "cross-spawn": "^7.0.6" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "typescript": "^6.0.3" + } +} diff --git a/dor-lib-common/src/agent-browser.ts b/dor-lib-common/src/agent-browser.ts new file mode 100644 index 00000000..40ebedf1 --- /dev/null +++ b/dor-lib-common/src/agent-browser.ts @@ -0,0 +1,43 @@ +// Workspace id baked into managed agent-browser session names. Hardcoded until +// Dormouse exposes real workspaces; encoded now to avoid a later rename. Private: +// callers build session names through sessionForKey, never by hand. +const WORKSPACE_ID = '1'; + +/** Env var that overrides which agent-browser binary to run; shared so `dor ab` + * and the host key off the same name. */ +export const AGENT_BROWSER_BIN_ENV = 'DORMOUSE_AGENT_BROWSER_BIN'; + +/** Default binary name, resolved on PATH when no override/explicit path is given. */ +export const DEFAULT_AGENT_BROWSER_BIN = 'agent-browser'; + +/** argv for `agent-browser stream status --json` against a session — the command + * whose output {@link parseStreamPort} reads. */ +export function streamStatusArgs(session: string): string[] { + return ['--session', session, 'stream', 'status', '--json']; +} + +/** + * Managed, workspace-scoped agent-browser session name: `dormouse..`. + * agent-browser session names become filesystem paths (the socket dir), so `/` + * can't separate the namespace — the daemon fails to start; dots keep it + * readable. Shared by `dor ab` (--key resolution) and the lib host (GUI sessions). + */ +export function sessionForKey(key: string): string { + return `dormouse.${WORKSPACE_ID}.${key}`; +} + +/** + * Parse the stream WebSocket port from `agent-browser stream status --json`. + * The CLI wraps payloads as either `{ port }` or `{ data: { port } }`; tolerate + * both, and return undefined for anything malformed or non-finite. Shared by + * `dor ab` (surface binding) and the lib host (panel stream recovery). + */ +export function parseStreamPort(stdout: string): number | undefined { + try { + const parsed = JSON.parse(stdout) as { port?: unknown; data?: { port?: unknown } }; + const port = parsed.data?.port ?? parsed.port; + return typeof port === 'number' && Number.isFinite(port) ? port : undefined; + } catch { + return undefined; + } +} diff --git a/dor-lib-common/src/cross-spawn.d.ts b/dor-lib-common/src/cross-spawn.d.ts new file mode 100644 index 00000000..202c8fbb --- /dev/null +++ b/dor-lib-common/src/cross-spawn.d.ts @@ -0,0 +1,11 @@ +// cross-spawn ships no types. Declare the single call shape we use; with +// @types/node present, ChildProcess/SpawnOptions are the real Node types, so +// this stays internal to dor-lib-common and never leaks into the public surface. +declare module 'cross-spawn' { + import type { ChildProcess, SpawnOptions } from 'node:child_process'; + export default function spawn( + command: string, + args: readonly string[], + options?: SpawnOptions, + ): ChildProcess; +} diff --git a/dor-lib-common/src/index.ts b/dor-lib-common/src/index.ts new file mode 100644 index 00000000..d9ec920a --- /dev/null +++ b/dor-lib-common/src/index.ts @@ -0,0 +1,9 @@ +export { spawnAndCapture } from './spawn.js'; +export type { SpawnCaptureResult } from './spawn.js'; +export { + parseStreamPort, + sessionForKey, + streamStatusArgs, + AGENT_BROWSER_BIN_ENV, + DEFAULT_AGENT_BROWSER_BIN, +} from './agent-browser.js'; diff --git a/dor-lib-common/src/spawn.ts b/dor-lib-common/src/spawn.ts new file mode 100644 index 00000000..8d560801 --- /dev/null +++ b/dor-lib-common/src/spawn.ts @@ -0,0 +1,71 @@ +import spawn from 'cross-spawn'; + +export interface SpawnCaptureSuccess { + readonly ok: true; + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +export interface SpawnCaptureFailure { + readonly ok: false; + /** Spawn-level failure — the process never ran (e.g. ENOENT). */ + readonly error: { readonly code?: string; readonly message: string }; +} + +export type SpawnCaptureResult = SpawnCaptureSuccess | SpawnCaptureFailure; + +// Grace window for 'close' to win after 'exit' before we resolve anyway. Long +// enough that a normal command's stdio drains (its output is written before the +// process exits), short enough that the daemon-holds-the-pipe case (see below) +// doesn't feel like a hang. +const CLOSE_GRACE_MS = 250; + +/** + * Spawn an external binary and capture its stdout/stderr — the single home for + * the hard-won Windows recipe `dor` and the agent-browser host both need. See + * docs/specs/dor-cli.md → "Spawning External Binaries". + * + * - cross-spawn resolves PATHEXT and routes `.cmd`/`.bat` through cmd.exe; Node's + * own spawn ENOENTs on a bare name and (>=22) EINVALs on a `.cmd` by full path. + * - windowsHide stops a console window flashing and stealing focus per spawn. + * - resolve on 'exit' (not 'close') with a grace + an exit-time output snapshot: + * `agent-browser open` leaves a daemon that on Windows inherits our stdio + * pipes, so 'close' never fires (waiting on it alone hangs forever) and the + * daemon's post-exit scribbles would otherwise leak into the captured output. + * + * Never throws: a spawn-level failure resolves as `{ ok: false, error }`. + */ +export function spawnAndCapture(binary: string, args: readonly string[]): Promise { + return new Promise((resolve) => { + const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); + let stdout = ''; + let stderr = ''; + // Latch on the first terminal event so the error-vs-exit/close race can't + // double-resolve; clearTimeout drops the grace timer once we've settled. + let settled = false; + let graceTimer: ReturnType | undefined; + const settle = (apply: () => void): void => { + if (settled) return; + settled = true; + if (graceTimer !== undefined) clearTimeout(graceTimer); + apply(); + }; + child.stdout?.on('data', (chunk: unknown) => { stdout += String(chunk); }); + child.stderr?.on('data', (chunk: unknown) => { stderr += String(chunk); }); + child.on('error', (error: NodeJS.ErrnoException) => + settle(() => resolve({ ok: false, error: { code: error.code, message: error.message } }))); + const finish = (code: number | null, out: string, err: string): void => + settle(() => resolve({ ok: true, exitCode: code ?? 1, stdout: out, stderr: err })); + // 'close' is the clean path (process exited and stdio drained). Fall back to + // 'exit' for the daemon-holds-the-pipe case where 'close' never fires; the + // grace lets 'close' win first so a normal command's full output flushes, and + // the exit-time snapshot keeps post-exit daemon noise out of the result. + child.on('close', (code: number | null) => finish(code, stdout, stderr)); + child.on('exit', (code: number | null) => { + const out = stdout; + const err = stderr; + graceTimer = setTimeout(() => finish(code, out, err), CLOSE_GRACE_MS); + }); + }); +} diff --git a/dor-lib-common/test/agent-browser.test.mjs b/dor-lib-common/test/agent-browser.test.mjs new file mode 100644 index 00000000..7da5236c --- /dev/null +++ b/dor-lib-common/test/agent-browser.test.mjs @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseStreamPort, sessionForKey } from '../dist/index.js'; + +test('sessionForKey namespaces a key under the workspace', () => { + assert.equal(sessionForKey('default'), 'dormouse.1.default'); + assert.equal(sessionForKey('gui-abc'), 'dormouse.1.gui-abc'); +}); + +test('parseStreamPort reads a top-level port', () => { + assert.equal(parseStreamPort(JSON.stringify({ port: 61218 })), 61218); +}); + +test('parseStreamPort reads a nested data.port', () => { + assert.equal(parseStreamPort(JSON.stringify({ data: { port: 5173 } })), 5173); +}); + +test('parseStreamPort returns undefined for malformed or portless output', () => { + assert.equal(parseStreamPort('not json'), undefined); + assert.equal(parseStreamPort(JSON.stringify({ data: {} })), undefined); + assert.equal(parseStreamPort(JSON.stringify({ port: 'nope' })), undefined); +}); diff --git a/dor-lib-common/test/spawn.test.mjs b/dor-lib-common/test/spawn.test.mjs new file mode 100644 index 00000000..8a1d24dd --- /dev/null +++ b/dor-lib-common/test/spawn.test.mjs @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnAndCapture } from '../dist/index.js'; + +const node = process.execPath; + +test('captures stdout and a zero exit code', async () => { + const result = await spawnAndCapture(node, ['-e', 'process.stdout.write("hello")']); + assert.equal(result.ok, true); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout, 'hello'); +}); + +test('captures stderr and a non-zero exit code', async () => { + const result = await spawnAndCapture(node, ['-e', 'process.stderr.write("boom"); process.exit(3)']); + assert.equal(result.ok, true); + assert.equal(result.exitCode, 3); + assert.equal(result.stderr, 'boom'); +}); + +test('reports a missing binary as a spawn failure, never throwing', async () => { + const result = await spawnAndCapture('dormouse-no-such-binary-xyz', []); + assert.equal(result.ok, false); + assert.equal(result.error.code, 'ENOENT'); +}); diff --git a/dor-lib-common/tsconfig.json b/dor-lib-common/tsconfig.json new file mode 100644 index 00000000..4d77c181 --- /dev/null +++ b/dor-lib-common/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ES2022", + "moduleResolution": "bundler", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "incremental": true, + "tsBuildInfoFile": "dist/.tsbuildinfo", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/dor/package.json b/dor/package.json index 533f0c98..b16a7561 100644 --- a/dor/package.json +++ b/dor/package.json @@ -12,7 +12,7 @@ "dist" ], "scripts": { - "prebuild": "node ../scripts/generate-dor-version.mjs", + "prebuild": "pnpm --filter dor-lib-common build && node ../scripts/generate-dor-version.mjs", "build": "tsc -p tsconfig.json && esbuild src/dor.ts --bundle --format=esm --platform=node --outfile=dist/dor.js --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\"", "test": "pnpm run build && node --test test/*.test.mjs" }, @@ -22,6 +22,6 @@ }, "dependencies": { "@stricli/core": "^1.2.7", - "cross-spawn": "^7.0.6" + "dor-lib-common": "workspace:*" } } diff --git a/dor/src/commands/agent-browser.ts b/dor/src/commands/agent-browser.ts index dd70019b..adb7c5f7 100644 --- a/dor/src/commands/agent-browser.ts +++ b/dor/src/commands/agent-browser.ts @@ -16,13 +16,17 @@ */ import { buildCommand } from '@stricli/core'; -// cross-spawn, not node:child_process — on Windows a bare command name never -// resolves a `.cmd`/`.bat` PATH shim (Node spawn ignores PATHEXT → ENOENT), and -// Node >=22 refuses to spawn a `.cmd` directly even by full path (EINVAL, the -// CVE-2024-27980 hardening). agent-browser ships as a `.cmd` shim, so both bite. -// cross-spawn routes through cmd.exe with correct escaping and is a no-op -// passthrough on POSIX. See docs/specs/dor-cli.md → "Spawning External Binaries". -import spawn from 'cross-spawn'; +// All external spawns go through dor-lib-common's spawnAndCapture, which owns the +// Windows recipe (cross-spawn for PATHEXT/.cmd, windowsHide, exit-vs-close). +// See docs/specs/dor-cli.md → "Spawning External Binaries". +import { + spawnAndCapture, + parseStreamPort, + sessionForKey, + streamStatusArgs, + AGENT_BROWSER_BIN_ENV, + DEFAULT_AGENT_BROWSER_BIN, +} from 'dor-lib-common'; import { existsSync } from 'node:fs'; import type { CliEnv, @@ -35,12 +39,8 @@ import type { } from './types.js'; import { fail, requireControlClient, stringParser } from './shared.js'; -/** Hardcoded until Dormouse exposes real workspaces; encoded now to avoid a rename. */ -const WORKSPACE_ID = '1'; - const INSTALL_HINT = 'npm i -g agent-browser'; const INSTALL_DOCS = 'https://agent-browser.dev'; -const BIN_ENV = 'DORMOUSE_AGENT_BROWSER_BIN'; // Extensions a bare command name can carry on Windows, in PATH-search order. // Shared by resolveBinaryPath (PATH walk) and existsCandidate (explicit path). @@ -53,7 +53,7 @@ const WINDOWS_BIN_EXTS = ['.cmd', '.exe', '.bat']; * looked for. */ function missingBinaryMessage(binary: string): string { - const lookedFor = binary === 'agent-browser' ? '' : ` (looked for '${binary}')`; + const lookedFor = binary === DEFAULT_AGENT_BROWSER_BIN ? '' : ` (looked for '${binary}')`; return [ `agent-browser is not installed${lookedFor}.`, '', @@ -63,19 +63,15 @@ function missingBinaryMessage(binary: string): string { ` ${INSTALL_HINT}`, '', `More: ${INSTALL_DOCS}`, - `Already installed? Make sure it's on your PATH, or set ${BIN_ENV} to its full path.`, + `Already installed? Make sure it's on your PATH, or set ${AGENT_BROWSER_BIN_ENV} to its full path.`, ].join('\n'); } -// agent-browser session names become filesystem paths (socket dir), so `/` is -// not usable as a namespace separator — the daemon fails to start. Dots keep -// the managed namespace readable: dormouse... +// A managed --key becomes part of an agent-browser session name (see +// sessionForKey in dor-lib-common), which becomes a filesystem path — so `/` is +// not usable; restrict to a readable, path-safe charset. const KEY_PATTERN = /^[A-Za-z0-9._-]+$/; -export function sessionForKey(key: string): string { - return `dormouse.${WORKSPACE_ID}.${key}`; -} - export const agentBrowserCommand: Command = { name: 'agent-browser', helpPatches: [ @@ -190,7 +186,7 @@ export async function runAgentBrowserCli(args: string[], options: CliOptions): P const { key, session, rest } = flags.value; const env = options.env ?? {}; - const binary = env.DORMOUSE_AGENT_BROWSER_BIN || 'agent-browser'; + const binary = env[AGENT_BROWSER_BIN_ENV] || DEFAULT_AGENT_BROWSER_BIN; const exec = options.execAgentBrowser ?? execAgentBrowserProcess; // Resolve the binary to an absolute path once: it both proves the install @@ -227,7 +223,7 @@ export async function runAgentBrowserCli(args: string[], options: CliOptions): P // passthrough rather than nagging about the missing surface. if (!(client instanceof Error)) { try { - const status = await exec(binary, ['--session', session, 'stream', 'status', '--json']); + const status = await exec(binary, streamStatusArgs(session)); const wsPort = parseStreamPort(status.stdout); // Pass the absolute path resolved above so the host (which may not share // this terminal's PATH) can run host-side tab/close commands. @@ -275,16 +271,6 @@ export function resolveBinaryPath(binary: string, env: CliEnv): string | undefin return undefined; } -function parseStreamPort(stdout: string): number | undefined { - try { - const parsed = JSON.parse(stdout) as { port?: unknown; data?: { port?: unknown } }; - const port = parsed.data?.port ?? parsed.port; - return typeof port === 'number' && Number.isFinite(port) ? port : undefined; - } catch { - return undefined; - } -} - function isMissingBinaryError(error: unknown): boolean { return !!error && typeof error === 'object' && (error as { code?: unknown }).code === 'ENOENT'; } @@ -314,58 +300,15 @@ function existsCandidate(path: string, isWindows: boolean): boolean { return WINDOWS_BIN_EXTS.some((ext) => existsSync(`${path}${ext}`)); } -// agent-browser talks to a daemon, so forwarded commands return quickly; -// buffering output until exit keeps this transport-agnostic with runCli's -// captured stdout/stderr at the cost of not streaming long-running output. -// -// Grace window for 'close' to win after 'exit' before we resolve anyway. See -// execAgentBrowserProcess: long enough that a normal command's stdio drains -// (its output was written before the process exited), short enough that the -// daemon-holds-the-pipe case doesn't feel like a hang. -const CLOSE_GRACE_MS = 250; - -function execAgentBrowserProcess(binary: string, args: string[]): Promise { - return new Promise((resolve, reject) => { - // windowsHide: cross-spawn runs `.cmd` shims through cmd.exe; without this - // each spawn flashes a console window that steals focus. No-op off Windows. - const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); - let stdout = ''; - let stderr = ''; - // A failed spawn races 'error' against the exit events; latch on the first so - // the loser can't overwrite the outcome (e.g. a stray exit code swallowing an - // ENOENT). clearTimeout drops the grace timer so it can't keep the event loop - // alive after we've already settled. - let settled = false; - let graceTimer: number | undefined; - const settle = (apply: () => void): void => { - if (settled) return; - settled = true; - if (graceTimer !== undefined) clearTimeout(graceTimer); - apply(); - }; - child.stdout.on('data', (chunk: unknown) => { stdout += String(chunk); }); - child.stderr.on('data', (chunk: unknown) => { stderr += String(chunk); }); - child.on('error', (error: Error) => settle(() => reject(error))); - const finish = (code: number | null, out: string, err: string): void => - settle(() => resolve({ exitCode: code ?? 1, stdout: out, stderr: err })); - // 'close' is the clean path: the process exited AND its stdio reached EOF, so - // all output is captured. But `agent-browser open` leaves a detached daemon - // that on Windows inherits our stdout/stderr pipes — they never reach EOF and - // 'close' never fires, so waiting on it alone hangs forever. Fall back to - // 'exit' (which fires when the foreground process ends regardless of the - // lingering pipe), giving 'close' a short grace to win first so a normal - // command's full output is still flushed before we resolve. - child.on('close', (code: number | null) => finish(code, stdout, stderr)); - child.on('exit', (code: number | null) => { - // Snapshot now: the foreground command has produced all its output. The - // surviving daemon keeps our inherited pipes open and may scribble into - // them during the grace below (this is how `dor ab open` printed a burst - // of blank lines on Windows) — resolve with the exit-time snapshot so that - // post-command noise is excluded. 'close', if it wins, still uses the live - // buffers since without a lingering daemon there's nothing extra to drop. - const out = stdout; - const err = stderr; - graceTimer = setTimeout(() => finish(code, out, err), CLOSE_GRACE_MS); - }); - }); +// The default exec: delegate the spawn/capture/Windows handling to +// spawnAndCapture, and adapt its never-throws result to this call site's +// throw-on-spawn-failure contract (callers catch ENOENT via isMissingBinaryError). +async function execAgentBrowserProcess(binary: string, args: string[]): Promise { + const result = await spawnAndCapture(binary, args); + if (!result.ok) { + const error: Error & { code?: string } = new Error(result.error.message); + error.code = result.error.code; + throw error; + } + return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; } diff --git a/dor/src/control-client.ts b/dor/src/control-client.ts index 59ca5dff..f180a480 100644 --- a/dor/src/control-client.ts +++ b/dor/src/control-client.ts @@ -18,6 +18,7 @@ import type { SplitSurfaceRequest, SplitSurfaceResponse, } from './commands/types.js'; +import { SURFACE_CONTROL_METHODS, type SurfaceControlMethod } from './protocol.js'; import type { DorControlResult } from './protocol.js'; export interface SocketControlClientOptions { @@ -47,38 +48,38 @@ export class SocketControlClient implements ControlClient { } listSurfaces(request: ListSurfacesRequest): Promise { - return this.request('surface.list', request); + return this.request(SURFACE_CONTROL_METHODS.list, request); } splitSurface(request: SplitSurfaceRequest): Promise { - return this.request('surface.split', request); + return this.request(SURFACE_CONTROL_METHODS.split, request); } ensureSurface(request: EnsureSurfaceRequest): Promise { - return this.request('surface.ensure', request); + return this.request(SURFACE_CONTROL_METHODS.ensure, request); } sendSurface(request: SendSurfaceRequest): Promise { - return this.request('surface.send', request); + return this.request(SURFACE_CONTROL_METHODS.send, request); } readSurface(request: ReadSurfaceRequest): Promise { - return this.request('surface.read', request); + return this.request(SURFACE_CONTROL_METHODS.read, request); } killSurface(request: KillSurfaceRequest): Promise { - return this.request('surface.kill', request); + return this.request(SURFACE_CONTROL_METHODS.kill, request); } iframeSurface(request: IframeSurfaceRequest): Promise { - return this.request('surface.iframe', request); + return this.request(SURFACE_CONTROL_METHODS.iframe, request); } agentBrowserSurface(request: AgentBrowserSurfaceRequest): Promise { - return this.request('surface.agentBrowser', request); + return this.request(SURFACE_CONTROL_METHODS.agentBrowser, request); } - private request(method: string, params: unknown): Promise { + private request(method: SurfaceControlMethod, params: unknown): Promise { const requestId = `dor-${this.idBase}-${++this.nextRequestId}`; return new Promise((resolve, reject) => { const socket = createConnection({ path: this.socketPath }); diff --git a/dor/src/node-runtime.d.ts b/dor/src/node-runtime.d.ts index 09781457..cb62bef9 100644 --- a/dor/src/node-runtime.d.ts +++ b/dor/src/node-runtime.d.ts @@ -12,41 +12,6 @@ declare module 'node:net' { export function createConnection(options: { path: string }): Socket; } -declare module 'node:child_process' { - export function execFileSync(command: string, args: readonly string[], options: { - encoding: 'utf8'; - timeout?: number; - }): string; - - export interface ChildProcessStream { - on(event: 'data', listener: (chunk: unknown) => void): void; - } - - export interface ChildProcess { - stdout: ChildProcessStream; - stderr: ChildProcessStream; - on(event: 'error', listener: (error: Error) => void): void; - on(event: 'exit', listener: (code: number | null) => void): void; - on(event: 'close', listener: (code: number | null) => void): void; - } - - export function spawn(command: string, args: readonly string[], options: { - stdio: readonly ['ignore', 'pipe', 'pipe']; - windowsHide?: boolean; - }): ChildProcess; -} - -// cross-spawn ships no types and dor avoids @types/node, so declare the one call -// shape we use. Drop-in for the node:child_process spawn above; returns the same -// minimal ChildProcess. -declare module 'cross-spawn' { - import type { ChildProcess } from 'node:child_process'; - export default function spawn(command: string, args: readonly string[], options: { - stdio: readonly ['ignore', 'pipe', 'pipe']; - windowsHide?: boolean; - }): ChildProcess; -} - declare module 'node:fs' { export function existsSync(path: string): boolean; } diff --git a/dor/src/protocol.ts b/dor/src/protocol.ts index 6ebfc9f3..b095542b 100644 --- a/dor/src/protocol.ts +++ b/dor/src/protocol.ts @@ -7,6 +7,25 @@ * layer. */ +/** + * The wire identifier for each surface control operation. Single source of truth + * shared by the CLI client (which emits them) and the webview handler (which + * dispatches on them) — reference these instead of bare `'surface.*'` literals so + * the two sides can't drift and a typo is a compile error, not a silent no-op. + */ +export const SURFACE_CONTROL_METHODS = { + list: 'surface.list', + split: 'surface.split', + ensure: 'surface.ensure', + send: 'surface.send', + read: 'surface.read', + kill: 'surface.kill', + iframe: 'surface.iframe', + agentBrowser: 'surface.agentBrowser', +} as const; + +export type SurfaceControlMethod = (typeof SURFACE_CONTROL_METHODS)[keyof typeof SURFACE_CONTROL_METHODS]; + /** A control request as it travels over a transport, correlated by `requestId`. */ export interface DorControlRequestPayload { requestId: string; diff --git a/lib/package.json b/lib/package.json index 0e5cf310..2a2f4dec 100644 --- a/lib/package.json +++ b/lib/package.json @@ -8,6 +8,7 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", + "pretest": "pnpm --filter dor-lib-common build", "test": "vitest run", "test:watch": "vitest", "storybook": "storybook dev -p 6006 --no-open --ci", @@ -19,8 +20,8 @@ "@xterm/addon-unicode-graphemes": "0.5.0-beta.288", "@xterm/xterm": "6.1.0-beta.288", "clsx": "^2.1.1", - "cross-spawn": "^7.0.6", "dockview-react": "^5.1.0", + "dor-lib-common": "workspace:*", "fflate": "0.8.3", "jsonc-parser": "3.3.1", "react": "^19.2.6", diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 75e34b04..63cfa250 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -44,6 +44,7 @@ import { import { orchestrateKill } from '../lib/kill-animation'; import { getPlatform, PLATFORM_STRING } from '../lib/platform'; import type { DorControlRequestPayload, DorControlResult } from 'dor/protocol'; +import { SURFACE_CONTROL_METHODS } from 'dor/protocol'; import type { Surface as DorSurface, SplitDirection as DorSplitDirection, @@ -1223,7 +1224,7 @@ export function Wall({ return { target: target.value, panel }; }; - if (detail.method === 'surface.list') { + if (detail.method === SURFACE_CONTROL_METHODS.list) { const surfaces = buildDorSurfaces(api); detail.respond({ ok: true, @@ -1236,7 +1237,7 @@ export function Wall({ return; } - if (detail.method === 'surface.split') { + if (detail.method === SURFACE_CONTROL_METHODS.split) { const directionParam = parseDorSplitDirection(params.direction); if (!directionParam) { detail.respond({ ok: false, error: `invalid split direction '${String(params.direction)}'` }); @@ -1276,7 +1277,7 @@ export function Wall({ return; } - if (detail.method === 'surface.ensure') { + if (detail.method === SURFACE_CONTROL_METHODS.ensure) { const command = dorCommandString(stringArrayParam(params.command)); if (!command) { detail.respond({ ok: false, error: 'command cannot be empty' }); @@ -1377,7 +1378,7 @@ export function Wall({ return; } - if (detail.method === 'surface.send') { + if (detail.method === SURFACE_CONTROL_METHODS.send) { const input = stringParam(params.input); if (input === undefined) { detail.respond({ ok: false, error: 'input is required' }); @@ -1405,7 +1406,7 @@ export function Wall({ return; } - if (detail.method === 'surface.read') { + if (detail.method === SURFACE_CONTROL_METHODS.read) { const target = resolveVisibleSurface(api, stringParam(params.surface), detail.surfaceId); if (!target.ok) { detail.respond({ ok: false, error: target.message }); @@ -1430,7 +1431,7 @@ export function Wall({ return; } - if (detail.method === 'surface.kill') { + if (detail.method === SURFACE_CONTROL_METHODS.kill) { const confirmation = killConfirmationParam(params.confirmation); if (!confirmation) { detail.respond({ ok: false, error: 'invalid kill confirmation' }); @@ -1465,7 +1466,7 @@ export function Wall({ return; } - if (detail.method === 'surface.iframe') { + if (detail.method === SURFACE_CONTROL_METHODS.iframe) { const url = stringParam(params.url); if (!url) { detail.respond({ ok: false, error: 'url is required' }); @@ -1499,7 +1500,7 @@ export function Wall({ return; } - if (detail.method === 'surface.agentBrowser') { + if (detail.method === SURFACE_CONTROL_METHODS.agentBrowser) { const session = stringParam(params.session); if (!session) { detail.respond({ ok: false, error: 'session is required' }); diff --git a/lib/src/host/agent-browser-host.test.ts b/lib/src/host/agent-browser-host.test.ts index f8a8023f..695fe345 100644 --- a/lib/src/host/agent-browser-host.test.ts +++ b/lib/src/host/agent-browser-host.test.ts @@ -1,4 +1,3 @@ -import { EventEmitter } from 'events'; import { mkdtempSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -9,10 +8,13 @@ type SpawnResult = { stdout?: string; stderr?: string; code?: number }; const spawnMock = vi.hoisted(() => vi.fn()); -// The host spawns via cross-spawn (default export) for cross-platform `.cmd` -// resolution; mock that, not node:child_process. -vi.mock('cross-spawn', () => ({ - default: spawnMock, +// The host spawns through dor-lib-common's spawnAndCapture; mock just that +// boundary (not its internal cross-spawn — spawnAndCapture's own behavior is +// covered by dor-lib-common's tests), keeping the package's other real exports +// (e.g. parseStreamPort). +vi.mock('dor-lib-common', async (importOriginal) => ({ + ...(await importOriginal()), + spawnAndCapture: spawnMock, })); function enqueueSpawnResults(results: SpawnResult[]) { @@ -20,18 +22,12 @@ function enqueueSpawnResults(results: SpawnResult[]) { spawnMock.mockImplementation((binary: string, args: string[]) => { const result = queue.shift(); if (!result) throw new Error(`unexpected spawn: ${binary} ${args.join(' ')}`); - const child = new EventEmitter() as EventEmitter & { - stdout: EventEmitter; - stderr: EventEmitter; - }; - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - queueMicrotask(() => { - if (result.stdout) child.stdout.emit('data', result.stdout); - if (result.stderr) child.stderr.emit('data', result.stderr); - child.emit('close', result.code ?? 0); + return Promise.resolve({ + ok: true as const, + exitCode: result.code ?? 0, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', }); - return child; }); } @@ -71,7 +67,6 @@ describe('agent-browser host relaunch', () => { expect(spawnMock).toHaveBeenCalledWith( '/usr/local/bin/agent-browser', ['--session', 'dormouse.1.default', 'tab', 'close', 'blank-tab'], - expect.anything(), ); }); }); diff --git a/lib/src/host/agent-browser-host.ts b/lib/src/host/agent-browser-host.ts index 347f46fd..1d7d7463 100644 --- a/lib/src/host/agent-browser-host.ts +++ b/lib/src/host/agent-browser-host.ts @@ -38,14 +38,18 @@ import * as os from 'os'; import * as path from 'path'; import { promises as fs } from 'fs'; -// cross-spawn, not child_process — on Windows a bare command name never resolves -// a `.cmd`/`.bat` PATH shim (Node spawn ignores PATHEXT → ENOENT), and Node >=22 -// refuses to spawn a `.cmd` directly even by full path (EINVAL, the -// CVE-2024-27980 hardening). agent-browser ships as a `.cmd` shim, so both bite; -// the GUI host hits this even for the absolute `binaryPath` dor ab resolved. -// cross-spawn routes through cmd.exe with correct escaping and is a no-op on -// POSIX. See docs/specs/dor-cli.md → "Spawning External Binaries". -import spawn from 'cross-spawn'; +// All external spawns go through dor-lib-common's spawnAndCapture, which owns the +// Windows recipe (cross-spawn for PATHEXT/.cmd, windowsHide, exit-vs-close). The +// GUI host needs it even for the absolute `binaryPath` dor ab resolved. +// See docs/specs/dor-cli.md → "Spawning External Binaries". +import { + spawnAndCapture, + parseStreamPort, + sessionForKey, + streamStatusArgs, + AGENT_BROWSER_BIN_ENV, + DEFAULT_AGENT_BROWSER_BIN, +} from 'dor-lib-common'; import { randomBytes } from 'crypto'; import { type AgentBrowserTab, parseAgentBrowserTabs } from '../lib/agent-browser-tab'; import { @@ -73,9 +77,6 @@ const EDIT_SCRIPTS: Record = { const STREAM_PORT_READ_ATTEMPTS = 4; const STREAM_PORT_READ_DELAY_MS = 150; -// Grace for 'close' to fire after 'exit' before resolving anyway, so a daemon -// holding the inherited stdio pipes can't hang the spawn. See spawnAgentBrowser. -const CLOSE_GRACE_MS = 250; const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); export interface AgentBrowserHostDeps { @@ -110,84 +111,44 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser // The host's PATH is often the GUI login PATH (no nvm/volta shims), so prefer // the absolute path `dor ab` resolved in the user's terminal; fall through on - // ENOENT in case it has gone stale. + // ENOENT (binary missing) to the next candidate in case it has gone stale. async function runWithBinaryFallback(args: string[], binaryPath?: string): Promise { const candidates = [...new Set([ binaryPath, - process.env.DORMOUSE_AGENT_BROWSER_BIN, - 'agent-browser', + process.env[AGENT_BROWSER_BIN_ENV], + DEFAULT_AGENT_BROWSER_BIN, ].filter((c): c is string => !!c))]; let lastError = ''; for (const binary of candidates) { - const result = await spawnAgentBrowser(binary, args); - if (result !== 'ENOENT') return result; + const result = await spawnAndCapture(binary, args); + if (result.ok) { + return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; + } + // Missing binary: record it and try the next candidate. Any other spawn + // failure is real — surface it rather than masking it behind a fallback. + if (result.error.code !== 'ENOENT') { + log(`[agent-browser] spawn failed: ${result.error.message}`); + return { exitCode: 1, stdout: '', stderr: result.error.message }; + } lastError = `'${binary}' was not found`; log(`[agent-browser] ${lastError}; trying next candidate`); } return { exitCode: 1, stdout: '', stderr: `agent-browser binary not found (${lastError})` }; } - function spawnAgentBrowser(binary: string, args: string[]): Promise { - return new Promise((resolve) => { - // windowsHide: cross-spawn runs `.cmd` shims through cmd.exe; without this - // each spawn flashes a console window that steals focus. No-op off Windows. - const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); - let stdout = ''; - let stderr = ''; - let settled = false; - let graceTimer: ReturnType | undefined; - const settle = (apply: () => void): void => { - if (settled) return; - settled = true; - if (graceTimer !== undefined) clearTimeout(graceTimer); - apply(); - }; - child.stdout.on('data', (chunk) => { stdout += String(chunk); }); - child.stderr.on('data', (chunk) => { stderr += String(chunk); }); - child.on('error', (err: NodeJS.ErrnoException) => settle(() => { - if (err.code === 'ENOENT') { - resolve('ENOENT'); - return; - } - log(`[agent-browser] spawn failed: ${err.message}`); - resolve({ exitCode: 1, stdout: '', stderr: err.message }); - })); - const finish = (code: number | null, out: string, err: string): void => - settle(() => resolve({ exitCode: code ?? 1, stdout: out, stderr: err })); - // Resolve on 'close' (clean: process exited and stdio drained), but fall - // back to 'exit' because `agent-browser open` leaves a detached daemon that - // on Windows inherits these pipes, so they never reach EOF and 'close' never - // fires. The grace lets 'close' win first so normal commands keep full - // output. See dor/src/commands/agent-browser.ts for the matching rationale. - child.on('close', (code) => finish(code, stdout, stderr)); - child.on('exit', (code) => { - // Snapshot at exit so output the surviving daemon writes into the - // inherited pipes during the grace doesn't leak into the result. - const out = stdout; - const err = stderr; - graceTimer = setTimeout(() => finish(code, out, err), CLOSE_GRACE_MS); - }); - }); - } - - // Read a session's stream WebSocket port via `stream status --json`. Mirrors - // the parse in dor/src/commands/agent-browser.ts: { port } or { data: { port } }. - // Right after `open` / `--headed open` (a fresh spawn, a pop-out, or a pop-in - // relaunch) the daemon may not have published the port yet; a single read - // would then return undefined and leave the panel pinned to a stale port — it - // reads "ended" though the session is live. Retry briefly to close that window. + // Read a session's stream WebSocket port via `stream status --json` (parsed by + // dor-lib-common's parseStreamPort). Right after `open` / `--headed open` (a + // fresh spawn, a pop-out, or a pop-in relaunch) the daemon may not have + // published the port yet; a single read would then return undefined and leave + // the panel pinned to a stale port — it reads "ended" though the session is + // live. Retry briefly to close that window. async function readStreamPort(session: string, binaryPath?: string): Promise { for (let attempt = 0; attempt < STREAM_PORT_READ_ATTEMPTS; attempt++) { - const result = await runWithBinaryFallback(['--session', session, 'stream', 'status', '--json'], binaryPath); + const result = await runWithBinaryFallback(streamStatusArgs(session), binaryPath); if (result.exitCode === 0) { - try { - const parsed = JSON.parse(result.stdout) as { port?: unknown; data?: { port?: unknown } }; - const port = parsed.data?.port ?? parsed.port; - if (typeof port === 'number' && Number.isFinite(port)) return port; - } catch { - // malformed output — fall through and retry - } + const port = parseStreamPort(result.stdout); + if (port !== undefined) return port; } if (attempt < STREAM_PORT_READ_ATTEMPTS - 1) await delay(STREAM_PORT_READ_DELAY_MS); } @@ -283,10 +244,10 @@ export function createAgentBrowserHost(deps: AgentBrowserHostDeps): AgentBrowser } // A fresh managed session for a surface spawned from the GUI (no `--key`), - // mirroring `dor ab`'s `dormouse..` namespacing so it can't - // collide with a user's own agent-browser sessions. + // using dor ab's workspace-scoped sessionForKey namespacing so it can't collide + // with a user's own agent-browser sessions. function generateGuiSession(): string { - return `dormouse.1.gui-${randomBytes(6).toString('hex')}`; + return sessionForKey(`gui-${randomBytes(6).toString('hex')}`); } // Reused per session so we don't litter tmp with one file per frame; the panel diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35906962..d5bb8c43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,9 @@ importers: '@stricli/core': specifier: ^1.2.7 version: 1.2.8 - cross-spawn: - specifier: ^7.0.6 - version: 7.0.6 + dor-lib-common: + specifier: workspace:* + version: link:../dor-lib-common devDependencies: esbuild: specifier: ^0.28.0 @@ -28,6 +28,19 @@ importers: specifier: ^6.0.3 version: 6.0.3 + dor-lib-common: + dependencies: + cross-spawn: + specifier: ^7.0.6 + version: 7.0.6 + devDependencies: + '@types/node': + specifier: ^22.12.0 + version: 22.20.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + lib: dependencies: '@phosphor-icons/react': @@ -45,12 +58,12 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 - cross-spawn: - specifier: ^7.0.6 - version: 7.0.6 dockview-react: specifier: ^5.1.0 version: 5.2.0(react@19.2.7) + dor-lib-common: + specifier: workspace:* + version: link:../dor-lib-common fflate: specifier: 0.8.3 version: 0.8.3 @@ -75,10 +88,10 @@ importers: version: 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.1)(react@19.2.7))(typescript@6.0.3) '@storybook/react-vite': specifier: ^10.4.0 - version: 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.1)(react@19.2.7))(typescript@6.0.3)(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0)) + version: 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.1)(react@19.2.7))(typescript@6.0.3)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)) '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.1(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.3.1(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)) '@types/react': specifier: ^19.2.14 version: 19.2.17 @@ -87,7 +100,7 @@ importers: version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.3(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0)) + version: 6.0.3(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)) chromatic: specifier: ^17.0.0 version: 17.7.2 @@ -102,10 +115,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.1.0(esbuild@0.28.1)(jiti@2.7.0) + version: 8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0) vitest: specifier: ^4.1.6 - version: 4.1.9(jsdom@29.1.1)(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.9(@types/node@22.20.0)(jsdom@29.1.1)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)) standalone: dependencies: @@ -148,7 +161,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.1(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.3.1(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)) '@tauri-apps/cli': specifier: ^2.11.2 version: 2.11.4 @@ -160,7 +173,7 @@ importers: version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.3(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0)) + version: 6.0.3(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)) cross-spawn: specifier: ^7.0.6 version: 7.0.6 @@ -178,10 +191,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.1.0(esbuild@0.28.1)(jiti@2.7.0) + version: 8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0) vitest: specifier: ^4.1.6 - version: 4.1.9(jsdom@29.1.1)(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.9(@types/node@22.20.0)(jsdom@29.1.1)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)) standalone/sidecar: dependencies: @@ -197,13 +210,13 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.1(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.3.1(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)) '@types/vscode': specifier: 1.85.0 version: 1.85.0 '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.3(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0)) + version: 6.0.3(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)) '@vscode/vsce': specifier: ^3.9.1 version: 3.9.2 @@ -221,7 +234,7 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.1.0(esbuild@0.28.1)(jiti@2.7.0) + version: 8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0) website: dependencies: @@ -249,10 +262,10 @@ importers: devDependencies: '@react-router/dev': specifier: ^7.15.1 - version: 7.18.0(jiti@2.7.0)(lightningcss@1.32.0)(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3)(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0)) + version: 7.18.0(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)) '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.1(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.3.1(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)) '@types/react': specifier: ^19.2.14 version: 19.2.17 @@ -267,10 +280,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.1.0(esbuild@0.28.1)(jiti@2.7.0) + version: 8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0) vitest: specifier: ^4.1.6 - version: 4.1.9(jsdom@29.1.1)(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0)) + version: 4.1.9(@types/node@22.20.0)(jsdom@29.1.1)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)) packages: @@ -1892,6 +1905,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@22.20.0': + resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -3814,6 +3830,9 @@ packages: underscore@1.13.8: resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.28.0: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} @@ -4651,11 +4670,11 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@6.0.3) - vite: 8.1.0(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0) optionalDependencies: typescript: 6.0.3 @@ -4915,7 +4934,7 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@react-router/dev@7.18.0(jiti@2.7.0)(lightningcss@1.32.0)(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3)(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0))': + '@react-router/dev@7.18.0(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(react-router@7.18.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@6.0.3)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: '@babel/core': 7.29.7 '@babel/generator': 7.29.7 @@ -4945,8 +4964,8 @@ snapshots: semver: 7.8.5 tinyglobby: 0.2.17 valibot: 1.4.2(typescript@6.0.3) - vite: 8.1.0(esbuild@0.28.1)(jiti@2.7.0) - vite-node: 3.2.4(jiti@2.7.0)(lightningcss@1.32.0) + vite: 8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0) + vite-node: 3.2.4(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -5185,25 +5204,25 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/builder-vite@10.4.6(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.1)(react@19.2.7))(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0))': + '@storybook/builder-vite@10.4.6(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.1)(react@19.2.7))(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: - '@storybook/csf-plugin': 10.4.6(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.1)(react@19.2.7))(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0)) + '@storybook/csf-plugin': 10.4.6(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.1)(react@19.2.7))(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)) storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.1)(react@19.2.7) ts-dedent: 2.3.0 - vite: 8.1.0(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.4.6(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.1)(react@19.2.7))(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0))': + '@storybook/csf-plugin@10.4.6(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.1)(react@19.2.7))(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.1)(react@19.2.7) unplugin: 2.3.11 optionalDependencies: esbuild: 0.28.1 rollup: 4.62.2 - vite: 8.1.0(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0) '@storybook/global@5.0.0': {} @@ -5220,11 +5239,11 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@storybook/react-vite@10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.1)(react@19.2.7))(typescript@6.0.3)(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0))': + '@storybook/react-vite@10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.1)(react@19.2.7))(typescript@6.0.3)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)) '@rollup/pluginutils': 5.4.0(rollup@4.62.2) - '@storybook/builder-vite': 10.4.6(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.1)(react@19.2.7))(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0)) + '@storybook/builder-vite': 10.4.6(esbuild@0.28.1)(rollup@4.62.2)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.1)(react@19.2.7))(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)) '@storybook/react': 10.4.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(storybook@10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.1)(react@19.2.7))(typescript@6.0.3) empathic: 2.0.1 magic-string: 0.30.21 @@ -5234,7 +5253,7 @@ snapshots: resolve: 1.22.12 storybook: 10.4.6(@testing-library/dom@10.4.1)(@types/react@19.2.17)(prettier@3.9.1)(react@19.2.7) tsconfig-paths: 4.2.0 - vite: 8.1.0(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -5323,12 +5342,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.1 '@tailwindcss/oxide-win32-x64-msvc': 4.3.1 - '@tailwindcss/vite@4.3.1(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0))': + '@tailwindcss/vite@4.3.1(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: '@tailwindcss/node': 4.3.1 '@tailwindcss/oxide': 4.3.1 tailwindcss: 4.3.1 - vite: 8.1.0(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0) '@tauri-apps/api@2.11.1': {} @@ -5479,6 +5498,10 @@ snapshots: '@types/estree@1.0.9': {} + '@types/node@22.20.0': + dependencies: + undici-types: 6.21.0 + '@types/normalize-package-data@2.4.4': {} '@types/react-dom@19.2.3(@types/react@19.2.17)': @@ -5503,10 +5526,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@6.0.3(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0))': + '@vitejs/plugin-react@6.0.3(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.0(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0) '@vitest/expect@3.2.4': dependencies: @@ -5525,13 +5548,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0))': + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.0(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -7366,6 +7389,8 @@ snapshots: underscore@1.13.8: {} + undici-types@6.21.0: {} + undici@7.28.0: {} unicorn-magic@0.1.0: {} @@ -7409,13 +7434,13 @@ snapshots: version-range@4.15.0: {} - vite-node@3.2.4(jiti@2.7.0)(lightningcss@1.32.0): + vite-node@3.2.4(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.6(jiti@2.7.0)(lightningcss@1.32.0) + vite: 7.3.6(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0) transitivePeerDependencies: - '@types/node' - jiti @@ -7430,7 +7455,7 @@ snapshots: - tsx - yaml - vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0): + vite@7.3.6(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0): dependencies: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.4) @@ -7439,11 +7464,12 @@ snapshots: rollup: 4.62.2 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 22.20.0 fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 - vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0): + vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -7451,14 +7477,15 @@ snapshots: rolldown: 1.1.3 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 22.20.0 esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 - vitest@4.1.9(jsdom@29.1.1)(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0)): + vitest@4.1.9(@types/node@22.20.0)(jsdom@29.1.1)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.0(esbuild@0.28.1)(jiti@2.7.0)) + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -7475,9 +7502,10 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.0(esbuild@0.28.1)(jiti@2.7.0) + vite: 8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 22.20.0 jsdom: 29.1.1 transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b878f119..079919ac 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - dor + - dor-lib-common - lib - standalone - standalone/sidecar