From a6915745e068209142e68564b967b1d2a2c35e03 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 17 Jul 2026 17:21:10 +0800 Subject: [PATCH 01/37] fix(bash-local): bound inherited pipe drain and spill files --- docs/config-catalog.md | 4 +- packages/bash/bash-local/README.md | 11 +-- packages/bash/bash-local/src/index.ts | 10 ++- packages/bash/bash-local/src/run.ts | 75 ++++++++++++++---- .../bash/bash-local/tests/executor.spec.ts | 1 + packages/bash/bash-local/tests/run.spec.ts | 77 +++++++++++++++++-- 6 files changed, 151 insertions(+), 27 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c94b5d571e..9ba1f73cf5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -152,7 +152,9 @@ export interface Config { maxTimeoutMs?: number /** Per-stream in-memory output cap; overflow spills to a temp file. */ maxOutputBytes?: number - /** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */ + /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ + maxSpillBytes?: number + /** Grace period for kill escalation and for inherited pipes after shell exit. */ graceMs?: number } ``` diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 6048f886a3..c6fc6e863d 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-bash-local -Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c ` per call in its own process group, collects bounded output with full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. +Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c ` per call in its own process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package. @@ -14,7 +14,8 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i timeoutMs: 120000 # default foreground timeout maxTimeoutMs: 600000 # cap for per-call overrides maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk - graceMs: 3000 # SIGTERM→SIGKILL escalation grace on kills + maxSpillBytes: 67108864 # per-stream full-output spill cap + graceMs: 3000 # kill escalation and post-exit pipe-drain grace ``` ## Behavior (and where it came from) @@ -22,8 +23,8 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices: - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. -- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools. -- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file. +- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). After the main shell exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the command open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools. +- **Tail-keep truncation + bounded spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A stream larger than `maxSpillBytes` discards its now-incomplete spill and returns only the marked truncated tail. If the final spill close reports a delayed writeback failure, the executor likewise withholds the path rather than advertising an incomplete file. - **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin`, when supplied, is written to the child and closed; with none supplied, fd 0 is `/dev/null` — the exact pre-seam default, so a command that probes stdin's file type is unaffected. Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). - **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. @@ -37,6 +38,6 @@ Indirectly, through `dsh-tool-bash`, which renders this executor's bounded stdou - **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them. - **POSIX-only** — the `bash` binary, detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported. - **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work. -- **Spill files are never deleted** — full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them. +- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are deleted immediately. The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring. diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 9c2b0b4511..4e942abb06 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -10,7 +10,7 @@ import z from 'schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { DEFAULT_GRACE_MS, runBash } from './run.ts' +import { DEFAULT_GRACE_MS, DEFAULT_MAX_SPILL_BYTES, runBash } from './run.ts' import type { RunInternals, RunningBash } from './run.ts' /** Plugin config (all optional — `static Config` supplies the defaults). */ @@ -23,7 +23,9 @@ export interface Config { maxTimeoutMs?: number /** Per-stream in-memory output cap; overflow spills to a temp file. */ maxOutputBytes?: number - /** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */ + /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ + maxSpillBytes?: number + /** Grace period for kill escalation and for inherited pipes after shell exit. */ graceMs?: number } @@ -46,6 +48,7 @@ export class LocalBashExecutor extends BashExecutor { timeoutMs: z.number().default(120_000), maxTimeoutMs: z.number().default(600_000), maxOutputBytes: z.number().default(64_000), + maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES), graceMs: z.number().default(DEFAULT_GRACE_MS), }) @@ -64,6 +67,7 @@ export class LocalBashExecutor extends BashExecutor { assertPositiveFinite('timeoutMs', this.config.timeoutMs) assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs) assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) + assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes) assertPositiveFinite('graceMs', this.config.graceMs) ctx.effect(() => async () => { // Await closure so even a TERM-trapping child cannot outlive the fiber. @@ -112,6 +116,7 @@ export class LocalBashExecutor extends BashExecutor { command: spec.command, cwd: spec.workdir, maxOutputBytes: this.config.maxOutputBytes, + maxSpillBytes: this.config.maxSpillBytes, graceMs: this.config.graceMs, signal: d.signal, stdin: spec.stdin, @@ -129,6 +134,7 @@ export class LocalBashExecutor extends BashExecutor { command: spec.command, cwd: spec.workdir, maxOutputBytes: this.config.maxOutputBytes, + maxSpillBytes: this.config.maxSpillBytes, graceMs: this.config.graceMs, signal: spec.signal, stdin: spec.stdin, diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts index 02e7a963be..d475bcb538 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/packages/bash/bash-local/src/run.ts @@ -8,7 +8,7 @@ import { type ChildProcessByStdio, spawn } from 'node:child_process' import type { Readable, Writable } from 'node:stream' import { randomBytes } from 'node:crypto' -import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs' +import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { CollectedOutput } from '@deepseek-ai/dsh-bash' @@ -54,7 +54,9 @@ export interface SpawnSpec { cwd: string /** Per-stream in-memory cap; overflow spills to disk (tail kept in memory). */ maxOutputBytes: number - /** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */ + /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ + maxSpillBytes: number + /** Grace period for kill escalation and for inherited pipes after shell exit. */ graceMs: number /** * Abort signal — kills the process group when it fires. The executor owns @@ -101,6 +103,9 @@ export interface RunInternals { /** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */ export const DEFAULT_GRACE_MS = 3_000 +/** Default per-stream spill cap (the `maxSpillBytes` config). */ +export const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024 + let spillCounter = 0 let defaultSpillDir: string | undefined @@ -115,9 +120,9 @@ function privateSpillDir(): string { } /** - * Collects one stream with a bounded in-memory tail. The FULL stream is - * always recoverable: on first overflow a spill file is created and every - * chunk (including those already collected) is appended there. + * Collects one stream with a bounded in-memory tail. On first overflow a + * spill file is created and every chunk (including those already collected) + * is appended there while the full stream remains within `maxSpillBytes`. * * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the * end of command output; the spill file covers the head. @@ -128,11 +133,13 @@ export class OutputCollector { private dropped = false private spillFd: number | undefined private spillFile: string | undefined + private spillDisabled = false /** Total bytes ever pushed (not just retained). */ private total = 0 constructor( private readonly maxBytes: number, + private readonly maxSpillBytes: number, private readonly label: string, private readonly spillDir: string, ) {} @@ -148,7 +155,7 @@ export class OutputCollector { push(chunk: Buffer): void { this.total += chunk.length const overflows = this.bytes + chunk.length > this.maxBytes - if (overflows || this.spillFd !== undefined) this.spillAll(chunk) + if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk) this.chunks.push(chunk) this.bytes += chunk.length while (this.bytes > this.maxBytes && this.chunks.length > 1) { @@ -170,6 +177,10 @@ export class OutputCollector { /** Open the spill file lazily and append `chunk` (and any prior chunks once). */ private spillAll(chunk: Buffer): void { + if (this.total > this.maxSpillBytes) { + this.discardSpill() + return + } if (this.spillFd === undefined) { // Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any // existing path, symlink or not) + owner-only mode: defeats spill-path @@ -184,6 +195,30 @@ export class OutputCollector { writeSync(this.spillFd, chunk) } + /** Stop spilling and remove the file once it can no longer hold the complete stream. */ + private discardSpill(): void { + const fd = this.spillFd + const file = this.spillFile + this.spillFd = undefined + this.spillFile = undefined + this.spillDisabled = true + if (fd !== undefined) { + try { + closeSync(fd) + } catch { + // Retain the descriptor so finalize can retry the failed close. + this.spillFd = fd + } + } + if (file !== undefined) { + try { + unlinkSync(file) + } catch { + // A failed unlink leaves at most maxSpillBytes behind, never an unbounded file. + } + } + } + /** * Incremental read in whole-stream byte coordinates: returns everything * pushed since `fromByte`. When `fromByte` has already slid out of the @@ -283,8 +318,8 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB ? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }) : spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }) - const stdout = new OutputCollector(spec.maxOutputBytes, 'stdout', spillDir) - const stderr = new OutputCollector(spec.maxOutputBytes, 'stderr', spillDir) + const stdout = new OutputCollector(spec.maxOutputBytes, spec.maxSpillBytes, 'stdout', spillDir) + const stderr = new OutputCollector(spec.maxOutputBytes, spec.maxSpillBytes, 'stderr', spillDir) child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) }) child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) }) @@ -310,12 +345,13 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB } const done = new Promise((resolve, reject) => { - child.on('error', (error) => { - // No meaningful close outcome follows a spawn failure. - cleanup() - reject(error) - }) - child.on('close', (exitCode, signal) => { + let settled = false + let pipeDrainTimer: NodeJS.Timeout | undefined + const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { + if (settled) return + settled = true + child.stdout.destroy() + child.stderr.destroy() cleanup() resolve({ exitCode, @@ -323,9 +359,20 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB stdout: stdout.finalize(), stderr: stderr.finalize(), }) + } + child.on('error', (error) => { + // No meaningful close outcome follows a spawn failure. + settled = true + cleanup() + reject(error) }) + child.on('exit', (exitCode, signal) => { + pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs) + }) + child.on('close', settle) function cleanup(): void { if (graceTimer !== undefined) clearTimeout(graceTimer) + if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer) spec.signal?.removeEventListener('abort', onAbort) } }) diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index b176cbaf86..4aa4f50ca1 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -66,6 +66,7 @@ describe('LocalBashExecutor.run', () => { await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/) await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/) await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/) + await expect(setup({ maxSpillBytes: 0 })).rejects.toThrow(/maxSpillBytes/) await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/) const { bash } = await setup() diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts index 923b3adf7b..b182575f1a 100644 --- a/packages/bash/bash-local/tests/run.spec.ts +++ b/packages/bash/bash-local/tests/run.spec.ts @@ -1,11 +1,14 @@ -import { mkdtempSync, readFileSync, statSync } from 'node:fs' +import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { killGroup, OutputCollector, runBash } from '../src/run.ts' import type { RunningBash } from '../src/run.ts' -const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } })) +const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ + failNextClose: { value: false }, + failNextUnlink: { value: false }, +})) vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() return { @@ -17,6 +20,13 @@ vi.mock('node:fs', async (importOriginal) => { } actual.closeSync(fd) }, + unlinkSync(path: Parameters[0]): void { + if (failNextUnlink.value) { + failNextUnlink.value = false + throw Object.assign(new Error('simulated EIO on unlink'), { code: 'EIO' }) + } + actual.unlinkSync(path) + }, } }) @@ -27,6 +37,7 @@ function spec(command: string, overrides: Partial[0]> command, cwd: process.cwd(), maxOutputBytes: 64_000, + maxSpillBytes: 64 * 1024 * 1024, graceMs: 3_000, ...overrides, } @@ -171,6 +182,22 @@ describe('runBash', () => { const result = await running.done expect(result.signal).toBe('SIGTERM') }) + + it('bounds inherited-pipe draining after the shell exits', async () => { + const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`) + const started = Date.now() + const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 })) + const descendant = await waitForPidFile(pidFile) + try { + const result = await running.done + expect(Date.now() - started).toBeLessThan(1_000) + expect(result.exitCode).toBe(0) + expect(result.stdout.text).toBe('shell-done\n') + } finally { + process.kill(descendant, 'SIGKILL') + await waitGone(descendant) + } + }) }) describe('stdin and extra env (set by in-process plugins)', () => { @@ -266,7 +293,7 @@ describe('output truncation and spill', () => { describe('OutputCollector', () => { it('keeps the tail of a single oversized chunk', () => { - const collector = new OutputCollector(10, 'test', spillDir) + const collector = new OutputCollector(10, 100, 'test', spillDir) collector.push(Buffer.from('0123456789abcdef')) const out = collector.finalize() expect(out.text).toBe('6789abcdef') @@ -275,7 +302,7 @@ describe('OutputCollector', () => { }) it('readFrom returns increments and flags lossy reads', () => { - const collector = new OutputCollector(10, 'test', spillDir) + const collector = new OutputCollector(10, 100, 'test', spillDir) collector.push(Buffer.from('aaaaa')) const first = collector.readFrom(0) expect(first.text).toBe('aaaaa') @@ -296,7 +323,7 @@ describe('OutputCollector', () => { }) it('contains close failures and drops the spill path', () => { - const collector = new OutputCollector(4, 'closefail', spillDir) + const collector = new OutputCollector(4, 100, 'closefail', spillDir) collector.push(Buffer.from('aaaa')) collector.push(Buffer.from('bbbb')) expect(collector.readFrom(0).spillPath).toBeDefined() @@ -310,6 +337,46 @@ describe('OutputCollector', () => { expect(out!.truncated).toBe(true) expect(out!.spillPath).toBeUndefined() }) + + it('discards a spill that exceeds its configured cap', () => { + const collector = new OutputCollector(4, 8, 'bounded', spillDir) + collector.push(Buffer.from('aaaa')) + collector.push(Buffer.from('bbbb')) + const spillPath = collector.readFrom(0).spillPath! + expect(readFileSync(spillPath, 'utf8')).toBe('aaaabbbb') + + collector.push(Buffer.from('c')) + collector.push(Buffer.from('dddd')) + const out = collector.finalize() + expect(out.text).toBe('dddd') + expect(out.truncated).toBe(true) + expect(out.spillPath).toBeUndefined() + expect(() => readFileSync(spillPath)).toThrow() + }) + + it('does not create a spill when the first overflowing chunk exceeds the cap', () => { + const collector = new OutputCollector(4, 4, 'no-spill', spillDir) + collector.push(Buffer.from('abcdefgh')) + const out = collector.finalize() + expect(out.text).toBe('efgh') + expect(out.truncated).toBe(true) + expect(out.spillPath).toBeUndefined() + }) + + it('contains cleanup failures while disabling an oversize spill', () => { + const collector = new OutputCollector(4, 8, 'cleanup-fail', spillDir) + collector.push(Buffer.from('aaaa')) + collector.push(Buffer.from('bbbb')) + const spillPath = collector.readFrom(0).spillPath! + + failNextClose.value = true + failNextUnlink.value = true + expect(() => { collector.push(Buffer.from('c')) }).not.toThrow() + expect(failNextClose.value).toBe(false) + expect(failNextUnlink.value).toBe(false) + expect(collector.finalize().spillPath).toBeUndefined() + unlinkSync(spillPath) + }) }) describe('killGroup', () => { From a38ff125a7a9c4d3e9fc3b0e6e86ade46e9b3876 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 17 Jul 2026 17:29:38 +0800 Subject: [PATCH 02/37] feat(dsbench): add SDK evaluation composition --- docs/config-catalog.md | 16 +- examples/README.md | 4 + examples/dsbench-coding-agent/README.md | 24 +++ examples/dsbench-coding-agent/cordis.yml | 77 +++++++++ examples/dsbench-coding-agent/package.json | 7 + .../tests/keyless-smoke.e2e.ts | 146 ++++++++++++++++++ packages/examples/acp-demo/src/index.ts | 4 +- packages/examples/agent-spine-demo/README.md | 2 +- .../examples/agent-spine-demo/src/index.ts | 20 ++- .../agent-spine-demo/tests/agent-core.spec.ts | 21 ++- packages/examples/stdio-demo/src/index.ts | 4 +- packages/llm/llm-deepseek/src/adapter.ts | 3 + .../llm/llm-deepseek/tests/adapter.spec.ts | 14 ++ packages/ui/jsonrpc/README.md | 2 +- packages/ui/jsonrpc/src/index.ts | 12 +- packages/ui/jsonrpc/src/server.ts | 16 +- packages/ui/jsonrpc/tests/server.spec.ts | 22 ++- 17 files changed, 365 insertions(+), 29 deletions(-) create mode 100644 examples/dsbench-coding-agent/README.md create mode 100644 examples/dsbench-coding-agent/cordis.yml create mode 100644 examples/dsbench-coding-agent/package.json create mode 100644 examples/dsbench-coding-agent/tests/keyless-smoke.e2e.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9ba1f73cf5..c888731228 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -56,7 +56,7 @@ export interface Config { skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ toolBash?: NonNullable - /** Generic background-task control-tool config forwarded through agent-core. */ + /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable } ``` @@ -120,12 +120,14 @@ export interface Config { skills?: SkillConfig /** Model-facing bash tool config, including this producer's background opt-in. */ toolBash?: toolBash.Config - /** Generic background-task control-tool wait bounds. */ - toolTasks?: toolTasks.Config + /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ + toolTasks?: toolTasks.Config | false } /** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */ export interface SkillConfig { + /** Mount the bundled local skill provider and model-facing skill tool (default true). */ + enabled?: boolean /** Registry-level discovery cache settings. */ registry?: SkillRegistryConfig /** Local filesystem skill provider settings. */ @@ -137,7 +139,7 @@ export interface SkillConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:55`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:57`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -343,8 +345,10 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:41`](../packages/hooks/hooks-c Requires: `agents` ```ts config-catalog -/** Runtime-only test seams; no field is configurable from `cordis.yml`. */ +/** JSON-RPC deployment config plus runtime-only test seams. */ export interface JsonRpcConfig { + /** Report max-token turn/subagent termination as a successful SDK result. */ + maxTokensAsSuccess?: boolean /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ @@ -724,7 +728,7 @@ export interface Config { skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ toolBash?: NonNullable - /** Generic background-task control-tool config forwarded through agent-core. */ + /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable /** * If set, the `main` agent RESUMES this persisted session id instead of diff --git a/examples/README.md b/examples/README.md index 5db1e18372..556a3bf41e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -21,6 +21,10 @@ Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a Run the Code Mode overlay with `pnpm run demo:code-mode`, or pass `acp` for the ACP example. See the [Code Mode example](coding-agent/README.md#code-mode) for its composition and a sample task. +## dsbench-coding-agent + +The unattended SDK composition used by DSBench: JSON-RPC stdio, foreground-only `bash`, `read` / `write` / `edit`, one foreground `subagent`, `todo_write`, JSONL persistence, and compaction. It excludes terminal UI, stdout logging, approvals, skills, and background task controls. See [dsbench-coding-agent/README.md](dsbench-coding-agent/README.md). + ## cordis-agent The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_mount` / `cordis_unmount`) let the agent inspect the live cordis runtime it runs inside, mount model-written plugins into it (an event listener, a brand-new tool for itself, or a service another mount injects), and dispose them again — all dynamic mounts grouped under one `cordis-dynamic` fiber subtree. The `ctx.fs`/`ctx.web` services ride along provider-only, as the capabilities those plugins build on. diff --git a/examples/dsbench-coding-agent/README.md b/examples/dsbench-coding-agent/README.md new file mode 100644 index 0000000000..ff45ba36cf --- /dev/null +++ b/examples/dsbench-coding-agent/README.md @@ -0,0 +1,24 @@ +# dsbench-coding-agent + +The DSBench deployment composition for the Python SDK's bundled JSON-RPC runtime. It intentionally loads no terminal UI, console logger, approval surface, or user-interaction tool because stdout belongs to the SDK protocol and benchmark turns are unattended. + +The model-facing tools are: + +- `bash`, foreground only +- `read`, `write`, and `edit` +- `subagent`, using one foreground in-process spawn provider +- `todo_write` + +The surrounding runtime also loads JSONL session persistence and automatic context compaction. `maxTokensAsSuccess` keeps a token-limited model turn as an accepted benchmark result while preserving its `max-tokens` reason. + +## Runtime environment + +| Variable | Purpose | +|---|---| +| `DEEPSEEK_API_KEY` | Credential passed to the OpenAI-compatible host endpoint | +| `DEEPSEEK_BASE_URL` | Host endpoint used by `dsh-llm-deepseek` | +| `DSH_CWD` | Benchmark workspace for bash and filesystem tools | +| `DSH_SESSION_ROOT` | JSONL trajectory directory | +| `DSH_SYSTEM_PROMPT` | DSBench-provided coding persona | + +Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CONFIG`. The bundled executable already carries every plugin named by this file; the target machine does not need Node.js. diff --git a/examples/dsbench-coding-agent/cordis.yml b/examples/dsbench-coding-agent/cordis.yml new file mode 100644 index 0000000000..67e1604b07 --- /dev/null +++ b/examples/dsbench-coding-agent/cordis.yml @@ -0,0 +1,77 @@ +# DSBench deployment for the bundled dsh-jsonrpc-agent runtime. +# stdout is reserved for JSON-RPC; do not add a console logger or terminal UI. + +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' + config: + maxTokensAsSuccess: true + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - deepseek-v4-flash + - deepseek-v4-pro + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() + timeoutMs: 60000 + +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a coding agent driven by DSBench.' + workspaceContext: false + skills: + enabled: false + toolBash: + enableRunInBackground: false + toolTasks: false + +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + enableRunInBackground: false + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + config: + contextWindow: 128000 + thresholdRatio: 0.8 + retainTokens: 20480 + summarizationModel: '' + maxTokens: 8192 + compactionRetries: 1 diff --git a/examples/dsbench-coding-agent/package.json b/examples/dsbench-coding-agent/package.json new file mode 100644 index 0000000000..900c88d69b --- /dev/null +++ b/examples/dsbench-coding-agent/package.json @@ -0,0 +1,7 @@ +{ + "name": "dsbench-coding-agent-example", + "private": true, + "version": "0.0.1", + "type": "module", + "description": "DSBench JSON-RPC coding-agent composition" +} diff --git a/examples/dsbench-coding-agent/tests/keyless-smoke.e2e.ts b/examples/dsbench-coding-agent/tests/keyless-smoke.e2e.ts new file mode 100644 index 0000000000..9bbc07a829 --- /dev/null +++ b/examples/dsbench-coding-agent/tests/keyless-smoke.e2e.ts @@ -0,0 +1,146 @@ +import { spawn } from 'node:child_process' +import { createServer } from 'node:http' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const binScript = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) + +function waitForLine( + lines: string[], + predicate: (value: Record) => boolean, + stderr: () => string, +): Promise> { + return new Promise((resolve, reject) => { + const deadline = Date.now() + 30_000 + const poll = (): void => { + while (lines.length > 0) { + const line = lines.shift()! + if (!line.trim()) continue + try { + const value = JSON.parse(line) as Record + if (predicate(value)) { + resolve(value) + return + } + } catch { + reject(new Error(`non-JSON stdout from DSBench runtime: ${line}`)) + return + } + } + if (Date.now() >= deadline) { + reject(new Error(`timed out waiting for JSON-RPC response; stderr=${stderr()}`)) + return + } + setTimeout(poll, 10) + } + poll() + }) +} + +describe('dsbench-coding-agent keyless smoke', () => { + it('boots the real Cordis tree and serves initialize/shutdown over clean stdout', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-dsbench-smoke-')) + const modelRequests: Record[] = [] + const modelServer = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + modelRequests.push(JSON.parse(body) as Record) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n') + response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n') + response.write('data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n') + response.end('data: [DONE]\n\n') + }) + }) + await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve)) + const address = modelServer.address() + if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port') + const child = spawn(process.execPath, [ + '--expose-internals', + '--import', + 'tsx', + binScript, + configPath, + ], { + cwd: repoRoot, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-smoke-no-call', + DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, + DSH_CWD: root, + DSH_SESSION_ROOT: join(root, '.sessions'), + }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + const lines: string[] = [] + let stdoutBuffer = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { + stdoutBuffer += chunk + const parts = stdoutBuffer.split('\n') + stdoutBuffer = parts.pop() ?? '' + lines.push(...parts) + }) + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { stderr += chunk }) + + try { + child.stdin.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { cwd: root, model: 'deepseek-v4-pro' }, + })}\n`) + const initialized = await waitForLine(lines, value => value.id === 1, () => stderr) + expect(initialized).toMatchObject({ + jsonrpc: '2.0', + id: 1, + result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } }, + }) + + child.stdin.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'session/prompt', + params: { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'inspect tools' }] }, + })}\n`) + const prompt = await waitForLine(lines, value => value.id === 2, () => stderr) + expect(prompt).toMatchObject({ jsonrpc: '2.0', id: 2, result: { accepted: true } }) + const tools = modelRequests[0]?.tools as { function?: { name?: string } }[] + expect(tools.map(tool => tool.function?.name).sort()).toEqual([ + 'bash', + 'edit', + 'read', + 'subagent', + 'todo_write', + 'write', + ]) + + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) + const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr) + expect(shutdown).toMatchObject({ jsonrpc: '2.0', id: 3, result: {} }) + if (child.exitCode === null) { + await new Promise((resolve, reject) => { + child.once('exit', (code) => { + if (code === 0) resolve() + else reject(new Error(`runtime exited ${code}; stderr=${stderr}`)) + }) + }) + } else { + expect(child.exitCode, stderr).toBe(0) + } + } finally { + if (child.exitCode === null) child.kill('SIGKILL') + await new Promise(resolve => modelServer.close(() => { resolve() })) + await rm(root, { recursive: true, force: true }) + } + }, 40_000) +}) diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index e52b21b9f6..ca94315093 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -46,7 +46,7 @@ export interface Config { skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ toolBash?: NonNullable - /** Generic background-task control-tool config forwarded through agent-core. */ + /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable } @@ -67,7 +67,7 @@ export const Config: z = z.object({ workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, - toolTasks: agentCore.ToolTasksConfigSchema, + toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), }) /* jscpd:ignore-end */ diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 0d02d5f597..a59c741994 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -46,7 +46,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. ## Why a code bundle, not a shared YAML include diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 5652078a0f..deb433eb85 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -30,6 +30,8 @@ export const name = 'agent-spine-demo' /** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */ export interface SkillConfig { + /** Mount the bundled local skill provider and model-facing skill tool (default true). */ + enabled?: boolean /** Registry-level discovery cache settings. */ registry?: SkillRegistryConfig /** Local filesystem skill provider settings. */ @@ -67,12 +69,13 @@ export interface Config { skills?: SkillConfig /** Model-facing bash tool config, including this producer's background opt-in. */ toolBash?: toolBash.Config - /** Generic background-task control-tool wait bounds. */ - toolTasks?: toolTasks.Config + /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ + toolTasks?: toolTasks.Config | false } /** The skill config schema exported for app packages that forward `skills`. */ export const SkillConfigSchema: z = z.object({ + enabled: z.boolean().default(true), registry: SkillService.Config, local: SkillLocal.Config, tool: toolSkill.Config, @@ -93,7 +96,7 @@ export const Config = z.intersect([ skills: SkillConfigSchema, workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), toolBash: ToolBashConfigSchema, - toolTasks: ToolTasksConfigSchema, + toolTasks: z.union([z.const(false), ToolTasksConfigSchema]), }) as unknown as z>, ]) as unknown as z @@ -134,8 +137,11 @@ export function apply(ctx: Context, config: Config): void { ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, }) ctx.plugin(ToolRegistry, config.tools ?? {}) - ctx.plugin(SkillService, config.skills?.registry ?? {}) - ctx.plugin(SkillLocal, config.skills?.local ?? {}) + const skillsEnabled = config.skills?.enabled ?? true + if (skillsEnabled) { + ctx.plugin(SkillService, config.skills?.registry ?? {}) + ctx.plugin(SkillLocal, config.skills?.local ?? {}) + } ctx.plugin(AgentRegistry) ctx.plugin(TaskService) ctx.plugin(invariants) @@ -145,7 +151,7 @@ export function apply(ctx: Context, config: Config): void { } // Both plugins prepend session-prefix messages. Registration order is the // rendered order, so workspace instructions must precede the skill catalog. - ctx.plugin(toolSkill, config.skills?.tool ?? {}) - ctx.plugin(toolTasks, config.toolTasks ?? {}) + if (skillsEnabled) ctx.plugin(toolSkill, config.skills?.tool ?? {}) + if (config.toolTasks !== false) ctx.plugin(toolTasks, config.toolTasks ?? {}) ctx.plugin(AgentLoop, { agents: config.agents ?? [] }) } diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 51562f547d..e9763b3488 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -301,6 +301,21 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) + it('can omit skills and model-facing task controls for a foreground-only deployment', async () => { + const ctx = await mount({ + workspaceContext: false, + skills: { enabled: false }, + toolBash: { enableRunInBackground: false }, + toolTasks: false, + }, true) + + expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['bash']) + expect(ctx.get('skills')).toBeUndefined() + expect(ctx.get('tasks')).toBeDefined() + + await ctx.fiber.dispose() + }) + it('picks shared spine config without leaking front-door fields', () => { const appConfig = { model: 'front-door-only', @@ -308,9 +323,9 @@ describe('dsh-agent-spine-demo bundle', () => { toolOrder: ['zulu'], tools: { mode: 'native' as const }, workspaceContext: false as const, - skills: {}, + skills: { enabled: false }, toolBash: { enableRunInBackground: false }, - toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, + toolTasks: false as const, } expect(agentCore.pickSpineConfig(appConfig)).toEqual({ @@ -318,7 +333,7 @@ describe('dsh-agent-spine-demo bundle', () => { toolOrder: appConfig.toolOrder, tools: appConfig.tools, workspaceContext: false, - skills: {}, + skills: appConfig.skills, toolBash: appConfig.toolBash, toolTasks: appConfig.toolTasks, }) diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts index d7ae866b53..754dd7d8de 100644 --- a/packages/examples/stdio-demo/src/index.ts +++ b/packages/examples/stdio-demo/src/index.ts @@ -51,7 +51,7 @@ export interface Config { skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ toolBash?: NonNullable - /** Generic background-task control-tool config forwarded through agent-core. */ + /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable /** * If set, the `main` agent RESUMES this persisted session id instead of @@ -77,7 +77,7 @@ export const Config: z = z.object({ welcome: z.string().default('ready.'), skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, - toolTasks: agentCore.ToolTasksConfigSchema, + toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), resumeSessionId: z.string(), workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), }) diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 30760a8fbc..aa169f320e 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -61,6 +61,9 @@ export class DeepSeekAdapter extends LlmAdapter { 'content-type': 'application/json', 'accept': 'text/event-stream', ...attributionHeaders(), + ...options.sessionId !== undefined + ? { 'x-deepseek-harness-session-id': String(options.sessionId) } + : {}, }, body: JSON.stringify(body), ...options.signal ? { signal: options.signal } : {}, diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 46f123a1c7..7898c62131 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -3,6 +3,7 @@ import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { LlmError, userAgent } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek' import { assemble } from './assemble.ts' @@ -131,6 +132,19 @@ describe('DeepSeekAdapter against a mock server', () => { expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish']) }) + it('forwards the harness session id for host-side trajectory routing', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = await harness(server.url) + + await assemble(ctx, { + model: 'deepseek-v4-flash', + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + sessionId: SessionId('child-session'), + }) + + expect(server.headers[0]?.['x-deepseek-harness-session-id']).toBe('child-session') + }) + it('forwards thinking config onto the wire', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) const ctx = await harness(server.url, { thinking: 'disabled', reasoningEffort: 'high' }) diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index bf686ea800..fdec9920f7 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -8,7 +8,7 @@ Stdio JSON-RPC plugin for out-of-process SDK clients such as Python `deepseek_ha ## Config -No `cordis.yml` keys. `JsonRpcConfig.input`, `output`, and `exit` are test-only runtime seams; production uses process stdio and `process.exit`. +`maxTokensAsSuccess` defaults to `false`. Set it to `true` for evaluation hosts that distinguish an accepted, token-limited agent result from an infrastructure failure. `JsonRpcConfig.input`, `output`, and `exit` are test-only runtime seams; production uses process stdio and `process.exit`. ## stdout is the protocol diff --git a/packages/ui/jsonrpc/src/index.ts b/packages/ui/jsonrpc/src/index.ts index 29fb1aeff8..fe782c934f 100644 --- a/packages/ui/jsonrpc/src/index.ts +++ b/packages/ui/jsonrpc/src/index.ts @@ -22,8 +22,10 @@ export const name = 'jsonrpc' // Only the agent factory is required; initialize reads the optional LLM seam with ctx.get(). export const inject = ['agents'] -/** Runtime-only test seams; no field is configurable from `cordis.yml`. */ +/** JSON-RPC deployment config plus runtime-only test seams. */ export interface JsonRpcConfig { + /** Report max-token turn/subagent termination as a successful SDK result. */ + maxTokensAsSuccess?: boolean /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ @@ -32,7 +34,9 @@ export interface JsonRpcConfig { exit?: (code: number) => void } -export const Config: Schema = Schema.object({}) +export const Config: Schema = Schema.object({ + maxTokensAsSuccess: Schema.boolean().default(false), +}) /** * Serve SDK requests over the configured streams. Effect disposal shuts down @@ -51,7 +55,9 @@ export function apply(ctx: Context, config: JsonRpcConfig): void { const exit = config.exit ?? ((code: number): void => { process.exit(code) }) const transport = new JsonRpcLineTransport(input, output) - const server = new HarnessSdkServer(ctx, transport) + const server = new HarnessSdkServer(ctx, transport, { + maxTokensAsSuccess: config.maxTokensAsSuccess ?? false, + }) // Share one exit task and attempt flush and disposal independently before exiting. let exitTask: Promise | undefined diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 91ef655c0c..44b746adce 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -59,6 +59,12 @@ interface SubagentRecord { parentSessionId: string | undefined } +/** Deployment-specific status mapping for SDK turn and subagent outcomes. */ +export interface HarnessSdkServerOptions { + /** Report max-token termination as an accepted result instead of an infrastructure error. */ + maxTokensAsSuccess?: boolean +} + /** * SDK server over one booted harness context and transport peer. Construction * subscribes to session, agent, and subagent lifecycle events until shutdown; @@ -78,6 +84,7 @@ export class HarnessSdkServer { constructor( private readonly ctx: Context, private readonly transport: JsonRpcTransportPeer, + private readonly options: HarnessSdkServerOptions = {}, ) { this.disposers.push(ctx.on('session/event', (session, event) => { if (event.type === 'turn/end') { @@ -116,7 +123,7 @@ export class HarnessSdkServer { agentId: String(info.id), ...(parentSessionId === undefined ? {} : { parentSessionId }), childSessionId, - status: info.stopReason === 'completed' ? 'ok' : 'error', + status: this.successStatus(info.stopReason), stopReason: info.stopReason, ...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }), }) @@ -253,7 +260,12 @@ export class HarnessSdkServer { private finishedStatus(reason: TurnEndReason | undefined): 'ok' | 'error' { if (!reason) return 'error' - return reason.kind === 'completed' ? 'ok' : 'error' + return this.successStatus(reason.kind) + } + + private successStatus(reason: string): 'ok' | 'error' { + if (reason === 'completed') return 'ok' + return reason === 'max-tokens' && this.options.maxTokensAsSuccess === true ? 'ok' : 'error' } private hasAdapterFor(model: string): boolean { diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index c993bd4e2c..c8a6bbb217 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -329,7 +329,7 @@ describe('HarnessSdkServer', () => { agentOptions: { model: 'deepseek' }, }) const transport = new FakeTransport() - const server = new HarnessSdkServer(ctx, transport) + const server = new HarnessSdkServer(ctx, transport, { maxTokensAsSuccess: true }) await settleSubagent(ctx, parentHandle.agent, { provider: 'fork', @@ -355,7 +355,7 @@ describe('HarnessSdkServer', () => { agentId: 'fallback-child-agent', parentSessionId: 'fallback-parent', childSessionId: 'fallback-child-session', - status: 'error', + status: 'ok', stopReason: 'max-tokens', lastAssistantMessage: [], }, @@ -443,6 +443,24 @@ describe('HarnessSdkServer', () => { } }) + it('can report max-token turn termination as an accepted evaluation result', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-max-tokens-success-')) + const ctx = await makeHarness(storageDir) + try { + const server = new HarnessSdkServer(ctx, new FakeTransport(), { maxTokensAsSuccess: true }) as unknown as { + finishedStatus(reason: unknown): 'ok' | 'error' + shutdown(): Promise> + } + + expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('ok') + expect(server.finishedStatus({ kind: 'error' })).toBe('error') + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + it('reports no adapter when the LLM service is absent', async () => { const ctx = new Context() try { From b50e02a2b33eae201e83b016b49aae4e50ab68ea Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 17 Jul 2026 23:33:30 +0800 Subject: [PATCH 03/37] ci: add manual pi-ai provider e2e --- .github/workflows/pi-ai-provider-e2e.yml | 77 +++++++++ .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 146 ++++++++++++++++++ vitest.e2e.config.ts | 5 +- 3 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/pi-ai-provider-e2e.yml create mode 100644 packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts diff --git a/.github/workflows/pi-ai-provider-e2e.yml b/.github/workflows/pi-ai-provider-e2e.yml new file mode 100644 index 0000000000..7a273c52f3 --- /dev/null +++ b/.github/workflows/pi-ai-provider-e2e.yml @@ -0,0 +1,77 @@ +name: E2E (pi-ai OpenAI and Anthropic) + +# This suite spends tokens against two external providers and is intentionally +# opt-in. It has no push, pull_request, schedule, or workflow_call trigger. +on: + workflow_dispatch: + inputs: + openai_model: + description: OpenAI model from pi-ai's installed catalog + required: true + default: gpt-5.5 + type: string + anthropic_model: + description: Anthropic model from pi-ai's installed catalog + required: true + default: claude-opus-4-8 + type: string + +permissions: + contents: read + +jobs: + e2e: + runs-on: ubuntu-latest + name: OpenAI Responses + Anthropic Messages + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Enable corepack (pnpm) + run: corepack enable + + - name: Resolve pnpm store path + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-24-pnpm- + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + # The tests self-skip locally when a credential is absent. A manually + # dispatched CI run must fail instead of reporting an all-skipped green. + - name: Preflight (require provider API keys) + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY_EXTERNAL }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY_EXTERNAL }} + run: | + set -euo pipefail + missing=0 + for name in OPENAI_API_KEY ANTHROPIC_API_KEY; do + if [ -z "${!name:-}" ]; then + echo "::error::${name} is empty. Configure the corresponding *_EXTERNAL repository secret." + missing=1 + fi + done + exit "$missing" + + - name: E2E tests (real OpenAI and Anthropic APIs) + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY_EXTERNAL }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY_EXTERNAL }} + DSH_PI_AI_OPENAI_MODEL: ${{ inputs.openai_model }} + DSH_PI_AI_ANTHROPIC_MODEL: ${{ inputs.anthropic_model }} + DSH_E2E_MAX_WORKERS: 2 + run: >- + pnpm exec vitest run --config vitest.e2e.config.ts + packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts new file mode 100644 index 0000000000..7fffba4618 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService, { CallId } from '@deepseek-ai/dsh-llm' +import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' +import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' +import type { PiAiReplayState } from '@deepseek-ai/dsh-llm-pi-ai' +import { assemble, type AssembledResult } from './assemble.ts' + +interface ProviderCase { + provider: 'openai' | 'anthropic' + api: 'openai-responses' | 'anthropic-messages' + model: string + apiKey?: string +} + +const providerCases: ProviderCase[] = [ + { + provider: 'openai', + api: 'openai-responses', + model: process.env.DSH_PI_AI_OPENAI_MODEL ?? 'gpt-5.5', + ...process.env.OPENAI_API_KEY ? { apiKey: process.env.OPENAI_API_KEY } : {}, + }, + { + provider: 'anthropic', + api: 'anthropic-messages', + model: process.env.DSH_PI_AI_ANTHROPIC_MODEL ?? 'claude-opus-4-8', + ...process.env.ANTHROPIC_API_KEY ? { apiKey: process.env.ANTHROPIC_API_KEY } : {}, + }, +] + +const contexts: Context[] = [] + +async function harness(): Promise { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: providerCases.map(profile => ({ + provider: profile.provider, + ...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey }, + })), + }) + return ctx +} + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +function ask(text: string): Message[] { + return [{ role: 'user', content: [{ type: 'text', text }] }] +} + +function textOf(result: AssembledResult): string { + return result.message.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') +} + +function expectNativeReplay(result: AssembledResult, profile: ProviderCase): PiAiReplayState { + const replayState = result.message.provenance?.replayState + expect(replayState).toMatchObject({ + kind: 'pi-ai', + version: 1, + api: profile.api, + provider: profile.provider, + model: profile.model, + }) + return replayState as PiAiReplayState +} + +const lookupTool: ToolSchema = { + name: 'lookup_code', + description: 'Look up the word represented by a short code.', + parameters: { + type: 'object', + properties: { code: { type: 'string', description: 'The code to look up.' } }, + required: ['code'], + }, +} + +for (const profile of providerCases) { + describe.skipIf(profile.apiKey === undefined)( + `llm-pi-ai ${profile.provider} e2e (${profile.api})`, + () => { + it('streams text with usage and native replay metadata', async () => { + const ctx = await harness() + const result = await assemble(ctx, { + provider: profile.provider, + model: profile.model, + messages: ask('Reply with exactly the word: pong'), + maxTokens: 64, + }) + + expect(result.finish.kind).toBe('stop') + expect(textOf(result).toLowerCase()).toContain('pong') + expect(result.usage?.inputTokens).toBeGreaterThan(0) + expect(result.usage?.outputTokens).toBeGreaterThan(0) + expect(expectNativeReplay(result, profile).stopReason).toBe('stop') + }) + + it('round-trips a tool call with provider-native replay metadata', async () => { + const ctx = await harness() + const prompt = ask('Use lookup_code with code "blue". Do not answer without calling the tool.') + const first = await assemble(ctx, { + provider: profile.provider, + model: profile.model, + messages: prompt, + tools: [lookupTool], + maxTokens: 256, + }) + + expect(first.finish.kind).toBe('tool-calls') + const call = first.message.content.find(block => block.type === 'tool-call') + expect(call).toBeDefined() + expect(call!.name).toBe('lookup_code') + expect(JSON.parse(call!.arguments)).toMatchObject({ code: 'blue' }) + expect(expectNativeReplay(first, profile).stopReason).toBe('toolUse') + + const second = await assemble(ctx, { + provider: profile.provider, + model: profile.model, + messages: [ + ...prompt, + first.message, + { + role: 'user', + content: [{ + type: 'tool-result', + toolCallId: CallId(call!.id), + content: [{ type: 'text', text: 'The code blue means ocean.' }], + }], + }, + ], + tools: [lookupTool], + maxTokens: 256, + }) + + expect(second.finish.kind).toBe('stop') + expect(textOf(second).toLowerCase()).toContain('ocean') + expect(expectNativeReplay(second, profile).stopReason).toBe('stop') + }) + }, + ) +} diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index adc8b3f3b2..6a84b13daf 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -2,8 +2,9 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' // Real-API suite, separate because it spends tokens. Each test self-skips without -// DEEPSEEK_API_KEY for keyless CI; the credentialed workflow preflights the secret. Values may come -// from the environment or gitignored root `.env`, with optional DEEPSEEK_BASE_URL. +// its provider credential for keyless CI; credentialed workflows preflight the +// secrets they require. Values may come from the environment or gitignored root +// `.env`, with provider-specific endpoint overrides where supported. try { // Node >= 21.7 native; throws when the file does not exist. process.loadEnvFile(new URL('.env', import.meta.url).pathname) From 923b04606eea08202b31eed132513b6d49628009 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:09:14 +0800 Subject: [PATCH 04/37] docs: DeepSeek Harness SDK follow-up work design draft --- docs/sdk-后续工作-设计.md | 197 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 docs/sdk-后续工作-设计.md diff --git a/docs/sdk-后续工作-设计.md b/docs/sdk-后续工作-设计.md new file mode 100644 index 0000000000..a2096cf42f --- /dev/null +++ b/docs/sdk-后续工作-设计.md @@ -0,0 +1,197 @@ +# DeepSeek Harness SDK 后续工作设计 + +> 状态:设计成稿,供通读与评审。定案后由 ccyu 转正式 RFC 并双语化。本文件为临时设计文档,不走 doc-sync / 文档预算门禁。 +> 一句话:**SDK 初版已合并;本轮把"创建项目""创建插件""遥测""交互测试"四块补齐,核心是抽出一个既撑交互又撑 headless 的创建内核,其余三块围绕它扩展。** + +## 0. 总览(一屏读完) + +抽一个既撑交互又撑 headless 的**创建内核**(`Prompter` seam + 复用已有 `ProjectEditSession` + `CreationDriver` 发 NDJSON),四块活围绕它扩展。 + +| 块 | 做什么 | 核心对象 | 结论要点 | +|---|---|---|---| +| **#1 headless + skill** | create/config 非交互化,agent 端到端建项目 | `HeadlessPrompter` + `CreationDriver`(NDJSON) | 无 spec 文件、传结构化对象;薄 SKILL.md 入口;beyond-eve | +| **#2 建插件** | `dsh-sdk create ` 拉插件并接线 | `PluginSource` + `PluginFetcher`(giget/pacote) | 只解压不执行、锁版本、经 `ProjectEditSession` 显式接线 | +| **#3 遥测** | 每个 `dsh-sdk` 命令上报 | `TelemetryReporter` / `ConsentResolver` / `SecretRedactor` | 发 cordis.yml+package.json 全文;不发 `.env`、疑似密钥脱敏;关闭 = cordis.yml 有明确 disabled 的遥测条目(甲)| +| **#4 交互测试** | 覆盖 wizard 各分支、快照 cordis.yml | `WizardHarness` + clack mock 注入 | 注入流为主、真 PTY 仅 1–2 个可选 smoke | + +**节奏**:先做地基(`Prompter` 重构,同时解锁 #1+#4,不并行)→ 再 fan out 四棵 teammate worktree。 + +细节见下文;不想通读的话,读完本节 + §5(砍掉的路线)+ §6(节奏)即可。 + +## 1. 背景与现状 + +SDK 初版(`packages/sdk/*`)已经落地三个包: + +| 包 | 职责 | 当前局限 | +|---|---|---| +| `@deepseek-ai/create-sdk` | `npm create @deepseek-ai/sdk` 交互式建项目 | **TTY-only**:flag 只能预填问题,创建仍须交互终端;feature 配置面窄,复杂 feature 难在命令行表达 | +| `@deepseek-ai/dsh-helper` | 项目领域模型:feature 催表、蓝图、`ProjectEditSession`(唯一的改写/提交边界) | create-sdk 与 `dsh-sdk config` 共用同一套催表与 configurator,但目前只被交互式 wizard 驱动 | +| `@deepseek-ai/dsh-scripts` | `dsh-sdk` launcher:`start` / `dev` / `build` / `config` | `config` 同样 **TTY-only**;`build` 只跑 tsdown、`create` 时项目尚不存在——两者都不 boot cordis | + +四块后续工作: + +1. **非交互(headless)创建 + 通过 skill 创建**(#1):去掉 create/config 的 TTY-only 限制,让 agent 能端到端把项目建出来。 +2. **`dsh-sdk create ` 从 github/npm 建插件**(#2):把外部插件拉进现有项目并接线。 +3. **遥测**(#3):每次 `dsh-sdk `(含 create/首次初始化)上报开发者周期数据。 +4. **终端交互测试**(#4):给 clack wizard 加可回归、可核对的交互测试。 + +## 2. 边界澄清:三件事分开 + +调研 `vercel/eve` 及其引用的 `skills` CLI 后,确认三件事必须分开、不能混: + +| 轨 | 命令入口 | 产物 | +|---|---|---| +| **建项目** | `npm create @deepseek-ai/sdk`(现有 wizard)+ 其 headless 形态(本轮新增) | 一个新的 SDK 项目 | +| **建插件** | `dsh-sdk create `(现有 create-plugin 扩展) | 现有项目里多一个接好线的插件 | +| **分发 skill** | 发 SKILL.md → agent `npx skills add deepseek-ai/` | 任意 agent 拉到我们的 skill playbook | + +事实依据:`skills`(vercel-labs/skills)是 markdown SKILL.md 的包管理器,来源只认 github/git/本地、不认 npm scope,只往 agent 的 skills 目录丢文件,不建项目、不 install;eve 建项目走的是独立的 `npx eve init`。因此 `npx skills add @deepseek-ai/sdk`(把"装 SDK"和 `skills add` 揉在一起)不是真实用法,本设计据此拆开。 + +## 3. 总体架构 + +### 3.1 核心洞见:一个创建内核,四条轨围绕它 + +四块活看似独立,实则都咬合在同一组对象上。**建项目和改配置的本质是"问答 → 改项目文件"**,而 `dsh-helper` 已经有唯一的改写边界 `ProjectEditSession`。本轮把"问答"这一侧也抽成 seam,就能让交互与 headless 共用一套逻辑,测试和 skill 顺势接上去。 + +``` + ┌───────────────────────────────┐ + │ CreationDriver / SetupRunner │ 编排:问答序列 → 组装 spec → 驱动改写 + │ (emits NDJSON) │ + └───────────────┬───────────────┘ + 借助 │ 驱动 + ┌─────────────────────┐ │ ┌────────────────────────────┐ + │ Prompter (seam) │◄──────────┘ │ ProjectEditSession (已有) │ 唯一改写/提交边界 + ├─────────────────────┤ └────────────────────────────┘ + │ InteractivePrompter │ clack + 注入 input/output(解锁 #4 测试) + │ HeadlessPrompter │ 永不阻塞:答案取自结构化 spec,缺必答项→抛错/发 action-required(解锁 #1 + skill) + └─────────────────────┘ +``` + +- **`Prompter`(新 seam)**:把"向用户要一个答案"抽象出来。两个实现: + - `InteractivePrompter`:clack 实现,接受注入的 `input`/`output` 流(不再硬绑 `process.stdin/stdout`)。这正是 #4 交互测试的前置。 + - `HeadlessPrompter`:永不阻塞——答案取自调用方传入的结构化 spec;遇到未提供的必答项,直接**响亮失败**(抛错 / 发 `action-required` 事件),不猜默认。这是 #1 headless 与 skill 驱动的地基。 +- **`ProjectEditSession`(复用已有)**:唯一的改写/提交边界。create、config、以及 #2 建插件改 cordis.yml,全部经它落盘。 +- **`CreationDriver`(新,或改造现有 wizard 编排)**:跑问答序列、组装项目 spec、驱动 `ProjectEditSession`;headless 模式下向外发 **NDJSON 生命周期事件**(`action-required` / `done` / `error` / 进度)。 +- **skill 路径**:agent import 这个内核、传结构化 config 对象、读 NDJSON 事件;附一层薄 SKILL.md 教 agent 怎么驱动。 + +**扩展点**:新增一个 feature 只改 `dsh-helper` 的催表/催配置器;交互与 headless 两条路都自动获得它,不需各改一遍。 + +> **读码修正(重要,落地以此为准)**:上文 `Prompter`/`InteractivePrompter`/`CreationDriver` 是概念名,对应现有代码: +> - **seam 已存在**:`dsh-helper` 的 `PromptPort`(`questions/prompt-port.ts`)即 `Prompter`;问答走 `Question.resolve(port, prefilled?)`——给了 prefill 就不碰 port。 +> - **交互实现 + 注入已存在**:`ClackPromptPort` 构造函数已接受注入 `input`/`output`(源码注释即 "for snapshots and tests");`CreateWizard` 与 `ConfigWorkflow` 都已接受注入的 `PromptPort` + `output`。 +> - **⇒ #4 交互测试不被地基阻塞**:注入点今天就有,已单独开 teammate 并行做(覆盖 create + config 两个 wizard)。 +> - **地基真正要做的(比原设想小)**:新增 `HeadlessPromptPort implements PromptPort`(缺项 fail-fast + 发 NDJSON)+ 补全 prefill 覆盖——目前 feature 选择走原始 `nestedMultiselect`、`FeatureConfigurator` 的 valueInputs 无 prefill、suggests 确认无 prefill;headless 要让结构化 spec 喂满这些点。 + +### 3.2 四块如何咬合到这组对象 + +| 块 | 落在哪个对象 | 关系 | +|---|---|---| +| #1 headless + skill | `HeadlessPromptPort`(实现已有 `PromptPort`)+ prefill 补全 + NDJSON | 地基(缩小版)| +| #4 交互测试 | 注入 `ClackPromptPort(mockIn, mockOut)` 进已有 `CreateWizard`/`ConfigWorkflow` + `WizardHarness` | **注入点已存在,不阻塞,已并行开工** | +| #2 建插件 | `PluginSource` + `PluginFetcher` + 经 `ProjectEditSession` 接线 | 复用改写边界 | +| #3 遥测 | launcher 侧 `TelemetryReporter` + `ConsentResolver` + `SecretRedactor` | 独立于内核,挂在 launcher 命令生命周期 | + +## 4. 详细设计 + +### 4.1 headless 创建 + skill(#1) + +**目标**:`create-sdk`(建新项目)与 `dsh-sdk config`(改现有项目)都能非交互运行;agent 能端到端把项目建完。 + +**设计**: + +- **Prompter seam**:如 §3。交互走 `InteractivePrompter`,headless 走 `HeadlessPrompter`(fail-fast + NDJSON),二者背后是同一套 `dsh-helper` 催表和同一个 `ProjectEditSession`。 +- **输入编码**(次要、可解耦): + - **agent 路径**:传结构化 config 对象(程序化,或 `--config-json '{...}'`)+ 读 NDJSON。**不需要 spec 文件**——与 eve 一致;我们 feature 比 eve 重(嵌套有限选项 + 密钥),结构化对象比一长串扁平 flag 干净。 + - **人 / CI 路径**(可选):`--config `(yaml/json)或 flags,只是喂给同一内核的另一种编码。 +- **skill**:核心是 headless 内核;agent 传参直接建完,缺必答项就响亮失败让 agent 补答。附一层**薄 SKILL.md**(指向内核、教 agent 驱动),让"通过 skill 创建"字面落地。 +- **比 eve 更进一步**:eve 把 headless 原语(`runHeadless` + 非阻塞 Prompter + NDJSON)造好了,却没接到它的 skill——它的 SKILL.md 只指向半交互 CLI,且 agent 跑 `eve init` 时只打印指引、打回给人。我们把 **skill → headless 内核接通**,才真正做到"headless 为 skill 服务"。 + +### 4.2 `dsh-sdk create ` 建插件(#2) + +**目标**:从 github repo 或 npm 包拉一个插件进现有项目并接线;安全第一。 + +**设计(只解压不执行 + 锁版本 + 显式接线)**: + +- **`PluginSource`(判别联合)**:`GithubSource`(`owner/repo[/subdir]#ref`)| `NpmSource`(`pkg@version`)。由 spec 字符串解析而来。 +- **`PluginFetcher`(seam)**:把源抓进 temp 目录,**绝不执行被拉代码的生命周期脚本**。 + - `GigetFetcher`(github/git):giget;`#ref` 先解析成 commit SHA 再下、记进 lock。 + - `PacoteFetcher`(npm):pacote `extract`(只解包不跑 postinstall),带 `integrity` 校验。来源类型限定放在**上游 `resolvePluginSource`**(只产出 `name@version`)作为主保证,不依赖 pacote 的 `allowRegistry`(`@types/pacote` 无此选项,且 registry tarball extract 本就不跑脚本)。 +- **接线(显式可审)**: + 1. `package.json` 精确锁版本(npm:exact + integrity;github:`github:owner/repo#`)。 + 2. 经 `ProjectEditSession` 改 `cordis.yml` 挂插件——**给 diff、要确认**再写。 + 3. `install --ignore-scripts`(pnpm v10 默认亦拦依赖 build 脚本)。 + 4. 打印清单(dep spec + 锁的 ref/integrity + cordis.yml diff)。 +- **信任模型**:学 `npm create` 的手感,但把信任反过来——**confirm-before-run,而非 run-on-fetch**。 +- **repo 初始化模式**(从模板仓库整体建项目)同走 giget(优于 degit——degit 的 `degit.json` 会自动跑动作);建远程新仓可用 `gh repo create --template`。注:eve 不支持 template-repo init,这是我们的自有取舍。 + +### 4.3 遥测(#3) + +**目标**:每次 `dsh-sdk `(create / dev / build / config / start / 首次初始化)上报当前 `cordis.yml` + `package.json` 内容。 + +**设计**: + +- **上报器位置**:在**我们自己的代码执行时机**里——`create-sdk` 进程 + `dsh-scripts` launcher 进程,包住命令生命周期。 + - 理由:`build` 只跑 tsdown、`create` 时项目还不存在,都不 boot cordis;写在 cordis.yml 里的 cordis 插件抓不到它们。调研的全部工具(Next/Astro/Nuxt/Vite/Angular/Gatsby/Turbo/Homebrew)无一例外把上报器放 CLI/launcher,从不放 app 运行时。 +- **`TelemetryReporter`(launcher 侧)**:包住命令,收集 `{command, 时长, 成败, cordis.yml 内容, package.json 内容}`。 +- **`ConsentResolver`**:在每个命令**解析(非 boot)`cordis.yml`**,读遥测插件状态当 consent。**关闭(甲,ccyu 拍板)= cordis.yml 里有一条明确 `disabled` 的遥测条目**;其余一切(无 cordis.yml / 有文件但无遥测条目 / 有 enabled 条目)都上报——唯一的关只有"存在且 disabled",无不对称。(可选、近零成本补充,留到实现定:额外认 `DO_NOT_TRACK` / CI 自动关。) +- **`SecretRedactor`(安全硬线)**:绝不发 `.env`;cordis.yml / package.json 里若出现类似密钥的值,**脱敏替换**(redact 占位,不整段丢)。依赖 SDK 约定——密钥只进 `.env`、cordis.yml 只引用 env 不内联——脱敏是兜底。 +- **匿名 id**:全局配置里的随机 UUID;**绝不从 git remote / repo URL 派生**。 +- **endpoint**:内置在代码里。 +- **consent 承载**:遥测作为 `create` 时默认打开的 feature 写进 cordis.yml(对用户可见、随项目)。 + +**在案取舍**:发全文会把第三方(含私有 scoped)包名、cordis 配置值(base-url/路径)暴露给 endpoint 持有方;主流工具都不发这些(Turbo 排除包名、Angular 禁模块名)。ccyu 作为本 SDK 维护者接受此暴露——目的即掌握开发者用了哪些 plugin/依赖/配置。 + +### 4.4 交互测试(#4) + +**目标**:CI 仅 mac/linux;覆盖 create wizard 主流程 + config wizard 各选择分支;产出不同选择下的 `cordis.yml` 快照便于核对。 + +**设计(clack mock 注入,不上真 PTY 打头阵)**: + +- **主力**:进程内注入 mock stdin/stdout。`@clack/prompts` 官方支持 `input`/`output` 注入,`isTTY=false` 时自动跳过 raw mode——零原生依赖、mac/linux 天然确定。**前置已就绪**:`ClackPromptPort` 已接受注入流,`CreateWizard`/`ConfigWorkflow` 已接受注入 `PromptPort`。 +- **`WizardHarness`(测试工具)**:用脚本化 keypress 序列(`input.emit('keypress', …)` 走 down/space/return)驱动 wizard、写到 temp 目录、返回生成的 `cordis.yml`。 +- **断言**:`test.each(选择组合)` → `toMatchFileSnapshot('.cordis.yml')`;**快照生成的 cordis.yml**,不快照交互 transcript(transcript 脆、且是在测 clack 自己)。 +- **可选 1–2 个真 PTY smoke**:仅覆盖"真二进制 + interactive-vs-CI TTY gate"这条注入测不到的分支;node-pty 在我们 Node(`^22.19 || >=24`)上有原生编译风险,**挪出关键路径**,缺工具链 self-skip。 + +## 5. 砍掉的路线(调研依据) + +| 砍掉的路线 | 理由 | +|---|---| +| #4 用真 PTY(node-pty)打头阵 | clack 官方支持注入流,无需真 TTY;node-pty 在我们 Node 版本上有原生编译风险。真 PTY 降级为 1–2 个可选 smoke | +| #4 快照交互 transcript | transcript 受重绘/spinner/ANSI 影响脆弱,且主要在测 clack 渲染而非我们的生成逻辑 | +| #3 上报器做成 cordis 插件 | `build`/`create` 不 boot cordis,插件抓不到;无一主流工具用 app 内插件做遥测 | +| #3 匿名 id 从 git remote 派生 | 会让"匿名"变假(Next 因此挨批) | +| #1 headless 以 spec 文件为主 | eve 没有 spec 文件;agent 路径传结构化对象更干净,spec 文件退成人/CI 可选 | +| #2 从 npm/github 拉完自动 install+build | 会执行被拉代码的 postinstall,供应链风险;改为只解压 + 显式接线 + `--ignore-scripts` | +| #1 用 `npx skills add` 创建项目 | `skills` 是 markdown SKILL.md 包管理器、不建项目、不认 npm scope,属概念混淆 | + +## 6. 推进节奏:地基缩小,已并发开工 + +**读码后修正**:`PromptPort` seam + 可注入的 `ClackPromptPort` + 可注入的 `CreateWizard`/`ConfigWorkflow` 都已存在,所以地基比原设想小,且 #4 不再被它阻塞。当前并发结构: + +1. **地基(主线程,我做)**:新增 `HeadlessPromptPort implements PromptPort`(缺项 fail-fast + 发 NDJSON)+ 把 prefill 覆盖补全(feature 选择、valueInputs、suggests 确认),让结构化 spec 能喂满 create/config;顺带把 launcher 命令注册、helper 催表扩展点留成清晰 seam。这是 #1 headless 与 #2/#3 接线步的公共前置。 +2. **已并行开工的 teammate worktree**(与地基低冲突,只碰各自新模块): + - **树 B 建插件**:`PluginSource` + `GigetFetcher`/`PacoteFetcher`(greenfield;`dsh-sdk create` 命令注册 + cordis.yml 接线等地基后再做)。 + - **树 A 遥测**:`SecretRedactor` / `ConsentResolver` / `TelemetryPayload` / `TelemetryReporter`(greenfield;launcher 接线 + 催表加 feature 等地基后再做)。 + - **树 C 交互测试**:`WizardHarness` + create/config 的 `test.each` cordis.yml 快照(注入点已就绪,可全量做)。 +3. **地基落地后的收尾**(接线,碰共享文件):#2 的 `dsh-sdk create` 命令注册 + cordis.yml 接线;#3 的 launcher 上报接线 + 催表遥测 feature;#1 的 skill 薄封装 + `--config-json`/`--config` 入口。 +4. **合并**:stacked PR,地基在底,其余 rebase 到地基上,逐层向上同步。 + - **树 D 薄 SKILL.md**:SKILL.md + 相关文档(并入第 3 步收尾)。 + +> 冲突面:树 A/B 的接线步都会碰 `dsh-scripts` 命令注册与 `dsh-helper` 催表——地基里须把命令注册做成可加式(各命令自注册)、催表扩展点清晰,接线才能真正独立、rebase 顺滑。greenfield 模块阶段(当前)不碰这些共享文件,所以能安全并行。 + +## 7. 各轨改动清单 + +| 轨 | 主要包 | 关键改动 | +|---|---|---| +| 地基 | dsh-helper, create-sdk, dsh-scripts | `HeadlessPromptPort`(实现已有 `PromptPort`)+ prefill 补全 + NDJSON + 命令注册/催表扩展点整理 | +| #1 headless+skill | create-sdk, dsh-scripts, (新)skill 包 | 结构化 spec 入口 `--config-json`/`--config`、薄 SKILL.md | +| #2 建插件 | (新)fetcher 包, dsh-scripts, dsh-helper | `PluginSource`、`GigetFetcher`/`PacoteFetcher`、`dsh-sdk create ` 注册、经 `ProjectEditSession` 接线+diff、新依赖 giget/pacote | +| #3 遥测 | (新)telemetry 包, dsh-scripts, dsh-helper | `TelemetryReporter`/`ConsentResolver`/`SecretRedactor`、launcher 接线、催表遥测 feature、内置 endpoint、全局 UUID | +| #4 测试 | packages/support, (已可注入)create-sdk/dsh-scripts | `WizardHarness`、create/config 的 `test.each` cordis.yml 快照、可选 PTY smoke | + +## 8. 调研来源 + +- eve / skills:https://github.com/vercel/eve · https://github.com/vercel-labs/skills +- 从源拉取:https://github.com/unjs/giget · https://github.com/npm/pacote · https://github.com/Rich-Harris/degit +- 遥测规范:https://nextjs.org/telemetry · https://astro.build/telemetry/ · https://github.com/nuxt/telemetry · https://angular.dev/cli/analytics · https://turborepo.dev/docs/telemetry · https://consoledonottrack.com +- PTY / clack 测试:https://github.com/bombshell-dev/clack · https://vitest.dev/guide/snapshot · https://github.com/microsoft/node-pty From dcd886798ff93bc6f4945ad4aea6e3c8658969ef Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:11:07 +0800 Subject: [PATCH 05/37] feat(sdk): headless PromptPort + create/config headless plan Add HeadlessPromptPort (fail-loud non-interactive PromptPort), thread prefilled feature values through FeatureConfigurator, and let CreateWizard and ConfigWorkflow accept a headless feature plan that skips the interactive tree/suggests prompts. Per-file 100% coverage on all changed files. --- packages/sdk/create-sdk/src/create-wizard.ts | 75 ++++++------ packages/sdk/create-sdk/tests/create.spec.ts | 50 ++++++++ .../src/features/feature-configurator.ts | 9 +- packages/sdk/helper/src/index.ts | 1 + .../src/questions/headless-prompt-port.ts | 97 ++++++++++++++++ .../helper/tests/headless-prompt-port.spec.ts | 95 +++++++++++++++ packages/sdk/helper/tests/questions.spec.ts | 31 +++++ .../sdk/scripts/src/config/config-workflow.ts | 108 +++++++++++------- packages/sdk/scripts/tests/scripts.spec.ts | 25 +++- 9 files changed, 415 insertions(+), 76 deletions(-) create mode 100644 packages/sdk/helper/src/questions/headless-prompt-port.ts create mode 100644 packages/sdk/helper/tests/headless-prompt-port.spec.ts diff --git a/packages/sdk/create-sdk/src/create-wizard.ts b/packages/sdk/create-sdk/src/create-wizard.ts index 8391fae1e4..fb6849ba2a 100644 --- a/packages/sdk/create-sdk/src/create-wizard.ts +++ b/packages/sdk/create-sdk/src/create-wizard.ts @@ -48,6 +48,7 @@ export class CreateWizard { private readonly versionProbe: PackageManagerVersionProbe private readonly userAgent: string private readonly linkWorkspaceRoot: string | undefined + private readonly featurePlan: readonly FeatureSelection[] | undefined /** Bind parsed args and infrastructure to one wizard run. */ constructor(options: { @@ -57,6 +58,7 @@ export class CreateWizard { releaseVersion: string versionProbe?: PackageManagerVersionProbe userAgent?: string + features?: readonly FeatureSelection[] }) { this.args = options.args this.port = options.port @@ -68,6 +70,7 @@ export class CreateWizard { this.linkWorkspaceRoot = options.args.linkWorkspace ? fileURLToPath(new URL('../../../../', import.meta.url)) : undefined + this.featurePlan = options.features } /** Collect all answers before constructing any project files. */ @@ -129,39 +132,43 @@ export class CreateWizard { const configurable = registry.all().filter(feature => feature.id === 'bash' || feature.id === 'persistence' || (!feature.required && feature.isApplicable(profile))) - const selected = [...requireAnswer(await this.port.nestedMultiselect({ - message: 'Select features', - options: configurable.map((feature) => { - const nested = feature.mode !== 'single' - const defaults = new Set(feature.defaultOptions(profile)) - return { - value: feature.id, - label: feature.summary, - required: feature.required, - default: feature.required || feature.id === 'hmr' || feature.id === 'fs' || feature.id === 'todo' - || feature.id === 'skill', - ...nested ? { - choiceMode: feature.mode === 'multiple' ? 'multiple' as const : 'exclusive' as const, - choices: feature.options.map(option => ({ - value: option.id, - label: option.label, - default: defaults.has(option.id), - })), - } : {}, + const selected = this.featurePlan + ? this.featurePlan.map(feature => ({ value: feature.id, choices: feature.options })) + : [...requireAnswer(await this.port.nestedMultiselect({ + message: 'Select features', + options: configurable.map((feature) => { + const nested = feature.mode !== 'single' + const defaults = new Set(feature.defaultOptions(profile)) + return { + value: feature.id, + label: feature.summary, + required: feature.required, + default: feature.required || feature.id === 'hmr' || feature.id === 'fs' || feature.id === 'todo' + || feature.id === 'skill', + ...nested ? { + choiceMode: feature.mode === 'multiple' ? 'multiple' as const : 'exclusive' as const, + choices: feature.options.map(option => ({ + value: option.id, + label: option.label, + default: defaults.has(option.id), + })), + } : {}, + } + }), + }))] + if (!this.featurePlan) { + for (const { value: id } of [...selected]) { + const feature = registry.get(id) + for (const suggestedId of feature.suggests) { + if (selected.some(item => item.value === suggestedId)) continue + const suggested = registry.get(suggestedId) + const add = requireAnswer(await new ConfirmQuestion({ + id: `${feature.id}.${suggested.id}`, + message: `Add the recommended ${suggested.summary.toLowerCase()} for ${feature.summary.toLowerCase()}?`, + initialValue: true, + }).resolve(this.port)) + if (add) selected.push({ value: suggested.id, choices: suggested.defaultOptions(profile) }) } - }), - }))] - for (const { value: id } of [...selected]) { - const feature = registry.get(id) - for (const suggestedId of feature.suggests) { - if (selected.some(item => item.value === suggestedId)) continue - const suggested = registry.get(suggestedId) - const add = requireAnswer(await new ConfirmQuestion({ - id: `${feature.id}.${suggested.id}`, - message: `Add the recommended ${suggested.summary.toLowerCase()} for ${feature.summary.toLowerCase()}?`, - initialValue: true, - }).resolve(this.port)) - if (add) selected.push({ value: suggested.id, choices: suggested.defaultOptions(profile) }) } } const fixed = new Set(selections.map(selection => selection.id)) @@ -174,12 +181,16 @@ export class CreateWizard { for (const choice of selected) { choices.set(choice.value, choice.choices.length > 0 ? choice.choices : undefined) } + const plannedById = new Map((this.featurePlan ?? []).map(feature => [feature.id, feature])) for (const [id, options] of choices) { + const planned = plannedById.get(id) selections.push(await configurator.configure( registry.get(id), profile, undefined, options, + planned?.secrets ?? {}, + planned?.values ?? {}, )) } return selections diff --git a/packages/sdk/create-sdk/tests/create.spec.ts b/packages/sdk/create-sdk/tests/create.spec.ts index 9d75700bb9..f4b66dc8ff 100644 --- a/packages/sdk/create-sdk/tests/create.spec.ts +++ b/packages/sdk/create-sdk/tests/create.spec.ts @@ -5,9 +5,11 @@ import { PassThrough, Writable } from 'node:stream' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' import { + HeadlessPromptPort, LocalPluginBlueprint, featureId, NpmPackageManager, + type FeatureSelection, type NestedMultiSelectValue, type PromptPort, } from '@deepseek-ai/dsh-helper' @@ -233,6 +235,54 @@ describe('CreateWizard and scaffolder', () => { expect(resolved.request.features.find(item => item.id === 'hmr')).toMatchObject({ options: ['default'] }) }) + it('runs headlessly from a feature plan without reaching the terminal', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'create-headless-')) + temporary.push(cwd) + const features: FeatureSelection[] = [ + { id: featureId('persistence'), options: ['sqlite'], values: { region: 'us' } }, + { id: featureId('web'), options: ['exa'], secrets: { apiKey: 'exa-key' } }, + ] + const resolved = await new CreateWizard({ + args: parseCreateArgs([ + 'my-agent', '--description=demo', '--provider=deepseek', '--api-key=deepseek-key', + '--model=deepseek-v4-flash', '--interface=stdio', '--pm=npm', '--no-install', + ]), + port: new HeadlessPromptPort(), + cwd, + releaseVersion: '0.0.1', + versionProbe: async () => '10.0.0', + features, + }).run() + expect(resolved.install).toBe(false) + expect(resolved.request.localPlugins).toEqual([]) + expect(resolved.request.features.find(item => item.id === 'web')).toMatchObject({ + options: ['exa'], secrets: { apiKey: 'exa-key' }, + }) + expect(resolved.request.features.find(item => item.id === 'persistence')).toMatchObject({ options: ['sqlite'] }) + expect(resolved.request.features.find(item => item.id === 'provider')).toMatchObject({ + secrets: { apiKey: 'deepseek-key' }, + }) + }) + + it('rejects a non-string feature value in a headless plan', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'create-headless-bad-')) + temporary.push(cwd) + const features = [ + { id: featureId('persistence'), options: ['sqlite'], values: { bad: 1 } }, + ] as unknown as FeatureSelection[] + await expect(new CreateWizard({ + args: parseCreateArgs([ + 'my-agent', '--description=demo', '--provider=deepseek', '--api-key=k', + '--model=m', '--interface=stdio', '--pm=npm', '--no-install', + ]), + port: new HeadlessPromptPort(), + cwd, + releaseVersion: '0.0.1', + versionProbe: async () => '10.0.0', + features, + }).run()).rejects.toThrow('must be a string') + }) + it('writes the project once and refuses every existing target', async () => { const root = await mkdtemp(join(tmpdir(), 'create-scaffold-')) temporary.push(root) diff --git a/packages/sdk/helper/src/features/feature-configurator.ts b/packages/sdk/helper/src/features/feature-configurator.ts index 47cc011d16..e6e14030f9 100644 --- a/packages/sdk/helper/src/features/feature-configurator.ts +++ b/packages/sdk/helper/src/features/feature-configurator.ts @@ -26,6 +26,7 @@ export class FeatureConfigurator { * @param current - currently installed selection, when configuring. * @param prefilledOptions - options already chosen by a tree picker. * @param prefilledSecrets - non-interactive secret values supplied by creation. + * @param prefilledValues - non-interactive value inputs supplied by a headless spec. * @returns normalized selection with captured values and secrets. */ async configure( @@ -34,6 +35,7 @@ export class FeatureConfigurator { current?: FeatureSelection, prefilledOptions?: readonly string[], prefilledSecrets: Readonly> = {}, + prefilledValues: Readonly> = {}, ): Promise { let options: readonly string[] switch (feature.mode) { @@ -69,6 +71,11 @@ export class FeatureConfigurator { id: feature.id, options, } + const coercedPrefilled: Record = {} + for (const [key, value] of Object.entries(prefilledValues)) { + if (typeof value !== 'string') throw new Error(`${feature.id}.${key} value must be a string`) + coercedPrefilled[key] = value + } const values: Record = {} for (const input of feature.valueInputs(selected, profile)) { const existing = current?.values?.[input.id] @@ -81,7 +88,7 @@ export class FeatureConfigurator { ...existing === undefined ? {} : { initialValue: existing }, validate: value => value.trim().length === 0 ? 'A value is required' : undefined, }) - values[input.id] = requireAnswer(await question.resolve(this.port)) + values[input.id] = requireAnswer(await question.resolve(this.port, coercedPrefilled[input.id])) } const base: FeatureSelection = Object.keys(values).length === 0 ? selected diff --git a/packages/sdk/helper/src/index.ts b/packages/sdk/helper/src/index.ts index 98c55c3f8b..85aba58a99 100644 --- a/packages/sdk/helper/src/index.ts +++ b/packages/sdk/helper/src/index.ts @@ -43,3 +43,4 @@ export { } from './questions/question.ts' export type { Question } from './questions/question.ts' export { ClackPromptPort } from './questions/clack-prompt-port.ts' +export { HeadlessPromptError, HeadlessPromptPort } from './questions/headless-prompt-port.ts' diff --git a/packages/sdk/helper/src/questions/headless-prompt-port.ts b/packages/sdk/helper/src/questions/headless-prompt-port.ts new file mode 100644 index 0000000000..658500aed0 --- /dev/null +++ b/packages/sdk/helper/src/questions/headless-prompt-port.ts @@ -0,0 +1,97 @@ +/** + * Non-interactive prompt port for headless create/config and skill-driven runs. + * + * @module @deepseek-ai/dsh-helper/questions/headless-prompt-port + */ + +import type { + ConfirmPromptRequest, + MultiSelectPromptRequest, + NestedMultiSelectRequest, + NestedMultiSelectValue, + PromptOutcome, + PromptPort, + SecretPromptRequest, + SelectPromptRequest, + TextPromptRequest, +} from './prompt-port.ts' + +/** + * Raised when a headless run reaches a decision that was neither prefilled nor + * carries a usable default. The message names the unanswered prompt so an agent + * or CI caller can see exactly which input the spec must supply. + */ +export class HeadlessPromptError extends Error { + /** The unanswered prompt's user-facing message. */ + readonly prompt: string + + /** Build an error naming the unanswered prompt. */ + constructor(prompt: string) { + super(`headless run needs an answer for: ${prompt}`) + this.name = 'HeadlessPromptError' + this.prompt = prompt + } +} + +/** Resolve an answered outcome. */ +function answered(value: T): Promise> { + return Promise.resolve({ status: 'answered', value }) +} + +/** Reject with a named unanswered-prompt error. */ +function unanswered(message: string): Promise> { + return Promise.reject(new HeadlessPromptError(message)) +} + +/** + * A {@link PromptPort} that never blocks on a terminal. + * + * Answers are expected to arrive as prefilled values through the `Question` / + * `FeatureConfigurator` layers, so in a fully specified run this port is never + * reached. When it *is* reached, it takes the prompt's own declared default + * (`defaultValue` / `initialValue`) if one exists; otherwise it fails loud with + * {@link HeadlessPromptError}. Nested feature selection has no scalar default, + * so it always fails loud — headless callers must supply the feature set through + * the spec rather than the tree picker. + */ +export class HeadlessPromptPort implements PromptPort { + /** Answer visible text from its default, or fail loud. */ + text(request: TextPromptRequest): Promise> { + const fallback = request.initialValue ?? request.defaultValue + if (fallback === undefined) return unanswered(request.message) + const diagnostic = request.validate?.(fallback) + if (diagnostic) return unanswered(`${request.message} (${diagnostic})`) + return answered(fallback) + } + + /** A secret has no safe default: always fail loud. */ + secret(request: SecretPromptRequest): Promise> { + return unanswered(request.message) + } + + /** Answer a single choice from its initial value, or fail loud. */ + select(request: SelectPromptRequest): Promise> { + if (request.initialValue === undefined) return unanswered(request.message) + return answered(request.initialValue) + } + + /** Answer a multi-choice from its initial values, or fail loud when required. */ + multiselect(request: MultiSelectPromptRequest): Promise> { + const initial = request.initialValues ?? [] + if (request.required && initial.length === 0) return unanswered(request.message) + return answered(initial) + } + + /** Answer a confirmation from its initial value, or fail loud. */ + confirm(request: ConfirmPromptRequest): Promise> { + if (request.initialValue === undefined) return unanswered(request.message) + return answered(request.initialValue) + } + + /** Nested feature selection has no scalar default: always fail loud. */ + nestedMultiselect( + request: NestedMultiSelectRequest, + ): Promise[]>> { + return unanswered(request.message) + } +} diff --git a/packages/sdk/helper/tests/headless-prompt-port.spec.ts b/packages/sdk/helper/tests/headless-prompt-port.spec.ts new file mode 100644 index 0000000000..12febcfeff --- /dev/null +++ b/packages/sdk/helper/tests/headless-prompt-port.spec.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { HeadlessPromptError, HeadlessPromptPort } from '../src/questions/headless-prompt-port.ts' + +/** Unwrap an answered outcome or fail the test. */ +async function answered(promise: Promise<{ status: 'answered'; value: T } | { status: 'cancelled' }>): Promise { + const outcome = await promise + if (outcome.status !== 'answered') throw new Error('expected an answered outcome') + return outcome.value +} + +describe('HeadlessPromptError', () => { + it('names the unanswered prompt', () => { + const error = new HeadlessPromptError('DeepSeek API key') + expect(error).toBeInstanceOf(Error) + expect(error.name).toBe('HeadlessPromptError') + expect(error.prompt).toBe('DeepSeek API key') + expect(error.message).toContain('DeepSeek API key') + }) +}) + +describe('HeadlessPromptPort', () => { + const port = new HeadlessPromptPort() + + describe('text', () => { + it('takes the initial value when present', async () => { + expect(await answered(port.text({ message: 'name', initialValue: 'agent' }))).toBe('agent') + }) + + it('falls back to the default value', async () => { + expect(await answered(port.text({ message: 'dir', defaultValue: 'my-agent' }))).toBe('my-agent') + }) + + it('prefers the initial value over the default value', async () => { + expect(await answered(port.text({ message: 'dir', initialValue: 'given', defaultValue: 'my-agent' }))).toBe('given') + }) + + it('fails loud when no default exists', async () => { + await expect(port.text({ message: 'base URL' })).rejects.toThrow(HeadlessPromptError) + }) + + it('fails loud when the default is invalid', async () => { + await expect(port.text({ + message: 'name', + defaultValue: '', + validate: value => value.length === 0 ? 'required' : undefined, + })).rejects.toThrow(/required/) + }) + }) + + describe('secret', () => { + it('always fails loud', async () => { + await expect(port.secret({ message: 'API key' })).rejects.toThrow(HeadlessPromptError) + }) + }) + + describe('select', () => { + it('takes the initial value when present', async () => { + expect(await answered(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }], initialValue: 'npm' }))).toBe('npm') + }) + + it('fails loud without an initial value', async () => { + await expect(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }] })).rejects.toThrow(HeadlessPromptError) + }) + }) + + describe('multiselect', () => { + it('returns the initial values', async () => { + expect(await answered(port.multiselect({ message: 'x', options: [], initialValues: ['a', 'b'] }))).toEqual(['a', 'b']) + }) + + it('returns an empty selection when none are supplied and none are required', async () => { + expect(await answered(port.multiselect({ message: 'x', options: [] }))).toEqual([]) + }) + + it('fails loud when required and nothing is preselected', async () => { + await expect(port.multiselect({ message: 'x', options: [], required: true })).rejects.toThrow(HeadlessPromptError) + }) + }) + + describe('confirm', () => { + it('takes the initial value when present', async () => { + expect(await answered(port.confirm({ message: 'install?', initialValue: false }))).toBe(false) + }) + + it('fails loud without an initial value', async () => { + await expect(port.confirm({ message: 'apply?' })).rejects.toThrow(HeadlessPromptError) + }) + }) + + describe('nestedMultiselect', () => { + it('always fails loud', async () => { + await expect(port.nestedMultiselect({ message: 'Select features', options: [] })).rejects.toThrow(HeadlessPromptError) + }) + }) +}) diff --git a/packages/sdk/helper/tests/questions.spec.ts b/packages/sdk/helper/tests/questions.spec.ts index 463f3b979f..5eb205075f 100644 --- a/packages/sdk/helper/tests/questions.spec.ts +++ b/packages/sdk/helper/tests/questions.spec.ts @@ -450,4 +450,35 @@ describe('feature configurator', () => { await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(new EmptyExclusive(), profile)) .rejects.toThrow('has no default option') }) + + it('configures fully from prefilled options, values, and secrets without prompting', async () => { + const registry = createBuiltinRegistry(profile) + const port = new QueuePromptPort([]) + const result = await new FeatureConfigurator(port).configure( + registry.get(featureId('provider')), + profile, + undefined, + ['custom'], + { apiKey: 'prefilled-key' }, + { baseURL: 'https://prefilled' }, + ) + expect(result).toMatchObject({ + options: ['custom'], + values: { baseURL: 'https://prefilled' }, + secrets: { apiKey: 'prefilled-key' }, + }) + expect(port.requests).toEqual([]) + }) + + it('rejects a non-string prefilled feature value', async () => { + const registry = createBuiltinRegistry(profile) + await expect(new FeatureConfigurator(new QueuePromptPort([])).configure( + registry.get(featureId('provider')), + profile, + undefined, + ['custom'], + { apiKey: 'k' }, + { baseURL: 123 }, + )).rejects.toThrow('must be a string') + }) }) diff --git a/packages/sdk/scripts/src/config/config-workflow.ts b/packages/sdk/scripts/src/config/config-workflow.ts index 7d3d7ee43a..016d9d09b8 100644 --- a/packages/sdk/scripts/src/config/config-workflow.ts +++ b/packages/sdk/scripts/src/config/config-workflow.ts @@ -28,6 +28,17 @@ export interface ConfigWorkflowResult { installError?: Error } +/** + * Non-interactive desired end-state for a config run: the complete set of enabled + * features, with options and any secrets/values a newly installed feature needs. + * Features not listed are reconciled to disabled, exactly as an interactive tree + * selection would be. Custom (non-feature) cordis plugins keep their current state; + * toggling them headlessly is not yet supported. + */ +export interface ConfigPlan { + features: readonly FeatureSelection[] +} + function featureTarget(feature: Feature): string { return `feature:${feature.id}` } @@ -66,48 +77,58 @@ export class ConfigWorkflow { } /** Select desired state, reconcile the working copy, review, and apply. */ - async run(project: SdkProject, registry: FeatureRegistry): Promise { + async run(project: SdkProject, registry: FeatureRegistry, plan?: ConfigPlan): Promise { const edit = project.edit(registry) const configurator = new FeatureConfigurator(this.port) const features = registry.all().filter(feature => feature.isApplicable(project.profile)) const inspections = new Map(edit.inspections().map(item => [item.id, item])) const custom = edit.cordisConfigEntries().filter(entry => !registry.ownerOfPackage(entry.name, project.profile)) - const desired = requireAnswer(await this.port.nestedMultiselect({ - message: 'Configure the project', - showChanges: true, - options: [ - ...features.map((feature) => { - const installation = inspections.get(feature.id) - /* v8 ignore next -- inspections() is built from this exact feature registry */ - if (!installation) throw new Error(`feature inspection is missing: ${feature.id}`) - const inconsistent = installation.state === 'inconsistent' - const selectedOptions = new Set(installation.options.length > 0 - ? installation.options - : feature.defaultOptions(project.profile)) - return { - value: featureTarget(feature), - label: feature.summary, - required: feature.required, - default: feature.required || installation.state === 'enabled' || inconsistent, - disabled: inconsistent, - ...inconsistent ? { warning: installation.diagnostics.join('; ') } : {}, - ...feature.mode === 'single' ? {} : { - choiceMode: feature.mode, - choices: feature.options.map(option => ({ - value: option.id, - label: option.label, - default: selectedOptions.has(option.id), - })), - }, - } - }), - ...custom.map(entry => ({ - value: pluginTarget(entry.id), - label: `${entry.name} [custom]`, - default: !entry.disabled, + const desired = plan + ? [ + ...plan.features.map(selection => ({ + value: featureTarget(registry.get(selection.id)), + choices: selection.options, })), - ], - })) + ...custom + .filter(entry => !entry.disabled) + .map(entry => ({ value: pluginTarget(entry.id), choices: [] as readonly string[] })), + ] + : requireAnswer(await this.port.nestedMultiselect({ + message: 'Configure the project', + showChanges: true, + options: [ + ...features.map((feature) => { + const installation = inspections.get(feature.id) + /* v8 ignore next -- inspections() is built from this exact feature registry */ + if (!installation) throw new Error(`feature inspection is missing: ${feature.id}`) + const inconsistent = installation.state === 'inconsistent' + const selectedOptions = new Set(installation.options.length > 0 + ? installation.options + : feature.defaultOptions(project.profile)) + return { + value: featureTarget(feature), + label: feature.summary, + required: feature.required, + default: feature.required || installation.state === 'enabled' || inconsistent, + disabled: inconsistent, + ...inconsistent ? { warning: installation.diagnostics.join('; ') } : {}, + ...feature.mode === 'single' ? {} : { + choiceMode: feature.mode, + choices: feature.options.map(option => ({ + value: option.id, + label: option.label, + default: selectedOptions.has(option.id), + })), + }, + } + }), + ...custom.map(entry => ({ + value: pluginTarget(entry.id), + label: `${entry.name} [custom]`, + default: !entry.disabled, + })), + ], + })) const desiredByTarget = new Map(desired.map(item => [item.value, item])) const targetProfile = { ...project.profile, @@ -117,6 +138,9 @@ export class ConfigWorkflow { if (!feature.isApplicable(targetProfile)) desiredByTarget.delete(featureTarget(feature)) } + const plannedById = new Map( + (plan?.features ?? []).map(selection => [selection.id, selection]), + ) for (const feature of features) { const installation = inspections.get(feature.id) /* v8 ignore next -- inspections() is built from this exact feature registry */ @@ -124,7 +148,7 @@ export class ConfigWorkflow { if (installation.state === 'inconsistent') continue const choice = desiredByTarget.get(featureTarget(feature)) if (!choice && !feature.required) continue - await this.enableOrConfigure(feature, installation, choice, project, edit, configurator) + await this.enableOrConfigure(feature, installation, choice, project, edit, configurator, plannedById.get(feature.id)) } for (const feature of [...features].reverse()) { @@ -176,6 +200,7 @@ export class ConfigWorkflow { project: SdkProject, edit: ReturnType, configurator: FeatureConfigurator, + planned?: FeatureSelection, ): Promise { const options = choice?.choices.length ? choice.choices @@ -183,7 +208,9 @@ export class ConfigWorkflow { ? installation.options : feature.defaultOptions(project.profile) if (installation.state === 'absent') { - const selection = await configurator.configure(feature, project.profile, undefined, options) + const selection = await configurator.configure( + feature, project.profile, undefined, options, planned?.secrets ?? {}, planned?.values ?? {}, + ) edit.installFeature(feature, selection) return } @@ -191,10 +218,7 @@ export class ConfigWorkflow { if (!installation.selection) throw new Error(`feature ${feature.id} has no readable selection`) if (!sameOptions(installation.options, options)) { const selection: FeatureSelection = await configurator.configure( - feature, - project.profile, - installation.selection, - options, + feature, project.profile, installation.selection, options, planned?.secrets ?? {}, planned?.values ?? {}, ) edit.configureFeature(feature, selection) } diff --git a/packages/sdk/scripts/tests/scripts.spec.ts b/packages/sdk/scripts/tests/scripts.spec.ts index 8d110799ea..42c74f5631 100644 --- a/packages/sdk/scripts/tests/scripts.spec.ts +++ b/packages/sdk/scripts/tests/scripts.spec.ts @@ -5,6 +5,7 @@ import { PassThrough, Writable } from 'node:stream' import { fileURLToPath, pathToFileURL } from 'node:url' import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { + HeadlessPromptPort, LocalPluginBlueprint, NpmPackageManager, SdkProject, @@ -29,7 +30,7 @@ import { parseDshSdkArgs, parseSdkBootArgs } from '../src/args.ts' import { PluginBuild, ProjectBuild, runProjectBuild } from '../src/build.ts' import { runDshSdkCommand, type DshSdkCommandContext } from '../src/command.ts' import { runConfigCommand } from '../src/config.ts' -import { ConfigWorkflow } from '../src/config/config-workflow.ts' +import { ConfigWorkflow, type ConfigPlan } from '../src/config/config-workflow.ts' import { initialize, resolve as resolveLocalPlugin } from '../src/local-plugin-loader-hooks.ts' const temporary: string[] = [] @@ -399,6 +400,28 @@ describe('ConfigWorkflow', () => { expect(output.read()).toContain('Disable feature: todo') }) + it('reconciles a headless plan without prompting and preserves custom plugins', async () => { + const project = await committedProject([], [new LocalPluginBlueprint('plugin', 'plugin')]) + const registry = createBuiltinRegistry(project.profile) + const output = outputBuffer() + let installs = 0 + const plan: ConfigPlan = { + features: [ + { id: featureId('bash'), options: ['local'] }, + { id: featureId('persistence'), options: ['jsonl'] }, + { id: featureId('todo'), options: ['default'] }, + { id: featureId('web'), options: ['exa'], secrets: { apiKey: 'exa-key' } }, + ], + } + const result = await new ConfigWorkflow( + new HeadlessPromptPort(), output.stream, async () => { installs += 1 }, + ).run(project, registry, plan) + expect(result.commit?.project.cordis.entry('tool-todo')).toBeDefined() + // the unlisted custom local plugin keeps its enabled state (not nuked by the plan) + expect(result.commit?.project.cordis.entry('plugin')?.disabled).toBeFalsy() + expect(installs).toBe(1) + }) + it('installs once after NPM dependency changes and keeps committed files on install failure', async () => { const project = await committedProject() const registry = createBuiltinRegistry(project.profile) From 825a63ab012671864a8d831b6d180a19538a5691 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:20:34 +0800 Subject: [PATCH 06/37] feat(sdk): add dsh-plugin-fetch source + fetcher seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greenfield #2 "建插件" modules for the forthcoming `dsh-sdk create ` command, in a new foundation-independent package so it touches none of the dsh-scripts / dsh-helper / create-sdk hotspots the foundation refactor edits. - `resolvePluginSource(spec)` parses `owner/repo[/subdir]#ref` (github) or `pkg@version` (npm) into a `PluginSource` discriminated union, failing loud on an ambiguous or malformed spec. - `PluginFetcher` seam + `fetchPlugin` tag dispatch returning a common `FetchedPlugin` (temp dir + immutable provenance). - `GigetFetcher` (github) over @bluwy/giget-core: resolve `#ref` to a commit SHA first, download that SHA; provenance pins the SHA. Chosen over unjs/giget for its single runtime dep and absent install/action surface. - `PacoteFetcher` (npm) over pacote: resolve the manifest, then extract the tarball verified against its registry integrity. Registry-only is enforced by the source resolver; extract runs no lifecycle scripts. - Branded `CommitSha`/`Integrity`; network + temp-dir boundaries are injected so the logic is unit-tested at 100% per-file coverage without network. Wiring (package.json pin, cordis.yml via ProjectEditSession with a confirmed diff, install --ignore-scripts) and the launcher command registration land later with the foundation. --- docs/module-graph.md | 3 + packages/sdk/README.md | 1 + packages/sdk/plugin-fetch/README.md | 32 + packages/sdk/plugin-fetch/package.json | 37 + packages/sdk/plugin-fetch/src/fetcher.ts | 90 ++ .../sdk/plugin-fetch/src/giget-fetcher.ts | 116 +++ packages/sdk/plugin-fetch/src/ids.ts | 39 + packages/sdk/plugin-fetch/src/index.ts | 48 + packages/sdk/plugin-fetch/src/never.ts | 19 + .../sdk/plugin-fetch/src/pacote-fetcher.ts | 129 +++ packages/sdk/plugin-fetch/src/source.ts | 126 +++ .../sdk/plugin-fetch/tests/fetcher.spec.ts | 64 ++ .../plugin-fetch/tests/giget-fetcher.spec.ts | 145 +++ packages/sdk/plugin-fetch/tests/ids.spec.ts | 39 + packages/sdk/plugin-fetch/tests/never.spec.ts | 19 + .../plugin-fetch/tests/pacote-fetcher.spec.ts | 108 +++ .../sdk/plugin-fetch/tests/source.spec.ts | 107 +++ packages/sdk/plugin-fetch/tsconfig.json | 15 + pnpm-lock.yaml | 833 +++++++++++++++++- .../verify-package-readme-model-experience.ts | 1 + tsconfig.build.json | 3 +- tsconfig.json | 3 +- 22 files changed, 1974 insertions(+), 3 deletions(-) create mode 100644 packages/sdk/plugin-fetch/README.md create mode 100644 packages/sdk/plugin-fetch/package.json create mode 100644 packages/sdk/plugin-fetch/src/fetcher.ts create mode 100644 packages/sdk/plugin-fetch/src/giget-fetcher.ts create mode 100644 packages/sdk/plugin-fetch/src/ids.ts create mode 100644 packages/sdk/plugin-fetch/src/index.ts create mode 100644 packages/sdk/plugin-fetch/src/never.ts create mode 100644 packages/sdk/plugin-fetch/src/pacote-fetcher.ts create mode 100644 packages/sdk/plugin-fetch/src/source.ts create mode 100644 packages/sdk/plugin-fetch/tests/fetcher.spec.ts create mode 100644 packages/sdk/plugin-fetch/tests/giget-fetcher.spec.ts create mode 100644 packages/sdk/plugin-fetch/tests/ids.spec.ts create mode 100644 packages/sdk/plugin-fetch/tests/never.spec.ts create mode 100644 packages/sdk/plugin-fetch/tests/pacote-fetcher.spec.ts create mode 100644 packages/sdk/plugin-fetch/tests/source.spec.ts create mode 100644 packages/sdk/plugin-fetch/tsconfig.json diff --git a/docs/module-graph.md b/docs/module-graph.md index 8b67c73d27..c9439d5501 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -138,6 +138,7 @@ flowchart TD end subgraph group_sdk["packages/sdk"] pkg_helper["helper"] + pkg_plugin_fetch["plugin-fetch"] pkg_scripts["scripts"] end subgraph group_tasks["packages/tasks"] @@ -152,6 +153,7 @@ flowchart TD pkg_llm --> pkg_brand pkg_code_runtime_worker --> pkg_code_runtime pkg_helper --> pkg_brand + pkg_plugin_fetch --> pkg_brand pkg_scripts --> pkg_app_boot pkg_llm_deepseek --> pkg_llm pkg_llm_pi_ai --> pkg_llm @@ -442,6 +444,7 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand) | +| [`plugin-fetch`](../packages/sdk/plugin-fetch) | `sdk` | [`brand`](../packages/util/brand) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) | diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 953d52ea76..92c8794e64 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -9,6 +9,7 @@ The [feature RFC](../../docs/rfc/proposed/feature/2026-07-14-sdk-developer-proje | [`helper`](helper/README.md) | Project aggregate, edit session, builtin features, project documents, templates, package managers, and prompt abstraction | | [`scripts`](scripts/README.md) | The `dsh-sdk` launcher: `start`, `dev`, `build`, and interactive `config` | | [`create-sdk`](create-sdk/README.md) | The `npm create @deepseek-ai/sdk` initializer | +| [`plugin-fetch`](plugin-fetch/README.md) | Fetch an external plugin (github/npm) into a temp dir — pinned and un-executed — for `dsh-sdk create` | `@deepseek-ai/create-sdk` is the one package-name exception to the repository's `@deepseek-ai/dsh-*` rule: npm's scoped initializer convention requires that name for `npm create @deepseek-ai/sdk`. diff --git a/packages/sdk/plugin-fetch/README.md b/packages/sdk/plugin-fetch/README.md new file mode 100644 index 0000000000..801dc2a2a6 --- /dev/null +++ b/packages/sdk/plugin-fetch/README.md @@ -0,0 +1,32 @@ +# `@deepseek-ai/dsh-plugin-fetch` + +Fetch an external Cordis plugin into a temp directory — pinned to an immutable commit or integrity and never executed — for the forthcoming `dsh-sdk create ` command. + +The package parses a source spec into a `PluginSource`, dispatches to the matching `PluginFetcher`, and returns a common `FetchedPlugin` (temp dir + immutable provenance) that the wiring step pins into `package.json`, mounts in `cordis.yml`, and installs with `--ignore-scripts`. + +| Export | Role | +|---|---| +| `resolvePluginSource(spec)` → `PluginSource` | Parse `owner/repo[/subdir]#ref` (github) or `pkg@version` (npm); fail loud on an ambiguous or malformed spec | +| `PluginFetcher` | The fetch seam: resolve the pin BEFORE download, extract without executing pulled code | +| `GigetFetcher` / `createGigetFetcher()` | Github fetcher over `@bluwy/giget-core`: resolve `#ref` to a commit SHA, download that SHA | +| `PacoteFetcher` / `createPacoteFetcher()` | Npm fetcher over `pacote`: resolve the manifest, then extract the tarball verified against its integrity | +| `fetchPlugin(source, fetchers)` → `FetchedPlugin` | Dispatch one source to its fetcher by discriminant tag | + +## Safety model — confirm-before-run, not run-on-fetch + +A fetch only downloads and unpacks; it runs no install, no `postinstall`/`prepare`, and no degit-style template actions. + +- **github** uses `@bluwy/giget-core` (one runtime dependency, `modern-tar`; no CLI, install, or JSON-registry surface) so a fetch can only download and untar a tarball. The commit is pinned first: `GigetFetcher` resolves `#ref` — or the default branch when absent — to an immutable SHA via the GitHub commits API, then downloads that SHA. Provenance carries the SHA so wiring pins `github:owner/repo#`. +- **npm** uses `pacote`. Registry-only is enforced upstream: `resolvePluginSource` produces only a `name@version` registry spec, so pacote never sees a git/file/dir spec whose lifecycle scripts would run, and a registry tarball extract is a plain untar. The manifest is resolved first so extract verifies the artifact against the registry-published integrity (a mismatch raises `EINTEGRITY`). Provenance carries the exact version, resolved URL, and integrity. + +Both network boundaries (giget download, GitHub ref resolution, pacote, temp-dir allocation) are constructor-injected, so the fetch logic is unit-tested without network; the `create*Fetcher()` factories wire the real libraries. + +## Model Experience + +None, as this developer tooling acquires plugin sources for the SDK launcher and registers no live agent or model surface. + +## Known Limitations and Deferred Work + +- **Wiring is not here yet** — pinning `package.json`, mounting `cordis.yml` through `ProjectEditSession` with a confirmed diff, and `install --ignore-scripts` land with the `dsh-sdk create` command. This package stops at a fetched, pinned temp directory. +- **npm registry authentication** — `PacoteFetcher` targets a public or default-configured registry; private-registry auth beyond pacote's ambient npm config is deferred. +- **Template-repo initialization** — the whole-project init mode (`dsh-sdk create` from a template repository) is out of scope; this package fetches a single plugin into an existing project. diff --git a/packages/sdk/plugin-fetch/package.json b/packages/sdk/plugin-fetch/package.json new file mode 100644 index 0000000000..e8e38090e6 --- /dev/null +++ b/packages/sdk/plugin-fetch/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-plugin-fetch", + "description": "Fetch an external Cordis plugin (github or npm) into a temp dir, pinned and un-executed, for dsh-sdk create", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "@bluwy/giget-core": "^0.1.7", + "pacote": "^22.0.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@types/pacote": "^11.1.8", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/sdk/plugin-fetch/src/fetcher.ts b/packages/sdk/plugin-fetch/src/fetcher.ts new file mode 100644 index 0000000000..7c3d3a763c --- /dev/null +++ b/packages/sdk/plugin-fetch/src/fetcher.ts @@ -0,0 +1,90 @@ +/** + * The `PluginFetcher` seam, its common `FetchedPlugin` result, and the + * tag-dispatched entry point. A fetcher acquires one plugin source into a fresh + * temp directory WITHOUT executing any pulled code, and reports immutable + * provenance the wiring step pins the dependency to. + * + * @module @deepseek-ai/dsh-plugin-fetch/fetcher + */ + +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { assertNever } from './never.ts' +import type { CommitSha, Integrity } from './ids.ts' +import type { GithubSource, NpmSource, PluginSource } from './source.ts' + +/** Immutable pin for a github fetch: the resolved commit the tarball came from. */ +export interface GithubProvenance { + readonly kind: 'github' + readonly sha: CommitSha +} + +/** Immutable pin for an npm fetch: exact version, tarball URL, and integrity. */ +export interface NpmProvenance { + readonly kind: 'npm' + /** Concrete resolved version (e.g. `1.2.3`), never the requested range/tag. */ + readonly version: string + /** Tarball URL the artifact resolved to. */ + readonly resolved: string + /** Subresource integrity the artifact was verified against. */ + readonly integrity: Integrity +} + +/** Provenance a fetch records so wiring can pin an immutable dependency. */ +export type PluginProvenance = GithubProvenance | NpmProvenance + +/** The common result of fetching any plugin source. */ +export interface FetchedPlugin { + /** Absolute temp directory holding the extracted, UN-executed source. */ + readonly dir: string + /** The source that produced this fetch, echoed for the wiring step. */ + readonly source: PluginSource + /** Immutable provenance to pin the dependency during wiring. */ + readonly provenance: PluginProvenance +} + +/** + * A fetcher for one source kind. Implementations resolve the immutable pin + * BEFORE download and must never run lifecycle scripts or template actions. + */ +export interface PluginFetcher { + /** The single source kind this fetcher handles. */ + readonly kind: S['kind'] + /** + * Fetch one source into a fresh temp directory. + * @param source - the resolved source to fetch. + * @returns the temp dir plus immutable provenance. + */ + fetch(source: S): Promise +} + +/** The per-kind fetchers {@link fetchPlugin} dispatches across. */ +export interface PluginFetchers { + readonly github: PluginFetcher + readonly npm: PluginFetcher +} + +/** + * Dispatch one source to its fetcher by discriminant tag. + * @param source - the resolved plugin source. + * @param fetchers - the per-kind fetchers to route across. + * @returns the fetch result from the matching fetcher. + */ +export function fetchPlugin(source: PluginSource, fetchers: PluginFetchers): Promise { + switch (source.kind) { + case 'github': return fetchers.github.fetch(source) + case 'npm': return fetchers.npm.fetch(source) + default: return assertNever(source, 'fetchPlugin') + } +} + +/** + * Create a fresh, empty temp directory for one fetch — the default temp-dir + * seam shared by the concrete fetchers. + * @param prefix - a `mkdtemp` name prefix identifying the fetch kind. + * @returns the absolute path of the created directory. + */ +export function createTempDir(prefix: string): Promise { + return mkdtemp(join(tmpdir(), prefix)) +} diff --git a/packages/sdk/plugin-fetch/src/giget-fetcher.ts b/packages/sdk/plugin-fetch/src/giget-fetcher.ts new file mode 100644 index 0000000000..55e001063c --- /dev/null +++ b/packages/sdk/plugin-fetch/src/giget-fetcher.ts @@ -0,0 +1,116 @@ +/** + * The github {@link PluginFetcher}, backed by `@bluwy/giget-core`. + * + * `@bluwy/giget-core` is chosen over unjs `giget`: it carries a single runtime + * dependency (`modern-tar`) versus giget's CLI/registry stack, and it dropped + * the `install` and JSON-registry options entirely, so a fetch can only ever + * download and untar a tarball — never run install or degit-style actions. That + * is exactly the "extract, never execute" guarantee this feature needs. + * + * The commit is pinned BEFORE download: {@link GigetFetcher} resolves `#ref` to + * an immutable SHA (default via the GitHub commits API), then downloads that + * SHA. Provenance carries the SHA so wiring pins `github:owner/repo#`. + * + * @module @deepseek-ai/dsh-plugin-fetch/giget-fetcher + */ + +import { downloadTemplate } from '@bluwy/giget-core' +import { createTempDir, type FetchedPlugin, type PluginFetcher } from './fetcher.ts' +import { commitSha, type CommitSha } from './ids.ts' +import type { GithubSource } from './source.ts' + +/** Temp-dir name prefix for github fetches. */ +export const GITHUB_TEMP_PREFIX = 'dsh-plugin-github-' + +/** Downloads a giget input string into `dir`; the tarball-extraction seam. */ +export type DownloadTemplate = ( + input: string, + options: { dir: string; force: 'clean' }, +) => Promise<{ dir: string }> + +/** Resolves a github source's ref to an immutable commit SHA before download. */ +export type ResolveRef = (source: GithubSource) => Promise + +/** The injected collaborators a {@link GigetFetcher} needs. */ +export interface GigetFetcherDeps { + /** Downloads a pinned giget input into a directory. */ + download: DownloadTemplate + /** Resolves `source.ref` (or the default branch) to a commit SHA. */ + resolveRef: ResolveRef + /** Allocates the fresh temp directory to download into. */ + createTempDir: (prefix: string) => Promise +} + +/** Build the giget input string that pins a github source to a commit SHA. */ +function gigetInput(source: GithubSource, sha: CommitSha): string { + const path = source.subdir ? `${source.owner}/${source.repo}/${source.subdir}` : `${source.owner}/${source.repo}` + return `${path}#${sha}` +} + +/** A human-readable label for one github source, for error messages. */ +function githubLabel(source: GithubSource): string { + return `${source.owner}/${source.repo}#${source.ref ?? 'HEAD'}` +} + +/** + * Resolve a github source's ref to an immutable SHA via the GitHub commits API. + * Uses the `application/vnd.github.sha` media type, which returns the resolved + * commit id as plain text. + * @param source - the github source; an absent `ref` resolves the default branch (`HEAD`). + * @param token - optional bearer token for private repositories. + * @returns the resolved immutable commit SHA. + * @throws if the GitHub API rejects the request. + */ +export async function defaultResolveRef(source: GithubSource, token?: string): Promise { + const ref = source.ref ?? 'HEAD' + const url = `https://api.github.com/repos/${source.owner}/${source.repo}/commits/${ref}` + const headers: Record = { Accept: 'application/vnd.github.sha' } + if (token !== undefined) headers.Authorization = `Bearer ${token}` + const response = await fetch(url, { headers }) + if (!response.ok) { + throw new Error(`cannot resolve github ref ${githubLabel(source)}: HTTP ${response.status}`) + } + return commitSha((await response.text()).trim()) +} + +/** Fetches a github plugin source by pinning `#ref` to a commit SHA, then downloading it. */ +export class GigetFetcher implements PluginFetcher { + readonly kind = 'github' as const + private readonly deps: GigetFetcherDeps + + /** Construct with injected download, ref-resolution, and temp-dir seams. */ + constructor(deps: GigetFetcherDeps) { + this.deps = deps + } + + async fetch(source: GithubSource): Promise { + const sha = await this.deps.resolveRef(source) + const dir = await this.deps.createTempDir(GITHUB_TEMP_PREFIX) + await this.deps.download(gigetInput(source, sha), { dir, force: 'clean' }) + return { dir, source, provenance: { kind: 'github', sha } } + } +} + +/** Options for the production github fetcher. */ +export interface GithubFetchOptions { + /** Bearer token for private repositories; defaults to `GITHUB_TOKEN`. */ + token?: string +} + +/** + * Build the production github fetcher wired to `@bluwy/giget-core` and the + * GitHub commits API. + * @param options - optional token override (else `process.env.GITHUB_TOKEN`). + * @returns a {@link GigetFetcher} using the real download and ref-resolution seams. + */ +export function createGigetFetcher(options: GithubFetchOptions = {}): GigetFetcher { + const token = options.token ?? process.env.GITHUB_TOKEN + return new GigetFetcher({ + download: (input, downloadOptions) => downloadTemplate(input, { + ...downloadOptions, + ...token !== undefined ? { providerOptions: { auth: token } } : {}, + }), + resolveRef: source => defaultResolveRef(source, token), + createTempDir, + }) +} diff --git a/packages/sdk/plugin-fetch/src/ids.ts b/packages/sdk/plugin-fetch/src/ids.ts new file mode 100644 index 0000000000..a3a2ffa575 --- /dev/null +++ b/packages/sdk/plugin-fetch/src/ids.ts @@ -0,0 +1,39 @@ +/** + * Branded provenance identities owned by the plugin-fetch layer. Both cross the + * fetch → wiring boundary and are opaque tokens that must not be confused with + * ordinary strings (a package name, a URL) at that seam. + * + * @module @deepseek-ai/dsh-plugin-fetch/ids + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** An immutable git commit object id a github fetch pins to. */ +export type CommitSha = Branded<'CommitSha'> + +/** + * Construct a {@link CommitSha}, validating the hexadecimal object-id shape. + * @param value - lowercase hex of an abbreviated or full commit id (7–64 chars, covering SHA-1 and SHA-256). + * @returns the branded commit id. + */ +export function commitSha(value: string): CommitSha { + if (!/^[0-9a-f]{7,64}$/.test(value)) { + throw new Error(`invalid commit sha: ${JSON.stringify(value)}`) + } + return value as CommitSha +} + +/** A Subresource Integrity string an npm fetch pins to. */ +export type Integrity = Branded<'Integrity'> + +/** + * Construct an {@link Integrity}, validating the SRI `-` shape. + * @param value - a single SRI entry using sha256, sha384, or sha512. + * @returns the branded integrity string. + */ +export function integrity(value: string): Integrity { + if (!/^sha(256|384|512)-[A-Za-z0-9+/]+={0,2}$/.test(value)) { + throw new Error(`invalid subresource integrity: ${JSON.stringify(value)}`) + } + return value as Integrity +} diff --git a/packages/sdk/plugin-fetch/src/index.ts b/packages/sdk/plugin-fetch/src/index.ts new file mode 100644 index 0000000000..d4b9682ff5 --- /dev/null +++ b/packages/sdk/plugin-fetch/src/index.ts @@ -0,0 +1,48 @@ +/** + * Fetch an external Cordis plugin (github or npm) into a temp directory — + * pinned to an immutable commit/integrity and never executed — for the + * `dsh-sdk create ` command. Parses a source spec, dispatches to the + * matching fetcher, and returns a common {@link FetchedPlugin} the wiring step + * pins and mounts. + * + * @module @deepseek-ai/dsh-plugin-fetch + */ + +export { resolvePluginSource } from './source.ts' +export type { GithubSource, NpmSource, PluginSource } from './source.ts' +export { commitSha, integrity } from './ids.ts' +export type { CommitSha, Integrity } from './ids.ts' +export { createTempDir, fetchPlugin } from './fetcher.ts' +export type { + FetchedPlugin, + GithubProvenance, + NpmProvenance, + PluginFetcher, + PluginFetchers, + PluginProvenance, +} from './fetcher.ts' +export { + createGigetFetcher, + defaultResolveRef, + GigetFetcher, + GITHUB_TEMP_PREFIX, +} from './giget-fetcher.ts' +export type { + DownloadTemplate, + GigetFetcherDeps, + GithubFetchOptions, + ResolveRef, +} from './giget-fetcher.ts' +export { + createPacoteFetcher, + NPM_TEMP_PREFIX, + PacoteFetcher, +} from './pacote-fetcher.ts' +export type { + NpmFetchOptions, + PacoteApi, + PacoteExtractResult, + PacoteFetcherDeps, + PacoteFetchOptions, + PacoteResolution, +} from './pacote-fetcher.ts' diff --git a/packages/sdk/plugin-fetch/src/never.ts b/packages/sdk/plugin-fetch/src/never.ts new file mode 100644 index 0000000000..664ab3293d --- /dev/null +++ b/packages/sdk/plugin-fetch/src/never.ts @@ -0,0 +1,19 @@ +/** + * Exhaustiveness helper for this package's closed unions. Kept local so the + * SDK plugin-fetch tooling stays free of the model-runtime `dsh-llm` dependency + * that owns the shared `assertNever`. + * + * @module @deepseek-ai/dsh-plugin-fetch/never + */ + +/** + * Mark an unreachable closed-union branch. A newly unhandled variant fails + * compilation at the call site; a value that escaped its type throws at runtime. + * @param value - the impossible value; typed `never` so a new variant fails to compile at every call site. + * @param context - optional label prefixed into the throw message. + * @returns never — it always throws, rendering the offending value. + */ +export function assertNever(value: never, context?: string): never { + const rendered = (JSON.stringify(value) as string | undefined) ?? String(value) + throw new Error(`unreachable variant${context ? ` in ${context}` : ''}: ${rendered}`) +} diff --git a/packages/sdk/plugin-fetch/src/pacote-fetcher.ts b/packages/sdk/plugin-fetch/src/pacote-fetcher.ts new file mode 100644 index 0000000000..dfb1d790b8 --- /dev/null +++ b/packages/sdk/plugin-fetch/src/pacote-fetcher.ts @@ -0,0 +1,129 @@ +/** + * The npm {@link PluginFetcher}, backed by `pacote`. + * + * Supply-chain safety comes from three layers: (1) {@link resolvePluginSource} + * only ever produces a registry `name@version` spec, so pacote classifies it as + * a registry source and cannot be steered to a git/file/dir spec whose + * lifecycle scripts would run; (2) a registry tarball extract is a plain untar — + * pacote runs no `prepare`/`postinstall` during {@link PacoteFetcher.fetch}; and + * (3) the later wiring step installs with `--ignore-scripts`. The manifest is + * resolved first so extract verifies the tarball against the registry-published + * integrity (a mismatch raises `EINTEGRITY`). + * + * @module @deepseek-ai/dsh-plugin-fetch/pacote-fetcher + */ + +import { extract as pacoteExtract, manifest as pacoteManifest } from 'pacote' +import { createTempDir, type FetchedPlugin, type PluginFetcher } from './fetcher.ts' +import { integrity } from './ids.ts' +import type { NpmSource } from './source.ts' + +/** Temp-dir name prefix for npm fetches. */ +export const NPM_TEMP_PREFIX = 'dsh-plugin-npm-' + +/** The subset of pacote options this fetcher passes through. */ +export interface PacoteFetchOptions { + /** Registry to resolve against; absent uses pacote's default. */ + registry?: string + /** Known resolved tarball URL, forwarded to extract. */ + resolved?: string + /** Expected integrity, forwarded to extract for `EINTEGRITY` verification. */ + integrity?: string +} + +/** The resolved registry manifest fields this fetcher pins from. */ +export interface PacoteResolution { + /** Resolved tarball URL. */ + _resolved: string + /** Registry-published integrity. */ + _integrity: string + /** Concrete resolved version. */ + version: string +} + +/** The extract result fields this fetcher pins from. */ +export interface PacoteExtractResult { + /** Resolved tarball URL of the extracted artifact. */ + resolved: string + /** Integrity of the extracted artifact. */ + integrity: string +} + +/** The pacote surface a {@link PacoteFetcher} depends on; the fetch seam. */ +export interface PacoteApi { + /** Resolve a registry spec to its pinned manifest fields. */ + manifest: (spec: string, options?: PacoteFetchOptions) => Promise + /** Untar a registry spec into `dest`, verifying integrity when supplied. */ + extract: (spec: string, dest: string, options?: PacoteFetchOptions) => Promise +} + +/** The injected collaborators a {@link PacoteFetcher} needs. */ +export interface PacoteFetcherDeps { + /** The pacote resolve/extract surface. */ + pacote: PacoteApi + /** Allocates the fresh temp directory to extract into. */ + createTempDir: (prefix: string) => Promise + /** Registry to resolve against; absent uses pacote's default. */ + registry?: string +} + +/** Fetches an npm plugin source by resolving its manifest, then extracting the verified tarball. */ +export class PacoteFetcher implements PluginFetcher { + readonly kind = 'npm' as const + private readonly deps: PacoteFetcherDeps + + /** Construct with injected pacote, temp-dir, and optional registry seams. */ + constructor(deps: PacoteFetcherDeps) { + this.deps = deps + } + + async fetch(source: NpmSource): Promise { + const spec = `${source.name}@${source.version}` + const registryOptions: PacoteFetchOptions = this.deps.registry !== undefined + ? { registry: this.deps.registry } + : {} + const resolution = await this.deps.pacote.manifest(spec, registryOptions) + const dir = await this.deps.createTempDir(NPM_TEMP_PREFIX) + const extracted = await this.deps.pacote.extract(spec, dir, { + ...registryOptions, + resolved: resolution._resolved, + integrity: resolution._integrity, + }) + return { + dir, + source, + provenance: { + kind: 'npm', + version: resolution.version, + resolved: extracted.resolved, + integrity: integrity(extracted.integrity), + }, + } + } +} + +/** Options for the production npm fetcher. */ +export interface NpmFetchOptions { + /** Registry to resolve against; absent uses pacote's default. */ + registry?: string +} + +/** + * Build the production npm fetcher wired to `pacote`. + * @param options - optional registry override. + * @returns a {@link PacoteFetcher} using the real pacote resolve/extract seam. + */ +export function createPacoteFetcher(options: NpmFetchOptions = {}): PacoteFetcher { + const pacote: PacoteApi = { + manifest: async (spec, pacoteOptions) => { + const resolved = await pacoteManifest(spec, pacoteOptions) + return { _resolved: resolved._resolved, _integrity: resolved._integrity, version: resolved.version } + }, + extract: (spec, dest, pacoteOptions) => pacoteExtract(spec, dest, pacoteOptions), + } + return new PacoteFetcher({ + pacote, + createTempDir, + ...options.registry !== undefined ? { registry: options.registry } : {}, + }) +} diff --git a/packages/sdk/plugin-fetch/src/source.ts b/packages/sdk/plugin-fetch/src/source.ts new file mode 100644 index 0000000000..7d7e3ffcc4 --- /dev/null +++ b/packages/sdk/plugin-fetch/src/source.ts @@ -0,0 +1,126 @@ +/** + * The `PluginSource` discriminated union and the resolver that parses one CLI + * spec string into it. Ambiguous or malformed specs fail loud here — the single + * earliest resolvable point — rather than surfacing as a confusing fetch error. + * + * Grammar: + * - github: `owner/repo[/subdir]#ref` — a `#` unambiguously marks a github ref; + * `ref` is optional and, when omitted, the fetcher pins the default branch. + * - npm: `pkg@version` (scoped `@scope/pkg@version`) — the `@version` is the + * only disambiguator from a bare `owner/repo` github locator. + * + * @module @deepseek-ai/dsh-plugin-fetch/source + */ + +/** A plugin pulled from a github (git tarball) repository. */ +export interface GithubSource { + readonly kind: 'github' + /** Repository owner (user or org). */ + readonly owner: string + /** Repository name. */ + readonly repo: string + /** Path within the repository to extract; absent means the repository root. */ + readonly subdir?: string + /** Branch, tag, or commit; absent means the repository's default branch. */ + readonly ref?: string +} + +/** A plugin pulled from an npm registry by exact package and version spec. */ +export interface NpmSource { + readonly kind: 'npm' + /** Package name, including any `@scope/` prefix. */ + readonly name: string + /** Registry version, range, or dist-tag (non-empty). */ + readonly version: string +} + +/** Every plugin origin `dsh-sdk create ` understands. */ +export type PluginSource = GithubSource | NpmSource + +/** One `/`-separated github name segment (owner, repo, or subdir component). */ +function isNameSegment(segment: string): boolean { + return /^[A-Za-z0-9._-]+$/.test(segment) && segment !== '.' && segment !== '..' +} + +/** A git ref: branch, tag, or commit; permits `/`-nested names, rejects traversal. */ +function isGitRef(ref: string): boolean { + return /^[A-Za-z0-9._/-]+$/.test(ref) + && !ref.includes('..') + && !ref.startsWith('/') + && !ref.endsWith('/') +} + +/** An npm package name, scoped (`@scope/name`) or unscoped. */ +function isNpmPackageName(name: string): boolean { + const segment = /^[a-z0-9][a-z0-9._-]*$/ + if (name.startsWith('@')) { + const slash = name.indexOf('/') + if (slash < 2 || slash === name.length - 1) return false + return segment.test(name.slice(1, slash)) && segment.test(name.slice(slash + 1)) + } + return segment.test(name) +} + +/** Parse a `owner/repo[/subdir]` locator with an optional already-split ref. */ +function tryParseGithubLocator(locator: string, ref: string | undefined): GithubSource | undefined { + if (!/^[^\s@#]+$/.test(locator)) return undefined + const [owner, repo, ...subdirSegments] = locator.split('/') + if (owner === undefined || repo === undefined) return undefined + if (!isNameSegment(owner) || !isNameSegment(repo)) return undefined + if (subdirSegments.some(segment => !isNameSegment(segment))) return undefined + if (ref !== undefined && !isGitRef(ref)) return undefined + const subdir = subdirSegments.join('/') + return { + kind: 'github', + owner, + repo, + ...subdir.length > 0 ? { subdir } : {}, + ...ref !== undefined ? { ref } : {}, + } +} + +/** Parse `pkg@version` (scoped or unscoped); undefined when it is not npm-shaped. */ +function tryParseNpmSource(spec: string): NpmSource | undefined { + if (/[\s#]/.test(spec)) return undefined + // A scoped spec's version `@` follows the scope's `/`; an unscoped spec's is + // the first `@`. A leading `@` with no version `@` yields index 0 (rejected). + const versionAt = spec.startsWith('@') ? spec.indexOf('@', spec.indexOf('/') + 1) : spec.indexOf('@') + if (versionAt <= 0) return undefined + const name = spec.slice(0, versionAt) + const version = spec.slice(versionAt + 1) + if (version.length === 0 || version.includes('/')) return undefined + if (!isNpmPackageName(name)) return undefined + return { kind: 'npm', name, version } +} + +/** + * Parse one `dsh-sdk create ` spec into a {@link PluginSource}. + * @param spec - the raw source argument. + * @returns the discriminated source. + * @throws if the spec is empty, malformed, or ambiguous between github and npm. + */ +export function resolvePluginSource(spec: string): PluginSource { + const trimmed = spec.trim() + if (trimmed.length === 0) throw new Error('plugin source must not be empty') + + const hashIndex = trimmed.indexOf('#') + if (hashIndex !== -1) { + const ref = trimmed.slice(hashIndex + 1) + if (ref.length === 0) { + throw new Error(`github plugin source is missing a ref after '#': ${JSON.stringify(spec)}`) + } + const source = tryParseGithubLocator(trimmed.slice(0, hashIndex), ref) + if (!source) { + throw new Error(`invalid github plugin source: ${JSON.stringify(spec)} — expected "owner/repo[/subdir]#ref"`) + } + return source + } + + const npm = tryParseNpmSource(trimmed) + if (npm) return npm + const github = tryParseGithubLocator(trimmed, undefined) + if (github) return github + throw new Error( + `unrecognized plugin source: ${JSON.stringify(spec)} — expected "owner/repo[/subdir]#ref" (github) or "pkg@version" (npm)`, + ) +} diff --git a/packages/sdk/plugin-fetch/tests/fetcher.spec.ts b/packages/sdk/plugin-fetch/tests/fetcher.spec.ts new file mode 100644 index 0000000000..b68a3d3ae1 --- /dev/null +++ b/packages/sdk/plugin-fetch/tests/fetcher.spec.ts @@ -0,0 +1,64 @@ +import { rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { describe, expect, it, vi } from 'vitest' +import { + createTempDir, + fetchPlugin, + type FetchedPlugin, + type PluginFetchers, +} from '../src/fetcher.ts' +import { commitSha } from '../src/ids.ts' +import type { GithubSource, NpmSource, PluginSource } from '../src/source.ts' + +function stubFetchers(): { fetchers: PluginFetchers; github: ReturnType; npm: ReturnType } { + const result = (dir: string): FetchedPlugin => ({ + dir, + source: { kind: 'github', owner: 'o', repo: 'r' }, + provenance: { kind: 'github', sha: commitSha('a'.repeat(40)) }, + }) + const github = vi.fn(async (source: GithubSource) => result(`github:${source.repo}`)) + const npm = vi.fn(async (source: NpmSource) => result(`npm:${source.name}`)) + return { + fetchers: { github: { kind: 'github', fetch: github }, npm: { kind: 'npm', fetch: npm } }, + github, + npm, + } +} + +describe('fetchPlugin', () => { + it('routes a github source to the github fetcher', async () => { + const { fetchers, github, npm } = stubFetchers() + const source: GithubSource = { kind: 'github', owner: 'o', repo: 'r' } + const result = await fetchPlugin(source, fetchers) + expect(github).toHaveBeenCalledWith(source) + expect(npm).not.toHaveBeenCalled() + expect(result.dir).toBe('github:r') + }) + + it('routes an npm source to the npm fetcher', async () => { + const { fetchers, github, npm } = stubFetchers() + const source: NpmSource = { kind: 'npm', name: 'plugin', version: '1.0.0' } + const result = await fetchPlugin(source, fetchers) + expect(npm).toHaveBeenCalledWith(source) + expect(github).not.toHaveBeenCalled() + expect(result.dir).toBe('npm:plugin') + }) + + it('throws on an unknown source kind', () => { + const { fetchers } = stubFetchers() + const bogus = { kind: 'svn' } as unknown as PluginSource + expect(() => fetchPlugin(bogus, fetchers)).toThrow(/unreachable variant in fetchPlugin/) + }) +}) + +describe('createTempDir', () => { + it('creates a fresh empty directory under the OS temp root', async () => { + const dir = await createTempDir('dsh-plugin-fetch-test-') + try { + expect(dir.startsWith(tmpdir())).toBe(true) + expect((await stat(dir)).isDirectory()).toBe(true) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/sdk/plugin-fetch/tests/giget-fetcher.spec.ts b/packages/sdk/plugin-fetch/tests/giget-fetcher.spec.ts new file mode 100644 index 0000000000..c0cafb31d5 --- /dev/null +++ b/packages/sdk/plugin-fetch/tests/giget-fetcher.spec.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import { downloadTemplate } from '@bluwy/giget-core' +import { + createGigetFetcher, + defaultResolveRef, + GigetFetcher, + GITHUB_TEMP_PREFIX, + type GigetFetcherDeps, +} from '../src/giget-fetcher.ts' +import type { CommitSha } from '../src/ids.ts' +import type { GithubSource } from '../src/source.ts' + +vi.mock('@bluwy/giget-core', () => ({ downloadTemplate: vi.fn(async (_input: string, options: { dir: string }) => ({ dir: options.dir, source: '', info: { name: '', tar: '' } })) })) + +const SHA = 'a'.repeat(40) + +/** A `fetch` mock typed with the call signature the assertions destructure. */ +function fetchReturning(response: Response): Mock<(url: string, init?: RequestInit) => Promise> { + return vi.fn((_url: string, _init?: RequestInit) => Promise.resolve(response)) +} + +function fakeDeps(overrides: Partial = {}): { + deps: GigetFetcherDeps + download: ReturnType + resolveRef: ReturnType + createTempDir: ReturnType +} { + const download = vi.fn(async () => ({ dir: '/tmp/x' })) + const resolveRef = vi.fn(async () => SHA as CommitSha) + const createTempDir = vi.fn(async () => '/tmp/dsh-plugin-github-abc') + return { deps: { download, resolveRef, createTempDir, ...overrides }, download, resolveRef, createTempDir } +} + +describe('GigetFetcher.fetch', () => { + it('pins the ref to a SHA, downloads that SHA, and reports provenance', async () => { + const { deps, download, resolveRef, createTempDir } = fakeDeps() + const source: GithubSource = { kind: 'github', owner: 'unjs', repo: 'template', ref: 'main' } + const result = await new GigetFetcher(deps).fetch(source) + + expect(resolveRef).toHaveBeenCalledWith(source) + expect(createTempDir).toHaveBeenCalledWith(GITHUB_TEMP_PREFIX) + expect(download).toHaveBeenCalledWith(`unjs/template#${SHA}`, { dir: '/tmp/dsh-plugin-github-abc', force: 'clean' }) + expect(result).toEqual({ + dir: '/tmp/dsh-plugin-github-abc', + source, + provenance: { kind: 'github', sha: SHA }, + }) + }) + + it('includes the subdir in the download input', async () => { + const { deps, download } = fakeDeps() + const source: GithubSource = { kind: 'github', owner: 'o', repo: 'r', subdir: 'packages/plugin' } + await new GigetFetcher(deps).fetch(source) + expect(download).toHaveBeenCalledWith(`o/r/packages/plugin#${SHA}`, expect.anything()) + }) + + it('exposes its source kind', () => { + expect(new GigetFetcher(fakeDeps().deps).kind).toBe('github') + }) +}) + +describe('defaultResolveRef', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('resolves the default branch (HEAD) with no auth header', async () => { + const fetchMock = fetchReturning(new Response(`${SHA}\n`, { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + const sha = await defaultResolveRef({ kind: 'github', owner: 'o', repo: 'r' }) + expect(sha).toBe(SHA) + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe('https://api.github.com/repos/o/r/commits/HEAD') + expect((init as RequestInit).headers).toEqual({ Accept: 'application/vnd.github.sha' }) + }) + + it('resolves an explicit ref and sends a bearer token', async () => { + const fetchMock = fetchReturning(new Response(SHA, { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + const sha = await defaultResolveRef({ kind: 'github', owner: 'o', repo: 'r', ref: 'v1.2.3' }, 'secret') + expect(sha).toBe(SHA) + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe('https://api.github.com/repos/o/r/commits/v1.2.3') + expect((init as RequestInit).headers).toEqual({ + Accept: 'application/vnd.github.sha', + Authorization: 'Bearer secret', + }) + }) + + it('throws with the HEAD label when the API rejects an unref-ed source', async () => { + vi.stubGlobal('fetch', fetchReturning(new Response('', { status: 404 }))) + await expect(defaultResolveRef({ kind: 'github', owner: 'o', repo: 'r' })).rejects.toThrow( + /cannot resolve github ref o\/r#HEAD: HTTP 404/, + ) + }) + + it('throws with the explicit-ref label when the API rejects', async () => { + vi.stubGlobal('fetch', fetchReturning(new Response('', { status: 403 }))) + await expect( + defaultResolveRef({ kind: 'github', owner: 'o', repo: 'r', ref: 'main' }), + ).rejects.toThrow(/cannot resolve github ref o\/r#main: HTTP 403/) + }) +}) + +describe('createGigetFetcher', () => { + const downloadMock = vi.mocked(downloadTemplate) + let savedToken: string | undefined + + beforeEach(() => { + downloadMock.mockClear() + savedToken = process.env.GITHUB_TOKEN + delete process.env.GITHUB_TOKEN + }) + + afterEach(() => { + vi.unstubAllGlobals() + if (savedToken === undefined) delete process.env.GITHUB_TOKEN + else process.env.GITHUB_TOKEN = savedToken + }) + + it('wires the real download without provider auth when no token is present', async () => { + vi.stubGlobal('fetch', fetchReturning(new Response(SHA, { status: 200 }))) + await createGigetFetcher().fetch({ kind: 'github', owner: 'o', repo: 'r', ref: 'main' }) + const [input, options] = downloadMock.mock.calls[0]! + expect(input).toBe(`o/r#${SHA}`) + expect(options?.dir).toContain(GITHUB_TEMP_PREFIX) + expect(options?.force).toBe('clean') + expect(options?.providerOptions).toBeUndefined() + }) + + it('passes an explicit token to both ref resolution and provider auth', async () => { + const fetchMock = fetchReturning(new Response(SHA, { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + await createGigetFetcher({ token: 'tok' }).fetch({ kind: 'github', owner: 'o', repo: 'r' }) + expect((fetchMock.mock.calls[0]![1] as RequestInit).headers).toMatchObject({ Authorization: 'Bearer tok' }) + const [, options] = downloadMock.mock.calls[0]! + expect(options).toMatchObject({ providerOptions: { auth: 'tok' } }) + }) + + it('reads GITHUB_TOKEN from the environment', async () => { + process.env.GITHUB_TOKEN = 'from-env' + vi.stubGlobal('fetch', fetchReturning(new Response(SHA, { status: 200 }))) + await createGigetFetcher().fetch({ kind: 'github', owner: 'o', repo: 'r' }) + const [, options] = downloadMock.mock.calls[0]! + expect(options).toMatchObject({ providerOptions: { auth: 'from-env' } }) + }) +}) diff --git a/packages/sdk/plugin-fetch/tests/ids.spec.ts b/packages/sdk/plugin-fetch/tests/ids.spec.ts new file mode 100644 index 0000000000..2f1ba6f518 --- /dev/null +++ b/packages/sdk/plugin-fetch/tests/ids.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { commitSha, integrity } from '../src/ids.ts' + +describe('commitSha', () => { + it('accepts abbreviated and full lowercase hex object ids', () => { + expect(commitSha('abc1234')).toBe('abc1234') + expect(commitSha('a'.repeat(40))).toBe('a'.repeat(40)) + expect(commitSha('0'.repeat(64))).toBe('0'.repeat(64)) + }) + + it.each([ + ['too short', 'abc123'], + ['uppercase', 'ABCDEF1'], + ['non-hex', 'ghijklm'], + ['too long', 'a'.repeat(65)], + ['empty', ''], + ])('rejects an invalid sha (%s)', (_label, value) => { + expect(() => commitSha(value)).toThrow(/invalid commit sha/) + }) +}) + +describe('integrity', () => { + it.each([ + 'sha512-abcABC123+/==', + 'sha384-abcABC123+/', + 'sha256-Zm9vYmFy', + ])('accepts a valid SRI entry (%s)', (value) => { + expect(integrity(value)).toBe(value) + }) + + it.each([ + ['missing algorithm', 'abcABC123'], + ['unsupported algorithm', 'sha1-abcABC123'], + ['illegal base64 char', 'sha512-abc*def'], + ['empty', ''], + ])('rejects an invalid integrity (%s)', (_label, value) => { + expect(() => integrity(value)).toThrow(/invalid subresource integrity/) + }) +}) diff --git a/packages/sdk/plugin-fetch/tests/never.spec.ts b/packages/sdk/plugin-fetch/tests/never.spec.ts new file mode 100644 index 0000000000..224ab66e29 --- /dev/null +++ b/packages/sdk/plugin-fetch/tests/never.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { assertNever } from '../src/never.ts' + +describe('assertNever', () => { + it('throws with the rendered value and a context label', () => { + expect(() => assertNever('surprise' as never, 'demo')).toThrow( + /unreachable variant in demo: "surprise"/, + ) + }) + + it('omits the context clause when none is given', () => { + expect(() => assertNever(7 as never)).toThrow(/unreachable variant: 7$/) + }) + + it('falls back to String() when the value is not JSON-serializable', () => { + // JSON.stringify(undefined) is undefined, exercising the String() fallback. + expect(() => assertNever(undefined as never)).toThrow(/unreachable variant: undefined$/) + }) +}) diff --git a/packages/sdk/plugin-fetch/tests/pacote-fetcher.spec.ts b/packages/sdk/plugin-fetch/tests/pacote-fetcher.spec.ts new file mode 100644 index 0000000000..958e56674e --- /dev/null +++ b/packages/sdk/plugin-fetch/tests/pacote-fetcher.spec.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { extract as pacoteExtract, manifest as pacoteManifest } from 'pacote' +import { + createPacoteFetcher, + NPM_TEMP_PREFIX, + PacoteFetcher, + type PacoteApi, + type PacoteFetcherDeps, +} from '../src/pacote-fetcher.ts' +import type { NpmSource } from '../src/source.ts' + +vi.mock('pacote', () => ({ manifest: vi.fn(), extract: vi.fn() })) + +const INTEGRITY = 'sha512-abcABC123+/==' +const RESOLVED = 'https://registry.npmjs.org/plugin/-/plugin-1.2.3.tgz' + +function fakePacote(): PacoteApi { + return { + manifest: vi.fn(async () => ({ _resolved: RESOLVED, _integrity: INTEGRITY, version: '1.2.3' })), + extract: vi.fn(async () => ({ resolved: RESOLVED, integrity: INTEGRITY })), + } +} + +function deps(overrides: Partial = {}): PacoteFetcherDeps { + return { + pacote: fakePacote(), + createTempDir: vi.fn(async () => '/tmp/dsh-plugin-npm-abc'), + ...overrides, + } +} + +const SOURCE: NpmSource = { kind: 'npm', name: 'plugin', version: '^1.0.0' } + +describe('PacoteFetcher.fetch', () => { + it('resolves the manifest, extracts with integrity, and reports provenance', async () => { + const d = deps() + const result = await new PacoteFetcher(d).fetch(SOURCE) + + expect(d.pacote.manifest).toHaveBeenCalledWith('plugin@^1.0.0', {}) + expect(d.createTempDir).toHaveBeenCalledWith(NPM_TEMP_PREFIX) + expect(d.pacote.extract).toHaveBeenCalledWith('plugin@^1.0.0', '/tmp/dsh-plugin-npm-abc', { + resolved: RESOLVED, + integrity: INTEGRITY, + }) + expect(result).toEqual({ + dir: '/tmp/dsh-plugin-npm-abc', + source: SOURCE, + provenance: { kind: 'npm', version: '1.2.3', resolved: RESOLVED, integrity: INTEGRITY }, + }) + }) + + it('forwards a configured registry to both manifest and extract', async () => { + const d = deps({ registry: 'https://npm.internal/' }) + await new PacoteFetcher(d).fetch(SOURCE) + expect(d.pacote.manifest).toHaveBeenCalledWith('plugin@^1.0.0', { registry: 'https://npm.internal/' }) + expect(d.pacote.extract).toHaveBeenCalledWith('plugin@^1.0.0', '/tmp/dsh-plugin-npm-abc', { + registry: 'https://npm.internal/', + resolved: RESOLVED, + integrity: INTEGRITY, + }) + }) + + it('rejects a registry integrity that is not a valid SRI', async () => { + const pacote = fakePacote() + pacote.extract = vi.fn(async () => ({ resolved: RESOLVED, integrity: 'not-sri' })) + await expect(new PacoteFetcher(deps({ pacote })).fetch(SOURCE)).rejects.toThrow( + /invalid subresource integrity/, + ) + }) + + it('exposes its source kind', () => { + expect(new PacoteFetcher(deps()).kind).toBe('npm') + }) +}) + +describe('createPacoteFetcher', () => { + const manifestMock = vi.mocked(pacoteManifest) + const extractMock = vi.mocked(pacoteExtract) + + beforeEach(() => { + manifestMock.mockReset() + extractMock.mockReset() + // The real overloaded pacote manifest returns a much wider shape; the fetcher reads only these fields. + manifestMock.mockResolvedValue( + { _resolved: RESOLVED, _integrity: INTEGRITY, version: '1.2.3' } as unknown as Awaited< + ReturnType + >, + ) + extractMock.mockResolvedValue({ from: 'plugin@1.2.3', resolved: RESOLVED, integrity: INTEGRITY }) + }) + + afterEach(() => vi.clearAllMocks()) + + it('wires the real pacote resolve/extract surface', async () => { + const result = await createPacoteFetcher().fetch(SOURCE) + expect(manifestMock).toHaveBeenCalledWith('plugin@^1.0.0', {}) + expect(extractMock).toHaveBeenCalledWith('plugin@^1.0.0', expect.stringContaining(NPM_TEMP_PREFIX), { + resolved: RESOLVED, + integrity: INTEGRITY, + }) + expect(result.provenance).toEqual({ kind: 'npm', version: '1.2.3', resolved: RESOLVED, integrity: INTEGRITY }) + }) + + it('forwards a configured registry through the real surface', async () => { + await createPacoteFetcher({ registry: 'https://npm.internal/' }).fetch(SOURCE) + expect(manifestMock).toHaveBeenCalledWith('plugin@^1.0.0', { registry: 'https://npm.internal/' }) + }) +}) diff --git a/packages/sdk/plugin-fetch/tests/source.spec.ts b/packages/sdk/plugin-fetch/tests/source.spec.ts new file mode 100644 index 0000000000..e585c5dfcf --- /dev/null +++ b/packages/sdk/plugin-fetch/tests/source.spec.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import { resolvePluginSource, type GithubSource, type NpmSource } from '../src/source.ts' + +describe('resolvePluginSource — github', () => { + it('parses owner/repo with a ref', () => { + expect(resolvePluginSource('unjs/template#main')).toEqual({ + kind: 'github', owner: 'unjs', repo: 'template', ref: 'main', + }) + }) + + it('parses a bare owner/repo without a ref', () => { + expect(resolvePluginSource('deepseek-ai/plugin')).toEqual({ + kind: 'github', owner: 'deepseek-ai', repo: 'plugin', + }) + }) + + it('parses a nested subdir with a ref', () => { + expect(resolvePluginSource('owner/repo/packages/plugin#v1.2.3')).toEqual({ + kind: 'github', owner: 'owner', repo: 'repo', subdir: 'packages/plugin', ref: 'v1.2.3', + }) + }) + + it('parses a subdir without a ref', () => { + expect(resolvePluginSource('owner/repo/sub')).toEqual({ + kind: 'github', owner: 'owner', repo: 'repo', subdir: 'sub', + }) + }) + + it('accepts a slash-nested ref', () => { + expect(resolvePluginSource('owner/repo#feature/x')).toEqual({ + kind: 'github', owner: 'owner', repo: 'repo', ref: 'feature/x', + }) + }) + + it('trims surrounding whitespace before parsing', () => { + expect(resolvePluginSource(' owner/repo#main ')).toEqual({ + kind: 'github', owner: 'owner', repo: 'repo', ref: 'main', + }) + }) + + it.each([ + ['empty ref after hash', 'owner/repo#'], + ['single locator segment with hash', 'owner#main'], + ['owner with @ and a hash', 'own@er/repo#main'], + ['ref with whitespace', 'owner/repo#bad ref'], + ['ref with traversal', 'owner/repo#a..b'], + ['ref with a leading slash', 'owner/repo#/main'], + ['ref with a trailing slash', 'owner/repo#main/'], + ['ref with an illegal char', 'owner/repo#ma:in'], + ])('rejects a malformed github spec (%s)', (_label, spec) => { + expect(() => resolvePluginSource(spec)).toThrow(/github plugin source|missing a ref/) + }) +}) + +describe('resolvePluginSource — npm', () => { + it('parses an unscoped name@version', () => { + expect(resolvePluginSource('react@18.2.0')).toEqual({ + kind: 'npm', name: 'react', version: '18.2.0', + }) + }) + + it('parses a scoped name@version', () => { + expect(resolvePluginSource('@deepseek-ai/dsh-tool-foo@0.0.1')).toEqual({ + kind: 'npm', name: '@deepseek-ai/dsh-tool-foo', version: '0.0.1', + }) + }) + + it('accepts a dist-tag as the version', () => { + expect(resolvePluginSource('some-plugin@latest')).toEqual({ + kind: 'npm', name: 'some-plugin', version: 'latest', + }) + }) + + it('accepts a range as the version', () => { + expect(resolvePluginSource('some-plugin@^1.0.0')).toEqual({ + kind: 'npm', name: 'some-plugin', version: '^1.0.0', + }) + }) +}) + +describe('resolvePluginSource — failures', () => { + it.each([ + ['empty', ''], + ['whitespace only', ' '], + ])('rejects a blank spec (%s)', (_label, spec) => { + expect(() => resolvePluginSource(spec)).toThrow(/must not be empty/) + }) + + it.each([ + ['bare word', 'plugin'], + ['internal whitespace', 'owner repo'], + ['empty npm version', 'pkg@'], + ['scoped without version', '@scope/pkg'], + ['scoped with empty scope', '@/pkg@1'], + ['unscoped name with slash and version', 'foo/bar@1'], + ['version containing a slash', 'foo@1/2'], + ['uppercase unscoped name', 'FOO@1.0.0'], + ['uppercase scope segment', '@Scope/pkg@1'], + ['uppercase scoped name segment', '@scope/PKG@1'], + ['empty scoped name segment', '@scope/@1'], + ['dot-only owner', './repo'], + ['traversal subdir segment', 'owner/repo/../x'], + ['double slash subdir', 'owner/repo//sub'], + ])('rejects an unrecognized/ambiguous spec (%s)', (_label, spec) => { + expect(() => resolvePluginSource(spec)).toThrow(/unrecognized plugin source|github plugin source/) + }) +}) diff --git a/packages/sdk/plugin-fetch/tsconfig.json b/packages/sdk/plugin-fetch/tsconfig.json new file mode 100644 index 0000000000..07c2567ff8 --- /dev/null +++ b/packages/sdk/plugin-fetch/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../util/brand" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9810a9a61e..25a8fadc47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1223,6 +1223,25 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/sdk/plugin-fetch: + dependencies: + '@bluwy/giget-core': + specifier: ^0.1.7 + version: 0.1.7 + pacote: + specifier: ^22.0.0 + version: 22.0.0 + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@types/pacote': + specifier: ^11.1.8 + version: 11.1.8 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/sdk/scripts: dependencies: '@deepseek-ai/dsh-helper': @@ -2939,6 +2958,10 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@bluwy/giget-core@0.1.7': + resolution: {integrity: sha512-6XG8TZt8DVYLuGDVSpFJaSMlNowOg5RGecvWbKvlgMoqVbztUAQ3AcWq6oZ5DoCnTzNAPcO7rhkwt8ZVgrS7CQ==} + engines: {node: '>=18'} + '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} @@ -3392,6 +3415,10 @@ packages: '@noble/hashes': optional: true + '@gar/promise-retry@1.0.3': + resolution: {integrity: sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==} + engines: {node: ^20.17.0 || >=22.9.0} + '@google/genai@1.52.0': resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} engines: {node: '>=20.0.0'} @@ -3440,6 +3467,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -3486,6 +3517,43 @@ packages: '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@npmcli/agent@5.0.2': + resolution: {integrity: sha512-EkzGmEsgbQ1rqWkRJe2P0oQHx/ylZozDUNPMXCklLuSFL3GY+QyEfBUjhjCsgGXzh4OGpnHvkboSQgczjP/jJg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@npmcli/fs@6.0.0': + resolution: {integrity: sha512-AheOs4swKka/XLtht6xxJDPezlQ7K2IYQ9Y8lST4JLDjnralnWuMM9AE2CdVcgQJ5omrXhsRzM7F7aYmeZBvKQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@npmcli/git@8.0.0': + resolution: {integrity: sha512-5P1oo+TbxZNAiiMBtpzHA8QyEGh5D69LYLexNWJEDXLdxnAZvT/SLitGJBXxjtCE4ftAcFOS/Tu2185MeIjooQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@npmcli/installed-package-contents@5.0.0': + resolution: {integrity: sha512-6Ay12sf2Lh7U1ifvnS1mq7TZFeh/rXHMXye+kV7jQrANIubaoVcleeh4HdFumxhsRYwm9OaHycB5lYmSwGrcIQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + hasBin: true + + '@npmcli/node-gyp@6.0.0': + resolution: {integrity: sha512-MFakpea4pcZNlHSTbMi15HK8RY8zl2UpgDtxhZCWOer+KRN3x7HFIMk/fKpOMgR55L4LIcA2qn8IHeyABhIFtw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@npmcli/package-json@8.0.0': + resolution: {integrity: sha512-agNZzYQ18MR0wKp3Emg1q5QbcC8CXigYp3Z3CvB0Sax9Ge9aF4cVyyuSG+5SbACSrZUKTvMjVULWiE1RJA38wg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@npmcli/promise-spawn@10.0.0': + resolution: {integrity: sha512-llZkSzeTsimFx64U+ThT2xQM2uEce8GIQUYvxgbB6ZFvBhV2LP9LeJJb3HT+syG0uCFLsTCHjV9SfC0WNU1vtA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@npmcli/redact@5.0.0': + resolution: {integrity: sha512-3zcN5Q3yEmeyxXBzqB6fXPQFzYa2ROsGFSr69W0ArXIAGJqxl/aFECOVPD2kbkYPm0U/EHxFKgclK3UA9WQg5A==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@npmcli/run-script@11.0.0': + resolution: {integrity: sha512-leBRl6F5F0TvWut8m1/aZcMTUHi2vXjKeMJ/Ik1lW7Q7Yy16Dhtkklu+cEqQww1p1NeLnUNzV3+uwpzqRcy9vw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + '@oxc-parser/binding-android-arm-eabi@0.133.0': resolution: {integrity: sha512-l/44caGse+VpnY9gx0yvvc5QnnG3yG1FO3KZgYvNL1GZrfK86zIwAOgGEVlxDyRymzrU/KHiblPFpevKOmJmUA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4115,6 +4183,30 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sigstore/bundle@5.0.0': + resolution: {integrity: sha512-wefjygudENbzbQMks1t5u34EP0fFoD0XvaEP7DOUP/sXKvogzEJYFw5E6pegGyp3onGWzVEYKVa3bNZWyTYX+A==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@sigstore/core@4.0.1': + resolution: {integrity: sha512-9v5hRjujn5NXq8o7XFEUgLyAtdr5Iisb4pzM05u3K61IS5q3hP3luWAndk0RkPPLTUFoTbg7Vb84UQ1ZQeajWQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@sigstore/protobuf-specs@0.5.1': + resolution: {integrity: sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g==} + engines: {node: ^18.17.0 || >=20.5.0} + + '@sigstore/sign@5.0.0': + resolution: {integrity: sha512-DSFivqz9/i5AkwZ5fq0YdjaJlc4o1WeS2Zffon0kqtChx0vy4W9NOjkEet9bF2vkzOufX72eVH8kZBIGtcBp1w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@sigstore/tuf@5.0.0': + resolution: {integrity: sha512-Zyqg9tcHps3uRAlKHLNmsW4ohsUZAjb9G+31r7lg0ICh/JOcadzmJsIRdjKljlRHpaR0K4aJ2kXXIdywdcdMlA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@sigstore/verify@4.1.0': + resolution: {integrity: sha512-p/s720RiWxLG8XtmfdPfEJOlATA6H/2knFqmtQbFkHKN3IrhWGUwPfpQAf1UnQIEES9IaH6zzhfjkrhTfeSdZw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + '@smithy/core@3.24.7': resolution: {integrity: sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==} engines: {node: '>=18.0.0'} @@ -4164,6 +4256,14 @@ packages: peerDependencies: eslint: ^9.0.0 || ^10.0.0 + '@tufjs/canonical-json@2.0.0': + resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==} + engines: {node: ^16.14.0 || >=18.0.0} + + '@tufjs/models@5.0.0': + resolution: {integrity: sha512-U4mVcdFGOi6pt8n38LdWZp67Svn7ppnU1Pj8SGOVaBi1X4gm+G4ztQlLfkoJbKSHfjA6WeaiJp2A4V83AJF6nQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -4311,18 +4411,36 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + '@types/node@22.20.0': resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} '@types/node@25.9.3': resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + '@types/npm-package-arg@6.1.4': + resolution: {integrity: sha512-vDgdbMy2QXHnAruzlv68pUtXCjmqUk3WrBAsRboRovsOmxbfn/WiYCjmecyKjGztnMps5dWp4Uq2prp+Ilo17Q==} + + '@types/npm-registry-fetch@8.0.9': + resolution: {integrity: sha512-7NxvodR5Yrop3pb6+n8jhJNyzwOX0+6F+iagNEoi9u1CGxruYAwZD8pvGc9prIkL0+FdX5Xp0p80J9QPrGUp/g==} + + '@types/npmlog@7.0.0': + resolution: {integrity: sha512-hJWbrKFvxKyWwSUXjZMYTINsSOY6IclhvGOZ97M8ac2tmR9hMwmTnYaMdpGhvju9ctWLTPhCS+eLfQNluiEjQQ==} + + '@types/pacote@11.1.8': + resolution: {integrity: sha512-/XLR0VoTh2JEO0jJg1q/e6Rh9bxjBq9vorJuQmtT7rRrXSiWz7e7NsvXVYJQ0i8JxMlBMPPYDTnrRe7MZRFA8Q==} + '@types/picomatch@3.0.2': resolution: {integrity: sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA==} '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/ssri@7.1.5': + resolution: {integrity: sha512-odD/56S3B51liILSk5aXJlnYt99S6Rt9EFDDqGtJM26rKHApHcwyU/UoYHrzKkdkHMAIquGWCuHtQTbes+FRQw==} + '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} @@ -4537,6 +4655,10 @@ packages: resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + abbrev@5.0.0: + resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -4555,6 +4677,10 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + agent-base@9.0.0: + resolution: {integrity: sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==} + engines: {node: '>= 20'} + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -4614,6 +4740,9 @@ packages: ast-v8-to-istanbul@1.0.4: resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -4668,6 +4797,10 @@ packages: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + cacache@21.0.1: + resolution: {integrity: sha512-pTwz/uj3Jyp6WXdJ6fWhR+7LVxVs6RyroQSn7KJwHsSxXuyGSp0pcMVcwSwTpCFq1X2YG8QBe0W+vN+cr0SwzA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -4703,6 +4836,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -4710,6 +4847,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -4997,6 +5138,10 @@ packages: delaunator@5.1.0: resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -5090,6 +5235,10 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -5105,6 +5254,10 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} @@ -5214,6 +5367,9 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + express-rate-limit@8.5.2: resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} engines: {node: '>= 16'} @@ -5301,6 +5457,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + formatly@0.3.0: resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} engines: {node: '>=18.3.0'} @@ -5318,6 +5478,10 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-minipass@3.0.3: + resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5361,6 +5525,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + globals@17.7.0: resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} engines: {node: '>=18'} @@ -5380,6 +5548,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -5396,6 +5567,10 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -5416,6 +5591,10 @@ packages: hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + hosted-git-info@10.1.1: + resolution: {integrity: sha512-DeOnSPAvOndYKfw075gt8yZzQ7S2hNztw34zBTfhIzLhmBTswIBg5/y+pqu/VD5cYWm5goAFTusDmUEmKZ0PEQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -5432,6 +5611,9 @@ packages: htmlparser2@6.1.0: resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==} + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -5440,10 +5622,18 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + http-proxy-agent@9.1.0: + resolution: {integrity: sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==} + engines: {node: '>= 20'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + https-proxy-agent@9.1.0: + resolution: {integrity: sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==} + engines: {node: '>= 20'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -5452,6 +5642,10 @@ packages: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} + ignore-walk@9.0.0: + resolution: {integrity: sha512-tCBEZV2z2FNpIDl2vrhiWzIHzs4qOAuIDEO85eS02vZ3L1U3P56qpPL8GuGGAijDktAEaq2swMkO/Fmbo7YmfQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -5477,6 +5671,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@7.0.0: + resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + internmap@1.0.1: resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} @@ -5520,6 +5718,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -5610,6 +5812,10 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-parse-even-better-errors@6.0.0: + resolution: {integrity: sha512-2/8adwnK1/+Fdjyts4r6wSpfANWw8zdNhU9U/Llk59c6O+DjSisPWPykwoL8gZmocP9Dy64S7oie2g+Mia123A==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + json-schema-to-ts@3.1.1: resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} engines: {node: '>=16'} @@ -5629,6 +5835,10 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonparse@1.3.1: + resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} + engines: {'0': node >= 0.2.0} + jsx-ast-utils-x@0.1.0: resolution: {integrity: sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -5836,6 +6046,10 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + make-fetch-happen@16.0.1: + resolution: {integrity: sha512-uUv1yxHzaKVVEPfcFeGSNov/Cehjv08ovlY8ImTljgL7Q+SiA0dAYLQ6SYVa2kkKqNj4Y3aZEI7xv2teadie0A==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + mark.js@8.11.1: resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} @@ -5998,10 +6212,18 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -6022,6 +6244,30 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass-collect@2.0.1: + resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass-fetch@6.0.0: + resolution: {integrity: sha512-AWI8bKapGmgx/J0E6IGYSKj8TiHebZkmKWSs8raPSw8KXwgEAJ+Bw3+LSdXHR6T/RHKAWCOYk2MiLrYluaUU6w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + minipass-flush@1.0.7: + resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass-sized@2.0.0: + resolution: {integrity: sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==} + engines: {node: '>=8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -6029,12 +6275,20 @@ packages: minisearch@7.2.0: resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} mj-context-menu@0.6.1: resolution: {integrity: sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==} + modern-tar@0.7.6: + resolution: {integrity: sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==} + engines: {node: '>=18.0.0'} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -6143,6 +6397,44 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp@13.0.1: + resolution: {integrity: sha512-piOr0S10qy5THB+q5BdqkoOx65XL/tjTMUAit3vciPNp+snTOBnGunWH1Rz7XZUxf2T9uFrfT/Ty4+aC3yPeyg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + hasBin: true + + nopt@10.0.1: + resolution: {integrity: sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + hasBin: true + + npm-bundled@6.0.0: + resolution: {integrity: sha512-EqdodKEW6pYM+dPxA66TZQfMEqVDiuzjDM9edSjuPI1mXUbUJwVxkgqMZSJvs8RTXz2CGq8HUol/AffTZX5g8w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + npm-install-checks@9.0.0: + resolution: {integrity: sha512-t05Izcgi7p15cpldqoiXYpjzlkTTvBw33sgjmL/JjcvtV0ydbm2O4iEXO8A6smqComu5FAQhUas86HTMQ6Z1Uw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + npm-normalize-package-bin@6.0.0: + resolution: {integrity: sha512-tdt4aFn9QamlhdN3HV2D2ccpBwO5/fyjjbXUxYA6uBjyekMZcZvDq0aSj9t5Jo+tih6AYFnt/cuIRn9013e0Uw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + npm-package-arg@14.0.0: + resolution: {integrity: sha512-69XQh3k+dtGa1p+7RaR57IuG3rCko96xr/nUfN4yDYBXbTYICiWcOpsFKLN2GtGE9cyIljE+f1exnaYt9MvM+Q==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + npm-packlist@11.3.0: + resolution: {integrity: sha512-cS1yVkyriZgQAbiK8PtwhZHEtsFOsKHsCg5Ww2ONckAvXIspgqd6o4WirOzvkupU24iMRZ4xtO4kb2iK2rbnag==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + npm-pick-manifest@12.0.0: + resolution: {integrity: sha512-8Fs3YLrnNOhrCdPNZy18MzNgVC58LTDAFzq1FdZO/p3BHeCC/coz+t4F5Pxabys8HJpyTUorMea26GkXsb4J/Q==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + npm-registry-fetch@20.0.1: + resolution: {integrity: sha512-vzc1svxw/kw1IRjFsLi6gaxe1Olqm88V0tIfu2u5raL0b1gChe6ZEXNkyUlKxUC7s/egt5NxZHkbY18tMKKLfQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -6199,6 +6491,10 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-map@7.0.5: + resolution: {integrity: sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==} + engines: {node: '>=18'} + p-retry@4.6.2: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} @@ -6209,6 +6505,11 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pacote@22.0.0: + resolution: {integrity: sha512-++VqeOZeL03uGM2MFLk96jGCSt1owBGkyFKoPr+trwNlZhCpjN2RrvwYxt8nTbs1wNMqSFYurq0TafVWkAIHig==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + hasBin: true + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -6247,6 +6548,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -6289,6 +6594,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + proc-log@7.0.0: + resolution: {integrity: sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -6303,6 +6612,15 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-agent-negotiate@1.1.0: + resolution: {integrity: sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==} + engines: {node: '>= 20'} + peerDependencies: + kerberos: ^2.0.0 + peerDependenciesMeta: + kerberos: + optional: true + publint@0.3.21: resolution: {integrity: sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==} engines: {node: '>=18'} @@ -6495,16 +6813,32 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sigstore@5.0.0: + resolution: {integrity: sha512-hJqJfoG/e4qFQaauQL00c6J6FrHLBGKtkFvW3JbTSIEFOhLrSjdSM/gWd/yUOfYo/gsERehTXGC1VZWX+9X4Dg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} slick@1.12.2: resolution: {integrity: sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smol-toml@1.6.1: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} + socks-proxy-agent@10.1.0: + resolution: {integrity: sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ==} + engines: {node: '>= 20'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -6516,6 +6850,15 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@4.0.0: + resolution: {integrity: sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + speakingurl@14.0.1: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} @@ -6524,6 +6867,10 @@ packages: resolution: {integrity: sha512-i/VCLG1fvRc95pMHRqG4aQNscv+9aIsqA2oI7ZQS51sTdUcDHYX6cpT8/tqZ+enjs1tKVwbRBWgxut9SWn+f9g==} hasBin: true + ssri@14.0.0: + resolution: {integrity: sha512-jQxKI0yx0ZnTKrqjKkLDV2DXkBQn3k49JVmVqDGcDwKDtGDbImD/GXsq04KD0VVzCQQ9wZJYal3RwR1GzWTSow==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -6584,6 +6931,10 @@ packages: tabbable@6.5.0: resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + tar@7.5.20: + resolution: {integrity: sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -6694,6 +7045,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tuf-js@6.0.0: + resolution: {integrity: sha512-zlJVOIO68hmgo1//X4ENEcTGfuOTAtDPi8PsTsG+FyxD85E/ww1ZnwBbWo/yCEExGpI+Kilg7Z3qCdHX2BoJTQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -6739,6 +7094,10 @@ packages: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} + undici@8.7.0: + resolution: {integrity: sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ==} + engines: {node: '>=22.19.0'} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -6772,6 +7131,10 @@ packages: resolution: {integrity: sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==} engines: {node: '>=10'} + validate-npm-package-name@8.0.0: + resolution: {integrity: sha512-SCv6OOV6Xj2/3cXy3dGmADluJTNcL3o7hZAglNPTe+WYuEuvxgJzxPrSDLZhF+CwyQOubqgecjMmTJGMVLWjYQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -6961,6 +7324,11 @@ packages: engines: {node: '>= 8'} hasBin: true + which@7.0.0: + resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -7010,6 +7378,13 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -7447,6 +7822,10 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@bluwy/giget-core@0.1.7': + dependencies: + modern-tar: 0.7.6 + '@braintree/sanitize-url@7.1.2': {} '@bramus/specificity@2.4.2': @@ -7774,6 +8153,8 @@ snapshots: '@exodus/bytes@1.15.1': {} + '@gar/promise-retry@1.0.3': {} + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.7.0 @@ -7828,6 +8209,10 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -7915,6 +8300,63 @@ snapshots: '@nodable/entities@2.2.0': {} + '@npmcli/agent@5.0.2': + dependencies: + agent-base: 9.0.0 + http-proxy-agent: 9.1.0 + https-proxy-agent: 9.1.0 + lru-cache: 11.5.1 + socks-proxy-agent: 10.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + + '@npmcli/fs@6.0.0': + dependencies: + semver: 7.8.4 + + '@npmcli/git@8.0.0': + dependencies: + '@gar/promise-retry': 1.0.3 + '@npmcli/promise-spawn': 10.0.0 + ini: 7.0.0 + lru-cache: 11.5.1 + npm-pick-manifest: 12.0.0 + proc-log: 7.0.0 + semver: 7.8.4 + which: 7.0.0 + + '@npmcli/installed-package-contents@5.0.0': + dependencies: + npm-bundled: 6.0.0 + npm-normalize-package-bin: 6.0.0 + + '@npmcli/node-gyp@6.0.0': {} + + '@npmcli/package-json@8.0.0': + dependencies: + '@npmcli/git': 8.0.0 + glob: 13.0.6 + hosted-git-info: 10.1.1 + json-parse-even-better-errors: 6.0.0 + proc-log: 7.0.0 + semver: 7.8.4 + spdx-expression-parse: 4.0.0 + + '@npmcli/promise-spawn@10.0.0': + dependencies: + which: 7.0.0 + + '@npmcli/redact@5.0.0': {} + + '@npmcli/run-script@11.0.0': + dependencies: + '@npmcli/node-gyp': 6.0.0 + '@npmcli/package-json': 8.0.0 + '@npmcli/promise-spawn': 10.0.0 + node-gyp: 13.0.1 + proc-log: 7.0.0 + '@oxc-parser/binding-android-arm-eabi@0.133.0': optional: true @@ -8288,6 +8730,39 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sigstore/bundle@5.0.0': + dependencies: + '@sigstore/protobuf-specs': 0.5.1 + + '@sigstore/core@4.0.1': {} + + '@sigstore/protobuf-specs@0.5.1': {} + + '@sigstore/sign@5.0.0': + dependencies: + '@gar/promise-retry': 1.0.3 + '@sigstore/bundle': 5.0.0 + '@sigstore/core': 4.0.1 + '@sigstore/protobuf-specs': 0.5.1 + make-fetch-happen: 16.0.1 + proc-log: 7.0.0 + transitivePeerDependencies: + - kerberos + - supports-color + + '@sigstore/tuf@5.0.0': + dependencies: + '@sigstore/protobuf-specs': 0.5.1 + tuf-js: 6.0.0 + transitivePeerDependencies: + - supports-color + + '@sigstore/verify@4.1.0': + dependencies: + '@sigstore/bundle': 5.0.0 + '@sigstore/core': 4.0.1 + '@sigstore/protobuf-specs': 0.5.1 + '@smithy/core@3.24.7': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -8354,6 +8829,13 @@ snapshots: estraverse: 5.3.0 picomatch: 4.0.4 + '@tufjs/canonical-json@2.0.0': {} + + '@tufjs/models@5.0.0': + dependencies: + '@tufjs/canonical-json': 2.0.0 + minimatch: 10.2.5 + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -8527,6 +9009,11 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 22.20.0 + form-data: 4.0.6 + '@types/node@22.20.0': dependencies: undici-types: 6.21.0 @@ -8535,10 +9022,35 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/npm-package-arg@6.1.4': {} + + '@types/npm-registry-fetch@8.0.9': + dependencies: + '@types/node': 22.20.0 + '@types/node-fetch': 2.6.13 + '@types/npm-package-arg': 6.1.4 + '@types/npmlog': 7.0.0 + '@types/ssri': 7.1.5 + + '@types/npmlog@7.0.0': + dependencies: + '@types/node': 22.20.0 + + '@types/pacote@11.1.8': + dependencies: + '@types/node': 22.20.0 + '@types/npm-registry-fetch': 8.0.9 + '@types/npmlog': 7.0.0 + '@types/ssri': 7.1.5 + '@types/picomatch@3.0.2': {} '@types/retry@0.12.0': {} + '@types/ssri@7.1.5': + dependencies: + '@types/node': 22.20.0 + '@types/tough-cookie@4.0.5': {} '@types/trusted-types@2.0.7': @@ -8815,6 +9327,8 @@ snapshots: '@xmldom/xmldom@0.9.10': {} + abbrev@5.0.0: {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -8828,6 +9342,8 @@ snapshots: agent-base@7.1.4: {} + agent-base@9.0.0: {} + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -8895,6 +9411,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + asynckit@0.4.0: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -8945,6 +9463,19 @@ snapshots: cac@7.0.0: {} + cacache@21.0.1: + dependencies: + '@npmcli/fs': 6.0.0 + fs-minipass: 3.0.3 + glob: 13.0.6 + lru-cache: 11.5.1 + minipass: 7.1.3 + minipass-collect: 2.0.1 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + p-map: 7.0.5 + ssri: 14.0.0 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -8987,12 +9518,18 @@ snapshots: dependencies: readdirp: 4.1.2 + chownr@3.0.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} commander@13.1.0: {} @@ -9298,6 +9835,8 @@ snapshots: dependencies: robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} + depd@2.0.0: {} dequal@2.0.3: {} @@ -9372,6 +9911,8 @@ snapshots: entities@8.0.0: {} + env-paths@2.2.1: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -9382,6 +9923,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + es-toolkit@1.49.0: {} esbuild@0.21.5: @@ -9556,6 +10104,8 @@ snapshots: expect-type@1.3.0: {} + exponential-backoff@3.1.3: {} + express-rate-limit@8.5.2(express@5.2.1): dependencies: express: 5.2.1 @@ -9679,6 +10229,14 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + formatly@0.3.0: dependencies: fd-package-json: 2.0.0 @@ -9691,6 +10249,10 @@ snapshots: fresh@2.0.0: {} + fs-minipass@3.0.3: + dependencies: + minipass: 7.1.3 + fsevents@2.3.3: optional: true @@ -9753,6 +10315,12 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + globals@17.7.0: {} globrex@0.1.2: {} @@ -9772,6 +10340,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + hachure-fill@0.5.2: {} handlebars@4.7.9: @@ -9787,6 +10357,10 @@ snapshots: has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -9815,6 +10389,10 @@ snapshots: hookable@6.1.1: {} + hosted-git-info@10.1.1: + dependencies: + lru-cache: 11.5.1 + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.15.1 @@ -9839,6 +10417,8 @@ snapshots: domutils: 2.8.0 entities: 2.2.0 + http-cache-semantics@4.2.0: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -9854,6 +10434,15 @@ snapshots: transitivePeerDependencies: - supports-color + http-proxy-agent@9.1.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3 + proxy-agent-negotiate: 1.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -9861,6 +10450,15 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@9.1.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3 + proxy-agent-negotiate: 1.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -9869,6 +10467,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ignore-walk@9.0.0: + dependencies: + minimatch: 10.2.5 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -9883,6 +10485,8 @@ snapshots: inherits@2.0.4: {} + ini@7.0.0: {} + internmap@1.0.1: {} internmap@2.0.3: {} @@ -9909,6 +10513,8 @@ snapshots: isexe@2.0.0: {} + isexe@4.0.0: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -10001,6 +10607,8 @@ snapshots: json-buffer@3.0.1: {} + json-parse-even-better-errors@6.0.0: {} + json-schema-to-ts@3.1.1: dependencies: '@babel/runtime': 7.29.7 @@ -10016,6 +10624,8 @@ snapshots: jsonc-parser@3.3.1: {} + jsonparse@1.3.1: {} + jsx-ast-utils-x@0.1.0: {} jszip@3.10.1: @@ -10207,6 +10817,24 @@ snapshots: dependencies: semver: 7.8.4 + make-fetch-happen@16.0.1: + dependencies: + '@gar/promise-retry': 1.0.3 + '@npmcli/agent': 5.0.2 + '@npmcli/redact': 5.0.0 + cacache: 21.0.1 + http-cache-semantics: 4.2.0 + minipass: 7.1.3 + minipass-fetch: 6.0.0 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + negotiator: 1.0.0 + proc-log: 7.0.0 + ssri: 14.0.0 + transitivePeerDependencies: + - kerberos + - supports-color + mark.js@8.11.1: {} markdown-it-mathjax3@4.3.2: @@ -10568,8 +11196,14 @@ snapshots: transitivePeerDependencies: - supports-color + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -10586,14 +11220,48 @@ snapshots: minimist@1.2.8: {} + minipass-collect@2.0.1: + dependencies: + minipass: 7.1.3 + + minipass-fetch@6.0.0: + dependencies: + minipass: 7.1.3 + minipass-sized: 2.0.0 + minizlib: 3.1.0 + optionalDependencies: + iconv-lite: 0.7.3 + + minipass-flush@1.0.7: + dependencies: + minipass: 3.3.6 + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + + minipass-sized@2.0.0: + dependencies: + minipass: 7.1.3 + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + minipass@7.1.3: {} minisearch@7.2.0: {} + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + mitt@3.0.1: {} mj-context-menu@0.6.1: {} + modern-tar@0.7.6: {} + mri@1.2.0: {} ms@2.1.3: {} @@ -10678,6 +11346,67 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-gyp@13.0.1: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.3 + graceful-fs: 4.2.11 + nopt: 10.0.1 + proc-log: 7.0.0 + semver: 7.8.4 + tar: 7.5.20 + tinyglobby: 0.2.17 + undici: 8.7.0 + which: 7.0.0 + + nopt@10.0.1: + dependencies: + abbrev: 5.0.0 + + npm-bundled@6.0.0: + dependencies: + npm-normalize-package-bin: 6.0.0 + + npm-install-checks@9.0.0: + dependencies: + semver: 7.8.4 + + npm-normalize-package-bin@6.0.0: {} + + npm-package-arg@14.0.0: + dependencies: + hosted-git-info: 10.1.1 + proc-log: 7.0.0 + semver: 7.8.4 + validate-npm-package-name: 8.0.0 + + npm-packlist@11.3.0: + dependencies: + glob: 13.0.6 + ignore-walk: 9.0.0 + proc-log: 7.0.0 + + npm-pick-manifest@12.0.0: + dependencies: + npm-install-checks: 9.0.0 + npm-normalize-package-bin: 6.0.0 + npm-package-arg: 14.0.0 + semver: 7.8.4 + + npm-registry-fetch@20.0.1: + dependencies: + '@npmcli/redact': 5.0.0 + jsonparse: 1.3.1 + make-fetch-happen: 16.0.1 + minipass: 7.1.3 + minipass-fetch: 6.0.0 + minizlib: 3.1.0 + npm-package-arg: 14.0.0 + proc-log: 7.0.0 + transitivePeerDependencies: + - kerberos + - supports-color + nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -10771,6 +11500,8 @@ snapshots: dependencies: p-limit: 3.1.0 + p-map@7.0.5: {} + p-retry@4.6.2: dependencies: '@types/retry': 0.12.0 @@ -10780,6 +11511,29 @@ snapshots: package-manager-detector@1.6.0: {} + pacote@22.0.0: + dependencies: + '@gar/promise-retry': 1.0.3 + '@npmcli/git': 8.0.0 + '@npmcli/installed-package-contents': 5.0.0 + '@npmcli/package-json': 8.0.0 + '@npmcli/promise-spawn': 10.0.0 + '@npmcli/run-script': 11.0.0 + cacache: 21.0.1 + fs-minipass: 3.0.3 + minipass: 7.1.3 + npm-package-arg: 14.0.0 + npm-packlist: 11.3.0 + npm-pick-manifest: 12.0.0 + npm-registry-fetch: 20.0.1 + proc-log: 7.0.0 + sigstore: 5.0.0 + ssri: 14.0.0 + tar: 7.5.20 + transitivePeerDependencies: + - kerberos + - supports-color + pako@1.0.11: {} parse5-htmlparser2-tree-adapter@6.0.1: @@ -10809,6 +11563,11 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.1 + minipass: 7.1.3 + path-to-regexp@8.4.2: {} pathe@2.0.3: {} @@ -10838,6 +11597,8 @@ snapshots: prelude-ls@1.2.1: {} + proc-log@7.0.0: {} + process-nextick-args@2.0.1: {} property-information@7.2.0: {} @@ -10853,7 +11614,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 25.9.3 + '@types/node': 22.20.0 long: 5.3.2 proxy-addr@2.0.7: @@ -10861,6 +11622,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-agent-negotiate@1.1.0: {} + publint@0.3.21: dependencies: '@publint/pack': 0.1.4 @@ -11144,18 +11907,54 @@ snapshots: signal-exit@4.1.0: {} + sigstore@5.0.0: + dependencies: + '@sigstore/bundle': 5.0.0 + '@sigstore/core': 4.0.1 + '@sigstore/protobuf-specs': 0.5.1 + '@sigstore/sign': 5.0.0 + '@sigstore/tuf': 5.0.0 + '@sigstore/verify': 4.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + sisteransi@1.0.5: {} slick@1.12.2: {} + smart-buffer@4.2.0: {} + smol-toml@1.6.1: {} + socks-proxy-agent@10.1.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3 + socks: 2.8.9 + transitivePeerDependencies: + - supports-color + + socks@2.8.9: + dependencies: + ip-address: 10.2.0 + smart-buffer: 4.2.0 + source-map-js@1.2.1: {} source-map@0.6.1: {} space-separated-tokens@2.0.2: {} + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@4.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + speakingurl@14.0.1: {} speech-rule-engine@4.1.4: @@ -11164,6 +11963,10 @@ snapshots: commander: 13.1.0 wicked-good-xpath: 1.3.0 + ssri@14.0.0: + dependencies: + minipass: 7.1.3 + stackback@0.0.2: {} statuses@2.0.2: {} @@ -11221,6 +12024,14 @@ snapshots: tabbable@6.5.0: {} + tar@7.5.20: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -11301,6 +12112,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tuf-js@6.0.0: + dependencies: + '@gar/promise-retry': 1.0.3 + '@tufjs/models': 5.0.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -11342,6 +12161,8 @@ snapshots: undici@7.28.0: {} + undici@8.7.0: {} + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -11377,6 +12198,8 @@ snapshots: valid-data-url@3.0.1: {} + validate-npm-package-name@8.0.0: {} + vary@1.1.2: {} vfile-message@4.0.3: @@ -11600,6 +12423,10 @@ snapshots: dependencies: isexe: 2.0.0 + which@7.0.0: + dependencies: + isexe: 4.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -11633,6 +12460,10 @@ snapshots: xmlchars@2.2.0: {} + yallist@4.0.0: {} + + yallist@5.0.0: {} + yaml@2.9.0: {} yocto-queue@0.1.0: {} diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 75fb2b75ad..4a845b4554 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -52,6 +52,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' }, 'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' }, 'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' }, + 'packages/sdk/plugin-fetch': { kind: 'none', reason: 'The fetcher acquires plugin sources into a temp dir and registers no live agent or model surface.' }, 'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, diff --git a/tsconfig.build.json b/tsconfig.build.json index dd13e018c3..9a6133db36 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -98,6 +98,7 @@ { "path": "./packages/mcp/mcp-client" }, { "path": "./packages/sdk/helper" }, { "path": "./packages/sdk/scripts" }, - { "path": "./packages/sdk/create-sdk" } + { "path": "./packages/sdk/create-sdk" }, + { "path": "./packages/sdk/plugin-fetch" } ] } diff --git a/tsconfig.json b/tsconfig.json index 22451dc10f..4588c81c72 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -109,6 +109,7 @@ { "path": "./packages/mcp/mcp-client" }, { "path": "./packages/sdk/helper" }, { "path": "./packages/sdk/scripts" }, - { "path": "./packages/sdk/create-sdk" } + { "path": "./packages/sdk/create-sdk" }, + { "path": "./packages/sdk/plugin-fetch" } ] } From fe668a6dfe5c9c64453eb78b8f167eb1407c0eb9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:16:51 +0800 Subject: [PATCH 07/37] feat(telemetry): greenfield dsh-sdk telemetry modules Add @deepseek-ai/dsh-telemetry, a launcher-side (non-plugin) library for the ConsentResolver (parses cordis.yml consent + DO_NOT_TRACK/CI), buildTelemetryPayload (redacted cordis.yml + package.json full content, never .env), getOrCreateAnonymousId (random UUID in a per-machine global config file), and TelemetryReporter (fire-and-forget, never blocks or crashes the command). Endpoint is a fixed .invalid placeholder pending the real endpoint. Launcher dispatch wiring and the helper feature-catalog entry are intentionally out of scope. Registers the package in tsconfig references, the module graph, and the README model-experience audit map. Per-file 100% coverage. --- docs/module-graph.md | 3 + packages/sdk/telemetry/README.md | 24 +++ packages/sdk/telemetry/package.json | 35 +++ packages/sdk/telemetry/src/anonymous-id.ts | 106 +++++++++ .../sdk/telemetry/src/consent-resolver.ts | 125 +++++++++++ packages/sdk/telemetry/src/index.ts | 45 ++++ packages/sdk/telemetry/src/payload.ts | 75 +++++++ packages/sdk/telemetry/src/reporter.ts | 149 +++++++++++++ packages/sdk/telemetry/src/secret-redactor.ts | 203 ++++++++++++++++++ .../sdk/telemetry/tests/anonymous-id.spec.ts | 100 +++++++++ .../telemetry/tests/consent-resolver.spec.ts | 131 +++++++++++ packages/sdk/telemetry/tests/payload.spec.ts | 59 +++++ packages/sdk/telemetry/tests/reporter.spec.ts | 134 ++++++++++++ .../telemetry/tests/secret-redactor.spec.ts | 169 +++++++++++++++ packages/sdk/telemetry/tsconfig.json | 13 ++ pnpm-lock.yaml | 13 ++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.build.json | 3 +- tsconfig.json | 3 +- 19 files changed, 1389 insertions(+), 2 deletions(-) create mode 100644 packages/sdk/telemetry/README.md create mode 100644 packages/sdk/telemetry/package.json create mode 100644 packages/sdk/telemetry/src/anonymous-id.ts create mode 100644 packages/sdk/telemetry/src/consent-resolver.ts create mode 100644 packages/sdk/telemetry/src/index.ts create mode 100644 packages/sdk/telemetry/src/payload.ts create mode 100644 packages/sdk/telemetry/src/reporter.ts create mode 100644 packages/sdk/telemetry/src/secret-redactor.ts create mode 100644 packages/sdk/telemetry/tests/anonymous-id.spec.ts create mode 100644 packages/sdk/telemetry/tests/consent-resolver.spec.ts create mode 100644 packages/sdk/telemetry/tests/payload.spec.ts create mode 100644 packages/sdk/telemetry/tests/reporter.spec.ts create mode 100644 packages/sdk/telemetry/tests/secret-redactor.spec.ts create mode 100644 packages/sdk/telemetry/tsconfig.json diff --git a/docs/module-graph.md b/docs/module-graph.md index c9439d5501..4cc2c36eeb 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -140,6 +140,7 @@ flowchart TD pkg_helper["helper"] pkg_plugin_fetch["plugin-fetch"] pkg_scripts["scripts"] + pkg_telemetry["telemetry"] end subgraph group_tasks["packages/tasks"] pkg_tasks["tasks"] @@ -155,6 +156,7 @@ flowchart TD pkg_helper --> pkg_brand pkg_plugin_fetch --> pkg_brand pkg_scripts --> pkg_app_boot + pkg_telemetry --> pkg_brand pkg_llm_deepseek --> pkg_llm pkg_llm_pi_ai --> pkg_llm pkg_session --> pkg_brand @@ -446,6 +448,7 @@ flowchart TD | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand) | | [`plugin-fetch`](../packages/sdk/plugin-fetch) | `sdk` | [`brand`](../packages/util/brand) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot) | +| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | diff --git a/packages/sdk/telemetry/README.md b/packages/sdk/telemetry/README.md new file mode 100644 index 0000000000..3a8b6ac4c2 --- /dev/null +++ b/packages/sdk/telemetry/README.md @@ -0,0 +1,24 @@ +# `@deepseek-ai/dsh-telemetry` + +Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain library the launcher imports around each command; it is **not** a Cordis plugin, because `build` and first-init `create` never boot Cordis. Wiring the reporter into the launcher command dispatch and adding the telemetry consent feature to the `dsh-helper` catalog live in their owning packages, not here. + +| Export | Role | +|---|---| +| `SecretRedactor` | Conservative safety backstop: replaces secret-shaped values (secret-like keys, known token shapes, PEM blocks, URL credentials, high-entropy opaque tokens) with a placeholder in both parsed values (`redactValue`) and raw text (`redactText`). Never drops a field or line. | +| `ConsentResolver` | Parses (never boots) a project `cordis.yml` and reads the telemetry entry's enabled/disabled state as consent; `DO_NOT_TRACK`/CI env force a hard opt-out. | +| `buildTelemetryPayload` | Assembles `{command, durationMs, success, cordisYmlContent, packageJsonContent}`, running the redactor over the full `cordis.yml` and `package.json` text. Never reads `.env`. | +| `getOrCreateAnonymousId` | Random UUID persisted in a per-user GLOBAL config file (never in the project, never derived from git). | +| `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. | + +Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. When `cordis.yml` does not yet exist (first `create`) consent defaults to allowed; when it exists without a telemetry entry consent defaults to denied — both are configurable on `ConsentResolver`. + +The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); its `.invalid` placeholder must be replaced with the real endpoint before release. + +## Model Experience + +None, as the reporter sends developer-cycle telemetry from the launcher and never reaches a model request. + +## Known Limitations and Deferred Work + +- **Placeholder endpoint** — `DSH_TELEMETRY_ENDPOINT` points at `.invalid` until the real endpoint is set. +- **Redaction is heuristic** — a conservative backstop, not a guarantee; secrets belong in `.env`, which is never read or reported. diff --git a/packages/sdk/telemetry/package.json b/packages/sdk/telemetry/package.json new file mode 100644 index 0000000000..fcb6efb797 --- /dev/null +++ b/packages/sdk/telemetry/package.json @@ -0,0 +1,35 @@ +{ + "name": "@deepseek-ai/dsh-telemetry", + "description": "Launcher-side dsh-sdk telemetry: secret redaction, consent resolution, anonymous id, payload builder, and fire-and-forget reporter", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "yaml": "^2.9.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/sdk/telemetry/src/anonymous-id.ts b/packages/sdk/telemetry/src/anonymous-id.ts new file mode 100644 index 0000000000..030fefa19f --- /dev/null +++ b/packages/sdk/telemetry/src/anonymous-id.ts @@ -0,0 +1,106 @@ +/** + * Per-machine anonymous telemetry id. + * + * The id is a random UUID persisted in a per-user GLOBAL config file — never in + * the project, and never derived from the git remote, repository URL, or any + * other identifying source (a derived id would make "anonymous" a fiction). The + * same id is reused across projects on one machine so telemetry counts machines, + * not repositories. + * + * @module @deepseek-ai/dsh-telemetry/anonymous-id + */ + +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** A machine-scoped anonymous telemetry id (random UUID v4). */ +export type AnonymousId = Branded<'AnonymousId'> + +/** Config directory name owned by the DeepSeek Harness across tools. */ +const CONFIG_NAMESPACE = 'deepseek-harness' + +/** Default file, inside the global config dir, storing the anonymous id. */ +export const ANONYMOUS_ID_FILE_NAME = 'telemetry.json' + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** Ambient seams for locating and generating the id; every field has a default. */ +export interface AnonymousIdOptions { + /** Environment consulted for `DSH_CONFIG_HOME`/`XDG_CONFIG_HOME`/`APPDATA`; defaults to `process.env`. */ + env?: NodeJS.ProcessEnv + /** Platform string used to pick the Windows path; defaults to `process.platform`. */ + platform?: NodeJS.Platform + /** Home directory resolver; defaults to `os.homedir`. */ + homeDir?: () => string + /** UUID generator; defaults to `crypto.randomUUID` (test seam). */ + randomUUID?: () => string +} + +/** + * Resolve the per-user global config directory for harness tooling. + * Precedence: `DSH_CONFIG_HOME` (explicit override) > `XDG_CONFIG_HOME` > + * platform default (`%APPDATA%` on Windows, else `~/.config`). + * @param options - environment, platform, and home-directory seams. + * @returns absolute config directory path for the harness namespace. + */ +export function globalConfigDir(options: AnonymousIdOptions = {}): string { + const env = options.env ?? process.env + const platform = options.platform ?? process.platform + const home = options.homeDir ?? homedir + if (env.DSH_CONFIG_HOME !== undefined && env.DSH_CONFIG_HOME.length > 0) return env.DSH_CONFIG_HOME + if (env.XDG_CONFIG_HOME !== undefined && env.XDG_CONFIG_HOME.length > 0) { + return join(env.XDG_CONFIG_HOME, CONFIG_NAMESPACE) + } + if (platform === 'win32' && env.APPDATA !== undefined && env.APPDATA.length > 0) { + return join(env.APPDATA, CONFIG_NAMESPACE) + } + return join(home(), '.config', CONFIG_NAMESPACE) +} + +/** Read a valid persisted id from the store, or `undefined` when absent/corrupt. */ +async function readPersistedId(file: string): Promise { + let text: string + try { + text = await readFile(file, 'utf8') + } catch { + // Absent or unreadable: the caller mints and persists a fresh id. + return undefined + } + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + // Corrupt JSON: the caller overwrites the store with a fresh id. + return undefined + } + if (parsed !== null && typeof parsed === 'object') { + const value = (parsed as Record).anonymousId + if (typeof value === 'string' && UUID_PATTERN.test(value)) return value as AnonymousId + } + return undefined +} + +/** + * Return the machine's anonymous id, creating and persisting one on first use. + * Persistence is best-effort: a write failure still returns a usable id for the + * current run so telemetry is never blocked by config-dir permissions. + * @param options - config-location and UUID-generation seams. + * @returns the stable per-machine anonymous id. + */ +export async function getOrCreateAnonymousId(options: AnonymousIdOptions = {}): Promise { + const file = join(globalConfigDir(options), ANONYMOUS_ID_FILE_NAME) + const existing = await readPersistedId(file) + if (existing !== undefined) return existing + const generate = options.randomUUID ?? randomUUID + const created = generate() as AnonymousId + try { + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, `${JSON.stringify({ anonymousId: created }, null, 2)}\n`, 'utf8') + } catch { + // Best-effort persistence: return the fresh id even when the store is unwritable. + } + return created +} diff --git a/packages/sdk/telemetry/src/consent-resolver.ts b/packages/sdk/telemetry/src/consent-resolver.ts new file mode 100644 index 0000000000..1d50904f39 --- /dev/null +++ b/packages/sdk/telemetry/src/consent-resolver.ts @@ -0,0 +1,125 @@ +/** + * Consent resolution for dsh-sdk telemetry. + * + * Consent is carried by the telemetry plugin's enabled/disabled state in the + * project `cordis.yml`: an enabled entry means opt-in, a `disabled: true` entry + * means opt-out. The resolver PARSES `cordis.yml` — it never boots a Cordis + * application — because several launcher commands (`build`, `create`) never + * boot Cordis at all. `DO_NOT_TRACK` and CI environment signals force a denial + * regardless of file state. + * + * @module @deepseek-ai/dsh-telemetry/consent-resolver + */ + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { parseDocument, type ScalarTag } from 'yaml' + +/** Default `cordis.yml` entry name that carries telemetry consent. */ +export const DEFAULT_TELEMETRY_PLUGIN_NAME = '@deepseek-ai/dsh-telemetry' + +/** + * Passthrough for Cordis' `!!js` expression tag so parsing consent never fails + * on projects that inline JavaScript expressions; the resolver only reads plain + * `name`/`disabled` scalars and does not evaluate expressions. + */ +const JS_EXPRESSION_TAG: ScalarTag = { + tag: 'tag:yaml.org,2002:js', + resolve: value => value, +} + +/** Why telemetry is or is not permitted for one command. */ +export type ConsentReason = + | 'enabled' + | 'disabled' + | 'absent' + | 'no-config' + | 'do-not-track' + | 'ci' + | 'unreadable' + +/** Resolved telemetry consent for one command invocation. */ +export interface ConsentDecision { + /** Whether telemetry may be sent. */ + allowed: boolean + /** The signal that determined {@link allowed}. */ + reason: ConsentReason +} + +/** Tuning for {@link ConsentResolver}; every field defaults to a documented value. */ +export interface ConsentResolverOptions { + /** `cordis.yml` entry name whose enabled state carries consent. */ + telemetryPluginName?: string + /** Environment used for `DO_NOT_TRACK`/CI checks; defaults to `process.env`. */ + env?: NodeJS.ProcessEnv + /** Honor `DO_NOT_TRACK`/CI env signals as a hard opt-out. Defaults to `true`. */ + honorEnvOptOut?: boolean + /** Consent when `cordis.yml` does not exist yet (first `create`). Defaults to `true` (telemetry is default-on). */ + allowWhenNoConfig?: boolean + /** Consent when `cordis.yml` exists but has no telemetry entry. Defaults to `false`. */ + allowWhenEntryAbsent?: boolean +} + +/** Whether an environment variable is set to a non-empty, non-"0"/"false" value. */ +function envEnabled(value: string | undefined): boolean { + if (value === undefined) return false + const normalized = value.trim().toLowerCase() + return normalized.length > 0 && normalized !== '0' && normalized !== 'false' +} + +/** Read a `cordis.yml` entry's `name`/`disabled` scalars, tolerating `!!js` tags. */ +function readTelemetryEntry(text: string, pluginName: string): { present: boolean; disabled: boolean } { + const document = parseDocument(text, { customTags: [JS_EXPRESSION_TAG] }) + const contents: unknown = document.toJS({ maxAliasCount: -1 }) + if (!Array.isArray(contents)) return { present: false, disabled: false } + for (const entry of contents) { + if (entry === null || typeof entry !== 'object') continue + const record = entry as Record + if (record.name === pluginName) return { present: true, disabled: record.disabled === true } + } + return { present: false, disabled: false } +} + +/** Resolve telemetry consent by parsing a project's `cordis.yml` and the environment. */ +export class ConsentResolver { + readonly #pluginName: string + readonly #env: NodeJS.ProcessEnv + readonly #honorEnvOptOut: boolean + readonly #allowWhenNoConfig: boolean + readonly #allowWhenEntryAbsent: boolean + + /** @param options - plugin name, environment, and default-decision knobs. */ + constructor(options: ConsentResolverOptions = {}) { + this.#pluginName = options.telemetryPluginName ?? DEFAULT_TELEMETRY_PLUGIN_NAME + this.#env = options.env ?? process.env + this.#honorEnvOptOut = options.honorEnvOptOut ?? true + this.#allowWhenNoConfig = options.allowWhenNoConfig ?? true + this.#allowWhenEntryAbsent = options.allowWhenEntryAbsent ?? false + } + + /** + * Resolve consent for a command run in the given project directory. + * @param projectDir - absolute or relative project root containing `cordis.yml`. + * @returns the consent decision and the signal that produced it. + */ + async resolve(projectDir: string): Promise { + if (this.#honorEnvOptOut) { + if (envEnabled(this.#env.DO_NOT_TRACK)) return { allowed: false, reason: 'do-not-track' } + if (envEnabled(this.#env.CI)) return { allowed: false, reason: 'ci' } + } + let text: string + try { + text = await readFile(join(projectDir, 'cordis.yml'), 'utf8') + } catch (error) { + // Missing cordis.yml is the first-init (`create`) path; any other read + // fault is treated conservatively as its own reason. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { allowed: this.#allowWhenNoConfig, reason: 'no-config' } + } + return { allowed: false, reason: 'unreadable' } + } + const entry = readTelemetryEntry(text, this.#pluginName) + if (!entry.present) return { allowed: this.#allowWhenEntryAbsent, reason: 'absent' } + return entry.disabled ? { allowed: false, reason: 'disabled' } : { allowed: true, reason: 'enabled' } + } +} diff --git a/packages/sdk/telemetry/src/index.ts b/packages/sdk/telemetry/src/index.ts new file mode 100644 index 0000000000..107956fa39 --- /dev/null +++ b/packages/sdk/telemetry/src/index.ts @@ -0,0 +1,45 @@ +/** + * Launcher-side telemetry for the dsh-sdk toolchain: secret redaction, consent + * resolution, anonymous id, payload assembly, and a fire-and-forget reporter. + * + * This package is a plain library the launcher imports around each command — it + * is NOT a Cordis plugin (several commands never boot Cordis). Wiring it into + * the launcher command dispatch and the helper feature catalog lives outside + * this package. + * + * @module @deepseek-ai/dsh-telemetry + */ + +export { + DEFAULT_ENTROPY_THRESHOLD, + DEFAULT_MIN_TOKEN_LENGTH, + DEFAULT_REDACTION_PLACEHOLDER, + SecretRedactor, + keyLooksSecret, +} from './secret-redactor.ts' +export type { SecretRedactorOptions } from './secret-redactor.ts' +export { + ConsentResolver, + DEFAULT_TELEMETRY_PLUGIN_NAME, +} from './consent-resolver.ts' +export type { + ConsentDecision, + ConsentReason, + ConsentResolverOptions, +} from './consent-resolver.ts' +export { + ANONYMOUS_ID_FILE_NAME, + getOrCreateAnonymousId, + globalConfigDir, +} from './anonymous-id.ts' +export type { AnonymousId, AnonymousIdOptions } from './anonymous-id.ts' +export { buildTelemetryPayload } from './payload.ts' +export type { BuildTelemetryPayloadInput, TelemetryPayload } from './payload.ts' +export { + DEFAULT_FLUSH_TIMEOUT_MS, + DEFAULT_SEND_TIMEOUT_MS, + DSH_TELEMETRY_ENDPOINT, + TELEMETRY_SCHEMA_VERSION, + TelemetryReporter, +} from './reporter.ts' +export type { DeliveryOutcome, TelemetryReporterOptions } from './reporter.ts' diff --git a/packages/sdk/telemetry/src/payload.ts b/packages/sdk/telemetry/src/payload.ts new file mode 100644 index 0000000000..96a7b19d76 --- /dev/null +++ b/packages/sdk/telemetry/src/payload.ts @@ -0,0 +1,75 @@ +/** + * Telemetry payload assembly. + * + * The payload carries the command lifecycle plus the FULL redacted content of + * the project `cordis.yml` and `package.json`. It NEVER reads or includes `.env` + * — secrets live only in `.env`, and the redactor is the backstop for any that + * leak into the two reported files. A file that does not exist (the first + * `create` run) simply omits its field. + * + * @module @deepseek-ai/dsh-telemetry/payload + */ + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { SecretRedactor } from './secret-redactor.ts' + +/** Project files whose full (redacted) content ships with the payload. */ +const REPORTED_FILES = ['cordis.yml', 'package.json'] as const + +/** One command's telemetry payload. */ +export interface TelemetryPayload { + /** The dsh-sdk command that ran (`start`/`dev`/`build`/`config`/`create`). */ + command: string + /** Wall-clock duration of the command in milliseconds. */ + durationMs: number + /** Whether the command completed without error. */ + success: boolean + /** Redacted full text of the project `cordis.yml`, absent when the file does not exist. */ + cordisYmlContent?: string + /** Redacted full text of the project `package.json`, absent when the file does not exist. */ + packageJsonContent?: string +} + +/** Inputs for {@link buildTelemetryPayload}. */ +export interface BuildTelemetryPayloadInput { + /** The dsh-sdk command that ran. */ + command: string + /** Wall-clock duration of the command in milliseconds. */ + durationMs: number + /** Whether the command completed without error. */ + success: boolean + /** Project root whose `cordis.yml` and `package.json` are read. */ + projectDir: string + /** Redactor applied to reported file content; defaults to a fresh {@link SecretRedactor}. */ + redactor?: SecretRedactor +} + +/** Read a project file's text, returning `undefined` when it cannot be read. */ +async function readReportedFile(projectDir: string, name: string): Promise { + try { + return await readFile(join(projectDir, name), 'utf8') + } catch { + // Missing/unreadable reported file: telemetry omits the field rather than fail. + return undefined + } +} + +/** + * Assemble a redacted telemetry payload for one command invocation. + * @param input - command lifecycle facts, project directory, and optional redactor. + * @returns the payload with redacted `cordis.yml`/`package.json` content. + */ +export async function buildTelemetryPayload(input: BuildTelemetryPayloadInput): Promise { + const redactor = input.redactor ?? new SecretRedactor() + const [cordisYml, packageJson] = await Promise.all( + REPORTED_FILES.map(name => readReportedFile(input.projectDir, name)), + ) + return { + command: input.command, + durationMs: input.durationMs, + success: input.success, + ...cordisYml !== undefined ? { cordisYmlContent: redactor.redactText(cordisYml) } : {}, + ...packageJson !== undefined ? { packageJsonContent: redactor.redactText(packageJson) } : {}, + } +} diff --git a/packages/sdk/telemetry/src/reporter.ts b/packages/sdk/telemetry/src/reporter.ts new file mode 100644 index 0000000000..d41c1db9b7 --- /dev/null +++ b/packages/sdk/telemetry/src/reporter.ts @@ -0,0 +1,149 @@ +/** + * Fire-and-forget telemetry reporter for the dsh-sdk launcher. + * + * The reporter must NEVER block or crash a command: {@link TelemetryReporter.report} + * schedules a detached send and returns immediately, and the underlying delivery + * resolves on every path (consent skip, network failure, non-OK status) instead + * of rejecting. {@link TelemetryReporter.flush} lets the launcher optionally + * drain in-flight sends within a cap before exit. + * + * @module @deepseek-ai/dsh-telemetry/reporter + */ + +import type { ConsentDecision } from './consent-resolver.ts' +import type { TelemetryPayload } from './payload.ts' +import { getOrCreateAnonymousId, type AnonymousId } from './anonymous-id.ts' +import { SecretRedactor } from './secret-redactor.ts' + +/** + * Placeholder collection endpoint. This is a fixed protocol constant, not a + * deployment tunable. + * + * FIXME(ccyu): replace with the real telemetry endpoint before release. The + * `.invalid` TLD guarantees delivery fails harmlessly until then. + */ +export const DSH_TELEMETRY_ENDPOINT = 'https://telemetry.example.invalid/v1/dsh-sdk' + +/** Wire-envelope schema version; bump on any incompatible body change. */ +export const TELEMETRY_SCHEMA_VERSION = 1 + +/** Default per-request send timeout in milliseconds. */ +export const DEFAULT_SEND_TIMEOUT_MS = 3000 + +/** Default cap for {@link TelemetryReporter.flush} in milliseconds. */ +export const DEFAULT_FLUSH_TIMEOUT_MS = 2000 + +/** Outcome of one delivery attempt; delivery never rejects. */ +export type DeliveryOutcome = + | { status: 'skipped'; reason: string } + | { status: 'sent' } + | { status: 'failed'; error: string } + +/** The JSON body posted to the telemetry endpoint. */ +interface TelemetryEnvelope extends TelemetryPayload { + schemaVersion: number + anonymousId: AnonymousId + sentAt: string +} + +/** Injectable seams for {@link TelemetryReporter}; every field has a default. */ +export interface TelemetryReporterOptions { + /** Collection endpoint; defaults to {@link DSH_TELEMETRY_ENDPOINT}. */ + endpoint?: string + /** `fetch` implementation; defaults to the global `fetch`. */ + fetch?: typeof globalThis.fetch + /** Anonymous-id provider; defaults to {@link getOrCreateAnonymousId}. */ + anonymousId?: () => Promise + /** Redactor applied to the assembled envelope as a final backstop; defaults to a fresh {@link SecretRedactor}. */ + redactor?: SecretRedactor + /** Per-request send timeout in milliseconds. */ + timeoutMs?: number + /** Clock for the envelope timestamp; defaults to `Date.now`. */ + now?: () => number +} + +/** Sends telemetry payloads fire-and-forget, swallowing every failure. */ +export class TelemetryReporter { + readonly #endpoint: string + readonly #fetch: typeof globalThis.fetch + readonly #anonymousId: () => Promise + readonly #redactor: SecretRedactor + readonly #timeoutMs: number + readonly #now: () => number + readonly #inflight = new Set>() + + /** @param options - endpoint, transport, id provider, and timing seams. */ + constructor(options: TelemetryReporterOptions = {}) { + this.#endpoint = options.endpoint ?? DSH_TELEMETRY_ENDPOINT + this.#fetch = options.fetch ?? globalThis.fetch + this.#anonymousId = options.anonymousId ?? getOrCreateAnonymousId + this.#redactor = options.redactor ?? new SecretRedactor() + this.#timeoutMs = options.timeoutMs ?? DEFAULT_SEND_TIMEOUT_MS + this.#now = options.now ?? Date.now + } + + /** + * Schedule a detached, non-blocking send. Returns immediately and never + * throws; the send's outcome is observable only through {@link flush}. + * @param payload - the command payload to report. + * @param consent - resolved consent; a denial short-circuits to a skip. + */ + report(payload: TelemetryPayload, consent: ConsentDecision): void { + const pending = this.#deliver(payload, consent) + this.#inflight.add(pending) + void pending.finally(() => this.#inflight.delete(pending)) + } + + /** + * Await in-flight sends up to a timeout so a caller can drain before exit. + * Resolves on the cap regardless of send progress; never rejects. + * @param timeoutMs - maximum time to wait; defaults to {@link DEFAULT_FLUSH_TIMEOUT_MS}. + */ + async flush(timeoutMs: number = DEFAULT_FLUSH_TIMEOUT_MS): Promise { + if (this.#inflight.size === 0) return + const drained = Promise.allSettled([...this.#inflight]).then(() => undefined) + let timer!: ReturnType + const capped = new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs) + }) + try { + await Promise.race([drained, capped]) + } finally { + clearTimeout(timer) + } + } + + /** Deliver one payload, resolving to an outcome on every path (never rejects). */ + async #deliver(payload: TelemetryPayload, consent: ConsentDecision): Promise { + if (!consent.allowed) return { status: 'skipped', reason: consent.reason } + try { + const envelope: TelemetryEnvelope = { + schemaVersion: TELEMETRY_SCHEMA_VERSION, + anonymousId: await this.#anonymousId(), + sentAt: new Date(this.#now()).toISOString(), + ...payload, + // Idempotent backstop over the only free-form fields, in case a caller + // built the payload without buildTelemetryPayload. Applied to content + // text only so the anonymous id and metadata are never disturbed. + ...payload.cordisYmlContent !== undefined + ? { cordisYmlContent: this.#redactor.redactText(payload.cordisYmlContent) } + : {}, + ...payload.packageJsonContent !== undefined + ? { packageJsonContent: this.#redactor.redactText(payload.packageJsonContent) } + : {}, + } + const response = await this.#fetch(this.#endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(envelope), + signal: AbortSignal.timeout(this.#timeoutMs), + }) + if (!response.ok) return { status: 'failed', error: `HTTP ${response.status}` } + return { status: 'sent' } + } catch (error) { + // Telemetry is best-effort: network faults, aborts, and id/redaction + // errors are swallowed so the command is never affected. + return { status: 'failed', error: error instanceof Error ? error.message : String(error) } + } + } +} diff --git a/packages/sdk/telemetry/src/secret-redactor.ts b/packages/sdk/telemetry/src/secret-redactor.ts new file mode 100644 index 0000000000..5b9af5f169 --- /dev/null +++ b/packages/sdk/telemetry/src/secret-redactor.ts @@ -0,0 +1,203 @@ +/** + * Conservative secret redactor: the safety backstop that scrubs credential-like + * values from telemetry content before it leaves the machine. + * + * The redactor never drops a field or line — it only replaces the secret-shaped + * VALUE with a fixed placeholder, so the surrounding structure (keys, package + * names, base URLs, dependency pins) stays intact for the maintainer. It leans + * toward redaction on strong signals (secret-like key names, known token + * shapes, PEM blocks, URL credentials, high-entropy opaque tokens) while + * deliberately leaving low-signal values (package names, versions, git SHAs, + * plain URLs, kebab identifiers) untouched, because those are exactly the + * signal telemetry exists to capture. + * + * @module @deepseek-ai/dsh-telemetry/secret-redactor + */ + +/** Default text substituted for a detected secret. */ +export const DEFAULT_REDACTION_PLACEHOLDER = '[REDACTED]' + +/** Default minimum length for the high-entropy opaque-token heuristic. */ +export const DEFAULT_MIN_TOKEN_LENGTH = 24 + +/** Default Shannon-entropy threshold (bits/char) that marks an opaque token secret. */ +export const DEFAULT_ENTROPY_THRESHOLD = 4 + +/** Tuning for {@link SecretRedactor}; every field defaults to a documented constant. */ +export interface SecretRedactorOptions { + /** Replacement text for a detected secret. */ + placeholder?: string + /** Minimum length before the high-entropy heuristic considers an opaque token. */ + minTokenLength?: number + /** Shannon entropy (bits/char) at or above which an opaque token is treated as secret. */ + entropyThreshold?: number +} + +/** + * Regexes for well-known credential shapes. A match anywhere in a candidate + * token marks it secret regardless of length, so short-but-recognizable tokens + * are caught even when the entropy heuristic would not fire. + */ +const KNOWN_SECRET_PATTERNS: readonly RegExp[] = [ + /sk-(?:ant-)?[A-Za-z0-9_-]{10,}/, // OpenAI / DeepSeek / Anthropic style + /gh[pousr]_[A-Za-z0-9]{16,}/, // GitHub personal/oauth/server/refresh tokens + /github_pat_[A-Za-z0-9_]{20,}/, // GitHub fine-grained PAT + /xox[baprs]-[A-Za-z0-9-]{10,}/, // Slack tokens + /AKIA[0-9A-Z]{16}/, // AWS access key id + /AIza[0-9A-Za-z_-]{35}/, // Google API key + /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/, // JWT +] + +/** + * Key names (normalized to lowercase, separators stripped) whose value is a + * secret. Split by match strategy so short/ambiguous words do not over-match: + * `author` must not trip the `auth` rule. + */ +const KEY_SUBSTRING_INDICATORS: readonly string[] = [ + 'password', 'passwd', 'passphrase', 'secret', 'apikey', 'apisecret', + 'clientsecret', 'privatekey', 'secretkey', 'accesskey', 'credential', + 'connectionstring', 'sastoken', 'xapikey', 'authtoken', 'accesstoken', + 'refreshtoken', 'idtoken', 'sessiontoken', 'bearertoken', +] +const KEY_SUFFIX_INDICATORS: readonly string[] = ['token'] +const KEY_EXACT_INDICATORS: readonly string[] = [ + 'auth', 'authorization', 'cookie', 'bearer', 'dsn', 'signature', +] + +/** + * Whether a key name marks its value as a secret. + * @param key - raw object key or assignment name. + * @returns whether the value under this key must be redacted. + */ +export function keyLooksSecret(key: string): boolean { + const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '') + if (normalized.length === 0) return false + if (KEY_SUBSTRING_INDICATORS.some(indicator => normalized.includes(indicator))) return true + if (KEY_SUFFIX_INDICATORS.some(indicator => normalized.endsWith(indicator))) return true + return KEY_EXACT_INDICATORS.includes(normalized) +} + +/** Shannon entropy in bits per character. */ +function shannonEntropy(value: string): number { + const counts = new Map() + for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1) + let entropy = 0 + for (const count of counts.values()) { + const probability = count / value.length + entropy -= probability * Math.log2(probability) + } + return entropy +} + +/** Opaque-token character set (base64/base64url plus common token punctuation). */ +const OPAQUE_TOKEN = /^[A-Za-z0-9+/=_.-]+$/ +/** Version-like leader kept visible (dependency pins, semver). */ +const VERSION_LIKE = /^v?\d+(?:\.\d+)+/ + +/** + * Conservative secret detector and redactor for telemetry content. + * Detection is a pure function of the input; construction only fixes tunables. + */ +export class SecretRedactor { + readonly #placeholder: string + readonly #minTokenLength: number + readonly #entropyThreshold: number + + /** @param options - placeholder text and heuristic thresholds. */ + constructor(options: SecretRedactorOptions = {}) { + this.#placeholder = options.placeholder ?? DEFAULT_REDACTION_PLACEHOLDER + this.#minTokenLength = options.minTokenLength ?? DEFAULT_MIN_TOKEN_LENGTH + this.#entropyThreshold = options.entropyThreshold ?? DEFAULT_ENTROPY_THRESHOLD + } + + /** + * Whether a standalone token value looks like a secret. + * @param value - candidate token, already trimmed of surrounding quotes. + * @returns whether the value should be redacted on its own merits. + */ + isSecretValue(value: string): boolean { + if (KNOWN_SECRET_PATTERNS.some(pattern => pattern.test(value))) return true + if (value.length < this.#minTokenLength) return false + if (!OPAQUE_TOKEN.test(value)) return false + // Git SHAs and integrity digests are hex and public — never a secret we hide. + if (/^[0-9a-fA-F]+$/.test(value)) return false + if (VERSION_LIKE.test(value)) return false + const classes = (/[a-z]/.test(value) ? 1 : 0) + (/[A-Z]/.test(value) ? 1 : 0) + (/[0-9]/.test(value) ? 1 : 0) + return classes >= 3 || shannonEntropy(value) >= this.#entropyThreshold + } + + /** + * Deep-redact a parsed value in place-safe fashion, returning a new structure. + * A secret-named key redacts its string value outright; every other string is + * judged on its own shape. Non-string leaves pass through untouched. + * @param value - parsed JSON-like value (object, array, or primitive). + * @returns a structurally identical value with secret strings replaced. + */ + redactValue(value: T): T { + return this.#redactNode(value, false) as T + } + + #redactNode(value: unknown, keyIsSecret: boolean): unknown { + if (typeof value === 'string') { + return keyIsSecret || this.isSecretValue(value) ? this.#placeholder : value + } + if (Array.isArray(value)) return value.map(item => this.#redactNode(item, false)) + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, this.#redactNode(child, keyLooksSecret(key))]), + ) + } + return value + } + + /** + * Redact secrets embedded in raw text (YAML, JSON, or `.env`-style content), + * preserving every line and key while replacing only secret-shaped values. + * @param text - raw file or message text. + * @returns text with detected secrets replaced by the placeholder. + */ + redactText(text: string): string { + let output = this.#redactPemBlocks(text) + output = this.#redactAssignments(output) + output = this.#redactUrlCredentials(output) + output = this.#redactBearerTokens(output) + return this.#redactStandaloneTokens(output) + } + + #redactPemBlocks(text: string): string { + return text.replace( + /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z ]+ )?PRIVATE KEY-----/g, + this.#placeholder, + ) + } + + #redactAssignments(text: string): string { + // `key: value`, `key = value`, or `"key": "value"` across YAML/JSON/.env. + return text.replace( + /("?)([A-Za-z0-9_.-]+)\1(\s*[:=]\s*)(["']?)([^\n\r"']+)\4/g, + (match, keyQuote: string, key: string, separator: string, valueQuote: string, value: string) => + keyLooksSecret(key) && value.trim().length > 0 + ? `${keyQuote}${key}${keyQuote}${separator}${valueQuote}${this.#placeholder}${valueQuote}` + : match, + ) + } + + #redactUrlCredentials(text: string): string { + // Redact only the password in `scheme://user:password@host`, keeping host visible. + return text.replace( + /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)(@)/gi, + (_match, prefix: string, _password: string, at: string) => `${prefix}${this.#placeholder}${at}`, + ) + } + + #redactBearerTokens(text: string): string { + return text.replace(/(bearer\s+)([a-z0-9._-]{8,})/gi, (_match, prefix: string) => `${prefix}${this.#placeholder}`) + } + + #redactStandaloneTokens(text: string): string { + // `/` is excluded so package names, file paths, and URLs are never split or + // redacted; a secret containing `/` is still scrubbed piecewise. + return text.replace(/[A-Za-z0-9][A-Za-z0-9+=_.-]{7,}/g, token => + this.isSecretValue(token) ? this.#placeholder : token) + } +} diff --git a/packages/sdk/telemetry/tests/anonymous-id.spec.ts b/packages/sdk/telemetry/tests/anonymous-id.spec.ts new file mode 100644 index 0000000000..df8bcffea2 --- /dev/null +++ b/packages/sdk/telemetry/tests/anonymous-id.spec.ts @@ -0,0 +1,100 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + ANONYMOUS_ID_FILE_NAME, + getOrCreateAnonymousId, + globalConfigDir, +} from '@deepseek-ai/dsh-telemetry' + +const dirs: string[] = [] + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-anon-')) + dirs.push(dir) + return dir +} + +afterEach(async () => { + await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +describe('globalConfigDir', () => { + it('prefers an explicit DSH_CONFIG_HOME override', () => { + expect(globalConfigDir({ env: { DSH_CONFIG_HOME: '/custom/dsh' } })).toBe('/custom/dsh') + }) + + it('falls back to XDG_CONFIG_HOME under the harness namespace', () => { + expect(globalConfigDir({ env: { XDG_CONFIG_HOME: '/xdg' } })).toBe(join('/xdg', 'deepseek-harness')) + }) + + it('uses %APPDATA% on Windows', () => { + expect(globalConfigDir({ env: { APPDATA: 'C:/Users/x/AppData/Roaming' }, platform: 'win32' })) + .toBe(join('C:/Users/x/AppData/Roaming', 'deepseek-harness')) + }) + + it('falls back to ~/.config on Windows without APPDATA and on posix', () => { + const home = () => '/home/dev' + expect(globalConfigDir({ env: {}, platform: 'win32', homeDir: home })) + .toBe(join('/home/dev', '.config', 'deepseek-harness')) + expect(globalConfigDir({ env: {}, platform: 'linux', homeDir: home })) + .toBe(join('/home/dev', '.config', 'deepseek-harness')) + }) + + it('reads process.env by default', () => { + // No override supplied: the call must not throw and must return an absolute path. + expect(globalConfigDir()).toContain('deepseek-harness') + }) +}) + +describe('getOrCreateAnonymousId', () => { + it('creates, persists, and returns a UUID on first use', async () => { + const dir = await tempDir() + const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } }) + expect(id).toMatch(UUID) + const stored: unknown = JSON.parse(await readFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'utf8')) + expect(stored).toEqual({ anonymousId: id }) + }) + + it('returns the same persisted id on subsequent calls', async () => { + const dir = await tempDir() + const first = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } }) + const second = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } }) + expect(second).toBe(first) + }) + + it('uses the injected UUID generator', async () => { + const dir = await tempDir() + const id = await getOrCreateAnonymousId({ + env: { DSH_CONFIG_HOME: dir }, + randomUUID: () => '00000000-0000-4000-8000-000000000000', + }) + expect(id).toBe('00000000-0000-4000-8000-000000000000') + }) + + it('regenerates when the stored file is corrupt JSON', async () => { + const dir = await tempDir() + await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'not json', 'utf8') + const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } }) + expect(id).toMatch(UUID) + }) + + it('regenerates when the stored value is not a valid UUID or object', async () => { + const dir = await tempDir() + await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), JSON.stringify({ anonymousId: 'nope' }), 'utf8') + expect(await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })).toMatch(UUID) + await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), '123', 'utf8') + expect(await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })).toMatch(UUID) + }) + + it('returns a usable id even when persistence fails', async () => { + const dir = await tempDir() + // A regular file where a directory is expected makes mkdir/writeFile fail. + await writeFile(join(dir, 'blocker'), 'x', 'utf8') + const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: join(dir, 'blocker') } }) + expect(id).toMatch(UUID) + }) +}) diff --git a/packages/sdk/telemetry/tests/consent-resolver.spec.ts b/packages/sdk/telemetry/tests/consent-resolver.spec.ts new file mode 100644 index 0000000000..e1f5f90f81 --- /dev/null +++ b/packages/sdk/telemetry/tests/consent-resolver.spec.ts @@ -0,0 +1,131 @@ +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { ConsentResolver, DEFAULT_TELEMETRY_PLUGIN_NAME, type ConsentDecision } from '@deepseek-ai/dsh-telemetry' + +const dirs: string[] = [] + +async function projectDir(cordisYml?: string): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-consent-')) + dirs.push(dir) + if (cordisYml !== undefined) await writeFile(join(dir, 'cordis.yml'), cordisYml, 'utf8') + return dir +} + +afterEach(async () => { + await Promise.all(dirs.splice(0).map(dir => import('node:fs/promises').then(fs => fs.rm(dir, { recursive: true, force: true })))) +}) + +const enabledYml = `- id: telemetry\n name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'\n` + +describe('ConsentResolver environment opt-out', () => { + it('denies when DO_NOT_TRACK is set', async () => { + const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '1' } }).resolve(await projectDir(enabledYml)) + expect(decision).toEqual({ allowed: false, reason: 'do-not-track' }) + }) + + it('denies when CI is set', async () => { + const decision = await new ConsentResolver({ env: { CI: 'true' } }).resolve(await projectDir(enabledYml)) + expect(decision).toEqual({ allowed: false, reason: 'ci' }) + }) + + it('ignores falsy env values and continues to the file', async () => { + const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '0', CI: 'false' } }) + .resolve(await projectDir(enabledYml)) + expect(decision).toEqual({ allowed: true, reason: 'enabled' }) + }) + + it('can be told to ignore env opt-out signals', async () => { + const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '1' }, honorEnvOptOut: false }) + .resolve(await projectDir(enabledYml)) + expect(decision).toEqual({ allowed: true, reason: 'enabled' }) + }) + + it('reads process.env by default', async () => { + const saved = { CI: process.env.CI, DO_NOT_TRACK: process.env.DO_NOT_TRACK } + delete process.env.CI + delete process.env.DO_NOT_TRACK + try { + const decision = await new ConsentResolver().resolve(await projectDir(enabledYml)) + expect(decision).toEqual({ allowed: true, reason: 'enabled' }) + } finally { + if (saved.CI !== undefined) process.env.CI = saved.CI + if (saved.DO_NOT_TRACK !== undefined) process.env.DO_NOT_TRACK = saved.DO_NOT_TRACK + } + }) +}) + +describe('ConsentResolver cordis.yml state', () => { + const resolver = new ConsentResolver({ env: {} }) + + it('allows when the telemetry entry is enabled', async () => { + expect(await resolver.resolve(await projectDir(enabledYml))) + .toEqual({ allowed: true, reason: 'enabled' }) + }) + + it('denies when the telemetry entry is disabled', async () => { + const yml = `- id: telemetry\n name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'\n disabled: true\n` + expect(await resolver.resolve(await projectDir(yml))) + .toEqual({ allowed: false, reason: 'disabled' }) + }) + + it('tolerates !!js expression tags while reading plain scalars', async () => { + const yml = [ + '- id: telemetry', + ` name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'`, + '- id: llm', + ' name: \'@deepseek-ai/dsh-llm-deepseek\'', + ' config:', + ' apiKey: !!js process.env.DEEPSEEK_API_KEY', + '', + ].join('\n') + expect(await resolver.resolve(await projectDir(yml))) + .toEqual({ allowed: true, reason: 'enabled' }) + }) + + it('reports absent when cordis.yml has no telemetry entry, defaulting to deny', async () => { + const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' + expect(await resolver.resolve(await projectDir(yml))) + .toEqual({ allowed: false, reason: 'absent' }) + }) + + it('can allow when the entry is absent', async () => { + const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' + const decision = await new ConsentResolver({ env: {}, allowWhenEntryAbsent: true }).resolve(await projectDir(yml)) + expect(decision).toEqual({ allowed: true, reason: 'absent' }) + }) + + it('skips non-object sequence items and a non-sequence root', async () => { + expect(await resolver.resolve(await projectDir('- just-a-string\n- id: x\n name: y\n'))) + .toEqual({ allowed: false, reason: 'absent' }) + expect(await resolver.resolve(await projectDir('root: not-a-sequence\n'))) + .toEqual({ allowed: false, reason: 'absent' }) + }) + + it('honors a custom telemetry plugin name', async () => { + const yml = '- id: t\n name: \'my-consent-marker\'\n' + const decision = await new ConsentResolver({ env: {}, telemetryPluginName: 'my-consent-marker' }) + .resolve(await projectDir(yml)) + expect(decision).toEqual({ allowed: true, reason: 'enabled' }) + }) +}) + +describe('ConsentResolver missing or unreadable cordis.yml', () => { + it('reports no-config and allows by default on first init', async () => { + expect(await new ConsentResolver({ env: {} }).resolve(await projectDir())) + .toEqual({ allowed: true, reason: 'no-config' }) + }) + + it('can deny on first init', async () => { + const decision = await new ConsentResolver({ env: {}, allowWhenNoConfig: false }).resolve(await projectDir()) + expect(decision).toEqual({ allowed: false, reason: 'no-config' }) + }) + + it('denies with an unreadable reason when cordis.yml is not a regular file', async () => { + const dir = await projectDir() + await mkdir(join(dir, 'cordis.yml')) // a directory where the resolver expects a file + expect(await new ConsentResolver({ env: {} }).resolve(dir)) + .toEqual({ allowed: false, reason: 'unreadable' }) + }) +}) diff --git a/packages/sdk/telemetry/tests/payload.spec.ts b/packages/sdk/telemetry/tests/payload.spec.ts new file mode 100644 index 0000000000..1af5139ef5 --- /dev/null +++ b/packages/sdk/telemetry/tests/payload.spec.ts @@ -0,0 +1,59 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { SecretRedactor, buildTelemetryPayload } from '@deepseek-ai/dsh-telemetry' + +const dirs: string[] = [] + +async function projectDir(files: Record): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-payload-')) + dirs.push(dir) + await Promise.all(Object.entries(files).map(([name, content]) => writeFile(join(dir, name), content, 'utf8'))) + return dir +} + +afterEach(async () => { + await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +describe('buildTelemetryPayload', () => { + it('carries lifecycle facts and redacted file content', async () => { + const dir = await projectDir({ + 'cordis.yml': '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n config:\n apiKey: sk-abcdefghij1234567890\n', + 'package.json': '{ "name": "my-app", "config": { "token": "sk-abcdefghij1234567890" } }', + }) + const payload = await buildTelemetryPayload({ command: 'build', durationMs: 42, success: true, projectDir: dir }) + expect(payload.command).toBe('build') + expect(payload.durationMs).toBe(42) + expect(payload.success).toBe(true) + expect(payload.cordisYmlContent).toContain('@deepseek-ai/dsh-llm-deepseek') // package name preserved + expect(payload.cordisYmlContent).not.toContain('sk-abcdefghij1234567890') // secret scrubbed + expect(payload.packageJsonContent).toContain('my-app') + expect(payload.packageJsonContent).not.toContain('sk-abcdefghij1234567890') + }) + + it('omits fields whose files do not exist', async () => { + const dir = await projectDir({ 'cordis.yml': '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' }) + const payload = await buildTelemetryPayload({ command: 'create', durationMs: 1, success: false, projectDir: dir }) + expect(payload.cordisYmlContent).toBeDefined() + expect('packageJsonContent' in payload).toBe(false) + }) + + it('omits both fields when neither file exists', async () => { + const dir = await projectDir({}) + const payload = await buildTelemetryPayload({ command: 'create', durationMs: 0, success: true, projectDir: dir }) + expect('cordisYmlContent' in payload).toBe(false) + expect('packageJsonContent' in payload).toBe(false) + }) + + it('uses a supplied redactor', async () => { + const dir = await projectDir({ 'package.json': '{ "password": "hunter2" }' }) + const redactor = new SecretRedactor({ placeholder: '<>' }) + const payload = await buildTelemetryPayload({ + command: 'config', durationMs: 5, success: true, projectDir: dir, redactor, + }) + expect(payload.packageJsonContent).toContain('<>') + expect(payload.packageJsonContent).not.toContain('hunter2') + }) +}) diff --git a/packages/sdk/telemetry/tests/reporter.spec.ts b/packages/sdk/telemetry/tests/reporter.spec.ts new file mode 100644 index 0000000000..5d8a490b9e --- /dev/null +++ b/packages/sdk/telemetry/tests/reporter.spec.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest' +import { + DSH_TELEMETRY_ENDPOINT, + SecretRedactor, + TELEMETRY_SCHEMA_VERSION, + TelemetryReporter, + type AnonymousId, + type ConsentDecision, + type TelemetryPayload, +} from '@deepseek-ai/dsh-telemetry' + +const ALLOW: ConsentDecision = { allowed: true, reason: 'enabled' } +const DENY: ConsentDecision = { allowed: false, reason: 'disabled' } +const anon = (value = 'anon-123'): (() => Promise) => async () => value as AnonymousId + +function okResponse(): Response { + return { ok: true } as Response +} + +describe('TelemetryReporter.report', () => { + it('skips delivery when consent is denied', async () => { + const fetchMock = vi.fn(async () => okResponse()) + const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon() }) + reporter.report({ command: 'build', durationMs: 1, success: true }, DENY) + await reporter.flush(50) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('posts a redacted envelope when consent is granted', async () => { + const fetchMock = vi.fn(() => Promise.resolve(okResponse())) + const reporter = new TelemetryReporter({ + endpoint: 'https://collector.test/telemetry', + fetch: fetchMock, + anonymousId: anon('anon-xyz'), + redactor: new SecretRedactor(), + now: () => 0, + timeoutMs: 100, + }) + const payload: TelemetryPayload = { + command: 'config', + durationMs: 7, + success: true, + cordisYmlContent: 'apiKey: sk-abcdefghij1234567890\nname: \'@deepseek-ai/dsh-llm-deepseek\'\n', + packageJsonContent: '{ "name": "app" }', + } + reporter.report(payload, ALLOW) + await reporter.flush(50) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const call = fetchMock.mock.calls[0]! + expect(call[0]).toBe('https://collector.test/telemetry') + const init = call[1]! + expect(init.method).toBe('POST') + const body = JSON.parse(init.body as string) as Record + expect(body.schemaVersion).toBe(TELEMETRY_SCHEMA_VERSION) + expect(body.anonymousId).toBe('anon-xyz') + expect(body.sentAt).toBe('1970-01-01T00:00:00.000Z') + expect(body.command).toBe('config') + expect(body.cordisYmlContent).not.toContain('sk-abcdefghij1234567890') + expect(body.cordisYmlContent).toContain('@deepseek-ai/dsh-llm-deepseek') + expect(body.packageJsonContent).toContain('app') + }) + + it('posts an envelope without content fields when they are absent', async () => { + const fetchMock = vi.fn(() => Promise.resolve(okResponse())) + const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), now: () => 0, timeoutMs: 100 }) + reporter.report({ command: 'start', durationMs: 2, success: true }, ALLOW) + await reporter.flush(50) + const body = JSON.parse(fetchMock.mock.calls[0]![1]!.body as string) as Record + expect('cordisYmlContent' in body).toBe(false) + expect('packageJsonContent' in body).toBe(false) + }) + + it('swallows a non-OK HTTP status', async () => { + const fetchMock = vi.fn(async () => ({ ok: false, status: 503 } as Response)) + const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 }) + reporter.report({ command: 'dev', durationMs: 3, success: true }, ALLOW) + await expect(reporter.flush(50)).resolves.toBeUndefined() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('swallows a transport failure', async () => { + const fetchMock = vi.fn(async () => { throw new Error('network down') }) + const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 }) + reporter.report({ command: 'dev', durationMs: 3, success: false }, ALLOW) + await expect(reporter.flush(50)).resolves.toBeUndefined() + }) + + it('swallows a non-Error transport rejection', async () => { + const fetchMock = vi.fn(async () => { throw 'boom' }) + const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 }) + reporter.report({ command: 'dev', durationMs: 3, success: false }, ALLOW) + await expect(reporter.flush(50)).resolves.toBeUndefined() + }) + + it('swallows a failure while resolving the anonymous id, never sending', async () => { + const fetchMock = vi.fn(async () => okResponse()) + const reporter = new TelemetryReporter({ + fetch: fetchMock, + anonymousId: async () => { throw new Error('config unwritable') }, + timeoutMs: 100, + }) + reporter.report({ command: 'build', durationMs: 1, success: true }, ALLOW) + await reporter.flush(50) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +describe('TelemetryReporter.flush', () => { + it('returns immediately when nothing is in flight', async () => { + const reporter = new TelemetryReporter({ fetch: vi.fn(async () => okResponse()), anonymousId: anon() }) + await expect(reporter.flush()).resolves.toBeUndefined() + }) + + it('resolves on the timeout cap when a send never settles', async () => { + const reporter = new TelemetryReporter({ + fetch: () => new Promise(() => {}), + anonymousId: anon(), + timeoutMs: 10, + }) + reporter.report({ command: 'start', durationMs: 1, success: true }, ALLOW) + const started = Date.now() + await reporter.flush(15) + expect(Date.now() - started).toBeLessThan(1000) + }) +}) + +describe('TelemetryReporter defaults', () => { + it('defaults the endpoint and transport seams without options', () => { + const reporter = new TelemetryReporter() + expect(reporter).toBeInstanceOf(TelemetryReporter) + expect(DSH_TELEMETRY_ENDPOINT).toContain('.invalid') + }) +}) diff --git a/packages/sdk/telemetry/tests/secret-redactor.spec.ts b/packages/sdk/telemetry/tests/secret-redactor.spec.ts new file mode 100644 index 0000000000..c3250d9ace --- /dev/null +++ b/packages/sdk/telemetry/tests/secret-redactor.spec.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_ENTROPY_THRESHOLD, + DEFAULT_MIN_TOKEN_LENGTH, + DEFAULT_REDACTION_PLACEHOLDER, + SecretRedactor, + keyLooksSecret, +} from '@deepseek-ai/dsh-telemetry' + +const REDACTED = DEFAULT_REDACTION_PLACEHOLDER + +describe('exported defaults', () => { + it('expose the documented tunable defaults', () => { + expect(DEFAULT_REDACTION_PLACEHOLDER).toBe('[REDACTED]') + expect(DEFAULT_MIN_TOKEN_LENGTH).toBe(24) + expect(DEFAULT_ENTROPY_THRESHOLD).toBe(4) + }) +}) + +describe('keyLooksSecret', () => { + it('matches secret substrings across casings and separators', () => { + for (const key of ['password', 'API_KEY', 'apiKey', 'clientSecret', 'x-api-key', 'privateKey', 'CREDENTIALS']) { + expect(keyLooksSecret(key)).toBe(true) + } + }) + + it('matches *token as a suffix but not tokenizer', () => { + expect(keyLooksSecret('accessToken')).toBe(true) + expect(keyLooksSecret('token')).toBe(true) + expect(keyLooksSecret('tokenizer')).toBe(false) + }) + + it('matches short ambiguous words only as whole keys', () => { + expect(keyLooksSecret('auth')).toBe(true) + expect(keyLooksSecret('authorization')).toBe(true) + expect(keyLooksSecret('cookie')).toBe(true) + expect(keyLooksSecret('author')).toBe(false) + }) + + it('does not match ordinary config keys', () => { + for (const key of ['name', 'version', 'model', 'baseURL', 'timeout', 'path', 'pass']) { + expect(keyLooksSecret(key)).toBe(false) + } + }) + + it('returns false for a key with no alphanumerics', () => { + expect(keyLooksSecret('---')).toBe(false) + }) +}) + +describe('SecretRedactor.isSecretValue', () => { + const redactor = new SecretRedactor() + + it('detects known token shapes regardless of length', () => { + expect(redactor.isSecretValue('sk-abcdefghij1234567890')).toBe(true) + expect(redactor.isSecretValue('sk-ant-abcdefghij1234567890')).toBe(true) + expect(redactor.isSecretValue('ghp_abcdefghijklmnop1234')).toBe(true) + expect(redactor.isSecretValue('github_pat_abcdefghijklmnopqrst')).toBe(true) + expect(redactor.isSecretValue('xoxb-abcdefghij-klmno')).toBe(true) + expect(redactor.isSecretValue('AKIA1234567890ABCDEF')).toBe(true) + expect(redactor.isSecretValue(`AIza${'a'.repeat(35)}`)).toBe(true) + expect(redactor.isSecretValue('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijklmnop')).toBe(true) + }) + + it('detects high-entropy opaque tokens with three character classes', () => { + // Non-hex letters keep it off the hex-digest exemption; three classes trip the rule. + expect(redactor.isSecretValue('zX9zX9zX9zX9zX9zX9zX9zX9')).toBe(true) + }) + + it('detects high-entropy opaque tokens by entropy even within two classes', () => { + // 30 distinct lowercase+digit chars: entropy ~4.9, only two classes. + const token = 'abcdefghijklmnopqrstuvwxyz0123' + expect(token.length).toBeGreaterThanOrEqual(DEFAULT_MIN_TOKEN_LENGTH) + expect(redactor.isSecretValue(token)).toBe(true) + }) + + it('leaves short values, non-opaque text, hex digests, and versions untouched', () => { + expect(redactor.isSecretValue('deepseek-chat')).toBe(false) // short + expect(redactor.isSecretValue('a token with spaces here!!')).toBe(false) // not opaque + expect(redactor.isSecretValue('a'.repeat(40))).toBe(false) // low entropy, one class + expect(redactor.isSecretValue('abcdef0123456789abcdef0123456789abcdef01')).toBe(false) // 40-hex git SHA + expect(redactor.isSecretValue('1.2.3.4.5.6.7.8.9.10.11.12')).toBe(false) // version-like + expect(redactor.isSecretValue('ZXQPZXQPZXQPZXQPZXQPZXQP')).toBe(false) // uppercase only, low entropy + }) + + it('honors a custom entropy threshold', () => { + const strict = new SecretRedactor({ entropyThreshold: 100 }) + // Two-class token can no longer trip the entropy branch under an impossible threshold. + expect(strict.isSecretValue('abcdefghijklmnopqrstuvwxyz0123')).toBe(false) + }) +}) + +describe('SecretRedactor.redactValue', () => { + const redactor = new SecretRedactor() + + it('redacts secret-keyed strings and secret-shaped strings, keeping structure', () => { + const result = redactor.redactValue({ + apiKey: 'short-not-shaped', + name: 'my-package', + token: 'sk-abcdefghij1234567890', + count: 3, + enabled: true, + missing: null, + nested: { password: 'p', note: 'plain text value' }, + list: ['harmless', 'sk-abcdefghij1234567890'], + }) + expect(result).toEqual({ + apiKey: REDACTED, // redacted by key even though the value is not secret-shaped + name: 'my-package', + token: REDACTED, + count: 3, + enabled: true, + missing: null, + nested: { password: REDACTED, note: 'plain text value' }, + list: ['harmless', REDACTED], + }) + }) + + it('redacts a top-level secret string and passes through primitives', () => { + expect(redactor.redactValue('sk-abcdefghij1234567890')).toBe(REDACTED) + expect(redactor.redactValue('plain')).toBe('plain') + expect(redactor.redactValue(42)).toBe(42) + expect(redactor.redactValue(null)).toBeNull() + }) +}) + +describe('SecretRedactor.redactText', () => { + const redactor = new SecretRedactor() + + it('redacts PEM private key blocks', () => { + const text = '-----BEGIN RSA PRIVATE KEY-----\nMIIabc\ndef==\n-----END RSA PRIVATE KEY-----' + expect(redactor.redactText(text)).toBe(REDACTED) + }) + + it('redacts secret-keyed assignments across YAML, JSON, and .env', () => { + expect(redactor.redactText('password: hunter2')).toBe(`password: ${REDACTED}`) + expect(redactor.redactText('apiKey: "sk-abcdefghij1234567890"')).toBe(`apiKey: "${REDACTED}"`) + expect(redactor.redactText('"token": "abcdefgh"')).toBe(`"token": "${REDACTED}"`) + expect(redactor.redactText('API_KEY=sk-abcdefghij1234567890')).toBe(`API_KEY=${REDACTED}`) + }) + + it('keeps non-secret assignments and whitespace-only secret values intact', () => { + expect(redactor.redactText('model: deepseek-chat')).toBe('model: deepseek-chat') + expect(redactor.redactText('password: \n')).toBe('password: \n') + }) + + it('redacts only the password in URL credentials, keeping the host', () => { + expect(redactor.redactText('url: https://user:s3cretPass@api.deepseek.com/v1')) + .toBe(`url: https://user:${REDACTED}@api.deepseek.com/v1`) + }) + + it('redacts bearer tokens embedded in free text', () => { + expect(redactor.redactText('sending Bearer abcdefgh12345678 now')) + .toBe(`sending Bearer ${REDACTED} now`) + }) + + it('redacts standalone secret-shaped tokens while keeping package names and paths', () => { + expect(redactor.redactText('key sk-abcdefghij1234567890 end')) + .toBe(`key ${REDACTED} end`) + expect(redactor.redactText('name: @deepseek-ai/dsh-telemetry')).toBe('name: @deepseek-ai/dsh-telemetry') + expect(redactor.redactText('path: ./plugins/local-plugin/src/index.ts')) + .toBe('path: ./plugins/local-plugin/src/index.ts') + }) + + it('is idempotent on already-redacted text', () => { + const once = redactor.redactText('password: hunter2') + expect(redactor.redactText(once)).toBe(once) + }) +}) diff --git a/packages/sdk/telemetry/tsconfig.json b/packages/sdk/telemetry/tsconfig.json new file mode 100644 index 0000000000..8acc8f11c5 --- /dev/null +++ b/packages/sdk/telemetry/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { "path": "../../util/brand" } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25a8fadc47..0acc85fcab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1267,6 +1267,19 @@ importers: specifier: ^4.22.4 version: 4.22.4 + packages/sdk/telemetry: + dependencies: + yaml: + specifier: ^2.9.0 + version: 2.9.0 + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/session-persistence/session-persistence: devDependencies: '@deepseek-ai/dsh-session': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 4a845b4554..004338fa2d 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -54,6 +54,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' }, 'packages/sdk/plugin-fetch': { kind: 'none', reason: 'The fetcher acquires plugin sources into a temp dir and registers no live agent or model surface.' }, 'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' }, + 'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, 'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' }, diff --git a/tsconfig.build.json b/tsconfig.build.json index 9a6133db36..9359d7a531 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -99,6 +99,7 @@ { "path": "./packages/sdk/helper" }, { "path": "./packages/sdk/scripts" }, { "path": "./packages/sdk/create-sdk" }, - { "path": "./packages/sdk/plugin-fetch" } + { "path": "./packages/sdk/plugin-fetch" }, + { "path": "./packages/sdk/telemetry" } ] } diff --git a/tsconfig.json b/tsconfig.json index 4588c81c72..48c5187fdc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -110,6 +110,7 @@ { "path": "./packages/sdk/helper" }, { "path": "./packages/sdk/scripts" }, { "path": "./packages/sdk/create-sdk" }, - { "path": "./packages/sdk/plugin-fetch" } + { "path": "./packages/sdk/plugin-fetch" }, + { "path": "./packages/sdk/telemetry" } ] } From a66b98d2705934cbf51c46beaa9c737b9c04176c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:03:39 +0800 Subject: [PATCH 08/37] fix(telemetry): report unless telemetry entry is explicitly disabled Per ccyu's decision (option A), remove the consent asymmetry in ConsentResolver: telemetry is OFF only when cordis.yml has a telemetry entry with disabled: true. A cordis.yml with no telemetry entry now reports (allowWhenEntryAbsent defaults to true) rather than denying. No cordis.yml, an enabled entry, and DO_NOT_TRACK/CI are unchanged. Both no-config and absent-entry defaults stay configurable. Updates the module/README docs and the unit tests (file-present-but-no-entry and non-object/non-sequence roots now report). Per-file 100% coverage holds. --- packages/sdk/telemetry/README.md | 2 +- packages/sdk/telemetry/src/consent-resolver.ts | 16 ++++++++-------- .../sdk/telemetry/tests/consent-resolver.spec.ts | 16 ++++++++-------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/sdk/telemetry/README.md b/packages/sdk/telemetry/README.md index 3a8b6ac4c2..43d13d3dcc 100644 --- a/packages/sdk/telemetry/README.md +++ b/packages/sdk/telemetry/README.md @@ -10,7 +10,7 @@ Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain li | `getOrCreateAnonymousId` | Random UUID persisted in a per-user GLOBAL config file (never in the project, never derived from git). | | `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. | -Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. When `cordis.yml` does not yet exist (first `create`) consent defaults to allowed; when it exists without a telemetry entry consent defaults to denied — both are configurable on `ConsentResolver`. +Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. Telemetry reports by default and is off only when a present telemetry entry is explicitly `disabled`: a missing `cordis.yml` (first `create`), an enabled entry, or a `cordis.yml` with no telemetry entry all report. `DO_NOT_TRACK`/CI always deny. The no-config and absent-entry defaults are configurable on `ConsentResolver`. The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); its `.invalid` placeholder must be replaced with the real endpoint before release. diff --git a/packages/sdk/telemetry/src/consent-resolver.ts b/packages/sdk/telemetry/src/consent-resolver.ts index 1d50904f39..a4327dc9f7 100644 --- a/packages/sdk/telemetry/src/consent-resolver.ts +++ b/packages/sdk/telemetry/src/consent-resolver.ts @@ -1,12 +1,12 @@ /** * Consent resolution for dsh-sdk telemetry. * - * Consent is carried by the telemetry plugin's enabled/disabled state in the - * project `cordis.yml`: an enabled entry means opt-in, a `disabled: true` entry - * means opt-out. The resolver PARSES `cordis.yml` — it never boots a Cordis - * application — because several launcher commands (`build`, `create`) never - * boot Cordis at all. `DO_NOT_TRACK` and CI environment signals force a denial - * regardless of file state. + * Telemetry is OFF only when `cordis.yml` contains a telemetry entry that is + * explicitly `disabled`; every other file state reports (no `cordis.yml`, an + * enabled entry, or no telemetry entry at all). The resolver PARSES `cordis.yml` + * — it never boots a Cordis application — because several launcher commands + * (`build`, `create`) never boot Cordis at all. `DO_NOT_TRACK` and CI + * environment signals force a denial regardless of file state. * * @module @deepseek-ai/dsh-telemetry/consent-resolver */ @@ -56,7 +56,7 @@ export interface ConsentResolverOptions { honorEnvOptOut?: boolean /** Consent when `cordis.yml` does not exist yet (first `create`). Defaults to `true` (telemetry is default-on). */ allowWhenNoConfig?: boolean - /** Consent when `cordis.yml` exists but has no telemetry entry. Defaults to `false`. */ + /** Consent when `cordis.yml` exists but has no telemetry entry. Defaults to `true` (report unless a present entry is disabled). */ allowWhenEntryAbsent?: boolean } @@ -94,7 +94,7 @@ export class ConsentResolver { this.#env = options.env ?? process.env this.#honorEnvOptOut = options.honorEnvOptOut ?? true this.#allowWhenNoConfig = options.allowWhenNoConfig ?? true - this.#allowWhenEntryAbsent = options.allowWhenEntryAbsent ?? false + this.#allowWhenEntryAbsent = options.allowWhenEntryAbsent ?? true } /** diff --git a/packages/sdk/telemetry/tests/consent-resolver.spec.ts b/packages/sdk/telemetry/tests/consent-resolver.spec.ts index e1f5f90f81..ca0cec3bbd 100644 --- a/packages/sdk/telemetry/tests/consent-resolver.spec.ts +++ b/packages/sdk/telemetry/tests/consent-resolver.spec.ts @@ -84,23 +84,23 @@ describe('ConsentResolver cordis.yml state', () => { .toEqual({ allowed: true, reason: 'enabled' }) }) - it('reports absent when cordis.yml has no telemetry entry, defaulting to deny', async () => { + it('reports (allows) when cordis.yml has no telemetry entry', async () => { const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' expect(await resolver.resolve(await projectDir(yml))) - .toEqual({ allowed: false, reason: 'absent' }) + .toEqual({ allowed: true, reason: 'absent' }) }) - it('can allow when the entry is absent', async () => { + it('can be told to deny when the entry is absent', async () => { const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' - const decision = await new ConsentResolver({ env: {}, allowWhenEntryAbsent: true }).resolve(await projectDir(yml)) - expect(decision).toEqual({ allowed: true, reason: 'absent' }) + const decision = await new ConsentResolver({ env: {}, allowWhenEntryAbsent: false }).resolve(await projectDir(yml)) + expect(decision).toEqual({ allowed: false, reason: 'absent' }) }) - it('skips non-object sequence items and a non-sequence root', async () => { + it('skips non-object sequence items and a non-sequence root, still reporting absent', async () => { expect(await resolver.resolve(await projectDir('- just-a-string\n- id: x\n name: y\n'))) - .toEqual({ allowed: false, reason: 'absent' }) + .toEqual({ allowed: true, reason: 'absent' }) expect(await resolver.resolve(await projectDir('root: not-a-sequence\n'))) - .toEqual({ allowed: false, reason: 'absent' }) + .toEqual({ allowed: true, reason: 'absent' }) }) it('honors a custom telemetry plugin name', async () => { From 472a683bdc76f33cee9d2dd492623f91bb0f4b48 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:21:37 +0800 Subject: [PATCH 09/37] feat(create-sdk): headless creation via --config/--config-json + NDJSON + skill Add a headless create path: --config / --config-json supply a structured project spec (answers + feature plan) that drives CreateWizard through a HeadlessPromptPort, bypassing the TTY. --json emits NDJSON lifecycle events (done / action-required / error) so an agent can fill a missing input and re-run. Ship a thin SKILL.md playbook for agent-driven creation. Per-file 100% coverage. --- packages/sdk/create-sdk/README.md | 6 +- packages/sdk/create-sdk/src/args.ts | 12 +++ packages/sdk/create-sdk/src/command.ts | 42 ++++++-- packages/sdk/create-sdk/src/headless.ts | 98 ++++++++++++++++++ packages/sdk/create-sdk/tests/create.spec.ts | 102 +++++++++++++++++++ skills/create-dsh-sdk-project/SKILL.md | 57 +++++++++++ 6 files changed, 307 insertions(+), 10 deletions(-) create mode 100644 packages/sdk/create-sdk/src/headless.ts create mode 100644 skills/create-dsh-sdk-project/SKILL.md diff --git a/packages/sdk/create-sdk/README.md b/packages/sdk/create-sdk/README.md index 66cddc030a..a5fa51b480 100644 --- a/packages/sdk/create-sdk/README.md +++ b/packages/sdk/create-sdk/README.md @@ -6,14 +6,14 @@ The supported package surface is the `create-sdk` bin. The package root exports The initializer rejects every existing target path, creates one `SdkProject` edit session, validates and commits it, then asks whether to install NPM dependencies and build. Install or build failures keep the generated project and print a retry command. -Public flags are `[directory]`, `--description`, `--provider`, `--base-url`, `--api-key`, `--model`, `--interface`, `--pm`, and `--install`/`--no-install`. Flags prefill matching questions, but creation always requires a TTY. +Public flags are `[directory]`, `--description`, `--provider`, `--base-url`, `--api-key`, `--model`, `--interface`, `--pm`, `--install`/`--no-install`, plus the headless flags `--config ` / `--config-json ` and `--json`. Interactive flags prefill matching questions; a headless spec (`--config`/`--config-json`) supplies every answer and its feature plan up front, so creation runs without a TTY and drives through a `HeadlessPromptPort` that fails loud on any missing required answer. `--json` emits NDJSON lifecycle events (`done` / `action-required` / `error`) so an agent can fill the named missing input and re-run. The provider choice is DeepSeek or a custom endpoint backed by `llm-pi-ai`. DeepSeek asks only for an API key and uses the public endpoint plus `deepseek-v4-flash`; custom also asks for a base URL. An empty key requires confirmation and creates a commented empty `.env` variable so provider startup fails clearly until it is filled. Existing plugin defaults are omitted; required SDK presets remain typed against the owning package's Config. ## Model Experience -Indirectly, through the generated project composition and its selected runtime plugins. +Indirectly, through the generated project composition and its selected runtime plugins; the headless `--config-json` + `--json` surface additionally lets an agent create a project end to end and react to `action-required` events. ## Known Limitations and Deferred Work -- **TTY-only creation** — flags prefill questions, but the wizard still requires an interactive terminal before it writes a project. +- **Headless local plugins** — the headless spec supplies project answers and the feature plan; scaffolding a local plugin (the interactive none/plugin/tool choice) is not yet expressible in the spec and defaults to none. diff --git a/packages/sdk/create-sdk/src/args.ts b/packages/sdk/create-sdk/src/args.ts index 521132caa9..897159bd7c 100644 --- a/packages/sdk/create-sdk/src/args.ts +++ b/packages/sdk/create-sdk/src/args.ts @@ -19,6 +19,9 @@ export interface CreateArgs { packageManager?: PackageManagerName install?: boolean linkWorkspace?: boolean + config?: string + configJson?: string + json?: boolean help: boolean } @@ -32,6 +35,9 @@ interface CommanderCreateOptions { pm?: PackageManagerName install?: boolean linkWorkspace?: boolean + config?: string + configJson?: string + json?: boolean help?: boolean } @@ -60,6 +66,9 @@ function createProgram(): Command { .addOption(new Option('--install').default(undefined)) .addOption(new Option('--no-install').default(undefined)) .option('--link-workspace') + .option('--config ') + .option('--config-json ') + .addOption(new Option('--json').default(undefined)) } /** Parse create-sdk positionals/options through Commander into a domain-neutral value. */ @@ -79,6 +88,9 @@ export function parseCreateArgs(argv: readonly string[]): CreateArgs { ...options.pm === undefined ? {} : { packageManager: options.pm }, ...options.install === undefined ? {} : { install: options.install }, ...options.linkWorkspace ? { linkWorkspace: true } : {}, + ...options.config === undefined ? {} : { config: options.config }, + ...options.configJson === undefined ? {} : { configJson: options.configJson }, + ...options.json === undefined ? {} : { json: options.json }, help: options.help ?? false, } } diff --git a/packages/sdk/create-sdk/src/command.ts b/packages/sdk/create-sdk/src/command.ts index 0c7076c90b..a7855ea027 100644 --- a/packages/sdk/create-sdk/src/command.ts +++ b/packages/sdk/create-sdk/src/command.ts @@ -7,12 +7,15 @@ import { readFile } from 'node:fs/promises' import { ClackPromptPort, + HeadlessPromptError, + HeadlessPromptPort, PromptCancelledError, type PackageManagerVersionProbe, type PromptPort, } from '@deepseek-ai/dsh-helper' -import { parseCreateArgs } from './args.ts' +import { parseCreateArgs, type CreateArgs } from './args.ts' import { CreateWizard, type ResolvedCreateRequest } from './create-wizard.ts' +import { resolveHeadless } from './headless.ts' import { scaffoldProject, type ScaffoldResult } from './project-scaffolder.ts' import { CREATE_TEMPLATES, packageManagerTemplateModel } from './templates/create-templates.ts' @@ -46,16 +49,18 @@ export async function createProject( context.stdout.write(CREATE_TEMPLATES.usage.render({})) return undefined } - if (!context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) { - throw new Error('create-sdk requires an interactive TTY') + const headless = await resolveHeadless(args) + if (!headless && !context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) { + throw new Error('create-sdk requires an interactive TTY, --config , or --config-json ') } const wizard = new CreateWizard({ - args, + args: headless ? headless.args : args, /* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */ - port: context.port ?? new ClackPromptPort(context.stdin, context.stdout), + port: context.port ?? (headless ? new HeadlessPromptPort() : new ClackPromptPort(context.stdin, context.stdout)), cwd: context.cwd, releaseVersion: context.releaseVersion ?? await readCreateSdkVersion(), ...context.versionProbe ? { versionProbe: context.versionProbe } : {}, + ...headless?.features ? { features: headless.features } : {}, }) const resolved = await wizard.run() const result = await scaffoldProject(resolved.directory, resolved.request) @@ -87,6 +92,17 @@ export async function createProject( return result } +/** Whether NDJSON lifecycle events were requested, tolerating unparseable argv. */ +function wantsJsonEvents(argv: readonly string[]): boolean { + let parsed: CreateArgs + try { + parsed = parseCreateArgs(argv) + } catch { + return false + } + return parsed.json === true +} + /** Run the create command with process defaults and convert cancellation to a clean exit. */ export async function runCreateCommand( argv: readonly string[] = process.argv.slice(2), @@ -97,15 +113,27 @@ export async function runCreateCommand( stderr: process.stderr, }, ): Promise { + const json = wantsJsonEvents(argv) + const emit = (event: Record): void => { + context.stdout.write(`${JSON.stringify(event)}\n`) + } try { await createProject(argv, context) + if (json) emit({ type: 'done' }) return 0 } catch (error) { if (error instanceof PromptCancelledError) { - context.stderr.write('create-sdk: cancelled\n') + if (json) emit({ type: 'error', reason: 'cancelled' }) + else context.stderr.write('create-sdk: cancelled\n') return 1 } - context.stderr.write(`create-sdk: ${error instanceof Error ? error.message : String(error)}\n`) + if (json && error instanceof HeadlessPromptError) { + emit({ type: 'action-required', prompt: error.prompt }) + return 1 + } + const message = error instanceof Error ? error.message : String(error) + if (json) emit({ type: 'error', message }) + else context.stderr.write(`create-sdk: ${message}\n`) return 1 } } diff --git a/packages/sdk/create-sdk/src/headless.ts b/packages/sdk/create-sdk/src/headless.ts new file mode 100644 index 0000000000..e32a053457 --- /dev/null +++ b/packages/sdk/create-sdk/src/headless.ts @@ -0,0 +1,98 @@ +/** + * Headless create input: a structured project spec supplied by an agent or CI + * instead of interactive prompts. + * + * @module @deepseek-ai/create-sdk/headless + */ + +import { readFile } from 'node:fs/promises' +import type { FeatureSelection, PackageManagerName, RunInterface } from '@deepseek-ai/dsh-helper' +import type { CreateArgs } from './args.ts' + +/** + * Structured, non-interactive create input. Scalar fields mirror {@link CreateArgs} + * project answers; `features` is the headless feature plan handed to `CreateWizard` + * (the interactive tree/suggests prompts are skipped). Absent required answers make + * the run fail loud through `HeadlessPromptPort` rather than blocking. + */ +export interface HeadlessCreateSpec { + directory?: string + description?: string + provider?: 'deepseek' | 'custom' + baseURL?: string + apiKey?: string + model?: string + interface?: RunInterface + pm?: PackageManagerName + install?: boolean + linkWorkspace?: boolean + features?: readonly FeatureSelection[] +} + +/** Resolved headless input: the args the wizard reads plus the feature plan. */ +export interface ResolvedHeadless { + args: CreateArgs + features: readonly FeatureSelection[] | undefined +} + +function asRecord(value: unknown, source: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${source}: expected a JSON object`) + } + return value as Record +} + +/** Parse and shallow-validate a headless spec from JSON text. */ +export function parseHeadlessSpec(text: string, source: string): HeadlessCreateSpec { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch (error) { + /* v8 ignore next -- JSON.parse only throws Error instances; the String() branch is defensive */ + throw new Error(`${source}: invalid JSON (${error instanceof Error ? error.message : String(error)})`) + } + const record = asRecord(parsed, source) + if (record.features !== undefined && !Array.isArray(record.features)) { + throw new Error(`${source}: "features" must be an array`) + } + return record +} + +/** + * Load a headless spec from `--config-json` (inline) or `--config` (a JSON file), + * returning `undefined` when neither is supplied. + * @param args - parsed create args. + * @param readFileText - file reader seam for tests. + * @returns the resolved args + feature plan, or `undefined` for interactive runs. + */ +export async function resolveHeadless( + args: CreateArgs, + readFileText: (path: string) => Promise = path => readFile(path, 'utf8'), +): Promise { + let text: string + let source: string + if (args.configJson !== undefined) { + text = args.configJson + source = '--config-json' + } else if (args.config !== undefined) { + source = args.config + text = await readFileText(args.config) + } else { + return undefined + } + const spec = parseHeadlessSpec(text, source) + const resolvedArgs: CreateArgs = { + ...spec.directory === undefined ? {} : { directory: spec.directory }, + ...spec.description === undefined ? {} : { description: spec.description }, + ...spec.provider === undefined ? {} : { provider: spec.provider }, + ...spec.baseURL === undefined ? {} : { baseURL: spec.baseURL }, + ...spec.apiKey === undefined ? {} : { apiKey: spec.apiKey }, + ...spec.model === undefined ? {} : { model: spec.model }, + ...spec.interface === undefined ? {} : { runInterface: spec.interface }, + ...spec.pm === undefined ? {} : { packageManager: spec.pm }, + ...spec.install === undefined ? {} : { install: spec.install }, + ...spec.linkWorkspace ? { linkWorkspace: true } : {}, + help: false, + } + return { args: resolvedArgs, features: spec.features } +} diff --git a/packages/sdk/create-sdk/tests/create.spec.ts b/packages/sdk/create-sdk/tests/create.spec.ts index f4b66dc8ff..1f2cdb75fc 100644 --- a/packages/sdk/create-sdk/tests/create.spec.ts +++ b/packages/sdk/create-sdk/tests/create.spec.ts @@ -30,6 +30,7 @@ import { type CreateCommandContext, } from '../src/command.ts' import { CreateWizard } from '../src/create-wizard.ts' +import { resolveHeadless } from '../src/headless.ts' import { scaffoldProject } from '../src/project-scaffolder.ts' class ScriptedPort implements PromptPort { @@ -482,6 +483,52 @@ describe('create command composition', () => { await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY') }) + it('creates headlessly from --config-json with no TTY', async () => { + const root = await mkdtemp(join(tmpdir(), 'create-headless-cmd-')) + temporary.push(root) + const spec = JSON.stringify({ + directory: 'agent', description: 'test', provider: 'deepseek', apiKey: 'key', + model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: false, + features: [{ id: 'persistence', options: ['jsonl'] }], + }) + const context = commandContext(root) + context.stdin.isTTY = false + context.stdout.isTTY = false + const result = await createProject(['--config-json', spec], context) + expect(result?.project.root).toBe(join(root, 'agent')) + }) + + it('emits NDJSON lifecycle events under --json', async () => { + const root = await mkdtemp(join(tmpdir(), 'create-headless-json-')) + temporary.push(root) + const base = { + description: 'test', model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: false, + } + const ok = commandContext(root) + ok.stdin.isTTY = false + ok.stdout.isTTY = false + const okSpec = JSON.stringify({ ...base, directory: 'done-agent', provider: 'deepseek', apiKey: 'key', features: [] }) + await expect(runCreateCommand(['--config-json', okSpec, '--json'], ok)).resolves.toBe(0) + expect(ok.readStdout()).toContain('{"type":"done"}') + + const missing = commandContext(root) + missing.stdin.isTTY = false + missing.stdout.isTTY = false + const missingSpec = JSON.stringify({ ...base, directory: 'miss-agent', provider: 'custom', baseURL: 'https://x', features: [] }) + await expect(runCreateCommand(['--config-json', missingSpec, '--json'], missing)).resolves.toBe(1) + expect(missing.readStdout()).toContain('"type":"action-required"') + + const broken = commandContext(root) + broken.stdin.isTTY = false + broken.stdout.isTTY = false + await expect(runCreateCommand(['--config-json', '{bad', '--json'], broken)).resolves.toBe(1) + expect(broken.readStdout()).toContain('"type":"error"') + + const cancelled = commandContext(root, new ScriptedPort([ScriptedPort.cancel])) + await expect(runCreateCommand(['--json', ...argv('cancel-agent', false)], cancelled)).resolves.toBe(1) + expect(cancelled.readStdout()).toContain('"reason":"cancelled"') + }) + it('creates through an injected prompt port and delegates optional setup', async () => { const root = await mkdtemp(join(tmpdir(), 'create-command-success-')) temporary.push(root) @@ -550,3 +597,58 @@ describe('create command composition', () => { await expect(runCreateCommand(['--help'], help)).resolves.toBe(0) }) }) + +describe('resolveHeadless', () => { + it('returns undefined without a config source', async () => { + expect(await resolveHeadless(parseCreateArgs(['agent']))).toBeUndefined() + }) + + it('maps every inline --config-json field into args plus the feature plan', async () => { + const spec = JSON.stringify({ + directory: 'a', description: 'd', provider: 'custom', baseURL: 'https://x', apiKey: 'k', + model: 'm', interface: 'acp', pm: 'pnpm', install: true, linkWorkspace: true, + features: [{ id: 'todo', options: ['default'] }], + }) + const resolved = await resolveHeadless(parseCreateArgs(['--config-json', spec])) + expect(resolved?.args).toMatchObject({ + directory: 'a', description: 'd', provider: 'custom', baseURL: 'https://x', apiKey: 'k', + model: 'm', runInterface: 'acp', packageManager: 'pnpm', install: true, linkWorkspace: true, help: false, + }) + expect(resolved?.features).toEqual([{ id: 'todo', options: ['default'] }]) + }) + + it('reads --config from a file via the injected reader and omits absent fields', async () => { + const resolved = await resolveHeadless( + parseCreateArgs(['--config', '/spec.json']), + async () => JSON.stringify({ description: 'from-file' }), + ) + expect(resolved?.args.description).toBe('from-file') + expect(resolved?.args.directory).toBeUndefined() + expect(resolved?.args.linkWorkspace).toBeUndefined() + expect(resolved?.features).toBeUndefined() + }) + + it('reads --config from disk with the default reader', async () => { + const dir = await mkdtemp(join(tmpdir(), 'create-headless-file-')) + temporary.push(dir) + const file = join(dir, 'spec.json') + await writeFile(file, JSON.stringify({ description: 'on-disk' })) + const resolved = await resolveHeadless(parseCreateArgs(['--config', file])) + expect(resolved?.args.description).toBe('on-disk') + }) + + it('fails loud on invalid JSON, a non-object root, or a non-array features field', async () => { + await expect(resolveHeadless(parseCreateArgs(['--config-json', '{bad']))).rejects.toThrow('invalid JSON') + await expect(resolveHeadless(parseCreateArgs(['--config-json', '[]']))).rejects.toThrow('expected a JSON object') + await expect(resolveHeadless(parseCreateArgs(['--config-json', 'null']))).rejects.toThrow('expected a JSON object') + await expect(resolveHeadless(parseCreateArgs(['--config-json', '5']))).rejects.toThrow('expected a JSON object') + await expect(resolveHeadless(parseCreateArgs(['--config-json', '{"features":1}']))).rejects.toThrow('must be an array') + }) + + it('accepts a minimal spec, leaving unspecified answers undefined', async () => { + const resolved = await resolveHeadless(parseCreateArgs(['--config-json', '{"directory":"x"}'])) + expect(resolved?.args.directory).toBe('x') + expect(resolved?.args.description).toBeUndefined() + expect(resolved?.features).toBeUndefined() + }) +}) diff --git a/skills/create-dsh-sdk-project/SKILL.md b/skills/create-dsh-sdk-project/SKILL.md new file mode 100644 index 0000000000..5b984f3b86 --- /dev/null +++ b/skills/create-dsh-sdk-project/SKILL.md @@ -0,0 +1,57 @@ +--- +name: create-dsh-sdk-project +description: Create a DeepSeek Harness SDK project non-interactively (headless), driven by an agent instead of the interactive wizard. Use when asked to scaffold a new DSH SDK project without a terminal. +--- + +# Create a DeepSeek Harness SDK project headlessly + +The `create-sdk` initializer normally runs an interactive wizard. To create a project +**without a terminal**, pass a structured spec and ask for machine-readable events: + +```sh +npm create @deepseek-ai/sdk -- --config-json '' --json +``` + +- `--config-json ''` supplies the whole spec inline (no prompts). Alternatively + `--config ` reads the same spec from a file. +- `--json` makes the command emit one NDJSON lifecycle event per line to stdout. + +## Spec shape + +All fields are optional except those a chosen feature requires. Unsupplied answers that +have a sensible default are taken from it; a *required* answer with no default (a secret, +a custom provider base URL, a required feature option) makes the run fail loud rather than +block. + +```json +{ + "directory": "my-agent", + "description": "A DeepSeek Harness agent", + "provider": "deepseek", + "apiKey": "", + "model": "deepseek-v4-flash", + "interface": "stdio", + "pm": "npm", + "install": false, + "features": [ + { "id": "persistence", "options": ["sqlite"] }, + { "id": "web", "options": ["exa"], "secrets": { "apiKey": "" } } + ] +} +``` + +`features` is the complete set of optional features to enable, each with its chosen +options and any secrets/values it needs. The interactive feature tree and its +recommended-feature prompts are skipped in headless mode. + +## Reacting to events + +Each line of stdout is one JSON object: + +- `{"type":"done"}` — the project was created (and installed, if `install` was true). +- `{"type":"action-required","prompt":""}` — a required answer was missing. + Add the corresponding field to the spec (e.g. an `apiKey`, a feature secret, a custom + `baseURL`) and re-run. +- `{"type":"error","message":""}` — the run failed for another reason. + +Iterate: read `action-required`, fill the named input into the spec, re-run until `done`. From 8052370155b4d45f1eba8764e89932962339e6a2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:40:34 +0800 Subject: [PATCH 10/37] =?UTF-8?q?chore(sdk):=20drop=20dsh-plugin-fetch=20(?= =?UTF-8?q?giget/pacote)=20=E2=80=94=20#2=20will=20use=20native=20npm/pnpm?= =?UTF-8?q?=20deps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External-plugin creation will add a package-manager-native dependency (github:owner/repo#ref or pkg@version) plus a cordis mount instead of fetching tarballs into a temp dir, so the giget/pacote fetch package is no longer needed. --- docs/module-graph.md | 3 - packages/sdk/README.md | 1 - packages/sdk/plugin-fetch/README.md | 32 - packages/sdk/plugin-fetch/package.json | 37 - packages/sdk/plugin-fetch/src/fetcher.ts | 90 -- .../sdk/plugin-fetch/src/giget-fetcher.ts | 116 --- packages/sdk/plugin-fetch/src/ids.ts | 39 - packages/sdk/plugin-fetch/src/index.ts | 48 - packages/sdk/plugin-fetch/src/never.ts | 19 - .../sdk/plugin-fetch/src/pacote-fetcher.ts | 129 --- packages/sdk/plugin-fetch/src/source.ts | 126 --- .../sdk/plugin-fetch/tests/fetcher.spec.ts | 64 -- .../plugin-fetch/tests/giget-fetcher.spec.ts | 145 --- packages/sdk/plugin-fetch/tests/ids.spec.ts | 39 - packages/sdk/plugin-fetch/tests/never.spec.ts | 19 - .../plugin-fetch/tests/pacote-fetcher.spec.ts | 108 --- .../sdk/plugin-fetch/tests/source.spec.ts | 107 --- packages/sdk/plugin-fetch/tsconfig.json | 15 - pnpm-lock.yaml | 831 ------------------ .../verify-package-readme-model-experience.ts | 1 - tsconfig.build.json | 1 - tsconfig.json | 1 - 22 files changed, 1971 deletions(-) delete mode 100644 packages/sdk/plugin-fetch/README.md delete mode 100644 packages/sdk/plugin-fetch/package.json delete mode 100644 packages/sdk/plugin-fetch/src/fetcher.ts delete mode 100644 packages/sdk/plugin-fetch/src/giget-fetcher.ts delete mode 100644 packages/sdk/plugin-fetch/src/ids.ts delete mode 100644 packages/sdk/plugin-fetch/src/index.ts delete mode 100644 packages/sdk/plugin-fetch/src/never.ts delete mode 100644 packages/sdk/plugin-fetch/src/pacote-fetcher.ts delete mode 100644 packages/sdk/plugin-fetch/src/source.ts delete mode 100644 packages/sdk/plugin-fetch/tests/fetcher.spec.ts delete mode 100644 packages/sdk/plugin-fetch/tests/giget-fetcher.spec.ts delete mode 100644 packages/sdk/plugin-fetch/tests/ids.spec.ts delete mode 100644 packages/sdk/plugin-fetch/tests/never.spec.ts delete mode 100644 packages/sdk/plugin-fetch/tests/pacote-fetcher.spec.ts delete mode 100644 packages/sdk/plugin-fetch/tests/source.spec.ts delete mode 100644 packages/sdk/plugin-fetch/tsconfig.json diff --git a/docs/module-graph.md b/docs/module-graph.md index 4cc2c36eeb..240772529b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -138,7 +138,6 @@ flowchart TD end subgraph group_sdk["packages/sdk"] pkg_helper["helper"] - pkg_plugin_fetch["plugin-fetch"] pkg_scripts["scripts"] pkg_telemetry["telemetry"] end @@ -154,7 +153,6 @@ flowchart TD pkg_llm --> pkg_brand pkg_code_runtime_worker --> pkg_code_runtime pkg_helper --> pkg_brand - pkg_plugin_fetch --> pkg_brand pkg_scripts --> pkg_app_boot pkg_telemetry --> pkg_brand pkg_llm_deepseek --> pkg_llm @@ -446,7 +444,6 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand) | -| [`plugin-fetch`](../packages/sdk/plugin-fetch) | `sdk` | [`brand`](../packages/util/brand) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) | diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 92c8794e64..953d52ea76 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -9,7 +9,6 @@ The [feature RFC](../../docs/rfc/proposed/feature/2026-07-14-sdk-developer-proje | [`helper`](helper/README.md) | Project aggregate, edit session, builtin features, project documents, templates, package managers, and prompt abstraction | | [`scripts`](scripts/README.md) | The `dsh-sdk` launcher: `start`, `dev`, `build`, and interactive `config` | | [`create-sdk`](create-sdk/README.md) | The `npm create @deepseek-ai/sdk` initializer | -| [`plugin-fetch`](plugin-fetch/README.md) | Fetch an external plugin (github/npm) into a temp dir — pinned and un-executed — for `dsh-sdk create` | `@deepseek-ai/create-sdk` is the one package-name exception to the repository's `@deepseek-ai/dsh-*` rule: npm's scoped initializer convention requires that name for `npm create @deepseek-ai/sdk`. diff --git a/packages/sdk/plugin-fetch/README.md b/packages/sdk/plugin-fetch/README.md deleted file mode 100644 index 801dc2a2a6..0000000000 --- a/packages/sdk/plugin-fetch/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# `@deepseek-ai/dsh-plugin-fetch` - -Fetch an external Cordis plugin into a temp directory — pinned to an immutable commit or integrity and never executed — for the forthcoming `dsh-sdk create ` command. - -The package parses a source spec into a `PluginSource`, dispatches to the matching `PluginFetcher`, and returns a common `FetchedPlugin` (temp dir + immutable provenance) that the wiring step pins into `package.json`, mounts in `cordis.yml`, and installs with `--ignore-scripts`. - -| Export | Role | -|---|---| -| `resolvePluginSource(spec)` → `PluginSource` | Parse `owner/repo[/subdir]#ref` (github) or `pkg@version` (npm); fail loud on an ambiguous or malformed spec | -| `PluginFetcher` | The fetch seam: resolve the pin BEFORE download, extract without executing pulled code | -| `GigetFetcher` / `createGigetFetcher()` | Github fetcher over `@bluwy/giget-core`: resolve `#ref` to a commit SHA, download that SHA | -| `PacoteFetcher` / `createPacoteFetcher()` | Npm fetcher over `pacote`: resolve the manifest, then extract the tarball verified against its integrity | -| `fetchPlugin(source, fetchers)` → `FetchedPlugin` | Dispatch one source to its fetcher by discriminant tag | - -## Safety model — confirm-before-run, not run-on-fetch - -A fetch only downloads and unpacks; it runs no install, no `postinstall`/`prepare`, and no degit-style template actions. - -- **github** uses `@bluwy/giget-core` (one runtime dependency, `modern-tar`; no CLI, install, or JSON-registry surface) so a fetch can only download and untar a tarball. The commit is pinned first: `GigetFetcher` resolves `#ref` — or the default branch when absent — to an immutable SHA via the GitHub commits API, then downloads that SHA. Provenance carries the SHA so wiring pins `github:owner/repo#`. -- **npm** uses `pacote`. Registry-only is enforced upstream: `resolvePluginSource` produces only a `name@version` registry spec, so pacote never sees a git/file/dir spec whose lifecycle scripts would run, and a registry tarball extract is a plain untar. The manifest is resolved first so extract verifies the artifact against the registry-published integrity (a mismatch raises `EINTEGRITY`). Provenance carries the exact version, resolved URL, and integrity. - -Both network boundaries (giget download, GitHub ref resolution, pacote, temp-dir allocation) are constructor-injected, so the fetch logic is unit-tested without network; the `create*Fetcher()` factories wire the real libraries. - -## Model Experience - -None, as this developer tooling acquires plugin sources for the SDK launcher and registers no live agent or model surface. - -## Known Limitations and Deferred Work - -- **Wiring is not here yet** — pinning `package.json`, mounting `cordis.yml` through `ProjectEditSession` with a confirmed diff, and `install --ignore-scripts` land with the `dsh-sdk create` command. This package stops at a fetched, pinned temp directory. -- **npm registry authentication** — `PacoteFetcher` targets a public or default-configured registry; private-registry auth beyond pacote's ambient npm config is deferred. -- **Template-repo initialization** — the whole-project init mode (`dsh-sdk create` from a template repository) is out of scope; this package fetches a single plugin into an existing project. diff --git a/packages/sdk/plugin-fetch/package.json b/packages/sdk/plugin-fetch/package.json deleted file mode 100644 index e8e38090e6..0000000000 --- a/packages/sdk/plugin-fetch/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-plugin-fetch", - "description": "Fetch an external Cordis plugin (github or npm) into a temp dir, pinned and un-executed, for dsh-sdk create", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "dependencies": { - "@bluwy/giget-core": "^0.1.7", - "pacote": "^22.0.0" - }, - "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "devDependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@types/pacote": "^11.1.8", - "cordis": "^4.0.0-rc.7" - } -} diff --git a/packages/sdk/plugin-fetch/src/fetcher.ts b/packages/sdk/plugin-fetch/src/fetcher.ts deleted file mode 100644 index 7c3d3a763c..0000000000 --- a/packages/sdk/plugin-fetch/src/fetcher.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * The `PluginFetcher` seam, its common `FetchedPlugin` result, and the - * tag-dispatched entry point. A fetcher acquires one plugin source into a fresh - * temp directory WITHOUT executing any pulled code, and reports immutable - * provenance the wiring step pins the dependency to. - * - * @module @deepseek-ai/dsh-plugin-fetch/fetcher - */ - -import { mkdtemp } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { assertNever } from './never.ts' -import type { CommitSha, Integrity } from './ids.ts' -import type { GithubSource, NpmSource, PluginSource } from './source.ts' - -/** Immutable pin for a github fetch: the resolved commit the tarball came from. */ -export interface GithubProvenance { - readonly kind: 'github' - readonly sha: CommitSha -} - -/** Immutable pin for an npm fetch: exact version, tarball URL, and integrity. */ -export interface NpmProvenance { - readonly kind: 'npm' - /** Concrete resolved version (e.g. `1.2.3`), never the requested range/tag. */ - readonly version: string - /** Tarball URL the artifact resolved to. */ - readonly resolved: string - /** Subresource integrity the artifact was verified against. */ - readonly integrity: Integrity -} - -/** Provenance a fetch records so wiring can pin an immutable dependency. */ -export type PluginProvenance = GithubProvenance | NpmProvenance - -/** The common result of fetching any plugin source. */ -export interface FetchedPlugin { - /** Absolute temp directory holding the extracted, UN-executed source. */ - readonly dir: string - /** The source that produced this fetch, echoed for the wiring step. */ - readonly source: PluginSource - /** Immutable provenance to pin the dependency during wiring. */ - readonly provenance: PluginProvenance -} - -/** - * A fetcher for one source kind. Implementations resolve the immutable pin - * BEFORE download and must never run lifecycle scripts or template actions. - */ -export interface PluginFetcher { - /** The single source kind this fetcher handles. */ - readonly kind: S['kind'] - /** - * Fetch one source into a fresh temp directory. - * @param source - the resolved source to fetch. - * @returns the temp dir plus immutable provenance. - */ - fetch(source: S): Promise -} - -/** The per-kind fetchers {@link fetchPlugin} dispatches across. */ -export interface PluginFetchers { - readonly github: PluginFetcher - readonly npm: PluginFetcher -} - -/** - * Dispatch one source to its fetcher by discriminant tag. - * @param source - the resolved plugin source. - * @param fetchers - the per-kind fetchers to route across. - * @returns the fetch result from the matching fetcher. - */ -export function fetchPlugin(source: PluginSource, fetchers: PluginFetchers): Promise { - switch (source.kind) { - case 'github': return fetchers.github.fetch(source) - case 'npm': return fetchers.npm.fetch(source) - default: return assertNever(source, 'fetchPlugin') - } -} - -/** - * Create a fresh, empty temp directory for one fetch — the default temp-dir - * seam shared by the concrete fetchers. - * @param prefix - a `mkdtemp` name prefix identifying the fetch kind. - * @returns the absolute path of the created directory. - */ -export function createTempDir(prefix: string): Promise { - return mkdtemp(join(tmpdir(), prefix)) -} diff --git a/packages/sdk/plugin-fetch/src/giget-fetcher.ts b/packages/sdk/plugin-fetch/src/giget-fetcher.ts deleted file mode 100644 index 55e001063c..0000000000 --- a/packages/sdk/plugin-fetch/src/giget-fetcher.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * The github {@link PluginFetcher}, backed by `@bluwy/giget-core`. - * - * `@bluwy/giget-core` is chosen over unjs `giget`: it carries a single runtime - * dependency (`modern-tar`) versus giget's CLI/registry stack, and it dropped - * the `install` and JSON-registry options entirely, so a fetch can only ever - * download and untar a tarball — never run install or degit-style actions. That - * is exactly the "extract, never execute" guarantee this feature needs. - * - * The commit is pinned BEFORE download: {@link GigetFetcher} resolves `#ref` to - * an immutable SHA (default via the GitHub commits API), then downloads that - * SHA. Provenance carries the SHA so wiring pins `github:owner/repo#`. - * - * @module @deepseek-ai/dsh-plugin-fetch/giget-fetcher - */ - -import { downloadTemplate } from '@bluwy/giget-core' -import { createTempDir, type FetchedPlugin, type PluginFetcher } from './fetcher.ts' -import { commitSha, type CommitSha } from './ids.ts' -import type { GithubSource } from './source.ts' - -/** Temp-dir name prefix for github fetches. */ -export const GITHUB_TEMP_PREFIX = 'dsh-plugin-github-' - -/** Downloads a giget input string into `dir`; the tarball-extraction seam. */ -export type DownloadTemplate = ( - input: string, - options: { dir: string; force: 'clean' }, -) => Promise<{ dir: string }> - -/** Resolves a github source's ref to an immutable commit SHA before download. */ -export type ResolveRef = (source: GithubSource) => Promise - -/** The injected collaborators a {@link GigetFetcher} needs. */ -export interface GigetFetcherDeps { - /** Downloads a pinned giget input into a directory. */ - download: DownloadTemplate - /** Resolves `source.ref` (or the default branch) to a commit SHA. */ - resolveRef: ResolveRef - /** Allocates the fresh temp directory to download into. */ - createTempDir: (prefix: string) => Promise -} - -/** Build the giget input string that pins a github source to a commit SHA. */ -function gigetInput(source: GithubSource, sha: CommitSha): string { - const path = source.subdir ? `${source.owner}/${source.repo}/${source.subdir}` : `${source.owner}/${source.repo}` - return `${path}#${sha}` -} - -/** A human-readable label for one github source, for error messages. */ -function githubLabel(source: GithubSource): string { - return `${source.owner}/${source.repo}#${source.ref ?? 'HEAD'}` -} - -/** - * Resolve a github source's ref to an immutable SHA via the GitHub commits API. - * Uses the `application/vnd.github.sha` media type, which returns the resolved - * commit id as plain text. - * @param source - the github source; an absent `ref` resolves the default branch (`HEAD`). - * @param token - optional bearer token for private repositories. - * @returns the resolved immutable commit SHA. - * @throws if the GitHub API rejects the request. - */ -export async function defaultResolveRef(source: GithubSource, token?: string): Promise { - const ref = source.ref ?? 'HEAD' - const url = `https://api.github.com/repos/${source.owner}/${source.repo}/commits/${ref}` - const headers: Record = { Accept: 'application/vnd.github.sha' } - if (token !== undefined) headers.Authorization = `Bearer ${token}` - const response = await fetch(url, { headers }) - if (!response.ok) { - throw new Error(`cannot resolve github ref ${githubLabel(source)}: HTTP ${response.status}`) - } - return commitSha((await response.text()).trim()) -} - -/** Fetches a github plugin source by pinning `#ref` to a commit SHA, then downloading it. */ -export class GigetFetcher implements PluginFetcher { - readonly kind = 'github' as const - private readonly deps: GigetFetcherDeps - - /** Construct with injected download, ref-resolution, and temp-dir seams. */ - constructor(deps: GigetFetcherDeps) { - this.deps = deps - } - - async fetch(source: GithubSource): Promise { - const sha = await this.deps.resolveRef(source) - const dir = await this.deps.createTempDir(GITHUB_TEMP_PREFIX) - await this.deps.download(gigetInput(source, sha), { dir, force: 'clean' }) - return { dir, source, provenance: { kind: 'github', sha } } - } -} - -/** Options for the production github fetcher. */ -export interface GithubFetchOptions { - /** Bearer token for private repositories; defaults to `GITHUB_TOKEN`. */ - token?: string -} - -/** - * Build the production github fetcher wired to `@bluwy/giget-core` and the - * GitHub commits API. - * @param options - optional token override (else `process.env.GITHUB_TOKEN`). - * @returns a {@link GigetFetcher} using the real download and ref-resolution seams. - */ -export function createGigetFetcher(options: GithubFetchOptions = {}): GigetFetcher { - const token = options.token ?? process.env.GITHUB_TOKEN - return new GigetFetcher({ - download: (input, downloadOptions) => downloadTemplate(input, { - ...downloadOptions, - ...token !== undefined ? { providerOptions: { auth: token } } : {}, - }), - resolveRef: source => defaultResolveRef(source, token), - createTempDir, - }) -} diff --git a/packages/sdk/plugin-fetch/src/ids.ts b/packages/sdk/plugin-fetch/src/ids.ts deleted file mode 100644 index a3a2ffa575..0000000000 --- a/packages/sdk/plugin-fetch/src/ids.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Branded provenance identities owned by the plugin-fetch layer. Both cross the - * fetch → wiring boundary and are opaque tokens that must not be confused with - * ordinary strings (a package name, a URL) at that seam. - * - * @module @deepseek-ai/dsh-plugin-fetch/ids - */ - -import type { Branded } from '@deepseek-ai/dsh-brand' - -/** An immutable git commit object id a github fetch pins to. */ -export type CommitSha = Branded<'CommitSha'> - -/** - * Construct a {@link CommitSha}, validating the hexadecimal object-id shape. - * @param value - lowercase hex of an abbreviated or full commit id (7–64 chars, covering SHA-1 and SHA-256). - * @returns the branded commit id. - */ -export function commitSha(value: string): CommitSha { - if (!/^[0-9a-f]{7,64}$/.test(value)) { - throw new Error(`invalid commit sha: ${JSON.stringify(value)}`) - } - return value as CommitSha -} - -/** A Subresource Integrity string an npm fetch pins to. */ -export type Integrity = Branded<'Integrity'> - -/** - * Construct an {@link Integrity}, validating the SRI `-` shape. - * @param value - a single SRI entry using sha256, sha384, or sha512. - * @returns the branded integrity string. - */ -export function integrity(value: string): Integrity { - if (!/^sha(256|384|512)-[A-Za-z0-9+/]+={0,2}$/.test(value)) { - throw new Error(`invalid subresource integrity: ${JSON.stringify(value)}`) - } - return value as Integrity -} diff --git a/packages/sdk/plugin-fetch/src/index.ts b/packages/sdk/plugin-fetch/src/index.ts deleted file mode 100644 index d4b9682ff5..0000000000 --- a/packages/sdk/plugin-fetch/src/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Fetch an external Cordis plugin (github or npm) into a temp directory — - * pinned to an immutable commit/integrity and never executed — for the - * `dsh-sdk create ` command. Parses a source spec, dispatches to the - * matching fetcher, and returns a common {@link FetchedPlugin} the wiring step - * pins and mounts. - * - * @module @deepseek-ai/dsh-plugin-fetch - */ - -export { resolvePluginSource } from './source.ts' -export type { GithubSource, NpmSource, PluginSource } from './source.ts' -export { commitSha, integrity } from './ids.ts' -export type { CommitSha, Integrity } from './ids.ts' -export { createTempDir, fetchPlugin } from './fetcher.ts' -export type { - FetchedPlugin, - GithubProvenance, - NpmProvenance, - PluginFetcher, - PluginFetchers, - PluginProvenance, -} from './fetcher.ts' -export { - createGigetFetcher, - defaultResolveRef, - GigetFetcher, - GITHUB_TEMP_PREFIX, -} from './giget-fetcher.ts' -export type { - DownloadTemplate, - GigetFetcherDeps, - GithubFetchOptions, - ResolveRef, -} from './giget-fetcher.ts' -export { - createPacoteFetcher, - NPM_TEMP_PREFIX, - PacoteFetcher, -} from './pacote-fetcher.ts' -export type { - NpmFetchOptions, - PacoteApi, - PacoteExtractResult, - PacoteFetcherDeps, - PacoteFetchOptions, - PacoteResolution, -} from './pacote-fetcher.ts' diff --git a/packages/sdk/plugin-fetch/src/never.ts b/packages/sdk/plugin-fetch/src/never.ts deleted file mode 100644 index 664ab3293d..0000000000 --- a/packages/sdk/plugin-fetch/src/never.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Exhaustiveness helper for this package's closed unions. Kept local so the - * SDK plugin-fetch tooling stays free of the model-runtime `dsh-llm` dependency - * that owns the shared `assertNever`. - * - * @module @deepseek-ai/dsh-plugin-fetch/never - */ - -/** - * Mark an unreachable closed-union branch. A newly unhandled variant fails - * compilation at the call site; a value that escaped its type throws at runtime. - * @param value - the impossible value; typed `never` so a new variant fails to compile at every call site. - * @param context - optional label prefixed into the throw message. - * @returns never — it always throws, rendering the offending value. - */ -export function assertNever(value: never, context?: string): never { - const rendered = (JSON.stringify(value) as string | undefined) ?? String(value) - throw new Error(`unreachable variant${context ? ` in ${context}` : ''}: ${rendered}`) -} diff --git a/packages/sdk/plugin-fetch/src/pacote-fetcher.ts b/packages/sdk/plugin-fetch/src/pacote-fetcher.ts deleted file mode 100644 index dfb1d790b8..0000000000 --- a/packages/sdk/plugin-fetch/src/pacote-fetcher.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * The npm {@link PluginFetcher}, backed by `pacote`. - * - * Supply-chain safety comes from three layers: (1) {@link resolvePluginSource} - * only ever produces a registry `name@version` spec, so pacote classifies it as - * a registry source and cannot be steered to a git/file/dir spec whose - * lifecycle scripts would run; (2) a registry tarball extract is a plain untar — - * pacote runs no `prepare`/`postinstall` during {@link PacoteFetcher.fetch}; and - * (3) the later wiring step installs with `--ignore-scripts`. The manifest is - * resolved first so extract verifies the tarball against the registry-published - * integrity (a mismatch raises `EINTEGRITY`). - * - * @module @deepseek-ai/dsh-plugin-fetch/pacote-fetcher - */ - -import { extract as pacoteExtract, manifest as pacoteManifest } from 'pacote' -import { createTempDir, type FetchedPlugin, type PluginFetcher } from './fetcher.ts' -import { integrity } from './ids.ts' -import type { NpmSource } from './source.ts' - -/** Temp-dir name prefix for npm fetches. */ -export const NPM_TEMP_PREFIX = 'dsh-plugin-npm-' - -/** The subset of pacote options this fetcher passes through. */ -export interface PacoteFetchOptions { - /** Registry to resolve against; absent uses pacote's default. */ - registry?: string - /** Known resolved tarball URL, forwarded to extract. */ - resolved?: string - /** Expected integrity, forwarded to extract for `EINTEGRITY` verification. */ - integrity?: string -} - -/** The resolved registry manifest fields this fetcher pins from. */ -export interface PacoteResolution { - /** Resolved tarball URL. */ - _resolved: string - /** Registry-published integrity. */ - _integrity: string - /** Concrete resolved version. */ - version: string -} - -/** The extract result fields this fetcher pins from. */ -export interface PacoteExtractResult { - /** Resolved tarball URL of the extracted artifact. */ - resolved: string - /** Integrity of the extracted artifact. */ - integrity: string -} - -/** The pacote surface a {@link PacoteFetcher} depends on; the fetch seam. */ -export interface PacoteApi { - /** Resolve a registry spec to its pinned manifest fields. */ - manifest: (spec: string, options?: PacoteFetchOptions) => Promise - /** Untar a registry spec into `dest`, verifying integrity when supplied. */ - extract: (spec: string, dest: string, options?: PacoteFetchOptions) => Promise -} - -/** The injected collaborators a {@link PacoteFetcher} needs. */ -export interface PacoteFetcherDeps { - /** The pacote resolve/extract surface. */ - pacote: PacoteApi - /** Allocates the fresh temp directory to extract into. */ - createTempDir: (prefix: string) => Promise - /** Registry to resolve against; absent uses pacote's default. */ - registry?: string -} - -/** Fetches an npm plugin source by resolving its manifest, then extracting the verified tarball. */ -export class PacoteFetcher implements PluginFetcher { - readonly kind = 'npm' as const - private readonly deps: PacoteFetcherDeps - - /** Construct with injected pacote, temp-dir, and optional registry seams. */ - constructor(deps: PacoteFetcherDeps) { - this.deps = deps - } - - async fetch(source: NpmSource): Promise { - const spec = `${source.name}@${source.version}` - const registryOptions: PacoteFetchOptions = this.deps.registry !== undefined - ? { registry: this.deps.registry } - : {} - const resolution = await this.deps.pacote.manifest(spec, registryOptions) - const dir = await this.deps.createTempDir(NPM_TEMP_PREFIX) - const extracted = await this.deps.pacote.extract(spec, dir, { - ...registryOptions, - resolved: resolution._resolved, - integrity: resolution._integrity, - }) - return { - dir, - source, - provenance: { - kind: 'npm', - version: resolution.version, - resolved: extracted.resolved, - integrity: integrity(extracted.integrity), - }, - } - } -} - -/** Options for the production npm fetcher. */ -export interface NpmFetchOptions { - /** Registry to resolve against; absent uses pacote's default. */ - registry?: string -} - -/** - * Build the production npm fetcher wired to `pacote`. - * @param options - optional registry override. - * @returns a {@link PacoteFetcher} using the real pacote resolve/extract seam. - */ -export function createPacoteFetcher(options: NpmFetchOptions = {}): PacoteFetcher { - const pacote: PacoteApi = { - manifest: async (spec, pacoteOptions) => { - const resolved = await pacoteManifest(spec, pacoteOptions) - return { _resolved: resolved._resolved, _integrity: resolved._integrity, version: resolved.version } - }, - extract: (spec, dest, pacoteOptions) => pacoteExtract(spec, dest, pacoteOptions), - } - return new PacoteFetcher({ - pacote, - createTempDir, - ...options.registry !== undefined ? { registry: options.registry } : {}, - }) -} diff --git a/packages/sdk/plugin-fetch/src/source.ts b/packages/sdk/plugin-fetch/src/source.ts deleted file mode 100644 index 7d7e3ffcc4..0000000000 --- a/packages/sdk/plugin-fetch/src/source.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * The `PluginSource` discriminated union and the resolver that parses one CLI - * spec string into it. Ambiguous or malformed specs fail loud here — the single - * earliest resolvable point — rather than surfacing as a confusing fetch error. - * - * Grammar: - * - github: `owner/repo[/subdir]#ref` — a `#` unambiguously marks a github ref; - * `ref` is optional and, when omitted, the fetcher pins the default branch. - * - npm: `pkg@version` (scoped `@scope/pkg@version`) — the `@version` is the - * only disambiguator from a bare `owner/repo` github locator. - * - * @module @deepseek-ai/dsh-plugin-fetch/source - */ - -/** A plugin pulled from a github (git tarball) repository. */ -export interface GithubSource { - readonly kind: 'github' - /** Repository owner (user or org). */ - readonly owner: string - /** Repository name. */ - readonly repo: string - /** Path within the repository to extract; absent means the repository root. */ - readonly subdir?: string - /** Branch, tag, or commit; absent means the repository's default branch. */ - readonly ref?: string -} - -/** A plugin pulled from an npm registry by exact package and version spec. */ -export interface NpmSource { - readonly kind: 'npm' - /** Package name, including any `@scope/` prefix. */ - readonly name: string - /** Registry version, range, or dist-tag (non-empty). */ - readonly version: string -} - -/** Every plugin origin `dsh-sdk create ` understands. */ -export type PluginSource = GithubSource | NpmSource - -/** One `/`-separated github name segment (owner, repo, or subdir component). */ -function isNameSegment(segment: string): boolean { - return /^[A-Za-z0-9._-]+$/.test(segment) && segment !== '.' && segment !== '..' -} - -/** A git ref: branch, tag, or commit; permits `/`-nested names, rejects traversal. */ -function isGitRef(ref: string): boolean { - return /^[A-Za-z0-9._/-]+$/.test(ref) - && !ref.includes('..') - && !ref.startsWith('/') - && !ref.endsWith('/') -} - -/** An npm package name, scoped (`@scope/name`) or unscoped. */ -function isNpmPackageName(name: string): boolean { - const segment = /^[a-z0-9][a-z0-9._-]*$/ - if (name.startsWith('@')) { - const slash = name.indexOf('/') - if (slash < 2 || slash === name.length - 1) return false - return segment.test(name.slice(1, slash)) && segment.test(name.slice(slash + 1)) - } - return segment.test(name) -} - -/** Parse a `owner/repo[/subdir]` locator with an optional already-split ref. */ -function tryParseGithubLocator(locator: string, ref: string | undefined): GithubSource | undefined { - if (!/^[^\s@#]+$/.test(locator)) return undefined - const [owner, repo, ...subdirSegments] = locator.split('/') - if (owner === undefined || repo === undefined) return undefined - if (!isNameSegment(owner) || !isNameSegment(repo)) return undefined - if (subdirSegments.some(segment => !isNameSegment(segment))) return undefined - if (ref !== undefined && !isGitRef(ref)) return undefined - const subdir = subdirSegments.join('/') - return { - kind: 'github', - owner, - repo, - ...subdir.length > 0 ? { subdir } : {}, - ...ref !== undefined ? { ref } : {}, - } -} - -/** Parse `pkg@version` (scoped or unscoped); undefined when it is not npm-shaped. */ -function tryParseNpmSource(spec: string): NpmSource | undefined { - if (/[\s#]/.test(spec)) return undefined - // A scoped spec's version `@` follows the scope's `/`; an unscoped spec's is - // the first `@`. A leading `@` with no version `@` yields index 0 (rejected). - const versionAt = spec.startsWith('@') ? spec.indexOf('@', spec.indexOf('/') + 1) : spec.indexOf('@') - if (versionAt <= 0) return undefined - const name = spec.slice(0, versionAt) - const version = spec.slice(versionAt + 1) - if (version.length === 0 || version.includes('/')) return undefined - if (!isNpmPackageName(name)) return undefined - return { kind: 'npm', name, version } -} - -/** - * Parse one `dsh-sdk create ` spec into a {@link PluginSource}. - * @param spec - the raw source argument. - * @returns the discriminated source. - * @throws if the spec is empty, malformed, or ambiguous between github and npm. - */ -export function resolvePluginSource(spec: string): PluginSource { - const trimmed = spec.trim() - if (trimmed.length === 0) throw new Error('plugin source must not be empty') - - const hashIndex = trimmed.indexOf('#') - if (hashIndex !== -1) { - const ref = trimmed.slice(hashIndex + 1) - if (ref.length === 0) { - throw new Error(`github plugin source is missing a ref after '#': ${JSON.stringify(spec)}`) - } - const source = tryParseGithubLocator(trimmed.slice(0, hashIndex), ref) - if (!source) { - throw new Error(`invalid github plugin source: ${JSON.stringify(spec)} — expected "owner/repo[/subdir]#ref"`) - } - return source - } - - const npm = tryParseNpmSource(trimmed) - if (npm) return npm - const github = tryParseGithubLocator(trimmed, undefined) - if (github) return github - throw new Error( - `unrecognized plugin source: ${JSON.stringify(spec)} — expected "owner/repo[/subdir]#ref" (github) or "pkg@version" (npm)`, - ) -} diff --git a/packages/sdk/plugin-fetch/tests/fetcher.spec.ts b/packages/sdk/plugin-fetch/tests/fetcher.spec.ts deleted file mode 100644 index b68a3d3ae1..0000000000 --- a/packages/sdk/plugin-fetch/tests/fetcher.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { rm, stat } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { describe, expect, it, vi } from 'vitest' -import { - createTempDir, - fetchPlugin, - type FetchedPlugin, - type PluginFetchers, -} from '../src/fetcher.ts' -import { commitSha } from '../src/ids.ts' -import type { GithubSource, NpmSource, PluginSource } from '../src/source.ts' - -function stubFetchers(): { fetchers: PluginFetchers; github: ReturnType; npm: ReturnType } { - const result = (dir: string): FetchedPlugin => ({ - dir, - source: { kind: 'github', owner: 'o', repo: 'r' }, - provenance: { kind: 'github', sha: commitSha('a'.repeat(40)) }, - }) - const github = vi.fn(async (source: GithubSource) => result(`github:${source.repo}`)) - const npm = vi.fn(async (source: NpmSource) => result(`npm:${source.name}`)) - return { - fetchers: { github: { kind: 'github', fetch: github }, npm: { kind: 'npm', fetch: npm } }, - github, - npm, - } -} - -describe('fetchPlugin', () => { - it('routes a github source to the github fetcher', async () => { - const { fetchers, github, npm } = stubFetchers() - const source: GithubSource = { kind: 'github', owner: 'o', repo: 'r' } - const result = await fetchPlugin(source, fetchers) - expect(github).toHaveBeenCalledWith(source) - expect(npm).not.toHaveBeenCalled() - expect(result.dir).toBe('github:r') - }) - - it('routes an npm source to the npm fetcher', async () => { - const { fetchers, github, npm } = stubFetchers() - const source: NpmSource = { kind: 'npm', name: 'plugin', version: '1.0.0' } - const result = await fetchPlugin(source, fetchers) - expect(npm).toHaveBeenCalledWith(source) - expect(github).not.toHaveBeenCalled() - expect(result.dir).toBe('npm:plugin') - }) - - it('throws on an unknown source kind', () => { - const { fetchers } = stubFetchers() - const bogus = { kind: 'svn' } as unknown as PluginSource - expect(() => fetchPlugin(bogus, fetchers)).toThrow(/unreachable variant in fetchPlugin/) - }) -}) - -describe('createTempDir', () => { - it('creates a fresh empty directory under the OS temp root', async () => { - const dir = await createTempDir('dsh-plugin-fetch-test-') - try { - expect(dir.startsWith(tmpdir())).toBe(true) - expect((await stat(dir)).isDirectory()).toBe(true) - } finally { - await rm(dir, { recursive: true, force: true }) - } - }) -}) diff --git a/packages/sdk/plugin-fetch/tests/giget-fetcher.spec.ts b/packages/sdk/plugin-fetch/tests/giget-fetcher.spec.ts deleted file mode 100644 index c0cafb31d5..0000000000 --- a/packages/sdk/plugin-fetch/tests/giget-fetcher.spec.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' -import { downloadTemplate } from '@bluwy/giget-core' -import { - createGigetFetcher, - defaultResolveRef, - GigetFetcher, - GITHUB_TEMP_PREFIX, - type GigetFetcherDeps, -} from '../src/giget-fetcher.ts' -import type { CommitSha } from '../src/ids.ts' -import type { GithubSource } from '../src/source.ts' - -vi.mock('@bluwy/giget-core', () => ({ downloadTemplate: vi.fn(async (_input: string, options: { dir: string }) => ({ dir: options.dir, source: '', info: { name: '', tar: '' } })) })) - -const SHA = 'a'.repeat(40) - -/** A `fetch` mock typed with the call signature the assertions destructure. */ -function fetchReturning(response: Response): Mock<(url: string, init?: RequestInit) => Promise> { - return vi.fn((_url: string, _init?: RequestInit) => Promise.resolve(response)) -} - -function fakeDeps(overrides: Partial = {}): { - deps: GigetFetcherDeps - download: ReturnType - resolveRef: ReturnType - createTempDir: ReturnType -} { - const download = vi.fn(async () => ({ dir: '/tmp/x' })) - const resolveRef = vi.fn(async () => SHA as CommitSha) - const createTempDir = vi.fn(async () => '/tmp/dsh-plugin-github-abc') - return { deps: { download, resolveRef, createTempDir, ...overrides }, download, resolveRef, createTempDir } -} - -describe('GigetFetcher.fetch', () => { - it('pins the ref to a SHA, downloads that SHA, and reports provenance', async () => { - const { deps, download, resolveRef, createTempDir } = fakeDeps() - const source: GithubSource = { kind: 'github', owner: 'unjs', repo: 'template', ref: 'main' } - const result = await new GigetFetcher(deps).fetch(source) - - expect(resolveRef).toHaveBeenCalledWith(source) - expect(createTempDir).toHaveBeenCalledWith(GITHUB_TEMP_PREFIX) - expect(download).toHaveBeenCalledWith(`unjs/template#${SHA}`, { dir: '/tmp/dsh-plugin-github-abc', force: 'clean' }) - expect(result).toEqual({ - dir: '/tmp/dsh-plugin-github-abc', - source, - provenance: { kind: 'github', sha: SHA }, - }) - }) - - it('includes the subdir in the download input', async () => { - const { deps, download } = fakeDeps() - const source: GithubSource = { kind: 'github', owner: 'o', repo: 'r', subdir: 'packages/plugin' } - await new GigetFetcher(deps).fetch(source) - expect(download).toHaveBeenCalledWith(`o/r/packages/plugin#${SHA}`, expect.anything()) - }) - - it('exposes its source kind', () => { - expect(new GigetFetcher(fakeDeps().deps).kind).toBe('github') - }) -}) - -describe('defaultResolveRef', () => { - afterEach(() => vi.unstubAllGlobals()) - - it('resolves the default branch (HEAD) with no auth header', async () => { - const fetchMock = fetchReturning(new Response(`${SHA}\n`, { status: 200 })) - vi.stubGlobal('fetch', fetchMock) - const sha = await defaultResolveRef({ kind: 'github', owner: 'o', repo: 'r' }) - expect(sha).toBe(SHA) - const [url, init] = fetchMock.mock.calls[0]! - expect(url).toBe('https://api.github.com/repos/o/r/commits/HEAD') - expect((init as RequestInit).headers).toEqual({ Accept: 'application/vnd.github.sha' }) - }) - - it('resolves an explicit ref and sends a bearer token', async () => { - const fetchMock = fetchReturning(new Response(SHA, { status: 200 })) - vi.stubGlobal('fetch', fetchMock) - const sha = await defaultResolveRef({ kind: 'github', owner: 'o', repo: 'r', ref: 'v1.2.3' }, 'secret') - expect(sha).toBe(SHA) - const [url, init] = fetchMock.mock.calls[0]! - expect(url).toBe('https://api.github.com/repos/o/r/commits/v1.2.3') - expect((init as RequestInit).headers).toEqual({ - Accept: 'application/vnd.github.sha', - Authorization: 'Bearer secret', - }) - }) - - it('throws with the HEAD label when the API rejects an unref-ed source', async () => { - vi.stubGlobal('fetch', fetchReturning(new Response('', { status: 404 }))) - await expect(defaultResolveRef({ kind: 'github', owner: 'o', repo: 'r' })).rejects.toThrow( - /cannot resolve github ref o\/r#HEAD: HTTP 404/, - ) - }) - - it('throws with the explicit-ref label when the API rejects', async () => { - vi.stubGlobal('fetch', fetchReturning(new Response('', { status: 403 }))) - await expect( - defaultResolveRef({ kind: 'github', owner: 'o', repo: 'r', ref: 'main' }), - ).rejects.toThrow(/cannot resolve github ref o\/r#main: HTTP 403/) - }) -}) - -describe('createGigetFetcher', () => { - const downloadMock = vi.mocked(downloadTemplate) - let savedToken: string | undefined - - beforeEach(() => { - downloadMock.mockClear() - savedToken = process.env.GITHUB_TOKEN - delete process.env.GITHUB_TOKEN - }) - - afterEach(() => { - vi.unstubAllGlobals() - if (savedToken === undefined) delete process.env.GITHUB_TOKEN - else process.env.GITHUB_TOKEN = savedToken - }) - - it('wires the real download without provider auth when no token is present', async () => { - vi.stubGlobal('fetch', fetchReturning(new Response(SHA, { status: 200 }))) - await createGigetFetcher().fetch({ kind: 'github', owner: 'o', repo: 'r', ref: 'main' }) - const [input, options] = downloadMock.mock.calls[0]! - expect(input).toBe(`o/r#${SHA}`) - expect(options?.dir).toContain(GITHUB_TEMP_PREFIX) - expect(options?.force).toBe('clean') - expect(options?.providerOptions).toBeUndefined() - }) - - it('passes an explicit token to both ref resolution and provider auth', async () => { - const fetchMock = fetchReturning(new Response(SHA, { status: 200 })) - vi.stubGlobal('fetch', fetchMock) - await createGigetFetcher({ token: 'tok' }).fetch({ kind: 'github', owner: 'o', repo: 'r' }) - expect((fetchMock.mock.calls[0]![1] as RequestInit).headers).toMatchObject({ Authorization: 'Bearer tok' }) - const [, options] = downloadMock.mock.calls[0]! - expect(options).toMatchObject({ providerOptions: { auth: 'tok' } }) - }) - - it('reads GITHUB_TOKEN from the environment', async () => { - process.env.GITHUB_TOKEN = 'from-env' - vi.stubGlobal('fetch', fetchReturning(new Response(SHA, { status: 200 }))) - await createGigetFetcher().fetch({ kind: 'github', owner: 'o', repo: 'r' }) - const [, options] = downloadMock.mock.calls[0]! - expect(options).toMatchObject({ providerOptions: { auth: 'from-env' } }) - }) -}) diff --git a/packages/sdk/plugin-fetch/tests/ids.spec.ts b/packages/sdk/plugin-fetch/tests/ids.spec.ts deleted file mode 100644 index 2f1ba6f518..0000000000 --- a/packages/sdk/plugin-fetch/tests/ids.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { commitSha, integrity } from '../src/ids.ts' - -describe('commitSha', () => { - it('accepts abbreviated and full lowercase hex object ids', () => { - expect(commitSha('abc1234')).toBe('abc1234') - expect(commitSha('a'.repeat(40))).toBe('a'.repeat(40)) - expect(commitSha('0'.repeat(64))).toBe('0'.repeat(64)) - }) - - it.each([ - ['too short', 'abc123'], - ['uppercase', 'ABCDEF1'], - ['non-hex', 'ghijklm'], - ['too long', 'a'.repeat(65)], - ['empty', ''], - ])('rejects an invalid sha (%s)', (_label, value) => { - expect(() => commitSha(value)).toThrow(/invalid commit sha/) - }) -}) - -describe('integrity', () => { - it.each([ - 'sha512-abcABC123+/==', - 'sha384-abcABC123+/', - 'sha256-Zm9vYmFy', - ])('accepts a valid SRI entry (%s)', (value) => { - expect(integrity(value)).toBe(value) - }) - - it.each([ - ['missing algorithm', 'abcABC123'], - ['unsupported algorithm', 'sha1-abcABC123'], - ['illegal base64 char', 'sha512-abc*def'], - ['empty', ''], - ])('rejects an invalid integrity (%s)', (_label, value) => { - expect(() => integrity(value)).toThrow(/invalid subresource integrity/) - }) -}) diff --git a/packages/sdk/plugin-fetch/tests/never.spec.ts b/packages/sdk/plugin-fetch/tests/never.spec.ts deleted file mode 100644 index 224ab66e29..0000000000 --- a/packages/sdk/plugin-fetch/tests/never.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { assertNever } from '../src/never.ts' - -describe('assertNever', () => { - it('throws with the rendered value and a context label', () => { - expect(() => assertNever('surprise' as never, 'demo')).toThrow( - /unreachable variant in demo: "surprise"/, - ) - }) - - it('omits the context clause when none is given', () => { - expect(() => assertNever(7 as never)).toThrow(/unreachable variant: 7$/) - }) - - it('falls back to String() when the value is not JSON-serializable', () => { - // JSON.stringify(undefined) is undefined, exercising the String() fallback. - expect(() => assertNever(undefined as never)).toThrow(/unreachable variant: undefined$/) - }) -}) diff --git a/packages/sdk/plugin-fetch/tests/pacote-fetcher.spec.ts b/packages/sdk/plugin-fetch/tests/pacote-fetcher.spec.ts deleted file mode 100644 index 958e56674e..0000000000 --- a/packages/sdk/plugin-fetch/tests/pacote-fetcher.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { extract as pacoteExtract, manifest as pacoteManifest } from 'pacote' -import { - createPacoteFetcher, - NPM_TEMP_PREFIX, - PacoteFetcher, - type PacoteApi, - type PacoteFetcherDeps, -} from '../src/pacote-fetcher.ts' -import type { NpmSource } from '../src/source.ts' - -vi.mock('pacote', () => ({ manifest: vi.fn(), extract: vi.fn() })) - -const INTEGRITY = 'sha512-abcABC123+/==' -const RESOLVED = 'https://registry.npmjs.org/plugin/-/plugin-1.2.3.tgz' - -function fakePacote(): PacoteApi { - return { - manifest: vi.fn(async () => ({ _resolved: RESOLVED, _integrity: INTEGRITY, version: '1.2.3' })), - extract: vi.fn(async () => ({ resolved: RESOLVED, integrity: INTEGRITY })), - } -} - -function deps(overrides: Partial = {}): PacoteFetcherDeps { - return { - pacote: fakePacote(), - createTempDir: vi.fn(async () => '/tmp/dsh-plugin-npm-abc'), - ...overrides, - } -} - -const SOURCE: NpmSource = { kind: 'npm', name: 'plugin', version: '^1.0.0' } - -describe('PacoteFetcher.fetch', () => { - it('resolves the manifest, extracts with integrity, and reports provenance', async () => { - const d = deps() - const result = await new PacoteFetcher(d).fetch(SOURCE) - - expect(d.pacote.manifest).toHaveBeenCalledWith('plugin@^1.0.0', {}) - expect(d.createTempDir).toHaveBeenCalledWith(NPM_TEMP_PREFIX) - expect(d.pacote.extract).toHaveBeenCalledWith('plugin@^1.0.0', '/tmp/dsh-plugin-npm-abc', { - resolved: RESOLVED, - integrity: INTEGRITY, - }) - expect(result).toEqual({ - dir: '/tmp/dsh-plugin-npm-abc', - source: SOURCE, - provenance: { kind: 'npm', version: '1.2.3', resolved: RESOLVED, integrity: INTEGRITY }, - }) - }) - - it('forwards a configured registry to both manifest and extract', async () => { - const d = deps({ registry: 'https://npm.internal/' }) - await new PacoteFetcher(d).fetch(SOURCE) - expect(d.pacote.manifest).toHaveBeenCalledWith('plugin@^1.0.0', { registry: 'https://npm.internal/' }) - expect(d.pacote.extract).toHaveBeenCalledWith('plugin@^1.0.0', '/tmp/dsh-plugin-npm-abc', { - registry: 'https://npm.internal/', - resolved: RESOLVED, - integrity: INTEGRITY, - }) - }) - - it('rejects a registry integrity that is not a valid SRI', async () => { - const pacote = fakePacote() - pacote.extract = vi.fn(async () => ({ resolved: RESOLVED, integrity: 'not-sri' })) - await expect(new PacoteFetcher(deps({ pacote })).fetch(SOURCE)).rejects.toThrow( - /invalid subresource integrity/, - ) - }) - - it('exposes its source kind', () => { - expect(new PacoteFetcher(deps()).kind).toBe('npm') - }) -}) - -describe('createPacoteFetcher', () => { - const manifestMock = vi.mocked(pacoteManifest) - const extractMock = vi.mocked(pacoteExtract) - - beforeEach(() => { - manifestMock.mockReset() - extractMock.mockReset() - // The real overloaded pacote manifest returns a much wider shape; the fetcher reads only these fields. - manifestMock.mockResolvedValue( - { _resolved: RESOLVED, _integrity: INTEGRITY, version: '1.2.3' } as unknown as Awaited< - ReturnType - >, - ) - extractMock.mockResolvedValue({ from: 'plugin@1.2.3', resolved: RESOLVED, integrity: INTEGRITY }) - }) - - afterEach(() => vi.clearAllMocks()) - - it('wires the real pacote resolve/extract surface', async () => { - const result = await createPacoteFetcher().fetch(SOURCE) - expect(manifestMock).toHaveBeenCalledWith('plugin@^1.0.0', {}) - expect(extractMock).toHaveBeenCalledWith('plugin@^1.0.0', expect.stringContaining(NPM_TEMP_PREFIX), { - resolved: RESOLVED, - integrity: INTEGRITY, - }) - expect(result.provenance).toEqual({ kind: 'npm', version: '1.2.3', resolved: RESOLVED, integrity: INTEGRITY }) - }) - - it('forwards a configured registry through the real surface', async () => { - await createPacoteFetcher({ registry: 'https://npm.internal/' }).fetch(SOURCE) - expect(manifestMock).toHaveBeenCalledWith('plugin@^1.0.0', { registry: 'https://npm.internal/' }) - }) -}) diff --git a/packages/sdk/plugin-fetch/tests/source.spec.ts b/packages/sdk/plugin-fetch/tests/source.spec.ts deleted file mode 100644 index e585c5dfcf..0000000000 --- a/packages/sdk/plugin-fetch/tests/source.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { resolvePluginSource, type GithubSource, type NpmSource } from '../src/source.ts' - -describe('resolvePluginSource — github', () => { - it('parses owner/repo with a ref', () => { - expect(resolvePluginSource('unjs/template#main')).toEqual({ - kind: 'github', owner: 'unjs', repo: 'template', ref: 'main', - }) - }) - - it('parses a bare owner/repo without a ref', () => { - expect(resolvePluginSource('deepseek-ai/plugin')).toEqual({ - kind: 'github', owner: 'deepseek-ai', repo: 'plugin', - }) - }) - - it('parses a nested subdir with a ref', () => { - expect(resolvePluginSource('owner/repo/packages/plugin#v1.2.3')).toEqual({ - kind: 'github', owner: 'owner', repo: 'repo', subdir: 'packages/plugin', ref: 'v1.2.3', - }) - }) - - it('parses a subdir without a ref', () => { - expect(resolvePluginSource('owner/repo/sub')).toEqual({ - kind: 'github', owner: 'owner', repo: 'repo', subdir: 'sub', - }) - }) - - it('accepts a slash-nested ref', () => { - expect(resolvePluginSource('owner/repo#feature/x')).toEqual({ - kind: 'github', owner: 'owner', repo: 'repo', ref: 'feature/x', - }) - }) - - it('trims surrounding whitespace before parsing', () => { - expect(resolvePluginSource(' owner/repo#main ')).toEqual({ - kind: 'github', owner: 'owner', repo: 'repo', ref: 'main', - }) - }) - - it.each([ - ['empty ref after hash', 'owner/repo#'], - ['single locator segment with hash', 'owner#main'], - ['owner with @ and a hash', 'own@er/repo#main'], - ['ref with whitespace', 'owner/repo#bad ref'], - ['ref with traversal', 'owner/repo#a..b'], - ['ref with a leading slash', 'owner/repo#/main'], - ['ref with a trailing slash', 'owner/repo#main/'], - ['ref with an illegal char', 'owner/repo#ma:in'], - ])('rejects a malformed github spec (%s)', (_label, spec) => { - expect(() => resolvePluginSource(spec)).toThrow(/github plugin source|missing a ref/) - }) -}) - -describe('resolvePluginSource — npm', () => { - it('parses an unscoped name@version', () => { - expect(resolvePluginSource('react@18.2.0')).toEqual({ - kind: 'npm', name: 'react', version: '18.2.0', - }) - }) - - it('parses a scoped name@version', () => { - expect(resolvePluginSource('@deepseek-ai/dsh-tool-foo@0.0.1')).toEqual({ - kind: 'npm', name: '@deepseek-ai/dsh-tool-foo', version: '0.0.1', - }) - }) - - it('accepts a dist-tag as the version', () => { - expect(resolvePluginSource('some-plugin@latest')).toEqual({ - kind: 'npm', name: 'some-plugin', version: 'latest', - }) - }) - - it('accepts a range as the version', () => { - expect(resolvePluginSource('some-plugin@^1.0.0')).toEqual({ - kind: 'npm', name: 'some-plugin', version: '^1.0.0', - }) - }) -}) - -describe('resolvePluginSource — failures', () => { - it.each([ - ['empty', ''], - ['whitespace only', ' '], - ])('rejects a blank spec (%s)', (_label, spec) => { - expect(() => resolvePluginSource(spec)).toThrow(/must not be empty/) - }) - - it.each([ - ['bare word', 'plugin'], - ['internal whitespace', 'owner repo'], - ['empty npm version', 'pkg@'], - ['scoped without version', '@scope/pkg'], - ['scoped with empty scope', '@/pkg@1'], - ['unscoped name with slash and version', 'foo/bar@1'], - ['version containing a slash', 'foo@1/2'], - ['uppercase unscoped name', 'FOO@1.0.0'], - ['uppercase scope segment', '@Scope/pkg@1'], - ['uppercase scoped name segment', '@scope/PKG@1'], - ['empty scoped name segment', '@scope/@1'], - ['dot-only owner', './repo'], - ['traversal subdir segment', 'owner/repo/../x'], - ['double slash subdir', 'owner/repo//sub'], - ])('rejects an unrecognized/ambiguous spec (%s)', (_label, spec) => { - expect(() => resolvePluginSource(spec)).toThrow(/unrecognized plugin source|github plugin source/) - }) -}) diff --git a/packages/sdk/plugin-fetch/tsconfig.json b/packages/sdk/plugin-fetch/tsconfig.json deleted file mode 100644 index 07c2567ff8..0000000000 --- a/packages/sdk/plugin-fetch/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../util/brand" - } - ] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0acc85fcab..cafebfaae6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1223,25 +1223,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/sdk/plugin-fetch: - dependencies: - '@bluwy/giget-core': - specifier: ^0.1.7 - version: 0.1.7 - pacote: - specifier: ^22.0.0 - version: 22.0.0 - devDependencies: - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@types/pacote': - specifier: ^11.1.8 - version: 11.1.8 - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/sdk/scripts: dependencies: '@deepseek-ai/dsh-helper': @@ -2971,10 +2952,6 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@bluwy/giget-core@0.1.7': - resolution: {integrity: sha512-6XG8TZt8DVYLuGDVSpFJaSMlNowOg5RGecvWbKvlgMoqVbztUAQ3AcWq6oZ5DoCnTzNAPcO7rhkwt8ZVgrS7CQ==} - engines: {node: '>=18'} - '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} @@ -3428,10 +3405,6 @@ packages: '@noble/hashes': optional: true - '@gar/promise-retry@1.0.3': - resolution: {integrity: sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==} - engines: {node: ^20.17.0 || >=22.9.0} - '@google/genai@1.52.0': resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} engines: {node: '>=20.0.0'} @@ -3480,10 +3453,6 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} - '@isaacs/fs-minipass@4.0.1': - resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} - engines: {node: '>=18.0.0'} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -3530,43 +3499,6 @@ packages: '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} - '@npmcli/agent@5.0.2': - resolution: {integrity: sha512-EkzGmEsgbQ1rqWkRJe2P0oQHx/ylZozDUNPMXCklLuSFL3GY+QyEfBUjhjCsgGXzh4OGpnHvkboSQgczjP/jJg==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - '@npmcli/fs@6.0.0': - resolution: {integrity: sha512-AheOs4swKka/XLtht6xxJDPezlQ7K2IYQ9Y8lST4JLDjnralnWuMM9AE2CdVcgQJ5omrXhsRzM7F7aYmeZBvKQ==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - '@npmcli/git@8.0.0': - resolution: {integrity: sha512-5P1oo+TbxZNAiiMBtpzHA8QyEGh5D69LYLexNWJEDXLdxnAZvT/SLitGJBXxjtCE4ftAcFOS/Tu2185MeIjooQ==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - '@npmcli/installed-package-contents@5.0.0': - resolution: {integrity: sha512-6Ay12sf2Lh7U1ifvnS1mq7TZFeh/rXHMXye+kV7jQrANIubaoVcleeh4HdFumxhsRYwm9OaHycB5lYmSwGrcIQ==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - hasBin: true - - '@npmcli/node-gyp@6.0.0': - resolution: {integrity: sha512-MFakpea4pcZNlHSTbMi15HK8RY8zl2UpgDtxhZCWOer+KRN3x7HFIMk/fKpOMgR55L4LIcA2qn8IHeyABhIFtw==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - '@npmcli/package-json@8.0.0': - resolution: {integrity: sha512-agNZzYQ18MR0wKp3Emg1q5QbcC8CXigYp3Z3CvB0Sax9Ge9aF4cVyyuSG+5SbACSrZUKTvMjVULWiE1RJA38wg==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - '@npmcli/promise-spawn@10.0.0': - resolution: {integrity: sha512-llZkSzeTsimFx64U+ThT2xQM2uEce8GIQUYvxgbB6ZFvBhV2LP9LeJJb3HT+syG0uCFLsTCHjV9SfC0WNU1vtA==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - '@npmcli/redact@5.0.0': - resolution: {integrity: sha512-3zcN5Q3yEmeyxXBzqB6fXPQFzYa2ROsGFSr69W0ArXIAGJqxl/aFECOVPD2kbkYPm0U/EHxFKgclK3UA9WQg5A==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - '@npmcli/run-script@11.0.0': - resolution: {integrity: sha512-leBRl6F5F0TvWut8m1/aZcMTUHi2vXjKeMJ/Ik1lW7Q7Yy16Dhtkklu+cEqQww1p1NeLnUNzV3+uwpzqRcy9vw==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - '@oxc-parser/binding-android-arm-eabi@0.133.0': resolution: {integrity: sha512-l/44caGse+VpnY9gx0yvvc5QnnG3yG1FO3KZgYvNL1GZrfK86zIwAOgGEVlxDyRymzrU/KHiblPFpevKOmJmUA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4196,30 +4128,6 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} - '@sigstore/bundle@5.0.0': - resolution: {integrity: sha512-wefjygudENbzbQMks1t5u34EP0fFoD0XvaEP7DOUP/sXKvogzEJYFw5E6pegGyp3onGWzVEYKVa3bNZWyTYX+A==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - '@sigstore/core@4.0.1': - resolution: {integrity: sha512-9v5hRjujn5NXq8o7XFEUgLyAtdr5Iisb4pzM05u3K61IS5q3hP3luWAndk0RkPPLTUFoTbg7Vb84UQ1ZQeajWQ==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - '@sigstore/protobuf-specs@0.5.1': - resolution: {integrity: sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g==} - engines: {node: ^18.17.0 || >=20.5.0} - - '@sigstore/sign@5.0.0': - resolution: {integrity: sha512-DSFivqz9/i5AkwZ5fq0YdjaJlc4o1WeS2Zffon0kqtChx0vy4W9NOjkEet9bF2vkzOufX72eVH8kZBIGtcBp1w==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - '@sigstore/tuf@5.0.0': - resolution: {integrity: sha512-Zyqg9tcHps3uRAlKHLNmsW4ohsUZAjb9G+31r7lg0ICh/JOcadzmJsIRdjKljlRHpaR0K4aJ2kXXIdywdcdMlA==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - '@sigstore/verify@4.1.0': - resolution: {integrity: sha512-p/s720RiWxLG8XtmfdPfEJOlATA6H/2knFqmtQbFkHKN3IrhWGUwPfpQAf1UnQIEES9IaH6zzhfjkrhTfeSdZw==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - '@smithy/core@3.24.7': resolution: {integrity: sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==} engines: {node: '>=18.0.0'} @@ -4269,14 +4177,6 @@ packages: peerDependencies: eslint: ^9.0.0 || ^10.0.0 - '@tufjs/canonical-json@2.0.0': - resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==} - engines: {node: ^16.14.0 || >=18.0.0} - - '@tufjs/models@5.0.0': - resolution: {integrity: sha512-U4mVcdFGOi6pt8n38LdWZp67Svn7ppnU1Pj8SGOVaBi1X4gm+G4ztQlLfkoJbKSHfjA6WeaiJp2A4V83AJF6nQ==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -4424,36 +4324,18 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node-fetch@2.6.13': - resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} - '@types/node@22.20.0': resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} '@types/node@25.9.3': resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} - '@types/npm-package-arg@6.1.4': - resolution: {integrity: sha512-vDgdbMy2QXHnAruzlv68pUtXCjmqUk3WrBAsRboRovsOmxbfn/WiYCjmecyKjGztnMps5dWp4Uq2prp+Ilo17Q==} - - '@types/npm-registry-fetch@8.0.9': - resolution: {integrity: sha512-7NxvodR5Yrop3pb6+n8jhJNyzwOX0+6F+iagNEoi9u1CGxruYAwZD8pvGc9prIkL0+FdX5Xp0p80J9QPrGUp/g==} - - '@types/npmlog@7.0.0': - resolution: {integrity: sha512-hJWbrKFvxKyWwSUXjZMYTINsSOY6IclhvGOZ97M8ac2tmR9hMwmTnYaMdpGhvju9ctWLTPhCS+eLfQNluiEjQQ==} - - '@types/pacote@11.1.8': - resolution: {integrity: sha512-/XLR0VoTh2JEO0jJg1q/e6Rh9bxjBq9vorJuQmtT7rRrXSiWz7e7NsvXVYJQ0i8JxMlBMPPYDTnrRe7MZRFA8Q==} - '@types/picomatch@3.0.2': resolution: {integrity: sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA==} '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} - '@types/ssri@7.1.5': - resolution: {integrity: sha512-odD/56S3B51liILSk5aXJlnYt99S6Rt9EFDDqGtJM26rKHApHcwyU/UoYHrzKkdkHMAIquGWCuHtQTbes+FRQw==} - '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} @@ -4668,10 +4550,6 @@ packages: resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} - abbrev@5.0.0: - resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -4690,10 +4568,6 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - agent-base@9.0.0: - resolution: {integrity: sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==} - engines: {node: '>= 20'} - ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -4753,9 +4627,6 @@ packages: ast-v8-to-istanbul@1.0.4: resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -4810,10 +4681,6 @@ packages: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} - cacache@21.0.1: - resolution: {integrity: sha512-pTwz/uj3Jyp6WXdJ6fWhR+7LVxVs6RyroQSn7KJwHsSxXuyGSp0pcMVcwSwTpCFq1X2YG8QBe0W+vN+cr0SwzA==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -4849,10 +4716,6 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - chownr@3.0.0: - resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} - engines: {node: '>=18'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -4860,10 +4723,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -5151,10 +5010,6 @@ packages: delaunator@5.1.0: resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -5248,10 +5103,6 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} - env-paths@2.2.1: - resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} - engines: {node: '>=6'} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -5267,10 +5118,6 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} @@ -5380,9 +5227,6 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - exponential-backoff@3.1.3: - resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} - express-rate-limit@8.5.2: resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} engines: {node: '>= 16'} @@ -5470,10 +5314,6 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - form-data@4.0.6: - resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} - engines: {node: '>= 6'} - formatly@0.3.0: resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} engines: {node: '>=18.3.0'} @@ -5491,10 +5331,6 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} - fs-minipass@3.0.3: - resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5538,10 +5374,6 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - glob@13.0.6: - resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} - engines: {node: 18 || 20 || >=22} - globals@17.7.0: resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} engines: {node: '>=18'} @@ -5561,9 +5393,6 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -5580,10 +5409,6 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -5604,10 +5429,6 @@ packages: hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} - hosted-git-info@10.1.1: - resolution: {integrity: sha512-DeOnSPAvOndYKfw075gt8yZzQ7S2hNztw34zBTfhIzLhmBTswIBg5/y+pqu/VD5cYWm5goAFTusDmUEmKZ0PEQ==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -5624,9 +5445,6 @@ packages: htmlparser2@6.1.0: resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==} - http-cache-semantics@4.2.0: - resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} - http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -5635,18 +5453,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} - http-proxy-agent@9.1.0: - resolution: {integrity: sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==} - engines: {node: '>= 20'} - https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - https-proxy-agent@9.1.0: - resolution: {integrity: sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==} - engines: {node: '>= 20'} - iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -5655,10 +5465,6 @@ packages: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} - ignore-walk@9.0.0: - resolution: {integrity: sha512-tCBEZV2z2FNpIDl2vrhiWzIHzs4qOAuIDEO85eS02vZ3L1U3P56qpPL8GuGGAijDktAEaq2swMkO/Fmbo7YmfQ==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -5684,10 +5490,6 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ini@7.0.0: - resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - internmap@1.0.1: resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} @@ -5731,10 +5533,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isexe@4.0.0: - resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} - engines: {node: '>=20'} - istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -5825,10 +5623,6 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - json-parse-even-better-errors@6.0.0: - resolution: {integrity: sha512-2/8adwnK1/+Fdjyts4r6wSpfANWw8zdNhU9U/Llk59c6O+DjSisPWPykwoL8gZmocP9Dy64S7oie2g+Mia123A==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - json-schema-to-ts@3.1.1: resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} engines: {node: '>=16'} @@ -5848,10 +5642,6 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - jsonparse@1.3.1: - resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} - engines: {'0': node >= 0.2.0} - jsx-ast-utils-x@0.1.0: resolution: {integrity: sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6059,10 +5849,6 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - make-fetch-happen@16.0.1: - resolution: {integrity: sha512-uUv1yxHzaKVVEPfcFeGSNov/Cehjv08ovlY8ImTljgL7Q+SiA0dAYLQ6SYVa2kkKqNj4Y3aZEI7xv2teadie0A==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - mark.js@8.11.1: resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} @@ -6225,18 +6011,10 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -6257,30 +6035,6 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass-collect@2.0.1: - resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} - engines: {node: '>=16 || 14 >=14.17'} - - minipass-fetch@6.0.0: - resolution: {integrity: sha512-AWI8bKapGmgx/J0E6IGYSKj8TiHebZkmKWSs8raPSw8KXwgEAJ+Bw3+LSdXHR6T/RHKAWCOYk2MiLrYluaUU6w==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - minipass-flush@1.0.7: - resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} - engines: {node: '>= 8'} - - minipass-pipeline@1.2.4: - resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} - engines: {node: '>=8'} - - minipass-sized@2.0.0: - resolution: {integrity: sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==} - engines: {node: '>=8'} - - minipass@3.3.6: - resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} - engines: {node: '>=8'} - minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -6288,20 +6042,12 @@ packages: minisearch@7.2.0: resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} - minizlib@3.1.0: - resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} - engines: {node: '>= 18'} - mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} mj-context-menu@0.6.1: resolution: {integrity: sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==} - modern-tar@0.7.6: - resolution: {integrity: sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==} - engines: {node: '>=18.0.0'} - mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -6410,44 +6156,6 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-gyp@13.0.1: - resolution: {integrity: sha512-piOr0S10qy5THB+q5BdqkoOx65XL/tjTMUAit3vciPNp+snTOBnGunWH1Rz7XZUxf2T9uFrfT/Ty4+aC3yPeyg==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - hasBin: true - - nopt@10.0.1: - resolution: {integrity: sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - hasBin: true - - npm-bundled@6.0.0: - resolution: {integrity: sha512-EqdodKEW6pYM+dPxA66TZQfMEqVDiuzjDM9edSjuPI1mXUbUJwVxkgqMZSJvs8RTXz2CGq8HUol/AffTZX5g8w==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - npm-install-checks@9.0.0: - resolution: {integrity: sha512-t05Izcgi7p15cpldqoiXYpjzlkTTvBw33sgjmL/JjcvtV0ydbm2O4iEXO8A6smqComu5FAQhUas86HTMQ6Z1Uw==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - npm-normalize-package-bin@6.0.0: - resolution: {integrity: sha512-tdt4aFn9QamlhdN3HV2D2ccpBwO5/fyjjbXUxYA6uBjyekMZcZvDq0aSj9t5Jo+tih6AYFnt/cuIRn9013e0Uw==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - npm-package-arg@14.0.0: - resolution: {integrity: sha512-69XQh3k+dtGa1p+7RaR57IuG3rCko96xr/nUfN4yDYBXbTYICiWcOpsFKLN2GtGE9cyIljE+f1exnaYt9MvM+Q==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - npm-packlist@11.3.0: - resolution: {integrity: sha512-cS1yVkyriZgQAbiK8PtwhZHEtsFOsKHsCg5Ww2ONckAvXIspgqd6o4WirOzvkupU24iMRZ4xtO4kb2iK2rbnag==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - npm-pick-manifest@12.0.0: - resolution: {integrity: sha512-8Fs3YLrnNOhrCdPNZy18MzNgVC58LTDAFzq1FdZO/p3BHeCC/coz+t4F5Pxabys8HJpyTUorMea26GkXsb4J/Q==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - npm-registry-fetch@20.0.1: - resolution: {integrity: sha512-vzc1svxw/kw1IRjFsLi6gaxe1Olqm88V0tIfu2u5raL0b1gChe6ZEXNkyUlKxUC7s/egt5NxZHkbY18tMKKLfQ==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -6504,10 +6212,6 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - p-map@7.0.5: - resolution: {integrity: sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==} - engines: {node: '>=18'} - p-retry@4.6.2: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} @@ -6518,11 +6222,6 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} - pacote@22.0.0: - resolution: {integrity: sha512-++VqeOZeL03uGM2MFLk96jGCSt1owBGkyFKoPr+trwNlZhCpjN2RrvwYxt8nTbs1wNMqSFYurq0TafVWkAIHig==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - hasBin: true - pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -6561,10 +6260,6 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-scurry@2.0.2: - resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} - engines: {node: 18 || 20 || >=22} - path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -6607,10 +6302,6 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - proc-log@7.0.0: - resolution: {integrity: sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -6625,15 +6316,6 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - proxy-agent-negotiate@1.1.0: - resolution: {integrity: sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==} - engines: {node: '>= 20'} - peerDependencies: - kerberos: ^2.0.0 - peerDependenciesMeta: - kerberos: - optional: true - publint@0.3.21: resolution: {integrity: sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==} engines: {node: '>=18'} @@ -6826,32 +6508,16 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - sigstore@5.0.0: - resolution: {integrity: sha512-hJqJfoG/e4qFQaauQL00c6J6FrHLBGKtkFvW3JbTSIEFOhLrSjdSM/gWd/yUOfYo/gsERehTXGC1VZWX+9X4Dg==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} slick@1.12.2: resolution: {integrity: sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==} - smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - smol-toml@1.6.1: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} - socks-proxy-agent@10.1.0: - resolution: {integrity: sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ==} - engines: {node: '>= 20'} - - socks@2.8.9: - resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -6863,15 +6529,6 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - spdx-exceptions@2.5.0: - resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} - - spdx-expression-parse@4.0.0: - resolution: {integrity: sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==} - - spdx-license-ids@3.0.23: - resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} - speakingurl@14.0.1: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} @@ -6880,10 +6537,6 @@ packages: resolution: {integrity: sha512-i/VCLG1fvRc95pMHRqG4aQNscv+9aIsqA2oI7ZQS51sTdUcDHYX6cpT8/tqZ+enjs1tKVwbRBWgxut9SWn+f9g==} hasBin: true - ssri@14.0.0: - resolution: {integrity: sha512-jQxKI0yx0ZnTKrqjKkLDV2DXkBQn3k49JVmVqDGcDwKDtGDbImD/GXsq04KD0VVzCQQ9wZJYal3RwR1GzWTSow==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -6944,10 +6597,6 @@ packages: tabbable@6.5.0: resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} - tar@7.5.20: - resolution: {integrity: sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==} - engines: {node: '>=18'} - tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -7058,10 +6707,6 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - tuf-js@6.0.0: - resolution: {integrity: sha512-zlJVOIO68hmgo1//X4ENEcTGfuOTAtDPi8PsTsG+FyxD85E/ww1ZnwBbWo/yCEExGpI+Kilg7Z3qCdHX2BoJTQ==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -7107,10 +6752,6 @@ packages: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} - undici@8.7.0: - resolution: {integrity: sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ==} - engines: {node: '>=22.19.0'} - unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -7144,10 +6785,6 @@ packages: resolution: {integrity: sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==} engines: {node: '>=10'} - validate-npm-package-name@8.0.0: - resolution: {integrity: sha512-SCv6OOV6Xj2/3cXy3dGmADluJTNcL3o7hZAglNPTe+WYuEuvxgJzxPrSDLZhF+CwyQOubqgecjMmTJGMVLWjYQ==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -7337,11 +6974,6 @@ packages: engines: {node: '>= 8'} hasBin: true - which@7.0.0: - resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - hasBin: true - why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -7391,13 +7023,6 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - yallist@4.0.0: - resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - - yallist@5.0.0: - resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} - engines: {node: '>=18'} - yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -7835,10 +7460,6 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@bluwy/giget-core@0.1.7': - dependencies: - modern-tar: 0.7.6 - '@braintree/sanitize-url@7.1.2': {} '@bramus/specificity@2.4.2': @@ -8166,8 +7787,6 @@ snapshots: '@exodus/bytes@1.15.1': {} - '@gar/promise-retry@1.0.3': {} - '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.7.0 @@ -8222,10 +7841,6 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@isaacs/fs-minipass@4.0.1': - dependencies: - minipass: 7.1.3 - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -8313,63 +7928,6 @@ snapshots: '@nodable/entities@2.2.0': {} - '@npmcli/agent@5.0.2': - dependencies: - agent-base: 9.0.0 - http-proxy-agent: 9.1.0 - https-proxy-agent: 9.1.0 - lru-cache: 11.5.1 - socks-proxy-agent: 10.1.0 - transitivePeerDependencies: - - kerberos - - supports-color - - '@npmcli/fs@6.0.0': - dependencies: - semver: 7.8.4 - - '@npmcli/git@8.0.0': - dependencies: - '@gar/promise-retry': 1.0.3 - '@npmcli/promise-spawn': 10.0.0 - ini: 7.0.0 - lru-cache: 11.5.1 - npm-pick-manifest: 12.0.0 - proc-log: 7.0.0 - semver: 7.8.4 - which: 7.0.0 - - '@npmcli/installed-package-contents@5.0.0': - dependencies: - npm-bundled: 6.0.0 - npm-normalize-package-bin: 6.0.0 - - '@npmcli/node-gyp@6.0.0': {} - - '@npmcli/package-json@8.0.0': - dependencies: - '@npmcli/git': 8.0.0 - glob: 13.0.6 - hosted-git-info: 10.1.1 - json-parse-even-better-errors: 6.0.0 - proc-log: 7.0.0 - semver: 7.8.4 - spdx-expression-parse: 4.0.0 - - '@npmcli/promise-spawn@10.0.0': - dependencies: - which: 7.0.0 - - '@npmcli/redact@5.0.0': {} - - '@npmcli/run-script@11.0.0': - dependencies: - '@npmcli/node-gyp': 6.0.0 - '@npmcli/package-json': 8.0.0 - '@npmcli/promise-spawn': 10.0.0 - node-gyp: 13.0.1 - proc-log: 7.0.0 - '@oxc-parser/binding-android-arm-eabi@0.133.0': optional: true @@ -8743,39 +8301,6 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} - '@sigstore/bundle@5.0.0': - dependencies: - '@sigstore/protobuf-specs': 0.5.1 - - '@sigstore/core@4.0.1': {} - - '@sigstore/protobuf-specs@0.5.1': {} - - '@sigstore/sign@5.0.0': - dependencies: - '@gar/promise-retry': 1.0.3 - '@sigstore/bundle': 5.0.0 - '@sigstore/core': 4.0.1 - '@sigstore/protobuf-specs': 0.5.1 - make-fetch-happen: 16.0.1 - proc-log: 7.0.0 - transitivePeerDependencies: - - kerberos - - supports-color - - '@sigstore/tuf@5.0.0': - dependencies: - '@sigstore/protobuf-specs': 0.5.1 - tuf-js: 6.0.0 - transitivePeerDependencies: - - supports-color - - '@sigstore/verify@4.1.0': - dependencies: - '@sigstore/bundle': 5.0.0 - '@sigstore/core': 4.0.1 - '@sigstore/protobuf-specs': 0.5.1 - '@smithy/core@3.24.7': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -8842,13 +8367,6 @@ snapshots: estraverse: 5.3.0 picomatch: 4.0.4 - '@tufjs/canonical-json@2.0.0': {} - - '@tufjs/models@5.0.0': - dependencies: - '@tufjs/canonical-json': 2.0.0 - minimatch: 10.2.5 - '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -9022,11 +8540,6 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node-fetch@2.6.13': - dependencies: - '@types/node': 22.20.0 - form-data: 4.0.6 - '@types/node@22.20.0': dependencies: undici-types: 6.21.0 @@ -9035,35 +8548,10 @@ snapshots: dependencies: undici-types: 7.24.6 - '@types/npm-package-arg@6.1.4': {} - - '@types/npm-registry-fetch@8.0.9': - dependencies: - '@types/node': 22.20.0 - '@types/node-fetch': 2.6.13 - '@types/npm-package-arg': 6.1.4 - '@types/npmlog': 7.0.0 - '@types/ssri': 7.1.5 - - '@types/npmlog@7.0.0': - dependencies: - '@types/node': 22.20.0 - - '@types/pacote@11.1.8': - dependencies: - '@types/node': 22.20.0 - '@types/npm-registry-fetch': 8.0.9 - '@types/npmlog': 7.0.0 - '@types/ssri': 7.1.5 - '@types/picomatch@3.0.2': {} '@types/retry@0.12.0': {} - '@types/ssri@7.1.5': - dependencies: - '@types/node': 22.20.0 - '@types/tough-cookie@4.0.5': {} '@types/trusted-types@2.0.7': @@ -9340,8 +8828,6 @@ snapshots: '@xmldom/xmldom@0.9.10': {} - abbrev@5.0.0: {} - accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -9355,8 +8841,6 @@ snapshots: agent-base@7.1.4: {} - agent-base@9.0.0: {} - ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -9424,8 +8908,6 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 - asynckit@0.4.0: {} - balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -9476,19 +8958,6 @@ snapshots: cac@7.0.0: {} - cacache@21.0.1: - dependencies: - '@npmcli/fs': 6.0.0 - fs-minipass: 3.0.3 - glob: 13.0.6 - lru-cache: 11.5.1 - minipass: 7.1.3 - minipass-collect: 2.0.1 - minipass-flush: 1.0.7 - minipass-pipeline: 1.2.4 - p-map: 7.0.5 - ssri: 14.0.0 - call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -9531,18 +9000,12 @@ snapshots: dependencies: readdirp: 4.1.2 - chownr@3.0.0: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 color-name@1.1.4: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - comma-separated-tokens@2.0.3: {} commander@13.1.0: {} @@ -9848,8 +9311,6 @@ snapshots: dependencies: robust-predicates: 3.0.3 - delayed-stream@1.0.0: {} - depd@2.0.0: {} dequal@2.0.3: {} @@ -9924,8 +9385,6 @@ snapshots: entities@8.0.0: {} - env-paths@2.2.1: {} - es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -9936,13 +9395,6 @@ snapshots: dependencies: es-errors: 1.3.0 - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.4 - es-toolkit@1.49.0: {} esbuild@0.21.5: @@ -10117,8 +9569,6 @@ snapshots: expect-type@1.3.0: {} - exponential-backoff@3.1.3: {} - express-rate-limit@8.5.2(express@5.2.1): dependencies: express: 5.2.1 @@ -10242,14 +9692,6 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.6: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.4 - mime-types: 2.1.35 - formatly@0.3.0: dependencies: fd-package-json: 2.0.0 @@ -10262,10 +9704,6 @@ snapshots: fresh@2.0.0: {} - fs-minipass@3.0.3: - dependencies: - minipass: 7.1.3 - fsevents@2.3.3: optional: true @@ -10328,12 +9766,6 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - glob@13.0.6: - dependencies: - minimatch: 10.2.5 - minipass: 7.1.3 - path-scurry: 2.0.2 - globals@17.7.0: {} globrex@0.1.2: {} @@ -10353,8 +9785,6 @@ snapshots: gopd@1.2.0: {} - graceful-fs@4.2.11: {} - hachure-fill@0.5.2: {} handlebars@4.7.9: @@ -10370,10 +9800,6 @@ snapshots: has-symbols@1.1.0: {} - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -10402,10 +9828,6 @@ snapshots: hookable@6.1.1: {} - hosted-git-info@10.1.1: - dependencies: - lru-cache: 11.5.1 - html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.15.1 @@ -10430,8 +9852,6 @@ snapshots: domutils: 2.8.0 entities: 2.2.0 - http-cache-semantics@4.2.0: {} - http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -10447,15 +9867,6 @@ snapshots: transitivePeerDependencies: - supports-color - http-proxy-agent@9.1.0: - dependencies: - agent-base: 9.0.0 - debug: 4.4.3 - proxy-agent-negotiate: 1.1.0 - transitivePeerDependencies: - - kerberos - - supports-color - https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -10463,15 +9874,6 @@ snapshots: transitivePeerDependencies: - supports-color - https-proxy-agent@9.1.0: - dependencies: - agent-base: 9.0.0 - debug: 4.4.3 - proxy-agent-negotiate: 1.1.0 - transitivePeerDependencies: - - kerberos - - supports-color - iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -10480,10 +9882,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ignore-walk@9.0.0: - dependencies: - minimatch: 10.2.5 - ignore@5.3.2: {} ignore@7.0.5: {} @@ -10498,8 +9896,6 @@ snapshots: inherits@2.0.4: {} - ini@7.0.0: {} - internmap@1.0.1: {} internmap@2.0.3: {} @@ -10526,8 +9922,6 @@ snapshots: isexe@2.0.0: {} - isexe@4.0.0: {} - istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -10620,8 +10014,6 @@ snapshots: json-buffer@3.0.1: {} - json-parse-even-better-errors@6.0.0: {} - json-schema-to-ts@3.1.1: dependencies: '@babel/runtime': 7.29.7 @@ -10637,8 +10029,6 @@ snapshots: jsonc-parser@3.3.1: {} - jsonparse@1.3.1: {} - jsx-ast-utils-x@0.1.0: {} jszip@3.10.1: @@ -10830,24 +10220,6 @@ snapshots: dependencies: semver: 7.8.4 - make-fetch-happen@16.0.1: - dependencies: - '@gar/promise-retry': 1.0.3 - '@npmcli/agent': 5.0.2 - '@npmcli/redact': 5.0.0 - cacache: 21.0.1 - http-cache-semantics: 4.2.0 - minipass: 7.1.3 - minipass-fetch: 6.0.0 - minipass-flush: 1.0.7 - minipass-pipeline: 1.2.4 - negotiator: 1.0.0 - proc-log: 7.0.0 - ssri: 14.0.0 - transitivePeerDependencies: - - kerberos - - supports-color - mark.js@8.11.1: {} markdown-it-mathjax3@4.3.2: @@ -11209,14 +10581,8 @@ snapshots: transitivePeerDependencies: - supports-color - mime-db@1.52.0: {} - mime-db@1.54.0: {} - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -11233,48 +10599,14 @@ snapshots: minimist@1.2.8: {} - minipass-collect@2.0.1: - dependencies: - minipass: 7.1.3 - - minipass-fetch@6.0.0: - dependencies: - minipass: 7.1.3 - minipass-sized: 2.0.0 - minizlib: 3.1.0 - optionalDependencies: - iconv-lite: 0.7.3 - - minipass-flush@1.0.7: - dependencies: - minipass: 3.3.6 - - minipass-pipeline@1.2.4: - dependencies: - minipass: 3.3.6 - - minipass-sized@2.0.0: - dependencies: - minipass: 7.1.3 - - minipass@3.3.6: - dependencies: - yallist: 4.0.0 - minipass@7.1.3: {} minisearch@7.2.0: {} - minizlib@3.1.0: - dependencies: - minipass: 7.1.3 - mitt@3.0.1: {} mj-context-menu@0.6.1: {} - modern-tar@0.7.6: {} - mri@1.2.0: {} ms@2.1.3: {} @@ -11359,67 +10691,6 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-gyp@13.0.1: - dependencies: - env-paths: 2.2.1 - exponential-backoff: 3.1.3 - graceful-fs: 4.2.11 - nopt: 10.0.1 - proc-log: 7.0.0 - semver: 7.8.4 - tar: 7.5.20 - tinyglobby: 0.2.17 - undici: 8.7.0 - which: 7.0.0 - - nopt@10.0.1: - dependencies: - abbrev: 5.0.0 - - npm-bundled@6.0.0: - dependencies: - npm-normalize-package-bin: 6.0.0 - - npm-install-checks@9.0.0: - dependencies: - semver: 7.8.4 - - npm-normalize-package-bin@6.0.0: {} - - npm-package-arg@14.0.0: - dependencies: - hosted-git-info: 10.1.1 - proc-log: 7.0.0 - semver: 7.8.4 - validate-npm-package-name: 8.0.0 - - npm-packlist@11.3.0: - dependencies: - glob: 13.0.6 - ignore-walk: 9.0.0 - proc-log: 7.0.0 - - npm-pick-manifest@12.0.0: - dependencies: - npm-install-checks: 9.0.0 - npm-normalize-package-bin: 6.0.0 - npm-package-arg: 14.0.0 - semver: 7.8.4 - - npm-registry-fetch@20.0.1: - dependencies: - '@npmcli/redact': 5.0.0 - jsonparse: 1.3.1 - make-fetch-happen: 16.0.1 - minipass: 7.1.3 - minipass-fetch: 6.0.0 - minizlib: 3.1.0 - npm-package-arg: 14.0.0 - proc-log: 7.0.0 - transitivePeerDependencies: - - kerberos - - supports-color - nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -11513,8 +10784,6 @@ snapshots: dependencies: p-limit: 3.1.0 - p-map@7.0.5: {} - p-retry@4.6.2: dependencies: '@types/retry': 0.12.0 @@ -11524,29 +10793,6 @@ snapshots: package-manager-detector@1.6.0: {} - pacote@22.0.0: - dependencies: - '@gar/promise-retry': 1.0.3 - '@npmcli/git': 8.0.0 - '@npmcli/installed-package-contents': 5.0.0 - '@npmcli/package-json': 8.0.0 - '@npmcli/promise-spawn': 10.0.0 - '@npmcli/run-script': 11.0.0 - cacache: 21.0.1 - fs-minipass: 3.0.3 - minipass: 7.1.3 - npm-package-arg: 14.0.0 - npm-packlist: 11.3.0 - npm-pick-manifest: 12.0.0 - npm-registry-fetch: 20.0.1 - proc-log: 7.0.0 - sigstore: 5.0.0 - ssri: 14.0.0 - tar: 7.5.20 - transitivePeerDependencies: - - kerberos - - supports-color - pako@1.0.11: {} parse5-htmlparser2-tree-adapter@6.0.1: @@ -11576,11 +10822,6 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 - path-scurry@2.0.2: - dependencies: - lru-cache: 11.5.1 - minipass: 7.1.3 - path-to-regexp@8.4.2: {} pathe@2.0.3: {} @@ -11610,8 +10851,6 @@ snapshots: prelude-ls@1.2.1: {} - proc-log@7.0.0: {} - process-nextick-args@2.0.1: {} property-information@7.2.0: {} @@ -11635,8 +10874,6 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - proxy-agent-negotiate@1.1.0: {} - publint@0.3.21: dependencies: '@publint/pack': 0.1.4 @@ -11920,54 +11157,18 @@ snapshots: signal-exit@4.1.0: {} - sigstore@5.0.0: - dependencies: - '@sigstore/bundle': 5.0.0 - '@sigstore/core': 4.0.1 - '@sigstore/protobuf-specs': 0.5.1 - '@sigstore/sign': 5.0.0 - '@sigstore/tuf': 5.0.0 - '@sigstore/verify': 4.1.0 - transitivePeerDependencies: - - kerberos - - supports-color - sisteransi@1.0.5: {} slick@1.12.2: {} - smart-buffer@4.2.0: {} - smol-toml@1.6.1: {} - socks-proxy-agent@10.1.0: - dependencies: - agent-base: 9.0.0 - debug: 4.4.3 - socks: 2.8.9 - transitivePeerDependencies: - - supports-color - - socks@2.8.9: - dependencies: - ip-address: 10.2.0 - smart-buffer: 4.2.0 - source-map-js@1.2.1: {} source-map@0.6.1: {} space-separated-tokens@2.0.2: {} - spdx-exceptions@2.5.0: {} - - spdx-expression-parse@4.0.0: - dependencies: - spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.23 - - spdx-license-ids@3.0.23: {} - speakingurl@14.0.1: {} speech-rule-engine@4.1.4: @@ -11976,10 +11177,6 @@ snapshots: commander: 13.1.0 wicked-good-xpath: 1.3.0 - ssri@14.0.0: - dependencies: - minipass: 7.1.3 - stackback@0.0.2: {} statuses@2.0.2: {} @@ -12037,14 +11234,6 @@ snapshots: tabbable@6.5.0: {} - tar@7.5.20: - dependencies: - '@isaacs/fs-minipass': 4.0.1 - chownr: 3.0.0 - minipass: 7.1.3 - minizlib: 3.1.0 - yallist: 5.0.0 - tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -12125,14 +11314,6 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - tuf-js@6.0.0: - dependencies: - '@gar/promise-retry': 1.0.3 - '@tufjs/models': 5.0.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -12174,8 +11355,6 @@ snapshots: undici@7.28.0: {} - undici@8.7.0: {} - unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -12211,8 +11390,6 @@ snapshots: valid-data-url@3.0.1: {} - validate-npm-package-name@8.0.0: {} - vary@1.1.2: {} vfile-message@4.0.3: @@ -12436,10 +11613,6 @@ snapshots: dependencies: isexe: 2.0.0 - which@7.0.0: - dependencies: - isexe: 4.0.0 - why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -12473,10 +11646,6 @@ snapshots: xmlchars@2.2.0: {} - yallist@4.0.0: {} - - yallist@5.0.0: {} - yaml@2.9.0: {} yocto-queue@0.1.0: {} diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 004338fa2d..125b06c28c 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -52,7 +52,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' }, 'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' }, 'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' }, - 'packages/sdk/plugin-fetch': { kind: 'none', reason: 'The fetcher acquires plugin sources into a temp dir and registers no live agent or model surface.' }, 'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' }, 'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, diff --git a/tsconfig.build.json b/tsconfig.build.json index 9359d7a531..386728bf3b 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -99,7 +99,6 @@ { "path": "./packages/sdk/helper" }, { "path": "./packages/sdk/scripts" }, { "path": "./packages/sdk/create-sdk" }, - { "path": "./packages/sdk/plugin-fetch" }, { "path": "./packages/sdk/telemetry" } ] } diff --git a/tsconfig.json b/tsconfig.json index 48c5187fdc..47d1067e88 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -110,7 +110,6 @@ { "path": "./packages/sdk/helper" }, { "path": "./packages/sdk/scripts" }, { "path": "./packages/sdk/create-sdk" }, - { "path": "./packages/sdk/plugin-fetch" }, { "path": "./packages/sdk/telemetry" } ] } From 16485392e48dc7fed028d948be6339e89dcc1d53 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:43:56 +0800 Subject: [PATCH 11/37] docs(sdk): retarget #2 to native npm/pnpm dependency + cordis mount --- docs/sdk-后续工作-设计.md | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/docs/sdk-后续工作-设计.md b/docs/sdk-后续工作-设计.md index a2096cf42f..d281052cee 100644 --- a/docs/sdk-后续工作-设计.md +++ b/docs/sdk-后续工作-设计.md @@ -10,7 +10,7 @@ | 块 | 做什么 | 核心对象 | 结论要点 | |---|---|---|---| | **#1 headless + skill** | create/config 非交互化,agent 端到端建项目 | `HeadlessPrompter` + `CreationDriver`(NDJSON) | 无 spec 文件、传结构化对象;薄 SKILL.md 入口;beyond-eve | -| **#2 建插件** | `dsh-sdk create ` 拉插件并接线 | `PluginSource` + `PluginFetcher`(giget/pacote) | 只解压不执行、锁版本、经 `ProjectEditSession` 显式接线 | +| **#2 建插件** | `dsh-sdk create ` 加依赖并挂载 | PM 原生 `add` + `ProjectEditSession` cordis 挂载 | npm/pnpm 原生依赖(`github:#sha` / `pkg@version`),不用 giget/pacote | | **#3 遥测** | 每个 `dsh-sdk` 命令上报 | `TelemetryReporter` / `ConsentResolver` / `SecretRedactor` | 发 cordis.yml+package.json 全文;不发 `.env`、疑似密钥脱敏;关闭 = cordis.yml 有明确 disabled 的遥测条目(甲)| | **#4 交互测试** | 覆盖 wizard 各分支、快照 cordis.yml | `WizardHarness` + clack mock 注入 | 注入流为主、真 PTY 仅 1–2 个可选 smoke | @@ -106,23 +106,17 @@ SDK 初版(`packages/sdk/*`)已经落地三个包: - **skill**:核心是 headless 内核;agent 传参直接建完,缺必答项就响亮失败让 agent 补答。附一层**薄 SKILL.md**(指向内核、教 agent 驱动),让"通过 skill 创建"字面落地。 - **比 eve 更进一步**:eve 把 headless 原语(`runHeadless` + 非阻塞 Prompter + NDJSON)造好了,却没接到它的 skill——它的 SKILL.md 只指向半交互 CLI,且 agent 跑 `eve init` 时只打印指引、打回给人。我们把 **skill → headless 内核接通**,才真正做到"headless 为 skill 服务"。 -### 4.2 `dsh-sdk create ` 建插件(#2) +### 4.2 `dsh-sdk create ` 建插件(#2,简单版) -**目标**:从 github repo 或 npm 包拉一个插件进现有项目并接线;安全第一。 +**目标**:把一个 github repo 或 npm 包当**依赖**加进现有项目并挂载;用包管理器原生能力,**不引 giget/pacote**。 -**设计(只解压不执行 + 锁版本 + 显式接线)**: +**设计(PM 原生依赖 + cordis 挂载)**: -- **`PluginSource`(判别联合)**:`GithubSource`(`owner/repo[/subdir]#ref`)| `NpmSource`(`pkg@version`)。由 spec 字符串解析而来。 -- **`PluginFetcher`(seam)**:把源抓进 temp 目录,**绝不执行被拉代码的生命周期脚本**。 - - `GigetFetcher`(github/git):giget;`#ref` 先解析成 commit SHA 再下、记进 lock。 - - `PacoteFetcher`(npm):pacote `extract`(只解包不跑 postinstall),带 `integrity` 校验。来源类型限定放在**上游 `resolvePluginSource`**(只产出 `name@version`)作为主保证,不依赖 pacote 的 `allowRegistry`(`@types/pacote` 无此选项,且 registry tarball extract 本就不跑脚本)。 -- **接线(显式可审)**: - 1. `package.json` 精确锁版本(npm:exact + integrity;github:`github:owner/repo#`)。 - 2. 经 `ProjectEditSession` 改 `cordis.yml` 挂插件——**给 diff、要确认**再写。 - 3. `install --ignore-scripts`(pnpm v10 默认亦拦依赖 build 脚本)。 - 4. 打印清单(dep spec + 锁的 ref/integrity + cordis.yml diff)。 -- **信任模型**:学 `npm create` 的手感,但把信任反过来——**confirm-before-run,而非 run-on-fetch**。 -- **repo 初始化模式**(从模板仓库整体建项目)同走 giget(优于 degit——degit 的 `degit.json` 会自动跑动作);建远程新仓可用 `gh repo create --template`。注:eve 不支持 template-repo init,这是我们的自有取舍。 +- **来源**:npm(`pkg@version`)或 github(`github:owner/repo#ref`,推荐锁 commit SHA)。npm/pnpm/yarn 原生支持这两种依赖来源,自己解析包名、把 commit/integrity 钉进 lockfile。 +- **流程**:`dsh-sdk create ` → 确认(TTY guard 同 config)→ 用项目的包管理器 `add `(PM 解析名字 / 装依赖 / 写 lockfile)→ 读回新增依赖名 → 经 `ProjectEditSession` 挂一条 cordis 条目引用它 → commit。 +- **不落 `plugins/`**:外部插件是 node_modules 依赖,不是本地生成插件(后者才走 `LocalPluginBlueprint` + 文件生成)。 +- **构建张力(暂缓)**:源码型 github 插件装时要 `prepare` build(pnpm 10.26 默认禁、需 `allowBuilds` 放行)——"预编译-only vs 允许构建"的取舍留到以后;先做能跑的简单版,交给 PM 默认行为。 +- **弃用**:早期设计的 `PluginSource`/`GigetFetcher`/`PacoteFetcher`(抓 tarball 到 temp 再接线)已随 `dsh-plugin-fetch` 包一起撤掉——PM 原生依赖覆盖了它。 ### 4.3 遥测(#3) @@ -185,7 +179,7 @@ SDK 初版(`packages/sdk/*`)已经落地三个包: |---|---|---| | 地基 | dsh-helper, create-sdk, dsh-scripts | `HeadlessPromptPort`(实现已有 `PromptPort`)+ prefill 补全 + NDJSON + 命令注册/催表扩展点整理 | | #1 headless+skill | create-sdk, dsh-scripts, (新)skill 包 | 结构化 spec 入口 `--config-json`/`--config`、薄 SKILL.md | -| #2 建插件 | (新)fetcher 包, dsh-scripts, dsh-helper | `PluginSource`、`GigetFetcher`/`PacoteFetcher`、`dsh-sdk create ` 注册、经 `ProjectEditSession` 接线+diff、新依赖 giget/pacote | +| #2 建插件 | dsh-scripts, dsh-helper | `dsh-sdk create ` 命令、PM `add`、经 `ProjectEditSession` 挂 cordis 条目、无新依赖 | | #3 遥测 | (新)telemetry 包, dsh-scripts, dsh-helper | `TelemetryReporter`/`ConsentResolver`/`SecretRedactor`、launcher 接线、催表遥测 feature、内置 endpoint、全局 UUID | | #4 测试 | packages/support, (已可注入)create-sdk/dsh-scripts | `WizardHarness`、create/config 的 `test.each` cordis.yml 快照、可选 PTY smoke | From ca7533880e8c90ef7e812d426e82cc2838c5aeb1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:01:48 +0800 Subject: [PATCH 12/37] =?UTF-8?q?feat(dsh-sdk):=20create=20=20?= =?UTF-8?q?=E2=80=94=20add=20external=20plugin=20as=20native=20PM=20depend?= =?UTF-8?q?ency=20+=20cordis=20mount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dsh-sdk create adds a github (github:owner/repo#ref) or npm (pkg@version) plugin as a package-manager-native dependency, then mounts the resolved dependency in cordis.yml through ProjectEditSession. Adds PackageManager.add(spec) and ProjectEditSession.addExternalPlugin(id, packageName). No giget/pacote. Per-file 100% coverage on the new/changed files. --- .../src/package-managers/package-manager.ts | 24 +++++ .../src/project/project-edit-session.ts | 17 ++++ packages/sdk/helper/tests/documents.spec.ts | 5 + packages/sdk/helper/tests/project.spec.ts | 22 +++++ packages/sdk/scripts/README.md | 1 + packages/sdk/scripts/src/args.ts | 6 +- packages/sdk/scripts/src/command.ts | 5 + packages/sdk/scripts/src/create-plugin.ts | 91 +++++++++++++++++++ packages/sdk/scripts/tests/scripts.spec.ts | 60 ++++++++++++ 9 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/scripts/src/create-plugin.ts diff --git a/packages/sdk/helper/src/package-managers/package-manager.ts b/packages/sdk/helper/src/package-managers/package-manager.ts index a6f2fdd103..6e6eda478a 100644 --- a/packages/sdk/helper/src/package-managers/package-manager.ts +++ b/packages/sdk/helper/src/package-managers/package-manager.ts @@ -148,6 +148,25 @@ export abstract class PackageManager { await this.runChecked(runner, this.buildCommand(), cwd, 'build') } + /** + * Build add-dependency command arguments for one already-normalized source spec. + * @param spec - a package-manager-native dependency source (`pkg@version` or `github:owner/repo#ref`). + * @returns arguments following the manager executable. + */ + addCommand(spec: string): readonly string[] { + return ['add', spec] + } + + /** + * Add one dependency from a native source spec and fail on non-zero or signalled exit. + * @param spec - a package-manager-native dependency source. + * @param cwd - project directory. + * @param runner - optional subprocess boundary. + */ + async add(spec: string, cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise { + await this.runChecked(runner, this.addCommand(spec), cwd, 'add') + } + private async runChecked(runner: CommandRunner, args: readonly string[], cwd: string, operation: string): Promise { const result = await runner.run(this.name, args, cwd) if (result.signal !== null) { @@ -184,6 +203,11 @@ export class NpmPackageManager extends PackageManager { override linkSpec(relativePath: string): string { return `file:${relativePath}` } + + /** npm adds a dependency through `install ` rather than an `add` verb. */ + override addCommand(spec: string): readonly string[] { + return ['install', spec] + } } /** pnpm workspace behavior. */ diff --git a/packages/sdk/helper/src/project/project-edit-session.ts b/packages/sdk/helper/src/project/project-edit-session.ts index 9b2c885ac5..0d0a1cb6dd 100644 --- a/packages/sdk/helper/src/project/project-edit-session.ts +++ b/packages/sdk/helper/src/project/project-edit-session.ts @@ -220,6 +220,23 @@ export class ProjectEditSession implements FeatureProjectView { this.addedPlugins.add(entry.id) } + /** + * Mount a Cordis entry for an external dependency the package manager has already + * added (github or npm), without generating files or re-adding the dependency. + * @param id - stable Cordis config entry id. + * @param packageName - the installed dependency's package name. + */ + addExternalPlugin(id: string, packageName: string): void { + this.assertOpen() + if (!this.manifest().npmDependency(packageName)) { + throw new Error(`external plugin dependency is not installed: ${packageName}`) + } + const cordis = this.cordis() + if (cordis.entry(id)) throw new Error(`Cordis config entry already exists: ${id}`) + cordis.addEntry({ id, name: packageName }) + this.addedPlugins.add(id) + } + /** Enable or disable one custom/manual Cordis config entry by stable id. */ setCustomPluginDisabled(id: string, disabled: boolean): void { this.assertOpen() diff --git a/packages/sdk/helper/tests/documents.spec.ts b/packages/sdk/helper/tests/documents.spec.ts index 1ba8b9bd68..3ce5eeccea 100644 --- a/packages/sdk/helper/tests/documents.spec.ts +++ b/packages/sdk/helper/tests/documents.spec.ts @@ -298,6 +298,11 @@ describe('package manager strategies', () => { await npm.install('/tmp', runner) await npm.build('/tmp', runner) expect(calls).toEqual([['npm', 'install'], ['npm', 'run', 'build']]) + await npm.add('some-pkg@1.0.0', '/tmp', runner) + const pnpm = createPackageManager('pnpm', '10.0.0') + await pnpm.add('github:o/r#sha', '/tmp', runner) + expect(calls).toContainEqual(['npm', 'install', 'some-pkg@1.0.0']) + expect(calls).toContainEqual(['pnpm', 'add', 'github:o/r#sha']) const failed: CommandRunner = { run: async () => ({ exitCode: 2, signal: null }) } await expect(npm.install('/tmp', failed)).rejects.toThrow('exited with code 2') const killed: CommandRunner = { run: async () => ({ exitCode: null, signal: 'SIGTERM' }) } diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index d55bb16be9..6a6268b554 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -690,6 +690,28 @@ describe('SdkProject and ProjectEditSession', () => { expect(committed.packageManifest().dependencies?.['@deepseek-ai/dsh-subagent']).toMatch(/^file:/) expect(createBuiltinRegistry(committed.profile).get(featureId('subagent')).inspect(committed).state).toBe('absent') }) + + it('mounts an external plugin dependency and rejects missing deps or duplicate entries', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-external-plugin-')) + temporary.push(root) + const creation = request() + const project = SdkProject.create(root, creation) + const registry = createBuiltinRegistry(project.profile) + const edit = project.edit(registry) + for (const item of creation.features) edit.installFeature(registry.get(item.id), item) + await edit.commit() + const manifestPath = join(root, 'package.json') + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { dependencies?: Record } + manifest.dependencies = { ...manifest.dependencies, 'ext-plugin': 'github:o/r#sha' } + await writeFile(manifestPath, JSON.stringify(manifest, null, 2)) + const reopened = await SdkProject.open(root) + const edit2 = reopened.edit(createBuiltinRegistry(reopened.profile)) + edit2.addExternalPlugin('ext-plugin', 'ext-plugin') + expect(() => { edit2.addExternalPlugin('ext-plugin', 'ext-plugin') }).toThrow('already exists') + expect(() => { edit2.addExternalPlugin('missing', 'not-a-dep') }).toThrow('not installed') + const commit = await edit2.commit() + expect(commit.project.cordis.entry('ext-plugin')?.name).toBe('ext-plugin') + }) }) describe('extension points', () => { diff --git a/packages/sdk/scripts/README.md b/packages/sdk/scripts/README.md index e87c375a4f..39d706177e 100644 --- a/packages/sdk/scripts/README.md +++ b/packages/sdk/scripts/README.md @@ -8,6 +8,7 @@ The `dsh-sdk` launcher owns SDK project startup and configuration. | `dsh-sdk dev [target] [-- args…]` | Register TypeScript and local-workspace source resolution, then use the start path | | `dsh-sdk build [args…]` | Invoke the project's installed tsdown with the project arguments | | `dsh-sdk config` | Open one interactive edit session, review accumulated changes, commit once, and install once when NPM dependencies changed | +| `dsh-sdk create ` | Add an external Cordis plugin from a native package-manager source (`pkg@version` or `github:owner/repo#ref`): confirm, ` add `, then mount the resolved dependency in `cordis.yml`. No giget/pacote; the package manager resolves and pins the source (github deps build via their own `prepare` under the manager's policy) | `ProjectBuild(tsdownConfig)` and `PluginBuild(tsdownConfig)` are exported only from `@deepseek-ai/dsh-scripts/dev/tsdown-config`. Development and production read the same `cordis.yml`. diff --git a/packages/sdk/scripts/src/args.ts b/packages/sdk/scripts/src/args.ts index 1b91269592..4d1ce867de 100644 --- a/packages/sdk/scripts/src/args.ts +++ b/packages/sdk/scripts/src/args.ts @@ -8,12 +8,13 @@ import { parseArgs as parseNodeArgs } from 'node:util' import { Command } from 'commander' /** Commands implemented by the dsh-sdk launcher. */ -type DshSdkCommand = 'start' | 'dev' | 'build' | 'config' +type DshSdkCommand = 'start' | 'dev' | 'build' | 'config' | 'create' /** Parsed dsh-sdk invocation. */ export interface DshSdkArgs { command?: DshSdkCommand target?: string + source?: string forwarded: readonly string[] help: boolean } @@ -60,6 +61,9 @@ export function parseDshSdkArgs(argv: readonly string[]): DshSdkArgs { program.command('config').helpOption(false).action(() => { parsed = { command: 'config', forwarded: [], help: false } }) + program.command('create ').helpOption(false).action((source: string) => { + parsed = { command: 'create', source, forwarded: [], help: false } + }) program.parse([...launcherArgv], { from: 'user' }) /* v8 ignore next -- every registered Commander action above assigns parsed or Commander throws */ if (!parsed) throw new Error('dsh-sdk command did not resolve') diff --git a/packages/sdk/scripts/src/command.ts b/packages/sdk/scripts/src/command.ts index 9351cfa39c..5174ec5ded 100644 --- a/packages/sdk/scripts/src/command.ts +++ b/packages/sdk/scripts/src/command.ts @@ -7,6 +7,7 @@ import { parseDshSdkArgs } from './args.ts' import { runProjectBuild } from './build.ts' import { runConfigCommand, type ConfigCommandContext } from './config.ts' +import { runCreatePluginCommand } from './create-plugin.ts' import { runSDK } from './runtime.ts' import { DSH_SDK_TEMPLATES } from './templates/dsh-sdk-templates.ts' @@ -19,6 +20,7 @@ export interface DshSdkCommandContext extends ConfigCommandContext { run?: typeof runSDK build?: typeof runProjectBuild config?: typeof runConfigCommand + createPlugin?: typeof runCreatePluginCommand } /** Run one parsed dsh-sdk command and return its process exit code. */ @@ -40,6 +42,7 @@ export async function runDshSdkCommand( const run = context.run ?? runSDK const build = context.build ?? runProjectBuild const config = context.config ?? runConfigCommand + const createPlugin = context.createPlugin ?? runCreatePluginCommand switch (args.command) { case 'start': await run(args.target, { cwd: context.cwd, argv: args.forwarded }); break case 'dev': await run(args.target, { cwd: context.cwd, dev: true, argv: args.forwarded }); break @@ -49,6 +52,8 @@ export async function runDshSdkCommand( if (result.installError) return 1 break } + /* v8 ignore next -- Commander requires , so create never dispatches without it */ + case 'create': await createPlugin(args.source ?? '', context); break } return 0 } catch (error) { diff --git a/packages/sdk/scripts/src/create-plugin.ts b/packages/sdk/scripts/src/create-plugin.ts new file mode 100644 index 0000000000..f658451fde --- /dev/null +++ b/packages/sdk/scripts/src/create-plugin.ts @@ -0,0 +1,91 @@ +/** + * dsh-sdk create command: add an external Cordis plugin (github or npm) as a + * native package-manager dependency and mount it in cordis.yml. + * + * @module @deepseek-ai/dsh-scripts/create-plugin + */ + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + ClackPromptPort, + ConfirmQuestion, + SdkProject, + createBuiltinRegistry, + requireAnswer, + type PackageManager, + type ProjectCommitResult, + type PromptPort, +} from '@deepseek-ai/dsh-helper' + +/** Process and interaction slice required by dsh-sdk create. */ +export interface CreatePluginContext { + cwd: string + stdin: NodeJS.ReadStream + stdout: NodeJS.WriteStream + port?: PromptPort + add?: (manager: PackageManager, spec: string, cwd: string) => Promise +} + +/** Result of a create run; `undefined` when the confirmation was declined. */ +export type CreatePluginResult = ProjectCommitResult | undefined + +/** Derive a stable cordis entry id from a package name's last path segment. */ +function pluginId(packageName: string): string { + const base = packageName.startsWith('@') ? packageName.slice(packageName.indexOf('/') + 1) : packageName + const id = base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + /* v8 ignore next -- a valid npm package name always yields a non-empty id */ + if (!id) throw new Error(`cannot derive a plugin id from package name: ${packageName}`) + return id +} + +/** Read the direct dependency names declared in a project's package.json. */ +async function dependencyNames(cwd: string): Promise> { + const manifest = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8')) as { + dependencies?: Record + } + /* v8 ignore next -- generated projects always declare a dependencies map */ + return new Set(Object.keys(manifest.dependencies ?? {})) +} + +/** + * Add one external plugin dependency to the current project and mount it. + * @param source - a package-manager-native source (`pkg@version` or `github:owner/repo#ref`). + * @param context - process, interaction, and dependency-add boundaries. + * @returns the commit result, or `undefined` when the confirmation was declined. + */ +export async function runCreatePluginCommand( + source: string, + context: CreatePluginContext, +): Promise { + const spec = source.trim() + if (!spec) throw new Error('dsh-sdk create requires a plugin source (pkg@version or github:owner/repo#ref)') + if (!context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) { + throw new Error('dsh-sdk create requires an interactive TTY') + } + const project = await SdkProject.open(context.cwd) + /* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */ + const port = context.port ?? new ClackPromptPort(context.stdin, context.stdout) + const confirmed = requireAnswer(await new ConfirmQuestion({ + id: 'create.confirm', + message: `Add plugin '${spec}' as a dependency and mount it in cordis.yml?`, + initialValue: true, + }).resolve(port)) + if (!confirmed) return undefined + + const before = await dependencyNames(context.cwd) + /* v8 ignore next -- production package-manager wiring is exercised by the built-bin smoke */ + const add = context.add ?? ((manager, source, cwd) => manager.add(source, cwd)) + await add(project.profile.packageManager, spec, context.cwd) + const after = await dependencyNames(context.cwd) + const added = [...after].filter(name => !before.has(name)) + if (added.length === 0) throw new Error(`dsh-sdk create: '${spec}' added no new dependency`) + + const reopened = await SdkProject.open(context.cwd) + const registry = createBuiltinRegistry(reopened.profile) + const edit = reopened.edit(registry) + for (const packageName of added) edit.addExternalPlugin(pluginId(packageName), packageName) + const commit = await edit.commit() + context.stdout.write(`Mounted ${added.join(', ')} in cordis.yml.\n`) + return commit +} diff --git a/packages/sdk/scripts/tests/scripts.spec.ts b/packages/sdk/scripts/tests/scripts.spec.ts index 42c74f5631..faa025926e 100644 --- a/packages/sdk/scripts/tests/scripts.spec.ts +++ b/packages/sdk/scripts/tests/scripts.spec.ts @@ -31,6 +31,7 @@ import { PluginBuild, ProjectBuild, runProjectBuild } from '../src/build.ts' import { runDshSdkCommand, type DshSdkCommandContext } from '../src/command.ts' import { runConfigCommand } from '../src/config.ts' import { ConfigWorkflow, type ConfigPlan } from '../src/config/config-workflow.ts' +import { runCreatePluginCommand } from '../src/create-plugin.ts' import { initialize, resolve as resolveLocalPlugin } from '../src/local-plugin-loader-hooks.ts' const temporary: string[] = [] @@ -559,3 +560,62 @@ describe('ConfigWorkflow', () => { expect(output.read()).toContain('Disable feature: ask-user') }) }) + +describe('dsh-sdk create', () => { + const writeDependency = (name: string) => async (_m: unknown, spec: string, cwd: string): Promise => { + const path = join(cwd, 'package.json') + const manifest = JSON.parse(await readFile(path, 'utf8')) as { dependencies?: Record } + manifest.dependencies = { ...manifest.dependencies, [name]: spec } + await writeFile(path, JSON.stringify(manifest, null, 2)) + } + + it('adds a dependency and mounts it after confirmation', async () => { + const project = await committedProject() + const context = { ...commandContext(project.root), port: new QueuePort([true]), add: writeDependency('my-ext-plugin') } + const result = await runCreatePluginCommand('github:o/r#sha', context) + expect(result?.project.cordis.entry('my-ext-plugin')?.name).toBe('my-ext-plugin') + expect(context.readStdout()).toContain('Mounted my-ext-plugin') + }) + + it('derives the cordis id from a scoped package name', async () => { + const project = await committedProject() + const context = { ...commandContext(project.root), port: new QueuePort([true]), add: writeDependency('@acme/cool-plugin') } + const result = await runCreatePluginCommand('@acme/cool-plugin@1.0.0', context) + expect(result?.project.cordis.entry('cool-plugin')?.name).toBe('@acme/cool-plugin') + }) + + it('returns undefined and adds nothing when declined', async () => { + const project = await committedProject() + let added = false + const context = { + ...commandContext(project.root), + port: new QueuePort([false]), + add: async () => { added = true }, + } + await expect(runCreatePluginCommand('pkg@1.0.0', context)).resolves.toBeUndefined() + expect(added).toBe(false) + }) + + it('rejects an empty source, a non-TTY session, and a no-op add', async () => { + const project = await committedProject() + await expect(runCreatePluginCommand(' ', { ...commandContext(project.root), port: new QueuePort([]) })) + .rejects.toThrow('requires a plugin source') + const noTty = commandContext(project.root) + noTty.stdin.isTTY = false + noTty.stdout.isTTY = false + await expect(runCreatePluginCommand('pkg@1.0.0', noTty)).rejects.toThrow('interactive TTY') + const noOutTty = commandContext(project.root) + noOutTty.stdout.isTTY = false + await expect(runCreatePluginCommand('pkg@1.0.0', noOutTty)).rejects.toThrow('interactive TTY') + await expect(runCreatePluginCommand('pkg@1.0.0', { + ...commandContext(project.root), port: new QueuePort([true]), add: async () => {}, + })).rejects.toThrow('added no new dependency') + }) + + it('dispatches create through the launcher', async () => { + const project = await committedProject() + const context = commandContext(project.root) + context.createPlugin = async () => undefined + await expect(runDshSdkCommand(['create', 'pkg@1.0.0'], context)).resolves.toBe(0) + }) +}) From 560ce3b539bc24ea994cfbe9476c21ecd834162e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:28:31 +0800 Subject: [PATCH 13/37] feat(dsh-sdk): launcher telemetry reporting around every command Wrap runDshSdkCommand so each command times itself and, in a finally block, resolves consent (option A) and sends one best-effort, fire-and-forget telemetry event (redacted cordis.yml + package.json content; never reads .env). Never affects the command's exit code. Adds dsh-scripts -> dsh-telemetry dependency. Default-on via absent consent entry; opt-out by a disabled telemetry entry. The config/create wizard opt-out toggle is deferred (see design doc). --- docs/sdk-后续工作-设计.md | 3 ++ packages/sdk/scripts/package.json | 1 + packages/sdk/scripts/src/command.ts | 15 +++++- packages/sdk/scripts/src/telemetry.ts | 63 ++++++++++++++++++++++ packages/sdk/scripts/tests/scripts.spec.ts | 47 ++++++++++++++++ packages/sdk/scripts/tsconfig.json | 1 + pnpm-lock.yaml | 3 ++ 7 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/scripts/src/telemetry.ts diff --git a/docs/sdk-后续工作-设计.md b/docs/sdk-后续工作-设计.md index d281052cee..1395ece5e0 100644 --- a/docs/sdk-后续工作-设计.md +++ b/docs/sdk-后续工作-设计.md @@ -133,6 +133,9 @@ SDK 初版(`packages/sdk/*`)已经落地三个包: - **endpoint**:内置在代码里。 - **consent 承载**:遥测作为 `create` 时默认打开的 feature 写进 cordis.yml(对用户可见、随项目)。 +> **接线现状(读码修正)**:launcher 上报已接通——`runDshSdkCommand` 计时包住每条命令,`finally` 里 resolve consent(甲)→ 建 redacted payload(cordis.yml+package.json 全文、不读 .env)→ fire-and-forget 上报 + flush,best-effort 永不影响命令结果。**默认开**:无遥测条目 → 甲 → 上报。**opt-out 现状**:在 cordis.yml 手动加一条 `disabled` 的 `@deepseek-ai/dsh-telemetry` 条目即关(`ConsentResolver` 读到 disabled → 不报;disabled 条目 cordis 不加载,故不会因"它不是运行时插件"而 boot 失败)。 +> **暂缓(催表 opt-out 开关)**:把"关遥测"做成 config/create 向导里的勾选项还没做。关键约束:`@deepseek-ai/dsh-telemetry` 是 **launcher 库、不是 cordis 运行时插件**,所以 consent 条目只能以 **disabled 形态**存在(enabled=无条目=甲默认报;要关才写 disabled 条目),不能像普通 feature 那样挂一个 enabled 的可 boot 条目。向导化这个"只在关闭时才出现条目"的特殊语义留作后续。 + **在案取舍**:发全文会把第三方(含私有 scoped)包名、cordis 配置值(base-url/路径)暴露给 endpoint 持有方;主流工具都不发这些(Turbo 排除包名、Angular 禁模块名)。ccyu 作为本 SDK 维护者接受此暴露——目的即掌握开发者用了哪些 plugin/依赖/配置。 ### 4.4 交互测试(#4) diff --git a/packages/sdk/scripts/package.json b/packages/sdk/scripts/package.json index ba441a528c..6fdbcbc3da 100644 --- a/packages/sdk/scripts/package.json +++ b/packages/sdk/scripts/package.json @@ -32,6 +32,7 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-helper": "workspace:^", + "@deepseek-ai/dsh-telemetry": "workspace:^", "commander": "^15.0.0", "node-addon-require-builtin": "^0.1.0" }, diff --git a/packages/sdk/scripts/src/command.ts b/packages/sdk/scripts/src/command.ts index 5174ec5ded..ebf9b06846 100644 --- a/packages/sdk/scripts/src/command.ts +++ b/packages/sdk/scripts/src/command.ts @@ -9,6 +9,7 @@ import { runProjectBuild } from './build.ts' import { runConfigCommand, type ConfigCommandContext } from './config.ts' import { runCreatePluginCommand } from './create-plugin.ts' import { runSDK } from './runtime.ts' +import { reportCommandTelemetry, type CommandTelemetryEvent } from './telemetry.ts' import { DSH_SDK_TEMPLATES } from './templates/dsh-sdk-templates.ts' /** Injectable process and command boundaries used by the dsh-sdk bin. */ @@ -21,6 +22,7 @@ export interface DshSdkCommandContext extends ConfigCommandContext { build?: typeof runProjectBuild config?: typeof runConfigCommand createPlugin?: typeof runCreatePluginCommand + telemetry?: (event: CommandTelemetryEvent) => Promise } /** Run one parsed dsh-sdk command and return its process exit code. */ @@ -33,12 +35,16 @@ export async function runDshSdkCommand( stderr: process.stderr, }, ): Promise { + const startedAt = Date.now() + let command: string | undefined + let success = true try { const args = parseDshSdkArgs(argv) if (args.help || !args.command) { context.stdout.write(DSH_SDK_TEMPLATES.usage.render({})) return 0 } + command = args.command const run = context.run ?? runSDK const build = context.build ?? runProjectBuild const config = context.config ?? runConfigCommand @@ -49,7 +55,7 @@ export async function runDshSdkCommand( case 'build': await build(args.forwarded, context.cwd); break case 'config': { const result = await config(context) - if (result.installError) return 1 + if (result.installError) { success = false; return 1 } break } /* v8 ignore next -- Commander requires , so create never dispatches without it */ @@ -57,7 +63,14 @@ export async function runDshSdkCommand( } return 0 } catch (error) { + success = false context.stderr.write(`dsh-sdk: ${error instanceof Error ? error.message : String(error)}\n`) return 1 + } finally { + if (command !== undefined) { + /* v8 ignore next -- production telemetry wiring is exercised by the built-bin smoke */ + const telemetry = context.telemetry ?? reportCommandTelemetry + await telemetry({ command, cwd: context.cwd, durationMs: Date.now() - startedAt, success }) + } } } diff --git a/packages/sdk/scripts/src/telemetry.ts b/packages/sdk/scripts/src/telemetry.ts new file mode 100644 index 0000000000..1ef74fdbb7 --- /dev/null +++ b/packages/sdk/scripts/src/telemetry.ts @@ -0,0 +1,63 @@ +/** + * Launcher-side telemetry wiring: resolve consent and send one fire-and-forget + * event around each dsh-sdk command. Best-effort — never affects the command's + * outcome or exit code. + * + * @module @deepseek-ai/dsh-scripts/telemetry + */ + +import { + ConsentResolver, + TelemetryReporter, + buildTelemetryPayload, + type ConsentDecision, +} from '@deepseek-ai/dsh-telemetry' + +/** One command's telemetry lifecycle facts. */ +export interface CommandTelemetryEvent { + /** The dsh-sdk command that ran. */ + command: string + /** Project directory whose consent, `cordis.yml`, and `package.json` are read. */ + cwd: string + /** Wall-clock duration in milliseconds. */ + durationMs: number + /** Whether the command completed without error. */ + success: boolean +} + +/** Injectable consent and delivery seams for tests. */ +export interface CommandTelemetryDeps { + resolve?: (cwd: string) => Promise + reporter?: Pick +} + +/** + * Resolve consent for the project and, when allowed, assemble and send one + * telemetry event, draining in-flight sends before returning. Swallows every + * error so telemetry can never change a command's result. + * @param event - the command lifecycle facts. + * @param deps - consent and delivery seams; defaults hit the real endpoint. + */ +export async function reportCommandTelemetry( + event: CommandTelemetryEvent, + deps: CommandTelemetryDeps = {}, +): Promise { + try { + /* v8 ignore next -- the production ConsentResolver is exercised by the built-bin smoke */ + const resolve = deps.resolve ?? (cwd => new ConsentResolver().resolve(cwd)) + const consent = await resolve(event.cwd) + if (!consent.allowed) return + const payload = await buildTelemetryPayload({ + command: event.command, + durationMs: event.durationMs, + success: event.success, + projectDir: event.cwd, + }) + /* v8 ignore next -- the production TelemetryReporter is exercised by the built-bin smoke */ + const reporter = deps.reporter ?? new TelemetryReporter() + reporter.report(payload, consent) + await reporter.flush() + } catch { + // Telemetry is best-effort; a consent, payload, or delivery fault never reaches the command. + } +} diff --git a/packages/sdk/scripts/tests/scripts.spec.ts b/packages/sdk/scripts/tests/scripts.spec.ts index faa025926e..3ab59f13d8 100644 --- a/packages/sdk/scripts/tests/scripts.spec.ts +++ b/packages/sdk/scripts/tests/scripts.spec.ts @@ -32,6 +32,7 @@ import { runDshSdkCommand, type DshSdkCommandContext } from '../src/command.ts' import { runConfigCommand } from '../src/config.ts' import { ConfigWorkflow, type ConfigPlan } from '../src/config/config-workflow.ts' import { runCreatePluginCommand } from '../src/create-plugin.ts' +import { reportCommandTelemetry, type CommandTelemetryEvent } from '../src/telemetry.ts' import { initialize, resolve as resolveLocalPlugin } from '../src/local-plugin-loader-hooks.ts' const temporary: string[] = [] @@ -619,3 +620,49 @@ describe('dsh-sdk create', () => { await expect(runDshSdkCommand(['create', 'pkg@1.0.0'], context)).resolves.toBe(0) }) }) + +describe('command telemetry', () => { + it('reports when consent allows and skips when denied or faulting', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-telemetry-')) + temporary.push(dir) + const sent: unknown[] = [] + const reporter = { report: () => { sent.push(1) }, flush: async () => {} } + await reportCommandTelemetry( + { command: 'build', cwd: dir, durationMs: 5, success: true }, + { resolve: async () => ({ allowed: true, reason: 'absent' }), reporter }, + ) + expect(sent).toHaveLength(1) + await reportCommandTelemetry( + { command: 'build', cwd: dir, durationMs: 5, success: true }, + { resolve: async () => ({ allowed: false, reason: 'disabled' }), reporter }, + ) + expect(sent).toHaveLength(1) + await expect(reportCommandTelemetry( + { command: 'build', cwd: dir, durationMs: 5, success: true }, + { resolve: async () => { throw new Error('boom') }, reporter }, + )).resolves.toBeUndefined() + expect(sent).toHaveLength(1) + }) + + it('emits a telemetry event carrying each command outcome', async () => { + const project = await committedProject() + const events: CommandTelemetryEvent[] = [] + const context = commandContext(project.root) + context.telemetry = async (event) => { events.push(event) } + context.build = async () => {} + await expect(runDshSdkCommand(['build'], context)).resolves.toBe(0) + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ command: 'build', cwd: project.root, success: true }) + + await runDshSdkCommand([], context) + expect(events).toHaveLength(1) + + context.build = async () => { throw new Error('boom') } + await expect(runDshSdkCommand(['build'], context)).resolves.toBe(1) + expect(events[1]).toMatchObject({ command: 'build', success: false }) + + context.config = async () => ({ installError: new Error('offline') }) + await expect(runDshSdkCommand(['config'], context)).resolves.toBe(1) + expect(events.at(-1)).toMatchObject({ command: 'config', success: false }) + }) +}) diff --git a/packages/sdk/scripts/tsconfig.json b/packages/sdk/scripts/tsconfig.json index 848de9a314..461c86c06d 100644 --- a/packages/sdk/scripts/tsconfig.json +++ b/packages/sdk/scripts/tsconfig.json @@ -7,6 +7,7 @@ "include": ["src"], "references": [ { "path": "../helper" }, + { "path": "../telemetry" }, { "path": "../../ui/app-boot" }, { "path": "../../../vendor/cordis" } ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cafebfaae6..68ee8e2640 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1228,6 +1228,9 @@ importers: '@deepseek-ai/dsh-helper': specifier: workspace:^ version: link:../helper + '@deepseek-ai/dsh-telemetry': + specifier: workspace:^ + version: link:../telemetry commander: specifier: ^15.0.0 version: 15.0.0 From ed418827c28845bc5e5d44ea9a5bcb6c275c24d9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:32:08 +0800 Subject: [PATCH 14/37] =?UTF-8?q?chore(sdk):=20satisfy=20CI=20gates=20?= =?UTF-8?q?=E2=80=94=20config=20catalog,=20md-wrap,=20knip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerate docs/config-catalog.md for the telemetry package, unwrap the design doc's multi-line blockquote paragraphs, and stop exporting the create-sdk headless spec internals that have no external consumer. --- docs/config-catalog.md | 1 + docs/sdk-后续工作-设计.md | 2 ++ packages/sdk/create-sdk/src/headless.ts | 4 ++-- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3e3d8141fa..7cccb69e0a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1432,4 +1432,5 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) - `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts)) +- `@deepseek-ai/dsh-telemetry` ([`packages/sdk/telemetry/src/index.ts`](../packages/sdk/telemetry/src/index.ts)) - `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts)) diff --git a/docs/sdk-后续工作-设计.md b/docs/sdk-后续工作-设计.md index 1395ece5e0..5f968c27ee 100644 --- a/docs/sdk-后续工作-设计.md +++ b/docs/sdk-后续工作-设计.md @@ -1,6 +1,7 @@ # DeepSeek Harness SDK 后续工作设计 > 状态:设计成稿,供通读与评审。定案后由 ccyu 转正式 RFC 并双语化。本文件为临时设计文档,不走 doc-sync / 文档预算门禁。 +> > 一句话:**SDK 初版已合并;本轮把"创建项目""创建插件""遥测""交互测试"四块补齐,核心是抽出一个既撑交互又撑 headless 的创建内核,其余三块围绕它扩展。** ## 0. 总览(一屏读完) @@ -134,6 +135,7 @@ SDK 初版(`packages/sdk/*`)已经落地三个包: - **consent 承载**:遥测作为 `create` 时默认打开的 feature 写进 cordis.yml(对用户可见、随项目)。 > **接线现状(读码修正)**:launcher 上报已接通——`runDshSdkCommand` 计时包住每条命令,`finally` 里 resolve consent(甲)→ 建 redacted payload(cordis.yml+package.json 全文、不读 .env)→ fire-and-forget 上报 + flush,best-effort 永不影响命令结果。**默认开**:无遥测条目 → 甲 → 上报。**opt-out 现状**:在 cordis.yml 手动加一条 `disabled` 的 `@deepseek-ai/dsh-telemetry` 条目即关(`ConsentResolver` 读到 disabled → 不报;disabled 条目 cordis 不加载,故不会因"它不是运行时插件"而 boot 失败)。 +> > **暂缓(催表 opt-out 开关)**:把"关遥测"做成 config/create 向导里的勾选项还没做。关键约束:`@deepseek-ai/dsh-telemetry` 是 **launcher 库、不是 cordis 运行时插件**,所以 consent 条目只能以 **disabled 形态**存在(enabled=无条目=甲默认报;要关才写 disabled 条目),不能像普通 feature 那样挂一个 enabled 的可 boot 条目。向导化这个"只在关闭时才出现条目"的特殊语义留作后续。 **在案取舍**:发全文会把第三方(含私有 scoped)包名、cordis 配置值(base-url/路径)暴露给 endpoint 持有方;主流工具都不发这些(Turbo 排除包名、Angular 禁模块名)。ccyu 作为本 SDK 维护者接受此暴露——目的即掌握开发者用了哪些 plugin/依赖/配置。 diff --git a/packages/sdk/create-sdk/src/headless.ts b/packages/sdk/create-sdk/src/headless.ts index e32a053457..164405e14f 100644 --- a/packages/sdk/create-sdk/src/headless.ts +++ b/packages/sdk/create-sdk/src/headless.ts @@ -15,7 +15,7 @@ import type { CreateArgs } from './args.ts' * (the interactive tree/suggests prompts are skipped). Absent required answers make * the run fail loud through `HeadlessPromptPort` rather than blocking. */ -export interface HeadlessCreateSpec { +interface HeadlessCreateSpec { directory?: string description?: string provider?: 'deepseek' | 'custom' @@ -43,7 +43,7 @@ function asRecord(value: unknown, source: string): Record { } /** Parse and shallow-validate a headless spec from JSON text. */ -export function parseHeadlessSpec(text: string, source: string): HeadlessCreateSpec { +function parseHeadlessSpec(text: string, source: string): HeadlessCreateSpec { let parsed: unknown try { parsed = JSON.parse(text) From 78f21bf703df0b32c1a4196b489565e30c6319e2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:25:56 +0800 Subject: [PATCH 15/37] fix(sdk): list create and headless flags in usage help dsh-sdk's usage template predates the create command and create-sdk's predates --config/--config-json/--json, so --help hid both surfaces the README already documents. Pin each with a help-output assertion. --- packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl | 3 +++ packages/sdk/create-sdk/tests/create.spec.ts | 1 + packages/sdk/scripts/src/templates/assets/usage.txt.tpl | 1 + packages/sdk/scripts/tests/scripts.spec.ts | 1 + 4 files changed, 6 insertions(+) diff --git a/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl b/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl index 2b734eb753..32f4d5c6d2 100644 --- a/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl +++ b/packages/sdk/create-sdk/src/templates/assets/usage.txt.tpl @@ -9,3 +9,6 @@ Options: --interface --pm --install / --no-install + --config + --config-json + --json diff --git a/packages/sdk/create-sdk/tests/create.spec.ts b/packages/sdk/create-sdk/tests/create.spec.ts index 1f2cdb75fc..0e4abd37c4 100644 --- a/packages/sdk/create-sdk/tests/create.spec.ts +++ b/packages/sdk/create-sdk/tests/create.spec.ts @@ -477,6 +477,7 @@ describe('create command composition', () => { context.stdout.isTTY = false await expect(createProject(['--help'], context)).resolves.toBeUndefined() expect(context.readStdout()).toContain('Usage: create-sdk') + expect(context.readStdout()).toContain('--config-json ') expect(context.readStdout()).not.toContain('--link-workspace') await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY') context.stdin.isTTY = true diff --git a/packages/sdk/scripts/src/templates/assets/usage.txt.tpl b/packages/sdk/scripts/src/templates/assets/usage.txt.tpl index d4122198f5..b372c65d17 100644 --- a/packages/sdk/scripts/src/templates/assets/usage.txt.tpl +++ b/packages/sdk/scripts/src/templates/assets/usage.txt.tpl @@ -5,3 +5,4 @@ Commands: dev [target] [-- args...] Start with TypeScript and local-plugin source resolution build [args...] Run the project's installed tsdown config Interactively edit project features + create Add an external plugin dependency (pkg@version or github:owner/repo#ref) and mount it in cordis.yml diff --git a/packages/sdk/scripts/tests/scripts.spec.ts b/packages/sdk/scripts/tests/scripts.spec.ts index 3ab59f13d8..8b887d74db 100644 --- a/packages/sdk/scripts/tests/scripts.spec.ts +++ b/packages/sdk/scripts/tests/scripts.spec.ts @@ -168,6 +168,7 @@ describe('Commander launcher arguments', () => { await expect(runDshSdkCommand(['unknown'], context)).resolves.toBe(1) await expect(runDshSdkCommand([], context)).resolves.toBe(0) expect(context.readStdout()).toContain('Usage: dsh-sdk') + expect(context.readStdout()).toContain('create ') const defaults = commandContext(root) await writeFile(join(root, 'main.mjs'), 'export function main() { return "ok" }\n') From 013db6e8f02eb8f4c6c0bdd0f21f62d69f2483da Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:50:46 +0800 Subject: [PATCH 16/37] fix(create-sdk): keep --json stdout pure NDJSON The SKILL.md contract says every stdout line is one JSON event, but createProject wrote the Created/Next-steps templates to stdout and the default install/build path inherited the launcher's stdio, so package- manager child output interleaved with the event stream. Under --json, route human progress to stderr and run install/build through a NodeCommandRunner that redirects child stdout+stderr to stderr. --- packages/sdk/create-sdk/src/command.ts | 13 ++++++--- packages/sdk/create-sdk/tests/create.spec.ts | 18 +++++++++++++ .../src/package-managers/package-manager.ts | 27 ++++++++++++++++--- packages/sdk/helper/tests/documents.spec.ts | 14 ++++++++++ 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/packages/sdk/create-sdk/src/command.ts b/packages/sdk/create-sdk/src/command.ts index a7855ea027..9897db2a21 100644 --- a/packages/sdk/create-sdk/src/command.ts +++ b/packages/sdk/create-sdk/src/command.ts @@ -9,6 +9,7 @@ import { ClackPromptPort, HeadlessPromptError, HeadlessPromptPort, + NodeCommandRunner, PromptCancelledError, type PackageManagerVersionProbe, type PromptPort, @@ -45,6 +46,9 @@ export async function createProject( context: CreateCommandContext, ): Promise { const args = parseCreateArgs(argv) + // Under --json, stdout carries only NDJSON events: human-readable progress + // and package-manager child output move to stderr. + const progress = args.json === true ? context.stderr : context.stdout if (args.help) { context.stdout.write(CREATE_TEMPLATES.usage.render({})) return undefined @@ -64,7 +68,7 @@ export async function createProject( }) const resolved = await wizard.run() const result = await scaffoldProject(resolved.directory, resolved.request) - context.stdout.write(CREATE_TEMPLATES.created.render({ + progress.write(CREATE_TEMPLATES.created.render({ name: resolved.request.name, directory: resolved.directory, })) @@ -72,8 +76,9 @@ export async function createProject( try { if (context.setup) await context.setup(resolved) else { - await resolved.request.packageManager.install(resolved.directory) - await resolved.request.packageManager.build(resolved.directory) + const runner = args.json === true ? new NodeCommandRunner(context.stderr) : new NodeCommandRunner() + await resolved.request.packageManager.install(resolved.directory, runner) + await resolved.request.packageManager.build(resolved.directory, runner) } } catch (error) { context.stderr.write(CREATE_TEMPLATES.setupFailure.render({ @@ -84,7 +89,7 @@ export async function createProject( throw error } } - context.stdout.write(CREATE_TEMPLATES.nextSteps.render({ + progress.write(CREATE_TEMPLATES.nextSteps.render({ directory: resolved.directory, setupRequired: !resolved.install, ...packageManagerTemplateModel(resolved.request.packageManager), diff --git a/packages/sdk/create-sdk/tests/create.spec.ts b/packages/sdk/create-sdk/tests/create.spec.ts index 0e4abd37c4..362e5046f9 100644 --- a/packages/sdk/create-sdk/tests/create.spec.ts +++ b/packages/sdk/create-sdk/tests/create.spec.ts @@ -8,6 +8,7 @@ import { HeadlessPromptPort, LocalPluginBlueprint, featureId, + NodeCommandRunner, NpmPackageManager, type FeatureSelection, type NestedMultiSelectValue, @@ -511,6 +512,12 @@ describe('create command composition', () => { const okSpec = JSON.stringify({ ...base, directory: 'done-agent', provider: 'deepseek', apiKey: 'key', features: [] }) await expect(runCreateCommand(['--config-json', okSpec, '--json'], ok)).resolves.toBe(0) expect(ok.readStdout()).toContain('{"type":"done"}') + // stdout stays pure NDJSON: every line parses, human progress goes to stderr + for (const line of ok.readStdout().split('\n').filter(line => line.length > 0)) { + expect(() => { JSON.parse(line) }).not.toThrow() + } + expect(ok.readStderr()).toContain('Created done-agent') + expect(ok.readStderr()).toContain('Next: cd') const missing = commandContext(root) missing.stdin.isTTY = false @@ -564,6 +571,17 @@ describe('create command composition', () => { await createProject(argv('agent', true), context) expect(install).toHaveBeenCalledOnce() expect(build).toHaveBeenCalledOnce() + const spec = JSON.stringify({ + directory: 'json-agent', description: 'test', provider: 'deepseek', apiKey: 'key', + model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: true, features: [], + }) + const json = commandContext(root) + json.stdin.isTTY = false + json.stdout.isTTY = false + await createProject(['--config-json', spec, '--json'], json) + // json mode hands install/build a runner that redirects child output to stderr + expect(install).toHaveBeenCalledTimes(2) + expect(install.mock.calls[1]?.[1]).toBeInstanceOf(NodeCommandRunner) install.mockRestore() build.mockRestore() }) diff --git a/packages/sdk/helper/src/package-managers/package-manager.ts b/packages/sdk/helper/src/package-managers/package-manager.ts index 6e6eda478a..8d6b617977 100644 --- a/packages/sdk/helper/src/package-managers/package-manager.ts +++ b/packages/sdk/helper/src/package-managers/package-manager.ts @@ -58,17 +58,38 @@ export function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): /** Node child-process command runner with inherited stdio and quiescent completion. */ export class NodeCommandRunner implements CommandRunner { - /** Spawn one child and settle only after its exit. */ + private readonly output: NodeJS.WritableStream | undefined + + /** + * @param output - redirect target for child stdout+stderr; the child inherits + * this process's stdio when absent. Callers whose own stdout carries a machine + * protocol (create-sdk --json NDJSON) redirect child output to keep the + * protocol stream pure. + */ + constructor(output?: NodeJS.WritableStream) { + this.output = output + } + + /** Spawn one child and settle only after exit, with redirected stdio drained. */ run(command: string, args: readonly string[], cwd: string): Promise { return new Promise((resolve, reject) => { + const output = this.output + if (output === undefined) { + const child = spawn(command, [...args], { cwd, env: scrubEnvironment(), stdio: 'inherit', shell: false }) + child.once('error', reject) + child.once('exit', (exitCode, signal) => { resolve({ exitCode, signal }) }) + return + } const child = spawn(command, [...args], { cwd, env: scrubEnvironment(), - stdio: 'inherit', + stdio: ['inherit', 'pipe', 'pipe'], shell: false, }) + child.stdout.pipe(output, { end: false }) + child.stderr.pipe(output, { end: false }) child.once('error', reject) - child.once('exit', (exitCode, signal) => { resolve({ exitCode, signal }) }) + child.once('close', (exitCode, signal) => { resolve({ exitCode, signal }) }) }) } } diff --git a/packages/sdk/helper/tests/documents.spec.ts b/packages/sdk/helper/tests/documents.spec.ts index 3ce5eeccea..469887036a 100644 --- a/packages/sdk/helper/tests/documents.spec.ts +++ b/packages/sdk/helper/tests/documents.spec.ts @@ -1,6 +1,7 @@ import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { Writable } from 'node:stream' import { afterEach, describe, expect, it } from 'vitest' import { CordisYamlFile, JsExpression } from '../src/documents/cordis-yaml-file.ts' import { EnvFile } from '../src/documents/env-file.ts' @@ -325,6 +326,19 @@ describe('package manager strategies', () => { const runner = new NodeCommandRunner() await expect(runner.run(process.execPath, ['-e', ''], root)).resolves.toEqual({ exitCode: 0, signal: null }) await expect(runner.run('missing-dsh-command', [], root)).rejects.toThrow() + let redirected = '' + const output = new Writable({ + write(chunk, _encoding, callback) { redirected += String(chunk); callback() }, + }) + const redirecting = new NodeCommandRunner(output) + await expect(redirecting.run( + process.execPath, + ['-e', 'process.stdout.write("child-out"); process.stderr.write("child-err")'], + root, + )).resolves.toEqual({ exitCode: 0, signal: null }) + expect(redirected).toContain('child-out') + expect(redirected).toContain('child-err') + await expect(redirecting.run('missing-dsh-command', [], root)).rejects.toThrow() }) it('discovers and rewrites a repository-local NPM dependency closure', async () => { From e674f4c20e21710b8b9ea14706c0955a2b681706 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:53:47 +0800 Subject: [PATCH 17/37] fix(telemetry): withhold package.json when cordis.yml is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildTelemetryPayload read the two reported files independently, so a dsh-sdk command mistakenly run in an arbitrary non-SDK directory (no cordis.yml, e.g. any unrelated repo) still uploaded that directory's package.json — dependency names and metadata of a project that never opted into the SDK toolchain. Gate the manifest on cordis.yml presence: without the config the directory is not an SDK project and its manifest is not ours to report. Consent semantics are unchanged. --- packages/sdk/telemetry/README.md | 2 +- packages/sdk/telemetry/src/payload.ts | 13 ++++++++++--- packages/sdk/telemetry/tests/payload.spec.ts | 12 +++++++++++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/sdk/telemetry/README.md b/packages/sdk/telemetry/README.md index 43d13d3dcc..8ec3da2c78 100644 --- a/packages/sdk/telemetry/README.md +++ b/packages/sdk/telemetry/README.md @@ -6,7 +6,7 @@ Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain li |---|---| | `SecretRedactor` | Conservative safety backstop: replaces secret-shaped values (secret-like keys, known token shapes, PEM blocks, URL credentials, high-entropy opaque tokens) with a placeholder in both parsed values (`redactValue`) and raw text (`redactText`). Never drops a field or line. | | `ConsentResolver` | Parses (never boots) a project `cordis.yml` and reads the telemetry entry's enabled/disabled state as consent; `DO_NOT_TRACK`/CI env force a hard opt-out. | -| `buildTelemetryPayload` | Assembles `{command, durationMs, success, cordisYmlContent, packageJsonContent}`, running the redactor over the full `cordis.yml` and `package.json` text. Never reads `.env`. | +| `buildTelemetryPayload` | Assembles `{command, durationMs, success, cordisYmlContent, packageJsonContent}`, running the redactor over the full `cordis.yml` and `package.json` text. Never reads `.env`; `package.json` ships only alongside a `cordis.yml`, so a command run in a non-SDK directory never uploads that directory's unrelated manifest. | | `getOrCreateAnonymousId` | Random UUID persisted in a per-user GLOBAL config file (never in the project, never derived from git). | | `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. | diff --git a/packages/sdk/telemetry/src/payload.ts b/packages/sdk/telemetry/src/payload.ts index 96a7b19d76..505cedb872 100644 --- a/packages/sdk/telemetry/src/payload.ts +++ b/packages/sdk/telemetry/src/payload.ts @@ -5,7 +5,9 @@ * the project `cordis.yml` and `package.json`. It NEVER reads or includes `.env` * — secrets live only in `.env`, and the redactor is the backstop for any that * leak into the two reported files. A file that does not exist (the first - * `create` run) simply omits its field. + * `create` run) simply omits its field, and `package.json` ships only when + * `cordis.yml` is present: without it the directory is not an SDK project, and + * its manifest belongs to whatever unrelated project the command ran in. * * @module @deepseek-ai/dsh-telemetry/payload */ @@ -27,7 +29,7 @@ export interface TelemetryPayload { success: boolean /** Redacted full text of the project `cordis.yml`, absent when the file does not exist. */ cordisYmlContent?: string - /** Redacted full text of the project `package.json`, absent when the file does not exist. */ + /** Redacted full text of the project `package.json`, absent when it or `cordis.yml` does not exist. */ packageJsonContent?: string } @@ -70,6 +72,11 @@ export async function buildTelemetryPayload(input: BuildTelemetryPayloadInput): durationMs: input.durationMs, success: input.success, ...cordisYml !== undefined ? { cordisYmlContent: redactor.redactText(cordisYml) } : {}, - ...packageJson !== undefined ? { packageJsonContent: redactor.redactText(packageJson) } : {}, + // package.json is an SDK-project manifest only alongside cordis.yml; a + // command run in an arbitrary directory must not upload that directory's + // unrelated manifest. + ...cordisYml !== undefined && packageJson !== undefined + ? { packageJsonContent: redactor.redactText(packageJson) } + : {}, } } diff --git a/packages/sdk/telemetry/tests/payload.spec.ts b/packages/sdk/telemetry/tests/payload.spec.ts index 1af5139ef5..ed3ab2f82f 100644 --- a/packages/sdk/telemetry/tests/payload.spec.ts +++ b/packages/sdk/telemetry/tests/payload.spec.ts @@ -47,8 +47,18 @@ describe('buildTelemetryPayload', () => { expect('packageJsonContent' in payload).toBe(false) }) + it('withholds package.json when cordis.yml is absent (not an SDK project)', async () => { + const dir = await projectDir({ 'package.json': '{ "name": "unrelated-repo" }' }) + const payload = await buildTelemetryPayload({ command: 'build', durationMs: 3, success: false, projectDir: dir }) + expect('cordisYmlContent' in payload).toBe(false) + expect('packageJsonContent' in payload).toBe(false) + }) + it('uses a supplied redactor', async () => { - const dir = await projectDir({ 'package.json': '{ "password": "hunter2" }' }) + const dir = await projectDir({ + 'cordis.yml': '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n', + 'package.json': '{ "password": "hunter2" }', + }) const redactor = new SecretRedactor({ placeholder: '<>' }) const payload = await buildTelemetryPayload({ command: 'config', durationMs: 5, success: true, projectDir: dir, redactor, From 7a5d3ae5ce9bf7b1354decba22d7e0dd9ca7a22d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:56:52 +0800 Subject: [PATCH 18/37] fix(telemetry): stop bearer redaction from eating plain prose The bearer rule matched any 8+ run of letters after the word, so package.json prose like "uses bearer authentication" lost its following word to the placeholder. Real bearer credentials always carry a digit; require one in the candidate token. --- packages/sdk/telemetry/src/secret-redactor.ts | 7 ++++++- packages/sdk/telemetry/tests/secret-redactor.spec.ts | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/sdk/telemetry/src/secret-redactor.ts b/packages/sdk/telemetry/src/secret-redactor.ts index 5b9af5f169..087ba2284a 100644 --- a/packages/sdk/telemetry/src/secret-redactor.ts +++ b/packages/sdk/telemetry/src/secret-redactor.ts @@ -191,7 +191,12 @@ export class SecretRedactor { } #redactBearerTokens(text: string): string { - return text.replace(/(bearer\s+)([a-z0-9._-]{8,})/gi, (_match, prefix: string) => `${prefix}${this.#placeholder}`) + // The candidate must contain a digit: real bearer credentials are never + // letters-only, while prose like "bearer authentication" is. + return text.replace( + /(bearer\s+)((?=[a-z._-]*[0-9])[a-z0-9._-]{8,})/gi, + (_match, prefix: string) => `${prefix}${this.#placeholder}`, + ) } #redactStandaloneTokens(text: string): string { diff --git a/packages/sdk/telemetry/tests/secret-redactor.spec.ts b/packages/sdk/telemetry/tests/secret-redactor.spec.ts index c3250d9ace..77d89d968e 100644 --- a/packages/sdk/telemetry/tests/secret-redactor.spec.ts +++ b/packages/sdk/telemetry/tests/secret-redactor.spec.ts @@ -154,6 +154,13 @@ describe('SecretRedactor.redactText', () => { .toBe(`sending Bearer ${REDACTED} now`) }) + it('keeps letters-only prose after the word bearer intact', () => { + expect(redactor.redactText('uses bearer authentication for requests')) + .toBe('uses bearer authentication for requests') + expect(redactor.redactText('"description": "bearer token-helper middleware"')) + .toBe('"description": "bearer token-helper middleware"') + }) + it('redacts standalone secret-shaped tokens while keeping package names and paths', () => { expect(redactor.redactText('key sk-abcdefghij1234567890 end')) .toBe(`key ${REDACTED} end`) From 1fdcd5a77314717391462d377e6991622db4eb31 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 19 Jul 2026 13:42:21 +0800 Subject: [PATCH 19/37] ci: run pi-ai OpenAI e2e through Azure --- .github/workflows/pi-ai-provider-e2e.yml | 20 ++++++++------- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 19 ++++++++++++++ .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 25 +++++++++++-------- 3 files changed, 45 insertions(+), 19 deletions(-) diff --git a/.github/workflows/pi-ai-provider-e2e.yml b/.github/workflows/pi-ai-provider-e2e.yml index 7a273c52f3..4be5f0c13a 100644 --- a/.github/workflows/pi-ai-provider-e2e.yml +++ b/.github/workflows/pi-ai-provider-e2e.yml @@ -1,12 +1,12 @@ -name: E2E (pi-ai OpenAI and Anthropic) +name: E2E (pi-ai Azure OpenAI and Anthropic) # This suite spends tokens against two external providers and is intentionally # opt-in. It has no push, pull_request, schedule, or workflow_call trigger. on: workflow_dispatch: inputs: - openai_model: - description: OpenAI model from pi-ai's installed catalog + azure_openai_model: + description: Azure OpenAI model from pi-ai's installed catalog required: true default: gpt-5.5 type: string @@ -22,7 +22,7 @@ permissions: jobs: e2e: runs-on: ubuntu-latest - name: OpenAI Responses + Anthropic Messages + name: Azure OpenAI Responses + Anthropic Messages timeout-minutes: 20 steps: - uses: actions/checkout@v6 @@ -52,12 +52,12 @@ jobs: # dispatched CI run must fail instead of reporting an all-skipped green. - name: Preflight (require provider API keys) env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY_EXTERNAL }} + AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY_EXTERNAL }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY_EXTERNAL }} run: | set -euo pipefail missing=0 - for name in OPENAI_API_KEY ANTHROPIC_API_KEY; do + for name in AZURE_OPENAI_API_KEY ANTHROPIC_API_KEY; do if [ -z "${!name:-}" ]; then echo "::error::${name} is empty. Configure the corresponding *_EXTERNAL repository secret." missing=1 @@ -65,11 +65,13 @@ jobs: done exit "$missing" - - name: E2E tests (real OpenAI and Anthropic APIs) + - name: E2E tests (real Azure OpenAI and Anthropic APIs) env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY_EXTERNAL }} + AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY_EXTERNAL }} + AZURE_OPENAI_API_VERSION: v1 ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY_EXTERNAL }} - DSH_PI_AI_OPENAI_MODEL: ${{ inputs.openai_model }} + DSH_PI_AI_AZURE_OPENAI_MODEL: ${{ inputs.azure_openai_model }} + DSH_PI_AI_AZURE_OPENAI_BASE_URL: https://openai-routerhub-resource.services.ai.azure.com/api/projects/openai/openai/ DSH_PI_AI_ANTHROPIC_MODEL: ${{ inputs.anthropic_model }} DSH_E2E_MAX_WORKERS: 2 run: >- diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 559c36bf27..3ebb23ee50 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -167,6 +167,25 @@ describe('PiAiAdapter provider routing', () => { expect(server.paths).toEqual(['/v1/responses']) }) + it('uses Azure OpenAI Responses with the configured project base path and API key', async () => { + vi.stubEnv('AZURE_OPENAI_API_VERSION', 'v1') + const server = await mockServer([{ status: 401, body: JSON.stringify({ error: { message: 'expected mock failure' } }) }]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: [{ + provider: 'azure-openai-responses', + apiKey: 'test-key', + baseURL: `${server.url}/api/projects/openai/openai/`, + maxRetries: 0, + }], + }) + const result = await assemble(ctx, { provider: 'azure-openai-responses', model: 'gpt-5.5', messages: [] }) + expect(result.finish.kind).toBe('error') + expect(server.paths).toEqual(['/api/projects/openai/openai/responses?api-version=v1']) + expect(server.headers[0]?.['api-key']).toBe('test-key') + }) + it.each([ [401, 'AUTH'], [400, 'INVALID_REQUEST'], diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 7fffba4618..1ed0959785 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -3,22 +3,26 @@ import { Context } from 'cordis' import LlmService, { CallId } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' -import type { PiAiReplayState } from '@deepseek-ai/dsh-llm-pi-ai' +import type { PiAiReplayState } from '../src/replay.ts' import { assemble, type AssembledResult } from './assemble.ts' interface ProviderCase { - provider: 'openai' | 'anthropic' - api: 'openai-responses' | 'anthropic-messages' + provider: 'azure-openai-responses' | 'anthropic' + api: 'azure-openai-responses' | 'anthropic-messages' model: string apiKey?: string + baseURL?: string } +const azureOpenAIBaseURL = process.env.DSH_PI_AI_AZURE_OPENAI_BASE_URL ?? process.env.AZURE_OPENAI_BASE_URL + const providerCases: ProviderCase[] = [ { - provider: 'openai', - api: 'openai-responses', - model: process.env.DSH_PI_AI_OPENAI_MODEL ?? 'gpt-5.5', - ...process.env.OPENAI_API_KEY ? { apiKey: process.env.OPENAI_API_KEY } : {}, + provider: 'azure-openai-responses', + api: 'azure-openai-responses', + model: process.env.DSH_PI_AI_AZURE_OPENAI_MODEL ?? 'gpt-5.5', + ...process.env.AZURE_OPENAI_API_KEY ? { apiKey: process.env.AZURE_OPENAI_API_KEY } : {}, + ...azureOpenAIBaseURL ? { baseURL: azureOpenAIBaseURL } : {}, }, { provider: 'anthropic', @@ -38,6 +42,7 @@ async function harness(): Promise { providers: providerCases.map(profile => ({ provider: profile.provider, ...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey }, + ...profile.baseURL === undefined ? {} : { baseURL: profile.baseURL }, })), }) return ctx @@ -90,7 +95,7 @@ for (const profile of providerCases) { provider: profile.provider, model: profile.model, messages: ask('Reply with exactly the word: pong'), - maxTokens: 64, + maxTokens: 1024, }) expect(result.finish.kind).toBe('stop') @@ -108,7 +113,7 @@ for (const profile of providerCases) { model: profile.model, messages: prompt, tools: [lookupTool], - maxTokens: 256, + maxTokens: 2048, }) expect(first.finish.kind).toBe('tool-calls') @@ -134,7 +139,7 @@ for (const profile of providerCases) { }, ], tools: [lookupTool], - maxTokens: 256, + maxTokens: 2048, }) expect(second.finish.kind).toBe('stop') From 51425dd61a481257a72f0351e7a564439e470be0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 19 Jul 2026 13:51:25 +0800 Subject: [PATCH 20/37] test(llm-pi-ai): surface provider e2e failures --- packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 1ed0959785..b830bfe08b 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -63,6 +63,13 @@ function textOf(result: AssembledResult): string { .join('') } +function expectFinish(result: AssembledResult, expected: 'stop' | 'tool-calls'): void { + if (result.finish.kind === 'error') { + throw new Error(`provider request failed (${result.finish.code ?? 'unknown'}): ${result.finish.message}`) + } + expect(result.finish.kind).toBe(expected) +} + function expectNativeReplay(result: AssembledResult, profile: ProviderCase): PiAiReplayState { const replayState = result.message.provenance?.replayState expect(replayState).toMatchObject({ @@ -98,7 +105,7 @@ for (const profile of providerCases) { maxTokens: 1024, }) - expect(result.finish.kind).toBe('stop') + expectFinish(result, 'stop') expect(textOf(result).toLowerCase()).toContain('pong') expect(result.usage?.inputTokens).toBeGreaterThan(0) expect(result.usage?.outputTokens).toBeGreaterThan(0) @@ -116,7 +123,7 @@ for (const profile of providerCases) { maxTokens: 2048, }) - expect(first.finish.kind).toBe('tool-calls') + expectFinish(first, 'tool-calls') const call = first.message.content.find(block => block.type === 'tool-call') expect(call).toBeDefined() expect(call!.name).toBe('lookup_code') @@ -142,7 +149,7 @@ for (const profile of providerCases) { maxTokens: 2048, }) - expect(second.finish.kind).toBe('stop') + expectFinish(second, 'stop') expect(textOf(second).toLowerCase()).toContain('ocean') expect(expectNativeReplay(second, profile).stopReason).toBe('stop') }) From 39dea6107ea7ec00994142b078b04e12506dc7a9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 19 Jul 2026 14:06:18 +0800 Subject: [PATCH 21/37] fix(ci): target Azure Foundry OpenAI v1 route --- .github/workflows/pi-ai-provider-e2e.yml | 5 ++--- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 12 ++++++------ .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 19 +++++++++++-------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/.github/workflows/pi-ai-provider-e2e.yml b/.github/workflows/pi-ai-provider-e2e.yml index 4be5f0c13a..d198abf5b5 100644 --- a/.github/workflows/pi-ai-provider-e2e.yml +++ b/.github/workflows/pi-ai-provider-e2e.yml @@ -68,10 +68,9 @@ jobs: - name: E2E tests (real Azure OpenAI and Anthropic APIs) env: AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY_EXTERNAL }} - AZURE_OPENAI_API_VERSION: v1 ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY_EXTERNAL }} - DSH_PI_AI_AZURE_OPENAI_MODEL: ${{ inputs.azure_openai_model }} - DSH_PI_AI_AZURE_OPENAI_BASE_URL: https://openai-routerhub-resource.services.ai.azure.com/api/projects/openai/openai/ + DSH_PI_AI_OPENAI_MODEL: ${{ inputs.azure_openai_model }} + DSH_PI_AI_OPENAI_BASE_URL: https://openai-routerhub-resource.services.ai.azure.com/api/projects/openai/openai/v1 DSH_PI_AI_ANTHROPIC_MODEL: ${{ inputs.anthropic_model }} DSH_E2E_MAX_WORKERS: 2 run: >- diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 3ebb23ee50..26fbb392de 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -167,22 +167,22 @@ describe('PiAiAdapter provider routing', () => { expect(server.paths).toEqual(['/v1/responses']) }) - it('uses Azure OpenAI Responses with the configured project base path and API key', async () => { - vi.stubEnv('AZURE_OPENAI_API_VERSION', 'v1') + it('uses OpenAI Responses against an Azure project v1 path with its API key header', async () => { const server = await mockServer([{ status: 401, body: JSON.stringify({ error: { message: 'expected mock failure' } }) }]) const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmPiAi, { providers: [{ - provider: 'azure-openai-responses', + provider: 'openai', apiKey: 'test-key', - baseURL: `${server.url}/api/projects/openai/openai/`, + baseURL: `${server.url}/api/projects/openai/openai/v1`, + headers: { 'api-key': 'test-key' }, maxRetries: 0, }], }) - const result = await assemble(ctx, { provider: 'azure-openai-responses', model: 'gpt-5.5', messages: [] }) + const result = await assemble(ctx, { provider: 'openai', model: 'gpt-5.5', messages: [] }) expect(result.finish.kind).toBe('error') - expect(server.paths).toEqual(['/api/projects/openai/openai/responses?api-version=v1']) + expect(server.paths).toEqual(['/api/projects/openai/openai/v1/responses']) expect(server.headers[0]?.['api-key']).toBe('test-key') }) diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index b830bfe08b..a2fcc2c143 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -7,22 +7,24 @@ import type { PiAiReplayState } from '../src/replay.ts' import { assemble, type AssembledResult } from './assemble.ts' interface ProviderCase { - provider: 'azure-openai-responses' | 'anthropic' - api: 'azure-openai-responses' | 'anthropic-messages' + provider: 'openai' | 'anthropic' + api: 'openai-responses' | 'anthropic-messages' model: string apiKey?: string baseURL?: string + headers?: Record } -const azureOpenAIBaseURL = process.env.DSH_PI_AI_AZURE_OPENAI_BASE_URL ?? process.env.AZURE_OPENAI_BASE_URL +const openAIBaseURL = process.env.DSH_PI_AI_OPENAI_BASE_URL +const azureOpenAIKey = process.env.AZURE_OPENAI_API_KEY const providerCases: ProviderCase[] = [ { - provider: 'azure-openai-responses', - api: 'azure-openai-responses', - model: process.env.DSH_PI_AI_AZURE_OPENAI_MODEL ?? 'gpt-5.5', - ...process.env.AZURE_OPENAI_API_KEY ? { apiKey: process.env.AZURE_OPENAI_API_KEY } : {}, - ...azureOpenAIBaseURL ? { baseURL: azureOpenAIBaseURL } : {}, + provider: 'openai', + api: 'openai-responses', + model: process.env.DSH_PI_AI_OPENAI_MODEL ?? 'gpt-5.5', + ...azureOpenAIKey ? { apiKey: azureOpenAIKey, headers: { 'api-key': azureOpenAIKey } } : {}, + ...openAIBaseURL ? { baseURL: openAIBaseURL } : {}, }, { provider: 'anthropic', @@ -43,6 +45,7 @@ async function harness(): Promise { provider: profile.provider, ...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey }, ...profile.baseURL === undefined ? {} : { baseURL: profile.baseURL }, + ...profile.headers === undefined ? {} : { headers: profile.headers }, })), }) return ctx From fc478d675c8ce32bc838f2f56a60ac0e0cdc1511 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 19 Jul 2026 14:10:47 +0800 Subject: [PATCH 22/37] fix(ci): suppress bearer auth for Azure API keys --- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 3 ++- packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 26fbb392de..af791bd806 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -176,7 +176,7 @@ describe('PiAiAdapter provider routing', () => { provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/api/projects/openai/openai/v1`, - headers: { 'api-key': 'test-key' }, + headers: { 'api-key': 'test-key', Authorization: '' }, maxRetries: 0, }], }) @@ -184,6 +184,7 @@ describe('PiAiAdapter provider routing', () => { expect(result.finish.kind).toBe('error') expect(server.paths).toEqual(['/api/projects/openai/openai/v1/responses']) expect(server.headers[0]?.['api-key']).toBe('test-key') + expect(server.headers[0]?.authorization).toBe('') }) it.each([ diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index a2fcc2c143..107c12d264 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -23,7 +23,9 @@ const providerCases: ProviderCase[] = [ provider: 'openai', api: 'openai-responses', model: process.env.DSH_PI_AI_OPENAI_MODEL ?? 'gpt-5.5', - ...azureOpenAIKey ? { apiKey: azureOpenAIKey, headers: { 'api-key': azureOpenAIKey } } : {}, + ...azureOpenAIKey + ? { apiKey: azureOpenAIKey, headers: { 'api-key': azureOpenAIKey, Authorization: '' } } + : {}, ...openAIBaseURL ? { baseURL: openAIBaseURL } : {}, }, { From 3956118c18badbacf0eec7e434e57a95c0335185 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 19 Jul 2026 14:53:39 +0800 Subject: [PATCH 23/37] fix(dsbench): adapt evaluation config to master runtime --- docs/config-catalog.md | 2 +- examples/dsbench-coding-agent/cordis.yml | 3 --- examples/dsbench-coding-agent/tests/keyless-smoke.e2e.ts | 2 +- examples/package.json | 3 +++ packages/ui/jsonrpc/src/index.ts | 4 +++- pnpm-lock.yaml | 9 +++++++++ 6 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5fe8ffe568..542728293c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -159,7 +159,7 @@ export interface SkillConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:57`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:59`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` diff --git a/examples/dsbench-coding-agent/cordis.yml b/examples/dsbench-coding-agent/cordis.yml index 67e1604b07..4d970c8b47 100644 --- a/examples/dsbench-coding-agent/cordis.yml +++ b/examples/dsbench-coding-agent/cordis.yml @@ -11,9 +11,6 @@ config: apiKey: !!js process.env.DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_BASE_URL - models: - - deepseek-v4-flash - - deepseek-v4-pro - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/dsbench-coding-agent/tests/keyless-smoke.e2e.ts b/examples/dsbench-coding-agent/tests/keyless-smoke.e2e.ts index 9bbc07a829..f5e2d7b938 100644 --- a/examples/dsbench-coding-agent/tests/keyless-smoke.e2e.ts +++ b/examples/dsbench-coding-agent/tests/keyless-smoke.e2e.ts @@ -97,7 +97,7 @@ describe('dsbench-coding-agent keyless smoke', () => { jsonrpc: '2.0', id: 1, method: 'initialize', - params: { cwd: root, model: 'deepseek-v4-pro' }, + params: { cwd: root, provider: 'deepseek', model: 'deepseek-v4-pro' }, })}\n`) const initialized = await waitForLine(lines, value => value.id === 1, () => stderr) expect(initialized).toMatchObject({ diff --git a/examples/package.json b/examples/package.json index 8d77731a05..01faf631ba 100644 --- a/examples/package.json +++ b/examples/package.json @@ -8,6 +8,7 @@ "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", "@deepseek-ai/dsh-acp-demo": "workspace:*", + "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-code-runtime-worker": "workspace:*", @@ -16,12 +17,14 @@ "@deepseek-ai/dsh-fs-policy": "workspace:*", "@deepseek-ai/dsh-hooks-claude": "workspace:*", "@deepseek-ai/dsh-hooks-codex": "workspace:*", + "@deepseek-ai/dsh-jsonrpc": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", "@deepseek-ai/dsh-llm-replay": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-stdio-demo": "workspace:*", diff --git a/packages/ui/jsonrpc/src/index.ts b/packages/ui/jsonrpc/src/index.ts index fe782c934f..099ef6008f 100644 --- a/packages/ui/jsonrpc/src/index.ts +++ b/packages/ui/jsonrpc/src/index.ts @@ -45,6 +45,8 @@ export const Config: Schema = Schema.object({ * owns root-context disposal for EOF and signals. */ export function apply(ctx: Context, config: JsonRpcConfig): void { + // Cordis applies the schema default before invoking the plugin. + const resolvedConfig = config as JsonRpcConfig & { maxTokensAsSuccess: boolean } // The later transport callback must dispose this plugin's fiber, not its ambient context. const fiber = ctx.fiber /* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */ @@ -56,7 +58,7 @@ export function apply(ctx: Context, config: JsonRpcConfig): void { const transport = new JsonRpcLineTransport(input, output) const server = new HarnessSdkServer(ctx, transport, { - maxTokensAsSuccess: config.maxTokensAsSuccess ?? false, + maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess, }) // Share one exit task and attempt flush and disposal independently before exiting. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c7e4a3d05..b363f3e88a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: '@deepseek-ai/dsh-acp-demo': specifier: workspace:* version: link:../packages/examples/acp-demo + '@deepseek-ai/dsh-agent-spine-demo': + specifier: workspace:* + version: link:../packages/examples/agent-spine-demo '@deepseek-ai/dsh-bash-local': specifier: workspace:* version: link:../packages/bash/bash-local @@ -125,6 +128,9 @@ importers: '@deepseek-ai/dsh-hooks-codex': specifier: workspace:* version: link:../packages/hooks/hooks-codex + '@deepseek-ai/dsh-jsonrpc': + specifier: workspace:* + version: link:../packages/ui/jsonrpc '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../packages/llm/llm @@ -143,6 +149,9 @@ importers: '@deepseek-ai/dsh-sandbox-local': specifier: workspace:* version: link:../packages/sandbox/sandbox-local + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:* + version: link:../packages/session-persistence/session-persistence-jsonl '@deepseek-ai/dsh-spill-local': specifier: workspace:* version: link:../packages/spill/spill-local From d2672460fed59aecc8ddcfd7325589d3afb7a8b6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 19 Jul 2026 15:36:48 +0800 Subject: [PATCH 24/37] fix(cli-demo): accept disabled task controls --- packages/examples/cli-demo/src/index.ts | 2 +- packages/examples/cli-demo/tests/cli-demo.spec.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/examples/cli-demo/src/index.ts b/packages/examples/cli-demo/src/index.ts index 1308209681..e5c77af9ed 100644 --- a/packages/examples/cli-demo/src/index.ts +++ b/packages/examples/cli-demo/src/index.ts @@ -61,7 +61,7 @@ export const Config: z = z.object({ toolOrder: z.array(z.string()).default(undefined as unknown as string[]), tools: ToolRegistry.Config, toolBash: agentCore.ToolBashConfigSchema, - toolTasks: agentCore.ToolTasksConfigSchema, + toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), }) /* jscpd:ignore-end */ diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts index 343d6184d7..2111ff6aa8 100644 --- a/packages/examples/cli-demo/tests/cli-demo.spec.ts +++ b/packages/examples/cli-demo/tests/cli-demo.spec.ts @@ -146,6 +146,21 @@ describe('dsh-cli-demo app composition', () => { expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined) }) + it('accepts false to keep task services without model-facing task controls', async () => { + const ctx = await mount({ + provider: 'mock', + model: 'mock', + skills: { enabled: false }, + toolTasks: false, + workspaceContext: false, + }) + + expect(ctx.get('tasks')).toBeDefined() + expect(ctx.get('tools')?.get('task_output')).toBeUndefined() + expect(ctx.get('tools')?.get('task_list')).toBeUndefined() + expect(ctx.get('tools')?.get('task_kill')).toBeUndefined() + }) + it('exposes the Loader-safe namespace plugin shape and schema', () => { expect(cliDemo.name).toBe('cli-demo') expect(cliDemo.Config).toBeDefined() From 5f9fe1415623d8fe7643a4c9a0bc764d6f7c2c2e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 19 Jul 2026 16:39:58 +0800 Subject: [PATCH 25/37] docs(bash-local): clarify spill cleanup semantics --- packages/bash/bash-local/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 8150119acd..159effec64 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -38,6 +38,6 @@ Indirectly, through `dsh-tool-bash`, which renders this executor's bounded stdou - **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them. - **POSIX-only** — the `bash` binary, detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported. - **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work. -- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are deleted immediately. +- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring. From bcc920369c5db08fdbd5ca0e40fac377955f25bf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:39:50 +0800 Subject: [PATCH 26/37] docs: document package KV cache effects --- docs/cookbook/adding-a-package.i18n.yaml | 4 +- docs/cookbook/adding-a-package.md | 8 ++- docs/cookbook/adding-a-package.zh.md | 8 ++- ...07-12-package-model-experience-contract.md | 14 ++--- packages/AGENTS.md | 2 +- packages/bash/bash-local/README.md | 2 + packages/bash/bash-sandbox/README.md | 6 ++ packages/bash/bash/README.md | 2 + packages/bash/tool-bash/README.md | 10 ++++ .../code-runtime-worker/README.md | 2 + packages/code-runtime/code-runtime/README.md | 2 + packages/compact/compact-basic/README.md | 6 ++ packages/compact/compact/README.md | 4 ++ packages/context/time-context/README.md | 2 + packages/context/workspace-context/README.md | 6 ++ packages/cordis/tool-cordis/README.md | 6 ++ packages/core/agent-loop/README.md | 6 ++ packages/core/agent/README.md | 4 ++ packages/core/session/README.md | 6 ++ packages/core/system-prompt/README.md | 4 ++ packages/core/tools/README.md | 6 ++ packages/examples/acp-demo/README.md | 2 + packages/examples/agent-spine-demo/README.md | 2 + packages/examples/cli-demo/README.md | 2 + packages/examples/jsonrpc-demo/README.md | 2 + packages/examples/stdio-demo/README.md | 4 ++ packages/fs/fs-local/README.md | 2 + packages/fs/fs-policy/README.md | 2 + packages/fs/fs/README.md | 2 + packages/fs/tool-fs-search/README.md | 8 +++ packages/fs/tool-fs/README.md | 10 ++++ packages/guard/repeat-tool-guard/README.md | 4 ++ packages/hooks/hook-protocol/README.md | 2 + packages/hooks/hooks-claude/README.md | 4 ++ packages/hooks/hooks-codex/README.md | 4 ++ packages/llm/llm-deepseek/README.md | 4 ++ packages/llm/llm-pi-ai/README.md | 4 ++ packages/llm/llm/README.md | 2 + packages/llm/token-meter/README.md | 2 + packages/mcp/mcp-client/README.md | 4 ++ packages/sandbox/sandbox-local/README.md | 2 + packages/sandbox/sandbox/README.md | 2 + packages/sdk/create-sdk/README.md | 2 + packages/sdk/helper/README.md | 2 + packages/sdk/scripts/README.md | 2 + .../session-persistence-jsonl/README.md | 2 + .../session-persistence-sqlite/README.md | 2 + .../session-persistence/README.md | 2 + .../session-query/session-query/README.md | 2 + packages/skill/skill-local/README.md | 2 + packages/skill/skill/README.md | 2 + packages/skill/tool-skill/README.md | 8 +++ packages/spill/spill-local/README.md | 2 + packages/spill/spill-policy/README.md | 2 + packages/spill/spill/README.md | 2 + packages/subagent/subagent-acp/README.md | 4 ++ packages/subagent/subagent-fork/README.md | 4 ++ .../subagent/subagent-inprocess/README.md | 8 +++ packages/subagent/subagent-spawn/README.md | 4 ++ .../subagent/subagent-subprocess/README.md | 2 + packages/subagent/subagent/README.md | 2 + packages/subagent/tool-subagent/README.md | 6 ++ packages/support/acp-snapshot/README.md | 2 + packages/support/agent-loop-testkit/README.md | 2 + packages/support/invariants/README.md | 2 + packages/support/llm-replay/README.md | 2 + packages/support/loader-smoke/README.md | 2 + packages/tasks/tasks/README.md | 2 + packages/tasks/tool-tasks/README.md | 6 ++ packages/timeout/timeout-policy/README.md | 2 + packages/todo/tool-todo/README.md | 4 ++ packages/ui/acp/README.md | 10 ++++ packages/ui/app-boot/README.md | 2 + packages/ui/jsonrpc/README.md | 2 + packages/ui/permission/README.md | 2 + packages/ui/stdio/README.md | 4 ++ packages/ui/tool-ask-user/README.md | 4 ++ packages/ui/tui/README.md | 4 ++ packages/ui/user-approval/README.md | 4 ++ packages/ui/user-interaction/README.md | 2 + packages/util/home/README.md | 2 + packages/util/retention/README.md | 2 + packages/util/timeout/README.md | 2 + packages/web/tool-web/README.md | 10 ++++ packages/web/web-fetch-local/README.md | 2 + packages/web/web-search-deepseek/README.md | 4 ++ packages/web/web-search-exa/README.md | 2 + packages/web/web-search-perplexity/README.md | 4 ++ packages/web/web/README.md | 2 + packages/workflow/tool-workflow/README.md | 6 ++ .../workflow/workflow-workerthread/README.md | 4 ++ packages/workflow/workflow/README.md | 2 + .../verify-package-readme-model-experience.ts | 58 +++++++++++++------ 93 files changed, 366 insertions(+), 34 deletions(-) diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 27c31ba1ef..692c7448ca 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -adding-a-package.md: 2930cee9ab64b382f6211335ae639bce45629d1d -adding-a-package.zh.md: 10e906c320203c3658c103fc8936540f72697d65 +adding-a-package.md: 2e4374caff589199288e67bbca58604231a17966 +adding-a-package.zh.md: 35b5b9a0705dd0b752d68093769a65c339f6b4e3 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 2930cee9ab..2e4374caff 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -16,7 +16,7 @@ packages/// src/index.ts # service default export or plugin (name/inject/apply/Config) tests/.spec.ts README.md # service API, events, extension points, design notes, - # + gated Model Experience context blocks or short sentence + # + gated Model Experience context blocks or short form # + the gated "Known Limitations and Deferred Work" section # (or a whitelist entry in scripts/verify-package-readme-limitations.ts) ``` @@ -55,6 +55,8 @@ Keep package-specific service API, config, events, extension points, and design **Token effect**: Fixed, conditional, retained, replaced, capped, or zero-direct token effect. +**KV Cache effect**: Append-only, prefix-stable, replacing, or independent behavior, including the exact conditions that may invalidate reuse. + #### Verbatim text for this context surface, when needed ```markdown @@ -66,9 +68,9 @@ Stable system-prompt prose of any length, or another long non-generated literal, - **Consumer-visible gap** — exact boundary, consequence, or maintainer constraint. ```` -Fill Model Experience from the implementation. Use one H3 per direct, conditional, capped, lifetime, or auxiliary-model surface, with the two fields shown above. Quote stable text owned by the package: system-prompt prose goes in a titled H4 plus `markdown` fence, other short literals stay inline with named placeholders, and other long literals use the same nested form. Summarize only data-dependent or provider-owned text. A tool-schema surface links its anchored section in the generated [tool catalog](../tool-catalog.md) and states only deltas absent there. Keep prompt and schema surfaces separate when scoping can hide one without the other. The [prose standard](../../.agents/skills/dsh-prose-standard/SKILL.md) governs completeness and ownership; the verifier enforces the mechanical shape. +Fill Model Experience from the implementation. Use one H3 per direct, conditional, capped, lifetime, or auxiliary-model surface, with the three fields shown above. Quote stable text owned by the package: system-prompt prose goes in a titled H4 plus `markdown` fence, other short literals stay inline with named placeholders, and other long literals use the same nested form. Summarize only data-dependent or provider-owned text. A tool-schema surface links its anchored section in the generated [tool catalog](../tool-catalog.md) and states only deltas absent there. Keep prompt and schema surfaces separate when scoping can hide one without the other. In `KV Cache effect`, distinguish append-only growth, a stable repeated prefix, replacement of earlier request tokens, and an independent model request, then name the package-owned changes that can invalidate reuse. “Does not invalidate” means the package preserves an already-reusable prefix; provider cache availability and eviction remain outside the package contract. The [prose standard](../../.agents/skills/dsh-prose-standard/SKILL.md) governs completeness and ownership; the verifier enforces the mechanical shape. -A package with no context effect or one consumer-owned path uses the audited `None, as ` or `Indirectly, through ` sentence in [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts); a model-agnostic generic package may instead join `NO_MODEL_EXPERIENCE_SECTION`. Do not expand either case into a description of another package's work. The limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) is independent. The [Model Experience RFC](../rfc/implemented/process/2026-07-12-package-model-experience-contract.md) records the rationale. +A package with no context effect or one consumer-owned path uses the audited `None, as ` or `Indirectly, through ` sentence in [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts), followed by a non-empty `**KV Cache effect**` field; a model-agnostic generic package may instead join `NO_MODEL_EXPERIENCE_SECTION`. Do not expand either case into a description of another package's work. The limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) is independent. The [Model Experience RFC](../rfc/implemented/process/2026-07-12-package-model-experience-contract.md) records the rationale. ## 5. Verify diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index 10e906c320..35b5b9a070 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -16,7 +16,7 @@ packages/// src/index.ts # service default export or plugin (name/inject/apply/Config) tests/.spec.ts README.md # service API, events, extension points, design notes, - # + gated Model Experience context blocks or short sentence + # + gated Model Experience context blocks or short form # + the gated "Known Limitations and Deferred Work" section # (or a whitelist entry in scripts/verify-package-readme-limitations.ts) ``` @@ -55,6 +55,8 @@ package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-c **Token effect**: Fixed, conditional, retained, replaced, capped, or zero-direct token effect. +**KV Cache effect**: Append-only, prefix-stable, replacing, or independent behavior, including the exact conditions that may invalidate reuse. + #### Verbatim text for this context surface, when needed ```markdown @@ -66,9 +68,9 @@ Stable system-prompt prose of any length, or another long non-generated literal, - **Consumer-visible gap** — exact boundary, consequence, or maintainer constraint. ```` -根据实现填写 Model Experience。每个直接、条件、上限、生命周期或辅助模型的 surface 使用一个 H3,包含上述两个字段。引用包拥有的稳定文本:系统提示词放在带标题的 H4 加 `markdown` 围栏中,其他短文本以命名占位符内联,其他长文本使用相同的嵌套形式。仅概述数据依赖或提供方拥有的文本。tool-schema surface 链接到生成的[工具目录](../tool-catalog.md)中对应的锚定章节,仅说明该处缺失的差异。当作用域可以隐藏 prompt 或 schema 其中之一而不影响另一个时,将二者分开。[行文标准](../../.agents/skills/dsh-prose-standard/SKILL.md)约束完整性与归属;验证器强制执行机械形状。 +根据实现填写 Model Experience。每个直接、条件、上限、生命周期或辅助模型的 surface 使用一个 H3,包含上述三个字段。引用包拥有的稳定文本:系统提示词放在带标题的 H4 加 `markdown` 围栏中,其他短文本以命名占位符内联,其他长文本使用相同的嵌套形式。仅概述数据依赖或提供方拥有的文本。tool-schema surface 链接到生成的[工具目录](../tool-catalog.md)中对应的锚定章节,仅说明该处缺失的差异。当作用域可以隐藏 prompt 或 schema 其中之一而不影响另一个时,将二者分开。填写 `KV Cache effect` 时,应区分仅追加增长、稳定重复的前缀、替换既有请求 token 和独立模型请求,并列出会使缓存复用失效、且由本包拥有的变化。“不使缓存失效”仅表示本包保留了已有的可复用前缀;缓存是否可用以及何时淘汰不属于本包契约。[行文标准](../../.agents/skills/dsh-prose-standard/SKILL.md)约束完整性与归属;验证器强制执行机械形状。 -没有上下文效果或仅有消费方拥有路径的包使用 [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts) 中经过审计的 `None, as ` 或 `Indirectly, through ` 语句;与模型无关的通用包可以改为加入 `NO_MODEL_EXPERIENCE_SECTION`。两种情况都不要展开为对另一个包工作的描述。limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) 独立管理。[Model Experience RFC](../rfc/implemented/process/2026-07-12-package-model-experience-contract.md) 记录了设计动机。 +没有上下文效果或仅有消费方拥有路径的包使用 [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts) 中经过审计的 `None, as ` 或 `Indirectly, through ` 语句,随后添加非空的 `**KV Cache effect**` 字段;与模型无关的通用包可以改为加入 `NO_MODEL_EXPERIENCE_SECTION`。两种情况都不要展开为对另一个包工作的描述。limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) 独立管理。[Model Experience RFC](../rfc/implemented/process/2026-07-12-package-model-experience-contract.md) 记录了设计动机。 ## 5. 验证 diff --git a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md index 0f9b0b02a0..5fd5d2b7c2 100644 --- a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md +++ b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md @@ -4,28 +4,28 @@ Status: implemented ## Problem -A package README can explain APIs and runtime mechanics without answering the question that dominates an agent harness's behavior and cost: what from this package reaches a model request, under which conditions, and how long those tokens remain. The omission is especially hard to audit in a plugin architecture. A consumer may turn a backend result into a tool message, a policy plugin may replace success with an error, compaction may remove old history, and an agent-scoped registration may change one agent's prompt or schemas while leaving every other agent unchanged. Reading only the nominally model-facing packages therefore misses real context effects, while reading source across every dependency is too expensive for routine review. +A package README can explain APIs and runtime mechanics without answering the questions that dominate an agent harness's behavior and cost: what from this package reaches a model request, under which conditions, how long those tokens remain, and whether later requests preserve a reusable KV-cache prefix. The omission is especially hard to audit in a plugin architecture. A consumer may turn a backend result into a tool message, a policy plugin may replace success with an error, compaction may remove old history, and an agent-scoped registration may change one agent's prompt or schemas while leaving every other agent unchanged. Reading only the nominally model-facing packages therefore misses real context effects, while reading source across every dependency is too expensive for routine review. ## Decision Every workspace package README with a model-facing or model-adjacent contract ends with the canonical [Model Experience section](../../../cookbook/adding-a-package.md#4-write-the-package-readme), immediately before `## Known Limitations and Deferred Work`; a package on the no-limitations allowlist ends with Model Experience itself. An audited model-agnostic generic package omits the section through `NO_MODEL_EXPERIENCE_SECTION`. -Packages with direct, conditional, capped, lifetime, multi-surface, or auxiliary-model effects use one H3 per context surface. Each names what the relevant model receives and when, then classifies the token effect. Stable package-owned text is quoted exactly: system-prompt prose and other long literals use a nested H4 plus `markdown` fence, while short literals stay inline with named interpolation placeholders. Tool-schema surfaces link their anchored section in the generated [tool catalog](../../../tool-catalog.md) and state only composition or configuration deltas; runtime-only definitions explain why the catalog omits them. Data-dependent and provider-owned text is summarized. Agent-scoped visibility is explicit, and prompt and schema surfaces remain separate when scoping can hide one without the other. +Packages with direct, conditional, capped, lifetime, multi-surface, or auxiliary-model effects use one H3 per context surface. Each names what the relevant model receives and when, classifies the token effect, and states the KV-cache effect. The cache field distinguishes append-only growth, a stable repeated prefix, replacement of earlier tokens, and an independent model request; it names every package-owned configuration, scope, lifecycle, compaction, or routing change that can alter the request before newly appended content. “Does not invalidate” means the package preserves an already-reusable prefix, not that a provider promises a cache hit or retention period. Stable package-owned text is quoted exactly: system-prompt prose and other long literals use a nested H4 plus `markdown` fence, while short literals stay inline with named interpolation placeholders. Tool-schema surfaces link their anchored section in the generated [tool catalog](../../../tool-catalog.md) and state only composition or configuration deltas; runtime-only definitions explain why the catalog omits them. Data-dependent and provider-owned text is summarized. Agent-scoped visibility is explicit, and prompt and schema surfaces remain separate when scoping can hide one without the other. -A package with no model-context effect, or one path rendered entirely by another package, uses the verifier's audited one-sentence form: `None, as ` or `Indirectly, through `. Pure transport and keyless test-support packages use the none form when they create no model-bound content. Provider backends use the indirect form even when they cap or filter data, and wiring bundles use it when named children own every effect. These sentences locate the contribution without restating the consumer. Structured sections likewise document only package-owned inputs, transformations, and deltas. +A package with no model-context effect, or one path rendered entirely by another package, uses the verifier's audited short form: one sentence beginning `None, as ` or `Indirectly, through ` followed by a `**KV Cache effect**` field. Pure transport and keyless test-support packages use the none form when they create no model-bound content. Provider backends use the indirect form even when they cap or filter data, and wiring bundles use it when named children own every effect. These sections locate the contribution and disclaim direct cache invalidation without restating the consumer. Structured sections likewise document only package-owned inputs, transformations, and deltas. -`verify-package-readme-model-experience` discovers package manifests and validates the three classifications, canonical final-section order, required fields, concrete literal evidence, nested verbatim blocks, and anchored tool-catalog links. It runs in `doc-sync` and the parallel gate runner. Review still owns coverage, link relevance, and factual accuracy. +`verify-package-readme-model-experience` discovers package manifests and validates the three classifications, canonical final-section order, required model-view, token, and KV-cache fields, concrete literal evidence, nested verbatim blocks, and anchored tool-catalog links. It runs in `doc-sync` and the parallel gate runner. Review still owns coverage, link relevance, and factual accuracy. ## Alternatives considered - **Document only packages that register prompts or tools** — rejected because backends, policy plugins, adapters, persistence, scoping, and compaction change the content or lifetime of tokens without owning a model-facing schema. - **Generate one central context-cost catalog from source** — rejected because an AST can find registrations but cannot infer semantic conditions such as history retention, output truncation, parent-versus-child visibility, or an auxiliary model boundary. The package README is the implementation-local contract; a central copy would add another drift surface. - **Require numeric token counts** — rejected because exact counts depend on the selected model tokenizer, adapter serialization, configuration, and runtime data. The stable contract is the growth shape: fixed per request, conditional per call, retained, replaced, capped, or zero-direct. -- **Use a three-column table** — rejected because exact source text and conditional result shapes make cells dense and difficult to scan. Repeated subsections give each context surface readable vertical space while preserving the same fields. +- **Use a table** — rejected because exact source text and conditional result shapes make cells dense and difficult to scan. Repeated subsections give each context surface readable vertical space while preserving the same fields. - **Allow every zero-impact package to omit the section** — rejected because unconstrained absence is ambiguous between an audited zero and forgotten documentation. Omission is reserved for model-agnostic generic packages named with a reason in the verifier; model-adjacent zero-impact packages keep one explicit sentence. -- **Require the full structured form for audited zero or simple indirect packages** — rejected because it repeats labels around one fact. A gated sentence preserves explicit coverage without the ceremony. +- **Require the full structured form for audited zero or simple indirect packages** — rejected because it repeats labels around one fact. A gated sentence plus cache field preserves explicit coverage without the ceremony. - **Convention without a gate** — rejected because a repo-wide contract must also cover every future package; review memory cannot reliably detect an omitted README section. ## Consequences -A reviewer can start at any model-facing or model-adjacent package and see its contribution to the conversation model, child models, and auxiliary calls without reconstructing the full plugin graph. Token-budget work can distinguish repeated request overhead from data-dependent history, and agent-scoped changes have an explicit documentation checkpoint. Package authors maintain one or more compact context-surface blocks or one classified sentence whenever model-visible behavior changes; audited generic packages carry no irrelevant model boilerplate. The structured fields do not promise provider-exact token counts; measurements remain model- and workload-specific, while the documented growth and visibility contract stays stable. +A reviewer can start at any model-facing or model-adjacent package and see its contribution to the conversation model, child models, and auxiliary calls without reconstructing the full plugin graph. Token-budget work can distinguish repeated request overhead from data-dependent history, while cache-sensitive work can identify append-only paths and the earliest package-owned prefix mutation. Agent-scoped changes have an explicit documentation checkpoint. Package authors maintain one or more compact context-surface blocks or one classified short form whenever model-visible behavior changes; audited generic packages carry no irrelevant model boilerplate. The structured fields do not promise provider-exact token counts or cache hits; measurements remain model-, provider-, and workload-specific, while the documented growth, visibility, and prefix-stability contract stays stable. diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 9ab0327cf9..38713e2e58 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -14,5 +14,5 @@ Naming notes: - `src/types.ts` contains only types — no runtime code. - Tests live at package level under `tests/`, not `src/__tests__/`. - A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code. -- Package READMEs document model/token effects using the [canonical Model Experience format](../docs/cookbook/adding-a-package.md#4-write-the-package-readme). +- Package READMEs document model, token, and KV-cache effects using the [canonical Model Experience format](../docs/cookbook/adding-a-package.md#4-write-the-package-readme). - Package READMEs put durable consumer gaps and non-obvious maintainer constraints under `## Known Limitations and Deferred Work`; ordinary cleanup stays in its TODO or RFC. Packages with none use a justified [allowlist entry](../scripts/verify-package-readme-limitations.ts) ([rationale](../docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md)). diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index afa5000767..cf9f86575e 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -31,6 +31,8 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; Indirectly, through `dsh-tool-bash`, which renders this executor's bounded stdout/stderr tails, background-process deltas, spill-file paths, and infrastructure failures. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`. diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index 347aefb679..ab8b6c62dd 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -42,18 +42,24 @@ The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landloc **Token effect**: Small fixed schema increment on requests where `bash` is visible; mode switches add no context tokens. +**KV Cache effect**: Prefix-stable while the executor advertises the same sandbox capabilities. Changing those capabilities alters the `bash` schema and may invalidate reuse from that definition; per-session mode switches do not. + ### Bash tool result, indirectly **What the model sees**: After ordinary bounded output, a denied call appends exactly `[sandbox: file access denied under mode]`. When escalation is available it next appends `[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`. A settled background runner failure instead appends `[sandbox: the sandbox runner itself failed under mode — the command did not run; this is a sandbox problem, not a command failure]`. **Token effect**: Zero additional tokens on an unremarkable allowed run beyond ordinary output. Denial or failure adds the quoted conditional marker, retained until compaction. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Bash tool error, indirectly **What the model sees**: If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the first stderr line as its detail. **Token effect**: Conditional error text is visible for that call and retained in history until compaction. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **Confinement covers file effects only** — network access and process visibility are unchanged, so the modes are not a general-purpose security sandbox. diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index e4b5bf1952..fffc206bd4 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -37,6 +37,8 @@ The seam also owns the per-session mode override vocabulary: the log-only `'bash Indirectly, through `dsh-tool-bash`, which turns executor output and sandbox facts into guidance and retained tool-result tokens. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **No interactive-input vocabulary** — `stdin` is written once at spawn and closed; the seam has no channel to feed a running task and no PTY session concept. diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index d5e65f1a39..eece3249eb 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -77,6 +77,8 @@ For sandboxing executors, each call resolves mode as one-shot escalation, then s **Token effect**: Small fixed input cost per request while the plugin is active, unchanged by sandbox mode or mode switches. +**KV Cache effect**: Prefix-stable while the registration scope and prompt text are unchanged. Plugin activation or disposal may invalidate reuse from this prompt section; sandbox mode switches do not. + #### Bash guidance ```markdown @@ -89,24 +91,32 @@ Check the [exit code: N] marker on every bash result; investigate failures befor **Token effect**: Fixed schema cost on every request where the tools are visible; sandbox support adds the escalation fields and its conditional description paragraph. +**KV Cache effect**: Prefix-stable while visibility, background support, and executor sandbox capabilities are unchanged. A restriction, config change, or executor change may invalidate reuse from the first changed tool definition. + ### Foreground result **What the model sees**: The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. With no output it emits exactly `(no output)`. Conditional lines are exactly `[output truncated; full output: ]`, `[sandbox: file access denied under mode]`, `[timed out after ms]`, `[killed by signal: ]`, and `[exit code: ]`; the sandbox escalation and runner-failure lines are quoted in [`dsh-bash-sandbox`](../bash-sandbox/README.md). **Token effect**: Zero result tokens before a call. Output is bounded per stream, while each emitted line remains in history until compaction. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Background task context and results **What the model sees**: Start returns exactly `started background task `. This producer supplies incremental process output, optional `[some output was dropped from memory; full output: ]`, sandbox facts, and terminal detail such as `exit code: ` or `signal: ` to the generic task runtime. [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md) owns the visible status line, completion notice, listing, and cancellation response. **Token effect**: The start acknowledgement is small and retained; collected output is data-dependent and bounded by the executor's stream buffers. Consuming reads do not repeat prior output. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Tool errors **What the model sees**: Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "" is not strictly wider than this call's current "" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`. **Token effect**: Only the failing call adds these retained tokens; a rejected escalation does not add command output because the command does not run. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay; a display-only known residual. diff --git a/packages/code-runtime/code-runtime-worker/README.md b/packages/code-runtime/code-runtime-worker/README.md index 342799dabb..ab39d027ba 100644 --- a/packages/code-runtime/code-runtime-worker/README.md +++ b/packages/code-runtime/code-runtime-worker/README.md @@ -37,6 +37,8 @@ The SDK surface is the default/named `WorkerCodeRuntime` class plus `Config`. Th Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders this worker's capped printed or returned data and exact `[dsh-code-runtime-worker] log capture truncated at bytes` and `… [truncated]` markers into a retained `run_code` result. Binding traffic and worker internals stay outside context. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **OS processes a program spawns survive termination** — `worker.terminate()` ends the thread only, weaker than bash-local's process-group kill; orphan cleanup is a deployment concern until a container backend exists. diff --git a/packages/code-runtime/code-runtime/README.md b/packages/code-runtime/code-runtime/README.md index 1f680741a1..49b79e1f52 100644 --- a/packages/code-runtime/code-runtime/README.md +++ b/packages/code-runtime/code-runtime/README.md @@ -22,6 +22,8 @@ Semantics every implementation must honor (contract details in the class JSDoc): Indirectly, through Code Mode in `dsh-tools`, which exposes `run_code` and returns program logs, values, or failures as retained tool-result tokens. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **`run()` is one-shot** — `logs` arrive only on the resolved `CodeRunResult`; the seam exposes no streaming-log or progress surface for a live program's output. diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 69dd0c5036..77a1e49a5d 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -60,6 +60,8 @@ Loading the plugin registers `ctx.compact`. With `auto: true` (the default) it c **Token effect**: The replacement reduces future input history rather than appending a second copy. The summary remains until a later compaction replaces it; one oversized indivisible unit can still exceed the budget. +**KV Cache effect**: Replacing rather than append-only. Each checkpoint invalidates reuse from the first replaced history token; the unchanged request prefix before that range remains reusable. + #### Conversation checkpoint preamble ```markdown @@ -72,12 +74,16 @@ This is an automatically generated checkpoint condensing an earlier span of the **Token effect**: This is a separate model call with data-dependent input and `maxTokens`-capped output. Convergence retries can pay this cost more than once. +**KV Cache effect**: Independent of the conversation request cache. An auxiliary call can reuse an exact transcript prefix, while a different selected range or rendering invalidates reuse from its first changed token. + ### Auxiliary summarizer system prompt **What the model sees**: The summarization model receives the checkpoint-writing instruction below. **Token effect**: Fixed auxiliary input cost plus the data-dependent transcript on every summarization attempt. +**KV Cache effect**: Prefix-stable for auxiliary calls while this instruction and the summarizer route are unchanged. Changing either starts a different prefix; transcript changes occur after the instruction. + #### Auxiliary summarizer system prompt ```markdown diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 55abc2b12e..65f89ff4a1 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -65,12 +65,16 @@ Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and **Token effect**: Zero direct tokens from this interface. A backend trades many retained history tokens for one summary and leaves the recent tail unchanged. +**KV Cache effect**: A successful backend replacement invalidates reuse from the first shadowed history token; the seam itself does not alter a request. + ### Transcript supplied to a compaction consumer **What the model sees**: `renderTranscript()` joins entries with one blank line and renders them exactly as `User: `, `Assistant: `, `Tool result (call ): `, `Tool error (call ): `, `[Context: ]`, or `[Steering: ]`. Non-text blocks render exactly as `[reasoning: ]`, `[tool-call: ()]`, `[tool-result: ]`, `[tool-result]`, or `[]`. **Token effect**: Data-dependent input tokens are paid only by the auxiliary model or consumer that requests this transcript; the conversation model does not receive a duplicate transcript. +**KV Cache effect**: No conversation-cache invalidation. A consumer's auxiliary request can reuse only the exact prefix produced by this rendering; changed or compacted entries invalidate reuse from their first difference. + ## Known Limitations and Deferred Work - **No model-facing consumer tier yet** — `@deepseek-ai/dsh-tool-compact` (the `/compact` tool) is deferred; compaction is reachable only via direct `ctx.compact` calls or a backend's auto listener. diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index fea92e726f..e5d462e75a 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -36,6 +36,8 @@ The time reading stays in derived conversation history until a later compaction **Token effect**: Each injected two-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every eligible preparation attempt. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + #### First step ```markdown diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index 430ffe8a41..a2e4a70596 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -82,6 +82,8 @@ Instruction content is read through `streamText()` under `maxSourceBytes`, even **Token effect**: The rendered baseline is frozen and resent on every request in that loop instance. `maxBytes` bounds the complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens. +**KV Cache effect**: Prefix-stable within one loop instance because the baseline is frozen. A new or resumed instance recomposes it, so instruction, precedence, cwd, candidate, or byte-budget changes may invalidate reuse from the first changed baseline token. + #### Baseline instruction template ```markdown @@ -104,6 +106,8 @@ Instructions from: AGENTS.md **Token effect**: Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and Code Mode defers the same message until after the outer `run_code` result. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + #### Additional instruction template ```markdown @@ -122,6 +126,8 @@ These instructions apply to work under `packages/app`. Use them as guidance when **Token effect**: Each confirmed change or removal is one retained history message bounded by `maxBytes`. Provider failures add no message, and an update omitted by the budget remains eligible for a later filesystem touch. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + #### Removal notice ```markdown diff --git a/packages/cordis/tool-cordis/README.md b/packages/cordis/tool-cordis/README.md index c43e2fcc4d..73672048af 100644 --- a/packages/cordis/tool-cordis/README.md +++ b/packages/cordis/tool-cordis/README.md @@ -40,18 +40,24 @@ Namespace plugin: named exports `name` / `inject` / `Config` / `apply`, no defau **Token effect**: Fixed schema cost on every request in that tool view. +**KV Cache effect**: Prefix-stable while this tool view is unchanged. Scoping or plugin lifecycle changes that hide these definitions may invalidate reuse from the first changed schema token. + ### Tool-call history and results **What the model sees**: Inspect joins selected sections exactly as `##
` then a newline and the data-dependent body, with one blank line between sections. Its broad API/event reports omit JSDoc; `name` with `what: "api"` or `what: "events"` returns one exact target with its original JSDoc. Mount returns `mounted (plugin "", state: )`, optionally inserting ` — waiting for service(s): (activates when provided)` before the closing parenthesis. Unmount returns `unmounted (plugin "")`; an unknown id becomes `Error: no dynamic plugin with id "" (list mounts with cordis_inspect what:"dynamic")`. The submitted mount program remains in the assistant tool-call history. **Token effect**: Inspect output and mount code are data-dependent and resent until compaction; lifecycle acknowledgements are small. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Later requests after a mount **What the model sees**: A mounted plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; unmount removes those contributions after quiescence. **Token effect**: Indirect token impact equals the mounted plugin's contributions and lasts only for the mount lifetime. +**KV Cache effect**: Mounting or unmounting a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged mount set remains prefix-stable. + ## Known Limitations and Deferred Work - **The sandbox is containment for honest code, not a security boundary** — host-realm helpers on the sandbox global are reachable, so mount code can reach Node; load this plugin as deliberately as you would grant a bash tool (see § Trust stance). diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index d604e2b16f..1045d26a62 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -76,18 +76,24 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p **Token effect**: System text, schemas, and prefix are paid again on every step. Per-agent scoping chooses the initial contributions, while the authoritative assembly waterfall can alter the final request and makes its listener responsible for protocol coherence. +**KV Cache effect**: Append-only only while system text, schemas, session prefix, and earlier history remain byte-identical under the same provider and model route. A token-bearing assembly rewrite or composition change may invalidate reuse from the first altered request token. + ### Retained message history **What the model sees**: Accepted user messages, assistant messages, tool calls and results, injected context, and steering are logged and sent on later steps. Raw stream chunks, lifecycle boundaries, and other log-only events are excluded. **Token effect**: Input grows with every surface message until a compaction replacement shadows older nodes; a multi-step tool turn resends the accumulated prefix and history each step. +**KV Cache effect**: Ordinary history growth is append-only and preserves reusable entries. A surface replacement or compaction invalidates reuse from the first shadowed history token. + ### Undispatched calls after cancellation **What the model sees**: If a later request replays an aborted step, each tool call that cancellation prevented from dispatching has the error result text `Error: tool call skipped because the step was aborted before execution`. **Token effect**: One fixed error result per skipped call remains in history until compaction shadows it. +**KV Cache effect**: Append-only; each synthetic result follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **Classification is unary** — calls whose safety depends on comparing siblings or resources must remain exclusive ([rationale](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md)). diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index bf5e563443..0443061e09 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -75,12 +75,16 @@ The handle every plugin programs against: **Token effect**: Accepted content becomes retained history or a repeated session prefix; blocked content contributes no request tokens. Size is caller- and plugin-dependent. +**KV Cache effect**: Accepted history and steering are append-only; a blocked submission sends no request. A session prefix remains stable within its loop instance, while a new or resumed instance may establish a different prefix. + ### Agent-scoped request composition **What the model sees**: Registrations through `agent.ctx` can shadow prompt sections or tools and can install agent-only interceptors during unpublished setup. **Token effect**: The package adds zero tokens itself; scoped contributions affect only that agent and disappear on disposal. +**KV Cache effect**: Prefix-stable while an agent's scoped registrations are unchanged. Setup or reload that changes prompt sections, tool definitions, or request listeners may invalidate reuse from the first affected request token. + ## Known Limitations and Deferred Work - **Initiator scope is process-local** — workers, child processes, HTTP, durable queues, and restarts materialize any required identity explicitly. diff --git a/packages/core/session/README.md b/packages/core/session/README.md index ba4e8ea94a..13d859e5f7 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -89,18 +89,24 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) **Token effect**: Appended surface entries are resent on later steps. A `replace` surface operation removes the shadowed entries from future inputs without deleting their raw log records. +**KV Cache effect**: Appended surface entries preserve reusable prefixes. A `replace` operation invalidates reuse from the first shadowed message even though the underlying event log stays append-only. + ### Crash-repair result **What the model sees**: If a persisted turn ended with unanswered tool calls, each synthetic error result contains exactly `Tool call interrupted by a crash; no result was recorded.` **Token effect**: Zero tokens in an intact session. Each repaired call adds this retained error text on resume. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Logged request header **What the model sees**: The session reconstructs the system prompt, tool schemas, call config, and session prefix that the loop actually sent. Header events do not add a second copy to message history; the prefix is prepended outside `deriveMessages()`. **Token effect**: Zero duplicate tokens from logging. The reconstructed prefix, system text, and schemas still incur their normal per-request cost. +**KV Cache effect**: Logging causes no invalidation, and exact reconstruction preserves request-prefix identity. A later header with changed prefix, prompt, or schemas may invalidate reuse from its first difference. + ## Known Limitations and Deferred Work - **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`. diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index e8975bf17e..05cc92aaf2 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -48,6 +48,8 @@ Design rationale: [the prompt-variables RFC](../../../docs/rfc/implemented/archi **Token effect**: Identity is a fixed per-request cost. Persona and plugin text are repeated per request and scale with their rendered content. +**KV Cache effect**: Prefix-stable while identity, persona, variables, section text, and order render identically. Any change may invalidate reuse from the first changed system-prompt token. + #### Harness identity ```markdown @@ -60,6 +62,8 @@ You are an AI agent powered by the DeepSeek Harness SDK. **Token effect**: Schema tokens repeat on every request. Restricting a tool removes its entire schema cost for that agent but not a separate prompt section; reordering changes cache shape but not semantic content. +**KV Cache effect**: Prefix-stable while the visible schema set, rendering, and order are unchanged. Registration, restriction, or reordering may invalidate reuse from the first changed schema token. + ## Known Limitations and Deferred Work - **Deployment-authored prompt text is config/composition only** — this plugin owns the global persona default, creator plugins may register agent-scoped shadows, and other sections come from the plugin that owns the fact; there is no end-user prompt-editing API. diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index bfd75fdcb6..b0b79d04cb 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -123,12 +123,16 @@ The agent loop groups consecutive `parallel` calls into a bounded rolling pool a **Token effect**: Fixed per-request cost proportional to the visible definitions. Restrictions that hide tools remove their entire schema cost for that agent. +**KV Cache effect**: Prefix-stable while visible definitions and their order are unchanged. Registration, disposal, or scoped restriction may invalidate reuse from the first changed schema token. + ### Code Mode schema and system prompt **What the model sees**: Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact `declare const tools` block. `both` exposes normal schemas and this Code Mode surface. **Token effect**: Fixed per-request cost proportional to the visible definitions. Code Mode trades end-tool schemas for generated SDK text plus one transport schema rather than promising a universal reduction. +**KV Cache effect**: Prefix-stable while the Code Mode selection, generated SDK, transport schema, and visible tool set are unchanged. Mode or filter changes may invalidate reuse from the first changed prompt or schema token. + #### Code Mode SDK instructions ```markdown @@ -150,6 +154,8 @@ The available tools: **Token effect**: Arguments, results, and additional context are data-dependent and resent until compaction. Restrictions that hide tools also remove their schemas before the model can call them. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **Concurrency policy is not an event seam** — `executionMode()` reads the resolved tool definition directly; plugins can only declare a classifier on definitions they own. diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 1dbda9a08a..98b62fc9bf 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -55,6 +55,8 @@ All diagnostics go to **stderr** — stdout is the protocol. Indirectly, through `dsh-agent-spine-demo` and `dsh-acp`, which compose each ACP agent's prompt, tools, and message history; this app bundle adds no model-bound content itself. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **JSONL persistence is baked in** — config chooses its root but cannot select a different backend; that requires a sibling entry or differently composed app package. diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 409aef5ef2..e2b3c47abe 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -56,6 +56,8 @@ A YAML include can deduplicate config but cannot own a bin or provide front-door Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, and `dsh-tools`, which this bundle mounts without adding model-bound wrapper content. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **The spine set is fixed in code** — `apply()` mounts every child unconditionally (including `tool-bash`); no config excludes or replaces one, so swapping the loop or dropping a spine member means composing a different bundle. diff --git a/packages/examples/cli-demo/README.md b/packages/examples/cli-demo/README.md index c938f6c583..40790885be 100644 --- a/packages/examples/cli-demo/README.md +++ b/packages/examples/cli-demo/README.md @@ -59,6 +59,8 @@ The headless-agent leaf supplies local bash, filesystem, skill, subagent, workfl **Token effect**: The task, prompt sections, tool schemas, assistant output, and tool results consume tokens on each model step. JSON event streaming and final rendering add no model tokens; delegated child work has its own model usage and is not included in the parent result's `usage` total. +**KV Cache effect**: Tool-round history is append-only while the one-shot agent's prompt, schemas, model route, and session prefix remain fixed. Changing that composition establishes a different request prefix; JSON output mode has no cache effect. + ## Known Limitations and Deferred Work - **One fresh top-level session per process** — its workspace cwd is the launch directory; there is no resume, second prompt, stdin context, or concurrent top-level session in this app. diff --git a/packages/examples/jsonrpc-demo/README.md b/packages/examples/jsonrpc-demo/README.md index 39cb4cd917..f7c9a71bea 100644 --- a/packages/examples/jsonrpc-demo/README.md +++ b/packages/examples/jsonrpc-demo/README.md @@ -20,6 +20,8 @@ stdout carries only JSON-RPC frames. The bin and boot guards diagnose on stderr, Indirectly, through the plugins loaded from the external `cordis.yml`, which own every model-bound prompt, schema, message, and result; this bin adds none of its own. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **The bin cannot prove that the config serves JSON-RPC** — a valid config with no `dsh-jsonrpc` entry boots successfully and serves nothing. diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md index ba4fc10101..4a50b5ccbd 100644 --- a/packages/examples/stdio-demo/README.md +++ b/packages/examples/stdio-demo/README.md @@ -83,12 +83,16 @@ Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — **Token effect**: Child prompt and schema costs repeat per request; user input and tool history grow until compaction. Terminal banners, logger output, cards, and rendered transcripts add zero model tokens. +**KV Cache effect**: User and tool history is append-only while the composed prompt, schemas, child model route, and session prefix remain fixed. A composition change or compaction may invalidate reuse from its first changed token; terminal rendering has no cache effect. + ### Human-answer result **What the model sees**: Through `dsh-tool-ask-user`, successful terminal answers use that package's exact compact JSON shape. Interruption becomes exactly `Error: ask_user_question was interrupted before the user answered`; a closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`. **Token effect**: Only a completed or failed tool call adds retained result tokens; prompts printed while waiting are terminal-only. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **One pre-created `main` agent drives the selected terminal UI** — there is no multi-session or concurrent-agent surface in this app; a run is one conversation. diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 65ed76efce..3403ef59fd 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -25,6 +25,8 @@ The package-root SDK surface is the default/named `LocalFileSystem` class plus ` Indirectly, through [`dsh-tool-fs`](../tool-fs/README.md), which renders this provider's line-windowed UTF-8 content, mutation acknowledgements, and exact provider messages in capped retained results while versions, atomic-write mechanics, and directory metadata remain internal. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **`config.cwd` is not a sandbox** — it is a resolution default, not containment: absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall ([capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences)). diff --git a/packages/fs/fs-policy/README.md b/packages/fs/fs-policy/README.md index bd85be88cf..d509e63645 100644 --- a/packages/fs/fs-policy/README.md +++ b/packages/fs/fs-policy/README.md @@ -55,6 +55,8 @@ Because the plugin influences the world only through events, removing it does no **Token effect**: Zero tokens on allowed operations beyond the ordinary tool result. A denial adds the small retained error result and avoids any success payload. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **Observed state does not survive a session resume** — persistence of the `WeakMap` record is deferred, so a resumed session must re-read files before guarded writes/edits. diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 6dae32d235..b67efe5fad 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -48,6 +48,8 @@ This package declares three events (see the generated [events catalog](../../../ Indirectly, through `dsh-tool-fs`, which renders provider text and errors as bounded, retained filesystem tool results. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Text-only by contract** — backends reject binary/non-UTF-8 content with `FS_NOT_TEXT`; binary-safe operations are a deliberate deferral of [the tool-schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md). diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 29afbebfec..4075890ba2 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -53,6 +53,8 @@ Search failures carry the package-owned `SearchError` (a `HarnessError` subclass **Token effect**: Fixed guidance cost per request while the plugin is active. +**KV Cache effect**: Prefix-stable while the plugin scope and guidance text are unchanged. Activation or disposal may invalidate reuse from this prompt section. + #### Glob guidance ```markdown @@ -71,18 +73,24 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read **Token effect**: Fixed schema cost on every request where the tools are visible. +**KV Cache effect**: Prefix-stable while tool visibility and definitions are unchanged. Registration lifecycle or scoped restrictions may invalidate reuse from the first changed schema token. + ### Results and spill notices **What the model sees**: `glob` returns one path per line; `grep` groups `Line : ` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved. **Token effect**: Inline paths and matches are bounded by `globMaxResults`, `grepMaxMatches`, and `grepMaxLineBytes`; the call and retained result remain in history until compaction. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Tool errors **What the model sees**: Failures are normalized as `Error: ` with structured `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, or `SEARCH_ABORTED` metadata for callers. **Token effect**: Only a failing call adds these retained tokens. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the bash workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation. diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 2f116a2b71..5bd59f8888 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -58,6 +58,8 @@ The package root exports only the Cordis plugin contract (`name`, `inject`, `Con **Token effect**: Fixed guidance cost per request while the plugin is active, even when a restriction hides one or more tools. +**KV Cache effect**: Prefix-stable while the plugin scope and guidance text are unchanged. Tool restrictions do not remove this section, but plugin activation or disposal may invalidate reuse from it. + #### Read guidance ```markdown @@ -82,24 +84,32 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces **Token effect**: Fixed schema cost on every request in that tool view. +**KV Cache effect**: Prefix-stable while the visible tool definitions and order are unchanged. Registration lifecycle or scoped restrictions may invalidate reuse from the first changed schema token. + ### Read result **What the model sees**: A successful read is exactly ``, newline, `file`, newline, ``, numbered lines as `: `, a blank line, one footer, and ``. The footer is exactly `(Output capped. Showing lines -. Use offset= to continue.)`, `(Showing lines - of . Use offset= to continue.)`, or `(End of file - total lines)`. A long line ends exactly `... (line truncated to chars)`. **Token effect**: Read output is capped by `readLimit`, `readMaxLineLength`, and `readMaxBytes`; the retained call and result are resent until compaction. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Write and edit results **What the model sees**: Write returns the exact five-line envelope ``, `file`, ``, `Created file` or `Updated file`, then ``. Edit returns exactly `The file has been updated successfully.` or, for `replace_all`, `The file has been updated. All occurrences were successfully replaced.` The full write or replacement text remains in the assistant tool-call arguments. **Token effect**: Success text is small, but large mutation arguments and any result are resent until compaction. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Tool errors **What the model sees**: Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, and `offset is out of range for "" ( lines)`; provider and policy templates are quoted in their package READMEs. **Token effect**: Only a failing call adds these retained tokens. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling [`dsh-tool-fs-search`](../tool-fs-search/) package supplies bash-backed `glob` and `grep` rather than extending the filesystem seam. diff --git a/packages/guard/repeat-tool-guard/README.md b/packages/guard/repeat-tool-guard/README.md index 2927e3f20a..2ca3edc46a 100644 --- a/packages/guard/repeat-tool-guard/README.md +++ b/packages/guard/repeat-tool-guard/README.md @@ -44,6 +44,8 @@ Unit suites drive a real agent loop against a mock adapter (no network) and cove **Token effect**: Zero tokens before the threshold. The reminder is retained history for that agent. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + #### First-threshold reminder ```markdown @@ -56,6 +58,8 @@ You are repeating the exact same tool call with identical arguments. Carefully a **Token effect**: Each reminder is retained history; `argumentsPreviewChars` bounds its data-dependent argument text, while agents keep independent counters. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + #### Later-threshold reminder ```markdown diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 96a423fea9..6513745173 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -33,6 +33,8 @@ Like every event they must sit inside an open turn. The mid-turn points (`PreToo Indirectly, through `dsh-hooks-claude` and `dsh-hooks-codex`, which can turn parsed hook output into prompt context, blocked outcomes, or continuation feedback. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **`HookOutput.updatedInput` is parsed but not honored** — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)); a bridge logs + warns when a hook sets it. See `src/types.ts` for the full contracts. diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index 4430f5511a..2498d0ac58 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -60,12 +60,16 @@ Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' } **Token effect**: No cost when hooks return no context. Hook text is data-dependent, logged, and resent in later conversation requests until compaction. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Blocked prompt or tool outcome **What the model sees**: Provider-supplied reasons pass through verbatim. When absent, a blocked prompt uses exactly `blocked by UserPromptSubmit hook`, a denied tool becomes `Error: blocked by PreToolUse hook`, blocked post-tool feedback is exactly `blocked by PostToolUse hook`, and a blocking stop adds steering exactly `continue: blocked by Stop hook`. `systemMessage` and `updatedInput` are logged or warned but are not model-visible in this implementation. **Token effect**: Blocking a prompt removes that prompt's request tokens; denial or feedback adds the retained fallback or provider text; forced continuation pays another full request. +**KV Cache effect**: A blocked prompt sends no request and invalidates nothing. Denial, feedback, and forced-continuation context append after the reusable prefix without rewriting it. + ## Known Limitations and Deferred Work - **Unsupported hook events (23 of Claude Code's current 30):** `Setup`, `InstructionsLoaded`, `UserPromptExpansion`, `MessageDisplay`, `PermissionRequest`, `PostToolUseFailure`, `PostToolBatch`, `PermissionDenied`, `Notification`, `TaskCreated`, `TaskCompleted`, `StopFailure`, `TeammateIdle`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `SessionEnd`, `Elicitation`, and `ElicitationResult`. Config for these events is parsed but never dispatched. The comparison baseline is Claude Code's [official hook-event reference](https://code.claude.com/docs/en/hooks#hook-events). diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index 0784857286..698fcc7a29 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -64,12 +64,16 @@ Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }` **Token effect**: No cost when hooks return no context. Hook text is data-dependent, logged, and resent until compaction. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Blocked prompt or tool outcome **What the model sees**: Provider-supplied reasons pass through verbatim. When absent, a blocked prompt uses exactly `blocked by UserPromptSubmit hook`, a denied tool becomes `Error: blocked by PreToolUse hook`, blocked post-tool feedback is exactly `blocked by PostToolUse hook`, and a blocking stop adds steering exactly `continue: blocked by Stop hook`. Codex `systemMessage` is not surfaced. **Token effect**: Blocking a prompt removes its request tokens; denial or feedback adds the retained fallback or provider text; forced continuation pays another full request. +**KV Cache effect**: A blocked prompt sends no request and invalidates nothing. Denial, feedback, and forced-continuation context append after the reusable prefix without rewriting it. + ## Known Limitations and Deferred Work - **Unsupported hook events (5 of Codex's current 10):** `PermissionRequest`, `PreCompact`, `PostCompact`, `SubagentStart`, and `SubagentStop`. Config for these events is silently dropped during parsing. The comparison baseline is Codex's [official hook reference](https://learn.chatgpt.com/docs/hooks). diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index b58da7bf57..019284a872 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -56,12 +56,16 @@ Unit suites run against a local `node:http` mock SSE server (no network). Real-A **Token effect**: Provider tokenization governs exact input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning avoids paying those tokens again; cache-read usage is reported when available. +**KV Cache effect**: An unchanged assembled prefix is eligible for DeepSeek cache reuse, which this adapter reports in usage. A model-route change or any upstream prompt, schema, prefix, or history change may prevent reuse from the first changed token; reasoning passback appends during tool round trips. + ### DeepSeek response **What the model sees**: Reasoning, text, and raw-string tool arguments are translated into harness chunks for the loop to log and assemble. **Token effect**: Generated tokens follow provider thinking and effort settings plus the request's `maxTokens`; only loop-retained blocks affect later input. +**KV Cache effect**: Loop-retained response blocks append to the next request and preserve its earlier reusable prefix; dropped blocks have no later cache effect. Changing the provider or model selects a different cache domain. + ## Known Limitations and Deferred Work - **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin). diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 1deae8f039..d295b9cfcf 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -67,12 +67,16 @@ Unit tests use pi-ai catalog models redirected to local mock servers and cover p **Token effect**: Provider tokenization governs exact input. Conversion adds no model-visible text; replay metadata may let a native API reuse provider-side state. +**KV Cache effect**: Conversion preserves logical request order without adding text, while the selected provider's serialization and replay state determine reuse. Changing adapter instance, provider, model, or any upstream request token may prevent reuse from the first difference. + ### Provider response **What the model sees**: pi-ai events become harness reasoning, text, tool-call, usage, and finish chunks. Parsed tool arguments cross the harness boundary as raw JSON strings. **Token effect**: Generated content affects later inputs only after the loop records it. pi-ai folds reasoning tokens into output usage when the provider does not report them separately. +**KV Cache effect**: Recorded response content appends to the next request and does not invalidate its earlier reusable prefix. Unrecorded transport metadata and usage accounting do not affect cache identity. + ## Known Limitations and Deferred Work - **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint. diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 44e7d80a05..2332bc0b0d 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -58,6 +58,8 @@ Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-l None, as this adapter registry forwards an already assembled request without adding or changing any model-bound text, schema, or message. +**KV Cache effect**: Pass-through; the registry preserves the assembled request prefix, while the selected adapter and provider own cache reuse and routing boundaries. + ## Known Limitations and Deferred Work - **No default retry/caching/rate-limit policy ships in this service** — `llm/stream` remains the call-wrapper seam; the agent loop separately offers proven model-request failures to `agent/request-error`, whose default preserves the original failure. diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index 262fc8d152..8db289a05a 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -42,6 +42,8 @@ Both plugins have usable defaults. A deployment with a different capacity config Indirectly, through consumers such as `dsh-compact-basic`; the service itself adds no prompt, message, schema, tool, or model call. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **The fixed heuristic is approximate** — content without reusable provider usage is priced by character count plus structural overhead, not an exact provider tokenizer or request serializer. diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index e8c4f54f5f..9968b2b6ff 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -74,12 +74,16 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call` **Token effect**: Data-dependent schema cost is paid on every request while the tools are registered. Re-sync replaces rather than accumulates schemas, and the server-qualified name adds tokens to every tool definition and call. +**KV Cache effect**: Prefix-stable while the discovered tool set and schemas are unchanged. A re-sync that adds, removes, renames, or changes a tool replaces definitions and may invalidate reuse from the first changed schema token. + ### Tool-call history and results **What the model sees**: The public tool name and JSON arguments remain in assistant history. Text result blocks are joined with newlines into one retained text result; image, audio, resource, and unsupported blocks become short placeholders, and MCP `isError` results follow the registry's model-visible error path. **Token effect**: Arguments and mapped text are retained until compaction. Binary and resource payloads are discarded rather than added to context. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **Initial discovery is asynchronous** — plugin load does not wait for connection and `listTools()`, so a turn started immediately after boot or HMR can assemble before the MCP tools are registered. diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index cbd765bbf9..723df60f17 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -25,6 +25,8 @@ Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/); see [the Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) and [`dsh-tool-bash`](../../bash/tool-bash/README.md), which render this provider's enforcement and denial facts while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and runner selection and profiles stay outside context. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Windows has no runner** — `win32` fails closed with `SANDBOX_UNAVAILABLE`; an AppContainer-family backend is deferred. diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index 93e274b485..9a56b53e8a 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -18,6 +18,8 @@ Implementations: [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) (Linux: ` **Token effect**: Conditional error text is visible for that call and retained in history until compaction. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + #### Exact error ```markdown diff --git a/packages/sdk/create-sdk/README.md b/packages/sdk/create-sdk/README.md index 66cddc030a..93d0bffe28 100644 --- a/packages/sdk/create-sdk/README.md +++ b/packages/sdk/create-sdk/README.md @@ -14,6 +14,8 @@ The provider choice is DeepSeek or a custom endpoint backed by `llm-pi-ai`. Deep Indirectly, through the generated project composition and its selected runtime plugins. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **TTY-only creation** — flags prefill questions, but the wizard still requires an interactive terminal before it writes a project. diff --git a/packages/sdk/helper/README.md b/packages/sdk/helper/README.md index 5b5608cdf0..9eb46d978e 100644 --- a/packages/sdk/helper/README.md +++ b/packages/sdk/helper/README.md @@ -18,6 +18,8 @@ The package root explicitly exports only the objects consumed by `create-sdk` an None, as the project domain edits files and never mounts a live agent or model request. +**KV Cache effect**: None; this package neither assembles nor sends a provider request. + ## Known Limitations and Deferred Work - **Commit is not transactional across files** — external edits are detected before each write, but a later failure does not roll back files already written. diff --git a/packages/sdk/scripts/README.md b/packages/sdk/scripts/README.md index e87c375a4f..63f417efbb 100644 --- a/packages/sdk/scripts/README.md +++ b/packages/sdk/scripts/README.md @@ -25,6 +25,8 @@ The root library exports `startSDK`, `runSDK`, and the `SdkBootArgs`/`SdkBootCon Indirectly, through the project `cordis.yml` tree loaded by `start` or `dev`. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Launcher arguments are schema-free** — `start` and `dev` preserve Node `parseArgs()` output rather than validating project-specific flags. diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 9fcf537123..dffad31ddb 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -40,6 +40,8 @@ The plugin buffers frozen session events and drains them on flush or disposal. A **Token effect**: Zero live-request tokens. A resumed agent pays for retained history and its current envelope, plus the quoted repair result for each interrupted call. +**KV Cache effect**: JSONL storage does not mutate live request prefixes. A resumed loop can reuse provider cache only when its reconstructed history, current envelope, and model route match; crash-repair results append. + ## Known Limitations and Deferred Work - **Only the current `SESSION_FORMAT_VERSION` (v0) loads** — the on-disk format is pre-release/unstable: a breaking format change is absorbed at v0 and non-current logs are rejected; there is no migration. diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 11fef96dad..748eb3e851 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -41,6 +41,8 @@ Like the JSONL backend, the plugin also installs the `session/event` → buffer **Token effect**: Zero live-request tokens. Resume restores retained history and pays the current envelope, plus the quoted repair result for each interrupted call. +**KV Cache effect**: SQLite storage does not mutate live request prefixes. A resumed loop can reuse provider cache only when its reconstructed history, current envelope, and model route match; crash-repair results append. + ## Known Limitations and Deferred Work - **`DatabaseSync` is synchronous** — every append transaction blocks the event loop for its duration; acceptable for local stores, a throughput ceiling for busy multi-session servers. diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 3390b2cc30..dbfa61ef1f 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -61,6 +61,8 @@ Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `ve **Token effect**: Zero tokens during ordinary persistence. Resume restores retained history cost and pays the current request envelope normally; each repaired call adds the quoted retained error text. +**KV Cache effect**: Persistence does not mutate live request prefixes. A resumed loop can reuse provider cache only when its reconstructed history, current envelope, and model route match; crash-repair results append without rewriting earlier history. + ## Known Limitations and Deferred Work - **No deletion or retention surface** — the seam is `create`/`append`/`load`/`list` only; pruning stored sessions is out-of-band backend maintenance. diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 269ba51e3e..8968a94d80 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -26,6 +26,8 @@ Persistence is optional and may mount or unmount dynamically. Cross-corpus listi None, as this trusted query service returns cloned session records only to its callers and registers no model-facing prompt, schema, tool, or message. +**KV Cache effect**: None; this package neither assembles nor sends a provider request. + ## Known Limitations and Deferred Work - **No caller authorization** — this is trusted context-wide infrastructure; a future model tool or UI must constrain which sessions its caller may inspect. diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index 8034441272..7fb8e3cdbe 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -40,6 +40,8 @@ Skills can be single-level directory bundles (`/SKILL.md`) or flat Markdow Indirectly, through `dsh-tool-skill`, which renders this provider's invocable names and capped descriptions into the session-prefix catalog and a selected instruction body plus resource-base guidance into retained tool history while paths, provider ranks, and disabled skills remain hidden. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Discovery is one level deep** — only `//SKILL.md` and `/.md` are recognized; nested skill trees and package manifests are ignored. diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 8edcd71ef0..98e51287f2 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -39,6 +39,8 @@ The registry does not render model guidance or register model-facing tools. [`@d Indirectly, through `dsh-tool-skill`, which renders provider summaries into the session prefix and loaded instructions into retained tool results. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Completed catalogs have no TTL or watcher invalidation** — a provider's underlying files or remote data can change without a registration revision, so a cached cwd stays stale until eviction or provider/runtime reload. diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index a2c7ff5c40..5af1131ccf 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -32,6 +32,8 @@ The tool does not call `agent.inject()` in v1. Its result is already recorded as **Token effect**: Repeated input cost scales with skill count and `catalogDescriptionMaxLength`; no catalog tokens are sent when the list is empty or the tool is hidden or shadowed. +**KV Cache effect**: Prefix-stable within a loop instance once the session prefix is composed. A new or resumed instance with different providers, skills, descriptions, visibility, or catalog limits may invalidate reuse from the first changed catalog token. + #### Skill catalog template ```markdown @@ -52,12 +54,16 @@ If the user names a skill, or the task clearly matches a skill's description, ca **Token effect**: Fixed schema cost per request where the tool is visible. +**KV Cache effect**: Prefix-stable while the tool definition and visibility are unchanged. Shadowing, restrictions, or plugin lifecycle changes may invalidate reuse from this schema. + ### Tool result **What the model sees**: A successful call uses the result template and the provider-managed, directory, URL, or opaque resource guidance below. **Token effect**: Loaded instructions are data-dependent tool-result tokens, resent on later steps until compaction; no duplicate `agent.inject()` copy is made. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + #### Skill result template ```markdown @@ -106,6 +112,8 @@ Load referenced resources only as needed. **Token effect**: Only a failing call adds these retained tokens. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **The catalog omits `whenToUse`, source, and provider metadata** — routing is based only on name and a capped description; `whenToUse` remains provider metadata and is not rendered by the loaded wrapper either. diff --git a/packages/spill/spill-local/README.md b/packages/spill/spill-local/README.md index 59d23b8a46..e19bc7e50e 100644 --- a/packages/spill/spill-local/README.md +++ b/packages/spill/spill-local/README.md @@ -22,6 +22,8 @@ Files land at `/session-/​-`: Indirectly, through spill consumers that render the local path and `read`/`grep` retrieval guidance. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Local spill files persist until external cleanup** — the backend has no session-lifecycle deletion or age-based retention policy, because persisted, resumed, and forked sessions may still reference a path. diff --git a/packages/spill/spill-policy/README.md b/packages/spill/spill-policy/README.md index bb20ac9dfc..a197c82179 100644 --- a/packages/spill/spill-policy/README.md +++ b/packages/spill/spill-policy/README.md @@ -40,6 +40,8 @@ The policy sees only the FINAL formatted tool result — not a tool's internal r **Token effect**: A successful replacement is at most `maxInlineBytes` UTF-8 bytes and remains in history until compaction; the full spill text is not resent to the model. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **Only final plain-text results are spillable** — mixed-content results, blocked feedback, and `read` pass through; provider truncation or tool-owned retention that happened earlier cannot be recovered here. diff --git a/packages/spill/spill/README.md b/packages/spill/spill/README.md index c80277339b..a790b73d5f 100644 --- a/packages/spill/spill/README.md +++ b/packages/spill/spill/README.md @@ -30,6 +30,8 @@ See the [tool output spill RFC](../../../docs/rfc/implemented/architecture/2026- Indirectly, through spill consumers that render a backend locator and retrieval guidance. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **The seam has no retrieval or deletion API** — consumers can only render the backend's locator and guidance; lifecycle and access semantics remain backend-specific. diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index a620051506..9fd9db3ce6 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -67,12 +67,16 @@ Keyless tests drive a scripted ACP subprocess over real stdio. The with-key e2e **Token effect**: The child pays for an independent full context and its own multi-step history. These tokens never enter the parent's context. +**KV Cache effect**: Independent of the parent request cache. Each ACP child can reuse only prefixes identical under its own provider, model, composition, and history; child steps otherwise grow append-only. + ### Parent tool result, indirectly **What the model sees**: Through `dsh-tool-subagent`, the parent receives only the child's final streamed assistant text or that consumer's exact stop-reason error, not intermediate messages or tool traffic. A request already cancelled before publication becomes exactly `Error: subagent request was aborted before the ACP child started`; other start failures pass through as `Error: `. **Token effect**: Parent input grows only by the final result or error, which is data-dependent and retained until compaction. This provider adds no parent schema itself. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **A fresh process per run** — persistent-process pooling is a future optimization ([the seam RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)). diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index 244811ab88..8ed1c38cd9 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -31,12 +31,16 @@ See [`dsh-subagent-spawn`](../subagent-spawn/README.md) for the run lifecycle, m **Token effect**: Forking duplicates retained completed history into separate child requests; the child then accumulates its own tokens independently. Persona changes repeated prompt cost, filtering changes schema or generated SDK cost, and a first-turn fork has no inherited history. +**KV Cache effect**: The child may reuse the inherited byte-identical prefix under the same provider and model. Persona, tool-filter, generated-SDK, or route changes may invalidate reuse before inherited history; later child history is append-only. + ### Parent tool result, indirectly **What the model sees**: The parent receives only the child's own final output through `dsh-tool-subagent`, not the inherited prefix or intermediate work. **Token effect**: Parent input grows by one data-dependent final result retained until compaction. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs. diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index ca54dab7c0..a709df0058 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -48,12 +48,16 @@ A clean turn that never commits the required structured value reports `error`; t **Token effect**: Child input is isolated from the parent and grows through the child's own steps. A persona changes repeated prompt text; filtering changes schema or generated SDK cost but not independently registered guidance. +**KV Cache effect**: Independent of the parent request cache. The child's later history is append-only, while persona, tool-filter, generated-SDK, provider, or model changes establish a different child prefix. + ### Structured-output system prompt, schema, and results **What the model sees**: A structured run adds the structured-output instruction below. It also adds a child-scoped `structured_output` definition with exact description `Report your final structured result. Call this exactly once, when your answer is complete; the arguments must match this tool's parameter schema exactly.` and the requested schema. This runtime-only definition is outside the generated shipped [tool package map](../../../docs/tool-catalog.md#tool-package-map). Success returns `Structured output recorded.`; a later call becomes ``Error: structured output already recorded: the run is complete, so `` is not executed``. **Token effect**: Fixed instruction and capability tokens are paid only by that child. Result text enters the child history, while the captured value alone becomes the parent result. +**KV Cache effect**: Prefix-stable inside the child while the structured-output instruction and schema are unchanged. Changing the schema or capability may invalidate the child's cache from that early segment; results append in child and parent histories. + #### Structured-output instruction ```markdown @@ -66,12 +70,16 @@ When you have your final answer, you MUST report it by calling the `structured_o **Token effect**: Zero tokens on a successful start; only the failed parent tool call retains this text. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Parent result, indirectly **What the model sees**: The driver extracts only the child's own last assistant output or captured structured value; seeded parent messages and intermediate child work do not become the result. **Token effect**: The parent receives one data-dependent result through the consumer; all other child tokens stay in the child session. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs. diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index 6f9982f15b..ca283eee2c 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/packages/subagent/subagent-spawn/README.md @@ -26,12 +26,16 @@ Spawn advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, pers **Token effect**: The child pays for a new independent context and history; no parent-history tokens are duplicated. Persona changes this child's repeated prompt cost, while filtering changes its schema or generated SDK cost. +**KV Cache effect**: Independent of the parent request cache. Child history grows append-only, while persona, tool-filter, generated-SDK, provider, or model changes establish a different child prefix. + ### Parent tool result, indirectly **What the model sees**: Through `dsh-tool-subagent`, the parent receives only the child's final output or stop-reason error. **Token effect**: Parent input grows by one data-dependent result retained until compaction. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs. diff --git a/packages/subagent/subagent-subprocess/README.md b/packages/subagent/subagent-subprocess/README.md index f15defcf2a..cc536e6d0e 100644 --- a/packages/subagent/subagent-subprocess/README.md +++ b/packages/subagent/subagent-subprocess/README.md @@ -41,6 +41,8 @@ A per-run isolated config directory for an external CLI child (the target of `CL Indirectly, through process-based subagent backends, whose child composition is constrained by credential scrubbing and isolated config directories. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **The credential scrub is name-based** — only variables matching `KEY` / `SECRET` / `TOKEN` are removed; differently named secrets such as `PASSWORD` pass through unless the backend supplies a stricter environment. diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 17edd0e3c7..76c66b84b1 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -66,6 +66,8 @@ The model-facing tool collects synchronously by default: it awaits the child res Indirectly, through `dsh-tool-subagent`, which renders provider-specific schemas and foreground or generic-background results while child working context remains child-only. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Runtime steering and continuation are seam-only capabilities** — `sendMessage` and `resume` have no model-facing consumer in the current tool. diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 825e252923..a7f8775fa0 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -36,18 +36,24 @@ Foreground and background calls are exclusive. Children may share the parent's w **Token effect**: Fixed schema cost per parent request; each provider instance adds one schema. +**KV Cache effect**: Prefix-stable while provider instances, names, descriptions, and schemas are unchanged. Provider registration lifecycle may invalidate parent reuse from the first changed tool definition. + ### Foreground result **What the model sees**: The call retains the description and prompt. Success contains only the child's final text; other outcomes become `Error: `. Intermediate child steps stay out of the parent. **Token effect**: The prompt and result remain in parent history until compaction; child working context remains in the child. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Background task result **What the model sees**: Start returns exactly `started background subagent task `. The generic task surface provides later status, final output, cancellation responses, and notices. **Token effect**: The acknowledgement is retained; final output enters parent history only when collected or injected. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **Background runs expose final output only** — intermediate child steps stay in the child session. diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 27e0012d19..dd22565eb5 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -46,6 +46,8 @@ Constraints: `suite.ts` imports vitest, so the package entry is importable only None, as this test-only harness records, normalizes, and compares ACP transcripts without changing the agent's assembled model request. +**KV Cache effect**: None; this package neither assembles nor sends a provider request. + ## Known Limitations and Deferred Work - **Session harvest is JSONL-only** — `runScenario` collects persisted `.jsonl` logs, so an example composed over the SQLite persistence backend has no snapshot path. diff --git a/packages/support/agent-loop-testkit/README.md b/packages/support/agent-loop-testkit/README.md index 350a8643e1..24aefeb29a 100644 --- a/packages/support/agent-loop-testkit/README.md +++ b/packages/support/agent-loop-testkit/README.md @@ -22,6 +22,8 @@ Tests of injection failures, partial topology, service load order, or service te None, as this test-only composition helper neither drives nor modifies model requests. +**KV Cache effect**: None; this package neither assembles nor sends a provider request. + ## Known Limitations and Deferred Work - **Only the mandatory prerequisite spine is shared** — adapters, optional plugins, `AgentLoop`, agents, and Context teardown remain caller-owned so scenario-specific ordering stays visible. diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 661e073d13..d9bb74769b 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -56,6 +56,8 @@ A seeded or forked session arrives with events already in its log because constr None, as this observer only validates events and frozen requests and never rewrites prompts, schemas, messages, or streams. +**KV Cache effect**: None; this package neither assembles nor sends a provider request. + ## Known Limitations and Deferred Work - **The request-reconstructability assertion covers loop-built requests only** — hand-built one-shots (e.g. compaction's summarize call) carry no live `sessionId` marker and are skipped. diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index b256ef9a59..2134e722a7 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -56,6 +56,8 @@ Named `name` / `inject` / `Config` / `apply`, with **no default export**: the co None, as this keyless test adapter sends no request to a provider model; it only replays recorded assistant chunks into the test loop. +**KV Cache effect**: None; this package neither assembles nor sends a provider request. + ## Known Limitations and Deferred Work - **First-call-order script binding assumes sequential delegation** — a cut that runs sibling subagents concurrently (or a compaction summarize call landing mid-run) would bind live sessions to recorded scripts non-deterministically; a stronger keying is deferred until such a scenario exists (`XXX(concurrent-subagents)`). diff --git a/packages/support/loader-smoke/README.md b/packages/support/loader-smoke/README.md index 0b783efc0d..bdb25180ca 100644 --- a/packages/support/loader-smoke/README.md +++ b/packages/support/loader-smoke/README.md @@ -10,6 +10,8 @@ This is support-tier test infrastructure, not product API. None, as this test-only harness boots example processes and inspects their streams without changing an assembled model request. +**KV Cache effect**: None; this package neither assembles nor sends a provider request. + ## Known Limitations and Deferred Work - **Built mode requires a prior build** — the config must also resolve every named package upward through `examples/node_modules`. diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index b061038714..90946c2728 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -26,6 +26,8 @@ See the [task type catalog](../../../docs/core-data-structures/tasks.md) and [ru Indirectly, through producer plugins and [`dsh-tool-tasks`](../tool-tasks/README.md), which render task ids, output, status, cancellation, and completion notices. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Tasks are process-local** — durable or cross-restart execution needs a separate lifecycle. diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index 520a874df1..cd62770a93 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -31,6 +31,8 @@ A default above the cap fails at load. **Token effect**: Small fixed input cost per request while active. +**KV Cache effect**: Prefix-stable while the plugin scope and guidance text are unchanged. Activation or disposal may invalidate reuse from this prompt section. + #### Background-task guidance ```markdown @@ -43,12 +45,16 @@ Track every background task id you start. You are notified in-session when a tas **Token effect**: Fixed schema cost on each request where the tools are visible. +**KV Cache effect**: Prefix-stable while tool definitions and visibility are unchanged. Registration lifecycle or scoped restrictions may invalidate reuse from the first changed schema token. + ### Results and notices **What the model sees**: Reads return output or `(no new output)` followed by `[status: ]` and optional detail. An empty list returns `(no background tasks)`. Kill returns `requested cancellation of task ` or the existing terminal status. Unreported owned completion uses the notice above. **Token effect**: Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **Completion notices do not wake idle agents** — callers needing an immediate result must use `task_output`. diff --git a/packages/timeout/timeout-policy/README.md b/packages/timeout/timeout-policy/README.md index 474185a4eb..37fd75ad01 100644 --- a/packages/timeout/timeout-policy/README.md +++ b/packages/timeout/timeout-policy/README.md @@ -41,6 +41,8 @@ Multiple `tools/execute` listeners compose by cordis registration order. Combine **Token effect**: Zero tokens on non-timeout calls. A timeout adds one small retained error result and can prevent a larger late provider result from entering context. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **Cooperative, never a hard kill** — the deadline only notifies via `exec.signal`; a tool that ignores the signal does not stop on timeout (see § Cooperative, not a hard kill). diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index f6097abe96..9517f70e5e 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -32,12 +32,16 @@ A function/namespace plugin: it exports `name` / `inject` / `apply` and NO defau **Token effect**: Fixed schema cost on every request where the tool is visible. +**KV Cache effect**: Prefix-stable while the definition and visibility are unchanged. Plugin lifecycle or scoped restrictions may invalidate reuse from this schema. + ### Tool-call history and result **What the model sees**: Each assistant tool call retains the entire replacement list in its arguments. Success returns exactly `Updated todo list: pending, in progress, completed.` Stable failures are ``Error: invalid todo: `content` must be a non-empty string``, `Error: invalid todos: duplicate content ""`, `Error: invalid todos: at most one task may be in_progress, got `, and `Error: todo_write requires an owning agent session`. The full `todo/write` session event is UI and replay state, not a second model message. **Token effect**: Token growth scales with every full list the model submits, and those call arguments remain until compaction. The result itself is small and fixed-shape. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **Single-owner scope only** — the list belongs to the one calling agent session; subagent/shared/swarm scopes are a deliberate cut (see § Single owner), and a non-agent caller is rejected. diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 4e19313302..11d3befbd6 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -98,30 +98,40 @@ The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loa **Token effect**: Prompt tokens are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions keep separate contexts. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Human answers and permission decisions **What the model sees**: When optional consumers are loaded, ACP form answers become the exact JSON shape documented by `dsh-tool-ask-user`. Failures become `Error: ACP user questions must come from an agent-owned request`, `Error: ACP user question has no matching session`, `Error: ACP elicitation request failed`, `Error: ask_user_question was cancelled by the user`, `Error: ask_user_question returned no answer`, or `Error: ask_user_question was aborted before the user answered`. Permission decisions control whether another tool yields success or denial. ACP tool cards, terminal output, diffs, and streamed session updates are UI-only. **Token effect**: Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Permission preset switches **What the model sees**: `session/set_config_option` emits no model message itself. When `dsh-permission` is composed, the bridge writes the selected preset through that service; the resulting model-visible policy prompt and change notice belong to [`dsh-user-approval`](../user-approval/README.md), while sandbox-mode effects belong to [`dsh-tool-bash`](../../bash/tool-bash/README.md). The ACP `Permissions` select, its option descriptions, pending idle value, and refreshed config response remain client-only. **Token effect**: Zero direct tokens from the ACP option or the log-only `permission/preset` event. Downstream cost is limited to the owning plugins' policy prompt, conditional retained change notice, and any changed tool outcome. +**KV Cache effect**: The ACP option and log event cause no direct invalidation. The downstream policy-prompt change may invalidate reuse from that system section, while its change notice appends to history. + ### Model switches **What the model sees**: The ACP selector itself emits no message. The selected provider/model pair supplies the next step's `{{provider}}` / `{{model}}` prompt variables and request routing together; all other call-config fields continue through the `agent/request` waterfall unchanged. **Token effect**: The selector adds no direct tokens. A changed model may tokenize the same retained prompt/history differently, and any persona text that interpolates provider or model changes accordingly. +**KV Cache effect**: Switching provider or model selects a different cache domain. If the persona interpolates either value, the rendered system prompt also changes and prevents reuse from its first changed token. + ### Loaded sessions **What the model sees**: `session/load` resumes the persisted log, after which the loop sends its reconstructed history and request header. Replaying that log to the editor is not an extra model message. **Token effect**: Restored context has the persistence and session packages' normal retained cost; ACP replay to the client adds none. +**KV Cache effect**: Loading does not rewrite the stored log, but the next request is reconstructed under the current envelope and route. Reuse requires that reconstruction to match; ACP replay to the client has no cache effect. + ## Known Limitations and Deferred Work - **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented. diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 5fed7b5023..d0512d4047 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -20,6 +20,8 @@ This package carries no loader hooks and no dev-mode surface: the `dsh-scripts` Indirectly, through the plugin tree it loads, which determines the prompts, schemas, messages, and model adapter in the resulting application. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Bare package specifiers depend on Loader internals** — production bins need `node --expose-internals` or the Loader's optional native fallback; an in-process caller without either must use resolvable relative/file specifiers or tsx path mapping. diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 6666867598..d3f610b3d3 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -30,6 +30,8 @@ The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to qu **Token effect**: Data-dependent user-message tokens enter retained session history and are resent on later turns until another package compacts them. The JSON-RPC frames, session notifications, and server bookkeeping add zero model-context tokens. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **The wire has no per-session close or prompt-cancel method** — SDK-created agents remain live until process shutdown, and one accepted prompt runs to agent idle before that session accepts another. diff --git a/packages/ui/permission/README.md b/packages/ui/permission/README.md index bd37890dd1..35eda1c3ac 100644 --- a/packages/ui/permission/README.md +++ b/packages/ui/permission/README.md @@ -10,6 +10,8 @@ The service requires a confining `ctx.bash` executor and `ctx.approval`. A table Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permission/preset` itself is log-only. +**KV Cache effect**: No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet. diff --git a/packages/ui/stdio/README.md b/packages/ui/stdio/README.md index caaa779c6e..c606d20e30 100644 --- a/packages/ui/stdio/README.md +++ b/packages/ui/stdio/README.md @@ -29,12 +29,16 @@ The plugin seeds display labels from the live agent registry, then tracks `agent **Token effect**: Submitted text is retained under the agent loop's normal session-history and compaction rules. The welcome banner, `> ` prompt, rendered transcript, and `[tool call]` / `[tool result]` terminal lines add no tokens. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Terminal user-interaction answers **What the model sees**: When a consumer calls `ctx.userInteraction.ask()`, this provider renders the question in the terminal and returns selected option labels or `custom` text. Through `dsh-tool-ask-user`, closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`; disposal or abort becomes `Error: ask_user_question was interrupted before the user answered`. **Token effect**: Waiting and terminal prompts add no tokens; the resolved answer or error is model-visible only through the calling tool or plugin's result. +**KV Cache effect**: Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **One configured session receives stdin** — the session/event renderer can print output from any session, but input lines always drive the configured `sessionId` rather than routing by the visible label. diff --git a/packages/ui/tool-ask-user/README.md b/packages/ui/tool-ask-user/README.md index 880b56ecc5..c1d920bff3 100644 --- a/packages/ui/tool-ask-user/README.md +++ b/packages/ui/tool-ask-user/README.md @@ -27,12 +27,16 @@ This is the consumer package for the user-interaction seam. It does not render U **Token effect**: Fixed schema cost on every request where the tool is visible. +**KV Cache effect**: Prefix-stable while the definition and visibility are unchanged. Plugin lifecycle or scoped restrictions may invalidate reuse from this schema. + ### Tool-call history and result **What the model sees**: The model's full questions remain in the assistant tool-call arguments. After the human answers, the next step sees compact JSON in the exact shape `{"answers":[{"id":"","selected":["