diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 1d931af0d..929f4a78e 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -645,9 +645,9 @@ Source of truth: `buildDorSurfacesInternal` in `lib/src/components/Wall.tsx`; `d ## Dor Tools -**Must route `dor tool` through the Tool launch contract**, including feature gating, approval, explicit-key reuse, and focus-neutral placement (`docs/specs/dor-tool.md` → CLI). Generated help owns syntax. +**Must route `dor tool` and `dor open` through the Tool launch contract**, including feature gating, approval, explicit-key reuse, and focus-neutral placement (`docs/specs/dor-tool.md` → CLI). Generated help owns syntax. -Source of truth: `toolCommand` in `dor/src/commands/tool.ts`; `ToolSurfaceResponse` in `dor/src/commands/types.ts`. +Source of truth: `toolCommand` in `dor/src/commands/tool.ts`; `openCommand` in `dor/src/commands/open.ts`; `ToolSurfaceResponse` in `dor/src/commands/types.ts`. ## Future diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index b155dece7..45a35a64a 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -89,7 +89,7 @@ Approval layout follows `docs/specs/layout.md` → Pane body. **Must validate a bounded regular, non-symlink grant receipt for the requested key.** Missing, corrupt, or mismatched records grant nothing; a filename alone is never approval. -Reserved: **Must keep future implicit glob dispatch user-global and limited to user-global Tools**, and gate any future repo `prespawn_*` execution on the same approval; see scope **dor-tools** under [Future](#future). +**Must keep implicit file dispatch user-global and limited to user-global Tools.** Reserved: any future repo `prespawn_*` execution uses the same approval; see scope **dor-tools** under [Future](#future). Source of truth: `createToolHost` in `lib/src/host/tool-host.ts`; `FileToolTrustStore` / `lookupTool` in `lib/src/host/tool-trust.ts`; `resolveUpstreamUrl` in `lib/src/host/git-upstream.ts`; `ToolApproval` in `lib/src/components/wall/ToolApproval.tsx`; `resolveToolApproval` in `lib/src/components/Wall.tsx`; `toolPendingFromParams` in `lib/src/components/wall/browser-surface.ts`. Tests: `lib/src/host/tool-trust.test.ts`, `lib/src/components/Wall.test.tsx`, `lib/src/components/wall/tool-surface.test.ts`. @@ -140,18 +140,31 @@ Source of truth: `TerminalPane` in `lib/src/components/TerminalPane.tsx`; `focus **Must return the Tool Surface handle.** A new Tool follows [Take-over](#take-over), otherwise splitting focus-neutrally. A matching Tool follows [Identity and dedupe](#identity-and-dedupe). -**Must retain `dor tool` as a Surface-producing command on every supported host**, never route it to a native editor. Generated help owns syntax and response types own shape. +**Must retain `dor tool` and `dor open` as Surface-producing commands on every supported host**, never route them to a native editor. Generated help owns syntax and response types own shape. Source of truth: `toolCommand` in `dor/src/commands/tool.ts`; `dor/test/snapshots/help/tool.md`; `ToolSurfaceResponse` in `dor/src/commands/types.ts`. +## Opening local files + +**Must accept exactly one existing local regular file for `dor open`**, resolved by the `$TARGET` rules in [Declaring tools](#declaring-tools). + +**Must select the first matching entry of the user file's ordered `open` list**, whose entries contain `match` and `tool`. `--tool` explicitly selects a user Tool. Every association must name an argument-list Tool in that same user file. Never discover project configuration during this lookup; project `open` rules are ignored with a warning during explicit project-tool lookup. + +**Must match patterns without `/` against the canonical filename, and patterns with `/` against both paths relative to the canonical CWD and canonical absolute paths**, separators normalized to `/`, with bundled picomatch: case-sensitive, dotfiles only by explicit pattern. Use the supplied CWD if canonicalization fails; matching never changes the Tool's run directory or `$CWD`. A miss names the user config path and suggests `--tool`. (rationale) + +**Must pass the canonical file path as the selected Tool's one input.** Reuse follows [Identity and dedupe](#identity-and-dedupe), `$TARGET` in the key providing per-file identity; placement follows [Take-over](#take-over). + +Source of truth: `openCommand` in `dor/src/commands/open.ts`; `resolveOpenTool` in `lib/src/host/tool-open.ts`; `parseToolFile` in `lib/src/host/tool-registry.ts`; `surface.tool` in `lib/src/components/wall/use-dor-control.ts`. Tests: `lib/src/host/tool-open.test.ts`, `dor/test/cli-output.test.mjs`, `lib/src/components/Wall.test.tsx`. + ## Take-over **Must run a standalone `dor tool` invocation in its calling pane when every takeover condition holds.** Otherwise use the ordinary split path. Trust approval and keyed reuse take precedence. (rationale) | Condition | Required state | | --- | --- | +| Verb | `dor tool`; `dor open` never transforms a plain terminal, though a keyed match in its own Tool pane reruns there | | Caller | Visible pane of the active Workspace; integrated plain terminal; not closing or dying | -| Command line | OSC 633 reports `dor tool` alone; compound shell syntax rejects takeover | +| Command line | OSC 633 reports the invocation alone; compound shell syntax rejects takeover | | Directory | Resolved Tool CWD equals the caller's reported CWD | | Placement | Neither `--surface` nor `--minimize` supplied | | Helper | No existing auxiliary helper; preserve it by splitting | @@ -207,10 +220,8 @@ Source of truth: `PersistedToolMetadata` in `lib/src/lib/session-types.ts`; `sav **Scope: dor-tools** — remaining design, in implementation order. -- **C — glob table + `dor open`.** User-global glob rules - (pattern → tool name), `dor open ` as sugar over `dor tool`, - and the loopback file/viewer endpoint a local *file* needs (the - iframe proxy instruments only `http://` upstreams). +- **C — local-file presentation.** The loopback file/viewer endpoint a local + file needs (the iframe proxy instruments only `http://` upstreams). - **D1 — reaping without cooperation.** Idle-threshold reap + rehydrate-from-args + `persist: "never"`: every stateless tool, no new API, no Windows question. diff --git a/docs/specs/dor-tool.rationale.md b/docs/specs/dor-tool.rationale.md index 689a7f730..657c29c38 100644 --- a/docs/specs/dor-tool.rationale.md +++ b/docs/specs/dor-tool.rationale.md @@ -65,3 +65,7 @@ A derived URL or browser daemon binding belongs to one execution. Reusing it aft Routing `dor tool` to a native editor on one host would change its result from a Surface handle to a host-specific side effect. Native file opening remains a separate operation. A Workspace transfer carries the live browser binding separately from its durable record. The arrival record can reach disk while the windows coordinate, whereas the content channel stays in memory; reusing the saved-record projection alone would reopen a Tool browser and lose its current page state. Pending approvals and unfinished browser startup still own asynchronous work in the source window, so the move waits for the user to resolve the approval or retry after startup. + +## Opening local files + +The VS Code host supports Node 18, which lacks native glob matching. Bundled picomatch keeps association behavior the same across hosts. Patterns with separators test both the CWD-relative and canonical absolute path: files above the CWD otherwise start with `../` and can miss patterns intended to cover an absolute directory. Canonicalization also gives symlink aliases one matching identity. Canonicalizing only the target mixed physical and logical paths under a symlinked CWD, so relative slash patterns missed files inside that directory. An absolute target can still be opened after its caller's CWD disappears; matching falls back to the supplied directory in that case. diff --git a/dor/src/cli.ts b/dor/src/cli.ts index 707973972..1d4646578 100644 --- a/dor/src/cli.ts +++ b/dor/src/cli.ts @@ -18,6 +18,7 @@ import { sendCommand } from './commands/send.js'; import { skillCommand } from './commands/skill.js'; import { splitCommand } from './commands/split.js'; import { toolCommand } from './commands/tool.js'; +import { openCommand } from './commands/open.js'; import { versionCommand } from './commands/version.js'; import { workspaceCommand } from './commands/workspace.js'; import { errorLine, errorMessage, fail } from './commands/shared.js'; @@ -90,6 +91,7 @@ const COMMANDS = [ splitCommand, ensureCommand, toolCommand, + openCommand, versionCommand, skillCommand, sendCommand, @@ -106,6 +108,7 @@ const ROUTES = { split: splitCommand.command, ensure: ensureCommand.command, tool: toolCommand.command, + open: openCommand.command, version: versionCommand.command, skill: skillCommand.command, send: sendCommand.command, diff --git a/dor/src/commands/open.ts b/dor/src/commands/open.ts new file mode 100644 index 000000000..df0c0674e --- /dev/null +++ b/dor/src/commands/open.ts @@ -0,0 +1,54 @@ +import { buildCommand } from '@stricli/core'; +import type { Command, DorCommandContext, WorkspaceScopedFlags } from './types.js'; +import { callerWorkingDirectory, stringParser, workspaceFlag, workspaceParam } from './shared.js'; +import { dispatchToolSurface } from './tool.js'; + +interface OpenFlags extends WorkspaceScopedFlags { + readonly json?: boolean; + readonly minimize?: boolean; + readonly fresh?: boolean; + readonly surface?: string; + readonly cwd?: string; + readonly tool?: string; +} + +export const openCommand: Command = { + name: 'open', + command: buildCommand({ + docs: { + brief: 'Open a local file with a Dor Tool.', + fullDescription: `Opens one existing local file. Relative paths resolve from the caller's directory (or --cwd); symlink aliases resolve to the same file. URLs, directories, and Surface handles are not accepted. + +The first matching rule in the user dormouse.yml selects a user Tool. --tool chooses a user Tool explicitly. Project associations and project Tools never participate in this lookup. The user file is $XDG_CONFIG_HOME/dormouse/dormouse.yml, or ~/.config/dormouse/dormouse.yml. + +The ordered open list contains {match, tool} entries. Patterns without a slash match the filename; patterns with a slash match both the canonical absolute path and the path relative to the invocation directory. Matching uses picomatch glob syntax with forward slashes and case sensitivity. Dotfiles require explicit patterns. + +The selected Tool receives the canonical absolute filename as one argument. Configure prespawn_dedupe: [$TARGET] to reveal the same file on repeated opens within a Workspace. --fresh bypasses reuse. + +Opening creates a focus-neutral split or reveals an existing Tool, never taking over the caller's terminal. The command prints the Surface handle; --json prints structured output.`, + }, + parameters: { + flags: { + json: { kind: 'boolean', brief: 'Print JSON output.', optional: true, withNegated: false }, + minimize: { kind: 'boolean', brief: 'Create the surface minimized.', optional: true, withNegated: false }, + fresh: { kind: 'boolean', brief: 'Open another instance even when the Tool has a key.', optional: true, withNegated: false }, + surface: { kind: 'parsed', parse: stringParser, brief: 'Surface to split when creating.', optional: true, placeholder: 'id|ref' }, + workspace: workspaceFlag, + cwd: { kind: 'parsed', parse: stringParser, brief: 'Directory for resolving the file.', optional: true, placeholder: 'path' }, + tool: { kind: 'parsed', parse: stringParser, brief: 'Use this user-global Tool.', optional: true, placeholder: 'name' }, + }, + positional: { kind: 'tuple', parameters: [{ parse: stringParser, brief: 'Local file to open.', placeholder: 'file' }] }, + }, + func(this: DorCommandContext, flags: OpenFlags, file: string) { + return dispatchToolSurface(this, { + file, + tool: flags.tool, + ...workspaceParam(flags.workspace), + fresh: flags.fresh === true, + minimized: flags.minimize === true, + surface: flags.surface, + cwd: callerWorkingDirectory(flags.cwd, this.options.env), + }, flags.json === true); + }, + }), +}; diff --git a/dor/src/commands/tool.ts b/dor/src/commands/tool.ts index 26eb9cf7e..9b54e81a7 100644 --- a/dor/src/commands/tool.ts +++ b/dor/src/commands/tool.ts @@ -5,6 +5,7 @@ import type { Command, DorCommandContext, ParseResult, + ToolSurfaceRequest, ToolSurfaceResponse, } from './types.js'; import { @@ -160,21 +161,28 @@ async function runToolCommand(this: DorCommandContext, flags: ToolFlags, ...rest return new Error('dor tool requires a tool name or -- '); } - const client = requireControlClient(this.options, TOOL_TIMEOUT_MS); - if (client instanceof Error) return client; + return dispatchToolSurface(this, { + ...(named ? { name: rest[0], args: rest.slice(1), global: flags.global === true } : { command: rest }), + ...workspaceParam(flags.workspace), + fresh: flags.fresh === true, + minimized: flags.minimize === true, + surface: flags.surface, + cwd: callerWorkingDirectory(flags.cwd, this.options.env), + }, flags.json === true); +} +/** The launch round trip `dor tool` and `dor open` share: one Tool request in, + * its handle out. */ +export async function dispatchToolSurface( + context: DorCommandContext, request: ToolSurfaceRequest, json: boolean, +): Promise { + const client = requireControlClient(context.options, TOOL_TIMEOUT_MS); + if (client instanceof Error) return client; try { - const response = await client.toolSurface({ - ...(named ? { name: rest[0], args: rest.slice(1), global: flags.global === true } : { command: rest }), - ...workspaceParam(flags.workspace), - fresh: flags.fresh === true, - minimized: flags.minimize === true, - surface: flags.surface, - cwd: callerWorkingDirectory(flags.cwd, this.options.env), - }); + const response = await client.toolSurface(request); // Lint output is advisory and must not pollute a `--json` parse. - for (const warning of response.warnings ?? []) writeStderr(this, `${warning}\n`); - writeStdout(this, renderToolResponse(response, flags.json === true)); + for (const warning of response.warnings ?? []) writeStderr(context, `${warning}\n`); + writeStdout(context, renderToolResponse(response, json)); return undefined; } catch (error) { return new Error(errorMessage(error)); diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index aeec82296..2d329106a 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -255,6 +255,9 @@ export interface EnsureSurfaceResponse { * authorized it (`docs/specs/dor-tool.md` -> Trust). */ export interface ToolSurfaceRequest extends WorkspaceScopedRequest { + /** Local-file dispatch; never eligible for caller takeover. */ + file?: string; + tool?: string; /** Registered tool name (`dor tool `). */ name?: string; args?: string[]; diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 8a9930798..51688f647 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -185,7 +185,7 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) { const named = typeof request.name === 'string'; const command = named ? `pnpm ${request.name}` - : buildShellCommandForKind('posix', request.command); + : request.file ? `viewer ${request.file}` : buildShellCommandForKind('posix', request.command); const keyed = named && request.name === 'storybook' && !request.fresh; return { status: keyed ? 'existing' : 'created', @@ -1799,3 +1799,24 @@ test('tool routes named and anonymous launches to an explicit Workspace', async assert.equal(client.requests[0].request.workspace, 'workspace:2'); } }); + +test('open forwards one file, explicit handler, placement, and Workspace to Tool dispatch', async () => { + const client = fixtureClient(); + const result = await runCli(['open', '--json', '--tool', 'markdown', '--workspace', 'workspace:2', '--fresh', '--minimize', '--surface', 'surface:4', 'a b.md'], { client, env: { PWD: '/repo' } }); + assert.equal(result.exitCode, 0); + client.requests[0].request.cwd = smudgeWindowsPaths(client.requests[0].request.cwd); + assert.deepEqual(client.requests[0], { method: 'toolSurface', request: { + file: 'a b.md', tool: 'markdown', cwd: '/repo', + workspace: 'workspace:2', fresh: true, minimized: true, surface: 'surface:4', + } }); + assert.equal(JSON.parse(result.stdout).surface_ref, 'surface:4'); +}); + +test('open requires exactly one file', async () => { + for (const args of [['open'], ['open', 'one.md', 'two.md']]) { + const client = fixtureClient(); + const result = await runCli(args, { client }); + assert.equal(result.exitCode, 1); + assert.equal(client.requests.length, 0); + } +}); diff --git a/dor/test/snapshots/help/dor.md b/dor/test/snapshots/help/dor.md index 9d445e507..3ffb3ae8a 100644 --- a/dor/test/snapshots/help/dor.md +++ b/dor/test/snapshots/help/dor.md @@ -8,6 +8,7 @@ USAGE dor ensure [--json] [--minimize] [--restart] [--surface id|ref] [--cwd path] [--workspace ref] -- ... dor tool [--global] [--json] [--minimize] [--fresh] [--surface id|ref] [--cwd path] [--workspace ref] [args...] dor tool [--json] [--minimize] [--surface id|ref] [--cwd path] [--workspace ref] -- ... + dor open [--json] [--minimize] [--fresh] [--surface id|ref] [--workspace ref] [--cwd path] [--tool name] dor version [--json] dor skill [--install] [--json] dor send ([--text value] [--key value] | --stdin | --sequence json) [--json] [--raw] [--workspace ref] @@ -30,6 +31,7 @@ COMMANDS split Create a new terminal surface by splitting an existing surface. ensure Ensure one surface is running a command. tool Run a command as a Dor Tool. + open Open a local file with a Dor Tool. version Print the dor CLI version. skill Print the Dormouse agent skill, or install its bootstrap stub. send Send text or key input to a terminal surface. diff --git a/dor/test/snapshots/help/open.md b/dor/test/snapshots/help/open.md new file mode 100644 index 000000000..c6db5e99c --- /dev/null +++ b/dor/test/snapshots/help/open.md @@ -0,0 +1,34 @@ +# dor open + +Invocation: `dor open --help` + +```text +USAGE + dor open [--json] [--minimize] [--fresh] [--surface id|ref] [--workspace ref] [--cwd path] [--tool name] + dor open --help + +Opens one existing local file. Relative paths resolve from the caller's directory (or --cwd); symlink aliases resolve to the same file. URLs, directories, and Surface handles are not accepted. + +The first matching rule in the user dormouse.yml selects a user Tool. --tool chooses a user Tool explicitly. Project associations and project Tools never participate in this lookup. The user file is $XDG_CONFIG_HOME/dormouse/dormouse.yml, or ~/.config/dormouse/dormouse.yml. + +The ordered open list contains {match, tool} entries. Patterns without a slash match the filename; patterns with a slash match both the canonical absolute path and the path relative to the invocation directory. Matching uses picomatch glob syntax with forward slashes and case sensitivity. Dotfiles require explicit patterns. + +The selected Tool receives the canonical absolute filename as one argument. Configure prespawn_dedupe: [$TARGET] to reveal the same file on repeated opens within a Workspace. --fresh bypasses reuse. + +Opening creates a focus-neutral split or reveals an existing Tool, never taking over the caller's terminal. The command prints the Surface handle; --json prints structured output. + +FLAGS + [--json] Print JSON output. + [--minimize] Create the surface minimized. + [--fresh] Open another instance even when the Tool has a key. + [--surface] Surface to split when creating. + [--workspace] Workspace to act in, instead of the caller's. + [--cwd] Directory for resolving the file. + [--tool] Use this user-global Tool. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments + +ARGUMENTS + file Local file to open. + +``` diff --git a/lib/package.json b/lib/package.json index 71aa855e6..59f1c2b88 100644 --- a/lib/package.json +++ b/lib/package.json @@ -32,19 +32,21 @@ "dor-lib-common": "workspace:*", "fflate": "0.8.3", "jsonc-parser": "3.3.1", + "picomatch": "^4.0.7", "react": "^19.2.6", "react-dom": "^19.2.6", "remote-lib-common": "workspace:*", "tailwind-merge": "^3.6.0", "tailwind-variants": "^3.2.2", - "yaml": "^2.9.0", - "uqr": "^0.1.3" + "uqr": "^0.1.3", + "yaml": "^2.9.0" }, "devDependencies": { "@storybook/addon-docs": "^10.4.0", "@storybook/react": "^10.4.0", "@storybook/react-vite": "^10.4.0", "@tailwindcss/vite": "^4.3.0", + "@types/picomatch": "^4.0.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index 4faa9bb10..a2adc36da 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -1478,6 +1478,51 @@ describe('Wall on the Lath engine', () => { } }); + it('dispatches open through the user host and splits even when the caller could be taken over', async () => { + setToolsEnabled(true); + const controller = new AbortController(); + let toolId: string | undefined; + vi.spyOn(terminalRegistry, 'isPaneOscDriven').mockReturnValue(true); + const toolControl = vi.fn(async () => ({ status: 'ok' as const, scope: 'user' as const, + projectRoot: '/config', path: '/config/dormouse.yml', name: 'viewer', run: ['view', '/repo/a.md'], + key: ['/repo/a.md'], render: 'iframe' as const, port: 'auto' as const, warnings: [] })); + Object.assign(fake, { toolControl }); + try { + await act(async () => root.render()); + await flush(); + terminalRegistry.seedTerminalManualCwd('pane-a', '/repo'); + terminalRegistry.applyTerminalSemanticEvents('pane-a', [ + { type: 'commandLine', commandLine: 'dor open a.md' }, + { type: 'commandStart', source: 'osc633_boundaries' }, + ]); + const respond = vi.fn(); + await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { + method: SURFACE_CONTROL_METHODS.tool, surfaceId: 'pane-a', params: { file: 'a.md', cwd: '/repo' }, signal: controller.signal, respond, + } }))); + await waitUntil(() => respond.mock.calls.length > 0); + expect(toolControl).toHaveBeenCalledWith({ op: 'open', target: 'a.md', cwd: '/repo', tool: undefined }); + expect(respond).toHaveBeenCalledWith(expect.objectContaining({ ok: true, result: expect.objectContaining({ status: 'created' }) })); + toolId = respond.mock.calls[0][0].result.surfaceId; + expect(toolId).not.toBe('pane-a'); + expect(leafCount()).toBe(2); + // This fixture stubs TerminalPane, so report the staged command's startup + // explicitly before disposing the Wall and its shared launch queue wait. + act(() => { + terminalRegistry.seedTerminalManualCwd(toolId!, '/repo'); + reportRunning(toolId!, 'view /repo/a.md'); + }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 150)); }); + } finally { + await act(async () => controller.abort()); + if (toolId) { + pendingShellOpts.delete(toolId); + act(() => terminalRegistry.removeTerminalPaneState(toolId!)); + } + act(() => terminalRegistry.removeTerminalPaneState('pane-a')); + setToolsEnabled(false); + } + }); + it('retries failed post-grant lookup without recording permission again', async () => { setToolsEnabled(true); let calls = 0; diff --git a/lib/src/components/wall/tool-takeover.test.ts b/lib/src/components/wall/tool-takeover.test.ts index 897948600..b5545bb8b 100644 --- a/lib/src/components/wall/tool-takeover.test.ts +++ b/lib/src/components/wall/tool-takeover.test.ts @@ -45,6 +45,7 @@ describe('isNakedToolInvocation', () => { describe('toolTakesOverCaller', () => { const passing: ToolTakeoverGate = { + verb: 'tool', explicitSurface: false, minimized: false, workspaceActive: true, @@ -60,6 +61,12 @@ describe('toolTakesOverCaller', () => { expect(toolTakesOverCaller(passing)).toBe(true); }); + it('only permits open to rerun an existing Tool, never take over a terminal', () => { + const opening: ToolTakeoverGate = { ...passing, verb: 'open', rawCommandLine: 'dor open README.md' }; + expect(toolTakesOverCaller(opening)).toBe(false); + expect(toolRerunsInCaller({ ...opening, kind: 'tool' })).toBe(true); + }); + it('splits when any condition fails', () => { const splits: Array<[string, Partial]> = [ ['--surface named a reference', { explicitSurface: true }], @@ -99,6 +106,7 @@ describe('toolTakesOverCaller', () => { // command line has finished by then, so it is not among the conditions. describe('after the prompt wait', () => { const passing: ToolTakeoverGate = { + verb: 'tool', explicitSurface: false, minimized: false, workspaceActive: true, diff --git a/lib/src/components/wall/tool-takeover.ts b/lib/src/components/wall/tool-takeover.ts index aed057841..40f5fdac9 100644 --- a/lib/src/components/wall/tool-takeover.ts +++ b/lib/src/components/wall/tool-takeover.ts @@ -16,19 +16,22 @@ const COMPOUND_SYNTAX = /[;&|<>()`\n\r]/; /** * Whether the shell reported running exactly one command and that command is - * `dor tool` — the human-intent signal, not a security boundary + * `dor` with the requested verb — the human-intent signal, not a security boundary * (`docs/specs/dor-tool.md` -> Take-over). Case folds on the launcher, which is - * a filename, and not on the verb, which stricli parses case-sensitively. + * a filename; the requested verb must match exactly. */ -export function isNakedToolInvocation(rawCommandLine: string | null | undefined): boolean { +export function isNakedToolInvocation(rawCommandLine: string | null | undefined, verb: 'tool' | 'open' = 'tool'): boolean { const line = rawCommandLine?.trim(); if (!line || COMPOUND_SYNTAX.test(line)) return false; - const [launcher, verb] = primaryCommandTokens(line); - return verb === 'tool' && commandProgramName(launcher ?? '').toLowerCase() === 'dor'; + const [launcher, commandVerb] = primaryCommandTokens(line); + return commandVerb === verb && commandProgramName(launcher ?? '').toLowerCase() === 'dor'; } /** What the placement rule reads. Every field is already known to the handler. */ export interface ToolTakeoverGate { + /** The `dor` verb the request came from: `open` never transforms a plain + * terminal, but may re-run its own Tool pane. */ + verb: 'tool' | 'open'; /** `--surface`: an explicit placement, which take-over must not override. */ explicitSurface: boolean; /** `--minimize`: a request for a background Surface, which the caller is not. */ @@ -59,7 +62,7 @@ export interface ToolTakeoverGate { * need it, and neither can proceed without it. */ function callerTypedTool(gate: ToolTakeoverGate): boolean { - return gate.oscDriven && isNakedToolInvocation(gate.rawCommandLine); + return gate.oscDriven && isNakedToolInvocation(gate.rawCommandLine, gate.verb); } /** @@ -68,7 +71,8 @@ function callerTypedTool(gate: ToolTakeoverGate): boolean { * (rationale). */ export function toolTakesOverCaller(gate: ToolTakeoverGate): boolean { - return gate.workspaceActive + return gate.verb === 'tool' + && gate.workspaceActive && !gate.explicitSurface && !gate.minimized && callerStillPlaceable(gate) diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index e4a694552..b3371bdd4 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -92,6 +92,8 @@ export type DorControlParams = { fresh?: unknown; args?: unknown; global?: unknown; + file?: unknown; + tool?: unknown; }; // The webview view of a control request: the shared wire payload, but with @@ -888,7 +890,13 @@ export function useDorControl({ detail.respond({ ok: false, error: 'cwd is required' }); return; } - const toolName = stringParam(params.name)?.trim(); + let toolName = stringParam(params.name)?.trim(); + const openFile = stringParam(params.file); + const opening = openFile !== undefined; + if (opening && (params.name !== undefined || params.command !== undefined || params.args !== undefined)) { + detail.respond({ ok: false, error: 'open accepts a file and optional tool, not a command' }); + return; + } let command: string; let toolRun: string | readonly string[]; let key: string[] | null = null; @@ -929,6 +937,7 @@ export function useDorControl({ const readCallerGate = (id: string, toolCwd: string): ToolTakeoverGate => { const state = getTerminalPaneState(id); return { + verb: opening ? 'open' : 'tool', explicitSurface: stringParam(params.surface) !== undefined, minimized: booleanParam(params.minimized), workspaceActive: !scope || getActiveWorkspaceId() === scope, @@ -941,7 +950,7 @@ export function useDorControl({ }; }; - if (toolName) { + if (toolName || opening) { // The registry, the closed substitution set, and the trust gate all // live behind this one host call (`dor/commands/types` -> // ToolSurfaceRequest). @@ -950,7 +959,9 @@ export function useDorControl({ detail.respond({ ok: false, error: 'this host cannot read a dormouse.yml; use `dor tool -- `' }); return; } - const lookup = await toolControl({ op: 'lookup', name: toolName, cwd, args: toolArgs, global: booleanParam(params.global) }); + const lookup = await toolControl(opening + ? { op: 'open', target: openFile, cwd, tool: stringParam(params.tool) } + : { op: 'lookup', name: toolName!, cwd, args: toolArgs, global: booleanParam(params.global) }); if (unavailable()) return; switch (lookup.status) { case 'trust-recorded': @@ -961,6 +972,7 @@ export function useDorControl({ toolRun = lookup.run; command = toolRunCommand(lookup.run); toolScope = lookup.scope; + toolName = lookup.name; // Namespaced under the host-resolved tool name, so two tools in // one repo with scope-only keys stay distinct and a runtime // re-key cannot name another tool's key. diff --git a/lib/src/host/tool-host.ts b/lib/src/host/tool-host.ts index ffea1853c..1acbd0dd0 100644 --- a/lib/src/host/tool-host.ts +++ b/lib/src/host/tool-host.ts @@ -11,9 +11,10 @@ import { dirname } from 'node:path'; import type { ToolControlResult, ToolHostRequest } from '../lib/platform/tool-types'; import { resolveUpstreamUrl } from './git-upstream'; -import { resolveToolInput, type ToolInput } from './tool-input'; +import { resolveOpenTool } from './tool-open'; +import type { ToolInput } from './tool-input'; import type { ToolEntry } from './tool-registry'; -import { readUserToolFile, userToolConfigPath } from './tool-user-config'; +import { readUserToolFile, resolveUserTool, userToolConfigPath } from './tool-user-config'; import { FileToolTrustStore, MemoryToolTrustStore, @@ -27,11 +28,11 @@ export interface ToolHost { handle(request: ToolHostRequest): Promise; } -/** The one wire shape for a resolved Tool, whichever file declared it. */ +/** The `ok` result for a project Tool after its trust gate. */ function okResult( entry: ToolEntry, input: ToolInput, - source: { projectRoot: string; path: string; warnings: readonly string[]; scope?: 'user' }, + source: { projectRoot: string; path: string; warnings: readonly string[] }, ): ToolControlResult { return { status: 'ok', @@ -39,7 +40,6 @@ function okResult( path: source.path, name: entry.name, ...input, - ...(source.scope ? { scope: source.scope } : {}), render: entry.render, port: entry.port, warnings: [...source.warnings], @@ -75,6 +75,8 @@ export function createToolHost(options: { stateDir?: string; userConfigPath?: st } try { + const userPath = options.userConfigPath ?? userToolConfigPath(); + if (request.op === 'open') return await resolveOpenTool(request, userPath); const args = request.args ?? []; const project = request.global ? null : await lookupTool(request.name, request.cwd, trust, { args }); if (project?.status === 'ok') { @@ -83,15 +85,11 @@ export function createToolHost(options: { stateDir?: string; userConfigPath?: st if (project && project.status !== 'no-file' && project.status !== 'unknown-tool') return project; // A project miss falls through to the user's own Tools, which need no grant. - const path = options.userConfigPath ?? userToolConfigPath(); - const file = await readUserToolFile(path); + const file = await readUserToolFile(userPath); const entry = file?.tools.get(request.name); - if (file && entry) { - const input = await resolveToolInput(entry, { projectRoot: null, cwd: request.cwd, args }); - return okResult(entry, input, { projectRoot: file.dir, path, warnings: file.warnings, scope: 'user' }); - } + if (file && entry) return await resolveUserTool(file, userPath, entry, request.cwd, args); if (project && (project.status !== 'no-file' || !file)) return project; - return { status: 'unknown-tool', projectRoot: dirname(path), path, names: [...(file?.tools.keys() ?? [])].sort() }; + return { status: 'unknown-tool', projectRoot: dirname(userPath), path: userPath, names: [...(file?.tools.keys() ?? [])].sort() }; } catch (error) { return { status: 'error', message: error instanceof Error ? error.message : String(error) }; } diff --git a/lib/src/host/tool-input.ts b/lib/src/host/tool-input.ts index eaef0f195..3f00e4e74 100644 --- a/lib/src/host/tool-input.ts +++ b/lib/src/host/tool-input.ts @@ -12,7 +12,7 @@ export interface ToolInput { /** A target is one existing regular file on this host. Resolve symlinks before * keying, so two paths to the same document reveal the same Tool. */ -async function resolveLocalToolTarget(input: string, cwd: string): Promise { +export async function resolveLocalToolTarget(input: string, cwd: string): Promise { if (hasShellInputControls(input) || hasShellInputControls(cwd)) { throw new ToolFileError('local file paths cannot contain terminal control characters'); } diff --git a/lib/src/host/tool-open.test.ts b/lib/src/host/tool-open.test.ts new file mode 100644 index 000000000..3124702d5 --- /dev/null +++ b/lib/src/host/tool-open.test.ts @@ -0,0 +1,102 @@ +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, it } from 'vitest'; +import { createToolHost } from './tool-host'; + +let root: string; +let config: string; +const writeConfig = (yaml: string) => writeFile(config, yaml); +const viewerConfig = (match: string) => `tools:\n viewer:\n run: [view, $TARGET]\nopen:\n - {match: '${match}', tool: viewer}\n`; +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'dor-open-'))); + config = join(root, 'user.yml'); + await mkdir(join(root, 'docs')); + await writeFile(join(root, 'docs', 'README.md'), 'hi'); + await writeConfig(`tools: + special: + run: [special, $TARGET] + prespawn_dedupe: [$TARGET] + markdown: + run: [markdown, $TARGET] +open: + - match: docs/README.md + tool: special + - match: '**/*.md' + tool: markdown +`); +}); +afterEach(async () => { await rm(root, { recursive: true, force: true }); }); +const host = () => createToolHost({ userConfigPath: config }); + +it('uses the first user rule and never discovers project definitions', async () => { + await writeFile(join(root, 'dormouse.yml'), 'this is not even valid Tool configuration'); + const target = join(root, 'docs', 'README.md'); + expect(await host().handle({ op: 'open', target: 'docs/README.md', cwd: root })).toMatchObject({ + status: 'ok', scope: 'user', name: 'special', run: ['special', target], key: [target], + }); + expect(await host().handle({ op: 'open', target, cwd: root, tool: 'markdown' })).toMatchObject({ + status: 'ok', scope: 'user', name: 'markdown', run: ['markdown', target], + }); +}); + +it('matches slashless patterns against the filename', async () => { + await writeConfig(viewerConfig('*.md')); + const target = join(root, 'docs', 'a b; $(touch nope).md'); + await writeFile(target, 'hi'); + expect(await host().handle({ op: 'open', target, cwd: root })).toMatchObject({ status: 'ok', run: ['view', target] }); +}); + +it.skipIf(process.platform === 'win32')('keys symlink aliases on the same canonical file', async () => { + const target = join(root, 'docs', 'README.md'); + await symlink(target, join(root, 'alias.md')); + const first = await host().handle({ op: 'open', target, cwd: root }); + const second = await host().handle({ op: 'open', target: 'alias.md', cwd: root }); + expect(second).toEqual(first); +}); + +it.each(['https://example.com/file.md', 'file:///etc/passwd', 'surface:3', 'docs', 'missing.md'])('rejects %s as a local regular-file target', async target => { + expect(await host().handle({ op: 'open', target, cwd: root })).toMatchObject({ status: 'error' }); +}); + +it('names the user configuration in unmatched-file errors', async () => { + const target = join(root, 'unknown.binary'); + await writeFile(target, 'hi'); + expect(await host().handle({ op: 'open', target, cwd: root })).toMatchObject({ status: 'error', message: expect.stringContaining(config) }); +}); + +it('matches catch-all rules above the invocation directory and canonical absolute rules', async () => { + await mkdir(join(root, 'work')); + await writeConfig(viewerConfig('**/*.md')); + const request = { op: 'open', target: '../docs/README.md', cwd: join(root, 'work') } as const; + expect(await host().handle(request)).toMatchObject({ status: 'ok', name: 'viewer' }); + await writeConfig(viewerConfig(join(root, 'docs').replace(/\\/g, '/') + '/**')); + expect(await host().handle(request)).toMatchObject({ status: 'ok', name: 'viewer' }); +}); + +it('matches the first specific rule through a symlinked working directory without changing the run directory', async () => { + await writeConfig(`tools: + special: + run: [special, $TARGET, $CWD] + prespawn_dedupe: [$TARGET] + markdown: + run: [markdown, $TARGET] +open: + - {match: docs/README.md, tool: special} + - {match: '**/*.md', tool: markdown} +`); + const cwd = join(root, 'alias'); + await symlink(root, cwd, 'junction'); + const target = join(root, 'docs', 'README.md'); + expect(await host().handle({ op: 'open', target: 'docs/README.md', cwd })).toMatchObject({ + status: 'ok', name: 'special', run: ['special', target, cwd], key: [target], + }); +}); + +it('still matches an absolute target when the working directory no longer exists', async () => { + const target = join(root, 'docs', 'README.md'); + const cwd = join(root, 'missing'); + expect(await host().handle({ op: 'open', target, cwd })).toMatchObject({ + status: 'ok', name: 'markdown', run: ['markdown', target], + }); +}); diff --git a/lib/src/host/tool-open.ts b/lib/src/host/tool-open.ts new file mode 100644 index 000000000..7ebf347ba --- /dev/null +++ b/lib/src/host/tool-open.ts @@ -0,0 +1,28 @@ +import { realpath } from 'node:fs/promises'; +import { basename, relative, sep } from 'node:path'; +import picomatch from 'picomatch'; +import type { ToolLookupResult } from '../lib/platform/tool-types'; +import { resolveLocalToolTarget } from './tool-input'; +import { readUserToolFile, resolveUserTool } from './tool-user-config'; + +/** Dispatch is entirely user-owned. Never discover a project file here, even + * when its Tool name shadows the rule's selected user Tool. */ +export async function resolveOpenTool( + request: { target: string; cwd: string; tool?: string }, + path: string, +): Promise { + const target = await resolveLocalToolTarget(request.target, request.cwd); + const file = await readUserToolFile(path); + const relativeBase = await realpath(request.cwd).catch(() => request.cwd); + const relativePath = relative(relativeBase, target).split(sep).join('/'); + const canonicalPath = target.split(sep).join('/'); + const name = request.tool ?? file?.open.find(rule => { + const matches = picomatch(rule.match, { windows: false }); + return rule.match.includes('/') ? matches(relativePath) || matches(canonicalPath) : matches(basename(target)); + })?.tool; + const entry = name && file?.tools.get(name); + if (!file || !entry) return { status: 'error', message: request.tool + ? `no user Tool '${request.tool}' in ${path}` + : `no Tool matches '${request.target}'; add an open rule to ${path}, or use dor open --tool ` }; + return resolveUserTool(file, path, entry, request.cwd, [target]); +} diff --git a/lib/src/host/tool-registry.test.ts b/lib/src/host/tool-registry.test.ts index 89033f504..f5866b30e 100644 --- a/lib/src/host/tool-registry.test.ts +++ b/lib/src/host/tool-registry.test.ts @@ -212,3 +212,19 @@ it('rejects a shell command with a target-only dedupe key at declaration time', expect(() => parse('tools:\n viewer:\n run: view\n prespawn_dedupe: [$TARGET]\n')) .toThrow('$TARGET in prespawn_dedupe requires an argument-list run'); }); + +it('keeps project associations inert and validates user associations at declaration time', () => { + expect(parse('open: malformed-but-inert\n').warnings).toEqual([expect.stringContaining('project open rules are ignored')]); + expect(() => parse('open: nope\n', USER)).toThrow("'open' must be an ordered list"); + expect(() => parse('open:\n - {match: "*.md", tool: missing}\n', USER)) + .toThrow('defined in this user file'); + expect(() => parse('tools:\n viewer:\n run: [viewer]\nopen:\n - {match: "*.md", tool: viewer, extra: true}\n', USER)) + .toThrow("unknown field 'extra'"); + expect(() => parse('tools:\n viewer:\n run: viewer\nopen:\n - {match: "*.md", tool: viewer}\n', USER)) + .toThrow("open rule for 'viewer' needs an argument-list run"); +}); + +it('rejects an explicit empty tools block while allowing an absent block', () => { + expect(() => parse('tools:\nopen: []\n', USER)).toThrow("'tools' must be a mapping"); + expect(parse('open: []\n', USER).tools.size).toBe(0); +}); diff --git a/lib/src/host/tool-registry.ts b/lib/src/host/tool-registry.ts index 069f3eaba..cafcfd03b 100644 --- a/lib/src/host/tool-registry.ts +++ b/lib/src/host/tool-registry.ts @@ -44,7 +44,10 @@ export interface ToolEntry { readonly dedupeTemplate: readonly string[] | null; } +export interface OpenRule { readonly match: string; readonly tool: string } + export interface ToolFile { + readonly open: readonly OpenRule[]; readonly scope: ToolScope; /** Absolute directory holding the file. `$PROJECT_ROOT` for a repo scope. */ readonly dir: string; @@ -126,12 +129,11 @@ export function parseToolFile( } // An empty file is a valid file with no tools, not a broken one. if (doc === null || doc === undefined) { - return { scope, dir, tools: new Map(), warnings: [] }; + return { scope, dir, tools: new Map(), warnings: [], open: [] }; } if (!isRecord(doc)) throw new ToolFileError(`${path}: expected a mapping at the top level`); - const toolsNode = doc.tools; - if (toolsNode === undefined) return { scope, dir, tools: new Map(), warnings: [] }; + const toolsNode = doc.tools === undefined ? {} : doc.tools; if (!isRecord(toolsNode)) throw new ToolFileError(`${path}: 'tools' must be a mapping of name to entry`); const tools = new Map(); @@ -196,7 +198,12 @@ export function parseToolFile( tools.set(name, { name, run: typeof run === 'string' ? run.trim() : run, render, port, dedupeTemplate }); } - return { scope, dir, tools, warnings }; + // Associations are user-only (`docs/specs/dor-tool.md` -> Opening local files). + if (scope === 'repo' && doc.open !== undefined) { + warnings.push(`${path}: project open rules are ignored; configure associations in the user file`); + } + const open = scope === 'repo' ? [] : parseOpenRules(doc.open, tools, path); + return { scope, dir, tools, warnings, open }; } export interface SubstitutionContext { @@ -229,6 +236,25 @@ export function substituteToolTokens(element: string, context: SubstitutionConte }); } +function parseOpenRules(node: unknown, tools: ReadonlyMap, path: string): OpenRule[] { + if (node === undefined) return []; + if (!Array.isArray(node)) throw new ToolFileError(`${path}: 'open' must be an ordered list`); + return node.map((rule: unknown) => { + const entry = isRecord(rule) && typeof rule.tool === 'string' ? tools.get(rule.tool) : undefined; + if (!isRecord(rule) || !entry || typeof rule.match !== 'string' || !rule.match) { + throw new ToolFileError(`${path}: each open rule needs a match pattern and a tool defined in this user file`); + } + const unknown = Object.keys(rule).find(key => key !== 'match' && key !== 'tool'); + if (unknown !== undefined) { + throw new ToolFileError(`${path}: open rule for '${entry.name}' has an unknown field '${unknown}' (known: match, tool)`); + } + if (typeof entry.run === 'string') { + throw new ToolFileError(`${path}: open rule for '${entry.name}' needs an argument-list run to receive the file`); + } + return { match: rule.match, tool: entry.name }; + }); +} + /** * Render an entry's key for one invocation. Returns `null` when the entry * declared no template — a tool has an identity if and only if it was given diff --git a/lib/src/host/tool-user-config.ts b/lib/src/host/tool-user-config.ts index dfd82dc33..2d23f0672 100644 --- a/lib/src/host/tool-user-config.ts +++ b/lib/src/host/tool-user-config.ts @@ -1,6 +1,8 @@ import { homedir } from 'node:os'; import { dirname, isAbsolute, join } from 'node:path'; -import { parseToolFile, type ToolFile } from './tool-registry'; +import type { ToolLookupResult } from '../lib/platform/tool-types'; +import { resolveToolInput } from './tool-input'; +import { parseToolFile, type ToolEntry, type ToolFile } from './tool-registry'; import { readToolFile } from './tool-trust'; export function userToolConfigPath(): string { @@ -16,3 +18,12 @@ export async function readUserToolFile(path: string): Promise { throw error; } } + +/** The `ok` result for a user Tool: no project root, no trust gate. */ +export async function resolveUserTool( + file: ToolFile, path: string, entry: ToolEntry, cwd: string, args: readonly string[], +): Promise { + const input = await resolveToolInput(entry, { cwd, projectRoot: null, args }); + return { status: 'ok', projectRoot: file.dir, path, name: entry.name, scope: 'user', + ...input, render: entry.render, port: entry.port, warnings: [...file.warnings] }; +} diff --git a/lib/src/lib/platform/tool-types.ts b/lib/src/lib/platform/tool-types.ts index 0f81528c9..8a85f8e71 100644 --- a/lib/src/lib/platform/tool-types.ts +++ b/lib/src/lib/platform/tool-types.ts @@ -7,6 +7,7 @@ */ export type ToolHostRequest = + | { op: 'open'; target: string; cwd: string; tool?: string } | { op: 'lookup'; name: string; cwd: string; args?: string[]; global?: boolean } | { op: 'trust'; kind: 'upstream' | 'folder'; projectRoot: string }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 990719cb7..e7d019470 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,7 @@ importers: version: 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(typescript@6.0.3) '@storybook/react-vite': specifier: ^10.4.0 - version: 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rollup@4.62.2)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rollup@4.62.2)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@types/react': specifier: ^19.2.14 version: 19.3.0 @@ -44,7 +44,7 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) storybook: specifier: ^10.4.0 version: 10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0) @@ -53,7 +53,7 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) dor: dependencies: @@ -125,6 +125,9 @@ importers: jsonc-parser: specifier: 3.3.1 version: 3.3.1 + picomatch: + specifier: ^4.0.7 + version: 4.0.7 react: specifier: ^19.2.6 version: 19.3.0 @@ -155,10 +158,13 @@ importers: version: 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3) '@storybook/react-vite': specifier: ^10.4.0 - version: 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rollup@4.62.2)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rollup@4.62.2)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) + '@types/picomatch': + specifier: ^4.0.3 + version: 4.0.3 '@types/react': specifier: ^19.2.14 version: 19.3.0 @@ -167,7 +173,7 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) chromatic: specifier: ^17.0.0 version: 17.8.0 @@ -188,10 +194,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.6 - version: 4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) relay: dependencies: @@ -278,7 +284,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@tauri-apps/cli': specifier: ^2.11.2 version: 2.11.4 @@ -290,7 +296,7 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) cross-spawn: specifier: ^7.0.6 version: 7.0.6 @@ -311,10 +317,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.6 - version: 4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) standalone/sidecar: dependencies: @@ -358,7 +364,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@types/node': specifier: ^24.0.0 version: 24.13.4 @@ -370,7 +376,7 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@vscode/vsce': specifier: ^3.9.1 version: 3.9.2(supports-color@7.2.0) @@ -391,10 +397,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.6 - version: 4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) website: dependencies: @@ -422,10 +428,10 @@ importers: devDependencies: '@react-router/dev': specifier: ^8.0.0 - version: 8.3.1(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 8.3.1(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@types/node': specifier: ^24.13.3 version: 24.13.4 @@ -443,10 +449,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.14 - version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + version: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.6 - version: 4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + version: 4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) packages: @@ -2309,6 +2315,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/picomatch@4.0.3': + resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} + '@types/react-dom@19.3.0': resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} peerDependencies: @@ -5448,11 +5457,11 @@ snapshots: optionalDependencies: '@types/node': 24.13.4 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@6.0.3) - vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: typescript: 6.0.3 @@ -5779,7 +5788,7 @@ snapshots: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - '@react-router/dev@8.3.1(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0))': + '@react-router/dev@8.3.1(react-router@8.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/generator': 7.29.8 @@ -5805,7 +5814,7 @@ snapshots: semver: 7.8.5 tinyglobby: 0.2.17 valibot: 1.4.2(typescript@6.0.3) - vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -6044,11 +6053,11 @@ snapshots: transitivePeerDependencies: - '@types/react-dom' - '@storybook/builder-vite@10.6.0(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0))': + '@storybook/builder-vite@10.6.0(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: storybook: 10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0) ts-dedent: 2.3.0 - vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) '@storybook/global@5.0.0': {} @@ -6065,11 +6074,11 @@ snapshots: '@types/react': 19.3.0 '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@storybook/react-vite@10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rollup@4.62.2)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0))': + '@storybook/react-vite@10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rollup@4.62.2)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@rollup/pluginutils': 5.4.0(rollup@4.62.2) - '@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + '@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@storybook/react': 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(supports-color@7.2.0)(typescript@6.0.3) empathic: 2.0.1 magic-string: 1.3.1 @@ -6079,7 +6088,7 @@ snapshots: resolve: 1.22.12 storybook: 10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0) tsconfig-paths: 4.2.0 - vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -6088,11 +6097,11 @@ snapshots: - rollup - supports-color - '@storybook/react-vite@10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rollup@4.62.2)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0))': + '@storybook/react-vite@10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rollup@4.62.2)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@6.0.3)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@rollup/pluginutils': 5.4.0(rollup@4.62.2) - '@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + '@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@storybook/react': 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0))(typescript@6.0.3) empathic: 2.0.1 magic-string: 1.3.1 @@ -6102,7 +6111,7 @@ snapshots: resolve: 1.22.12 storybook: 10.6.0(@types/react@19.3.0)(prettier@3.9.6)(react@19.3.0) tsconfig-paths: 4.2.0 - vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -6206,12 +6215,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/vite@4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0))': + '@tailwindcss/vite@4.3.3(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) '@tauri-apps/api@2.11.1': {} @@ -6380,6 +6389,8 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/picomatch@4.0.3': {} + '@types/react-dom@19.3.0(@types/react@19.3.0)': dependencies: '@types/react': 19.3.0 @@ -6412,10 +6423,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0))': + '@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) '@vitest/expect@3.2.4': dependencies: @@ -6434,13 +6445,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0))': + '@vitest/mocker@4.1.11(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -8847,7 +8858,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0): + vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -8861,10 +8872,10 @@ snapshots: jiti: 2.7.0 yaml: 2.9.0 - vitest@4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)): + vitest@4.1.11(@types/node@24.13.4)(jsdom@29.1.1)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)) + '@vitest/mocker': 4.1.11(vite@8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -8881,7 +8892,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0) + vite: 8.3.0(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.4 diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index e22056f1a..73398cd66 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -6,8 +6,8 @@ "docs/specs/auto-update.md": 1100, "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4600, - "docs/specs/dor-cli.md": 5950, - "docs/specs/dor-tool.md": 3300, + "docs/specs/dor-cli.md": 6000, + "docs/specs/dor-tool.md": 3500, "docs/specs/glossary.md": 3000, "docs/specs/layout.md": 8850, "docs/specs/mobile-terminal-ui.md": 1950, diff --git a/website/src/data/dependencies-npm.json b/website/src/data/dependencies-npm.json index 370d4ea8c..24aaa5b46 100644 --- a/website/src/data/dependencies-npm.json +++ b/website/src/data/dependencies-npm.json @@ -335,6 +335,13 @@ "author": "Sindre Sorhus", "homepage": "https://github.com/sindresorhus/path-key" }, + { + "name": "picomatch", + "version": "4.0.7", + "license": "MIT", + "author": "Jon Schlinkert (https://github.com/jonschlinkert)", + "homepage": "https://github.com/micromatch/picomatch" + }, { "name": "react", "version": "19.3.0",