From a6915745e068209142e68564b967b1d2a2c35e03 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 17 Jul 2026 17:21:10 +0800 Subject: [PATCH 01/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] =?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/29] 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/29] =?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/29] 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/29] =?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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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 fe5c3e2a7ae58757a96a5d338a1b506be2fe824f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:21:39 +0800 Subject: [PATCH 26/29] docs(rfc): formalize SDK follow-up design --- docs/rfc/INDEX.md | 1 + ...07-17-sdk-follow-up-capabilities.i18n.yaml | 6 + .../2026-07-17-sdk-follow-up-capabilities.md | 118 +++++++++++ ...026-07-17-sdk-follow-up-capabilities.zh.md | 118 +++++++++++ docs/sdk-后续工作-设计.md | 196 ------------------ 5 files changed, 243 insertions(+), 196 deletions(-) create mode 100644 docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml create mode 100644 docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md create mode 100644 docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md delete mode 100644 docs/sdk-后续工作-设计.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index ab39d4f314..20a5966ba4 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -15,6 +15,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [SQLite FTS5 session search](proposed/feature/2026-07-10-sqlite-session-query-provider.md) | 2026-07-10 | | [Stream workflow progress through tool calls](proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md) | 2026-07-13 | | [Developer-owned SDK projects](proposed/feature/2026-07-14-sdk-developer-projects.md) | 2026-07-14 | +| [SDK follow-up capabilities](proposed/feature/2026-07-17-sdk-follow-up-capabilities.md) | 2026-07-17 | ### Simplification diff --git a/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml b/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml new file mode 100644 index 0000000000..0982e51461 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 +2026-07-17-sdk-follow-up-capabilities.md: 8fa83018b3eace1844148ed02cc77ac67571a67c +2026-07-17-sdk-follow-up-capabilities.zh.md: b6cd56ac315ff758c128eade671544ae3820e8e9 diff --git a/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md b/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md new file mode 100644 index 0000000000..8fa83018b3 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md @@ -0,0 +1,118 @@ +# RFC: SDK follow-up capabilities + +Status: proposed + +English | [中文](2026-07-17-sdk-follow-up-capabilities.zh.md) + +## Problem + +The first SDK release creates and edits developer-owned Cordis projects through the shared model defined by the [developer-project RFC](2026-07-14-sdk-developer-projects.md) and the [project-editing architecture](../architecture/2026-07-15-sdk-project-editing-architecture.md). Its create and config workflows are interactive, external Cordis plugins require manual dependency and configuration edits, command-line telemetry has no owning boundary, and interactive branches lack a stable test strategy. + +These gaps are coupled. Create and config already share questions, feature configuration, and `ProjectEditSession`; adding separate automation paths would duplicate that domain logic. External-plugin installation must update both the package manager's files and `cordis.yml`. Telemetry must observe commands such as create and build that do not boot Cordis. Interactive testing must exercise Harness behavior without making terminal rendering a brittle product contract. + +## Proposal + +The SDK extends the existing prompt and project-editing boundaries instead of creating parallel workflows. A non-interactive prompt port and structured feature plan drive create and config, `dsh-sdk create ` delegates dependency resolution to the project package manager before mounting the resolved package through `ProjectEditSession`, launcher-side telemetry wraps every `dsh-sdk` command, and injected prompt streams provide the primary interactive-test seam. + +| Capability | Product entrypoint | Owning mechanism | Required outcome | +|---|---|---|---| +| Headless project creation | `create-sdk --config ` or `--config-json ` with optional `--json` | `HeadlessPromptPort`, structured project answers, and a complete feature plan | No terminal blocking; missing required input is explicit | +| External Cordis plugin installation | `dsh-sdk create ` | Native package-manager `add` plus `ProjectEditSession` | The dependency and `cordis.yml` entry identify the package manager's resolved package | +| Developer-cycle telemetry | Every `dsh-sdk` command | Launcher-side consent, payload, redaction, anonymous identity, and delivery services | Reporting is best-effort and cannot change the command result | +| Interactive regression coverage | Create and config tests | Injected `PromptPort` input/output and filesystem assertions | Tests cover Harness decisions and generated files without snapshotting terminal repainting | + +## Shared headless workflow + +### Structured input and lifecycle events + +Headless create accepts a JSON object either inline through `--config-json` or from a file through `--config`. Scalar fields supply the ordinary create answers, while `features` supplies the complete selected feature set, feature options, secrets, and dedicated values. Defaults remain valid only where the owning question declares one; the headless path never invents an answer for a required prompt. + +With `--json`, stdout is an NDJSON event stream. `done` means creation and any requested setup completed, `action-required` names an unanswered required prompt, and `error` reports another failure. Human-readable progress and package-manager output go to stderr so every stdout line remains parseable as one event. A caller responds to `action-required` by adding the missing value and running the command again. + +Create and config consume the same feature-plan shape. Create exposes it through the command-line inputs above; config uses it at the shared workflow boundary so a later automation entrypoint does not need a second feature-selection model. + +### Prompt and project-editing boundaries + +`PromptPort` remains the only boundary between SDK questions and an interaction implementation. `ClackPromptPort` handles terminals. `HeadlessPromptPort` consumes defaults exposed by the question contract and otherwise fails with the unanswered prompt; prefilled values normally prevent the port from being called. + +Both paths use the same `Question` objects, `FeatureConfigurator`, `SdkProject`, and `ProjectEditSession`. The headless path therefore changes how answers arrive, not how features are interpreted or files are committed. + +### Agent skill + +The repository ships a thin `SKILL.md` that teaches an agent to construct the structured input, request NDJSON, fill an `action-required` value, and retry. The skill invokes the public CLI and does not import an internal SDK API or introduce another project specification. + +## External Cordis plugin installation + +`dsh-sdk create ` accepts a package-manager-native npm specifier such as `pkg@version` or a GitHub specifier such as `github:owner/repo#ref`. After confirmation, it asks the project's package manager to add the source, compares the direct dependency names before and after the operation, reopens the project, and mounts each newly resolved package in `cordis.yml` through `ProjectEditSession`. + +The package manager owns source parsing, version or commit resolution, integrity data, lockfile updates, and any build policy. The SDK does not download or unpack a second copy through giget or pacote. An external plugin remains a dependency under `node_modules`; local plugin scaffolding remains a separate project-creation concern. + +## Launcher telemetry + +### Consent and collection + +Telemetry wraps the `dsh-sdk` launcher command lifecycle because create and build do not reliably boot Cordis. One event records the command name, duration, success, a random per-user anonymous identifier, and redacted `cordis.yml` and `package.json` text when those project files are eligible. + +Reporting is enabled unless a present telemetry config entry is explicitly disabled. `DO_NOT_TRACK` and CI deny reporting regardless of project configuration. A missing `cordis.yml` does not itself deny the event, but `package.json` content is included only when `cordis.yml` establishes that the directory is an SDK project. + +### Safety and delivery + +The payload builder never reads `.env`. It redacts secret-shaped keys and values, known token forms, PEM blocks, URL credentials, and high-entropy opaque strings in the two eligible text files. Redaction is a safety backstop rather than a guarantee; SDK projects must keep credentials in `.env`. + +The reporter uses a fixed endpoint and resolves every send path without throwing. Command dispatch records success or failure in a `finally` path, starts reporting after the command outcome is known, and drains within a bounded interval. Consent parsing, payload construction, storage, or network failures are swallowed only at this telemetry boundary and never alter the command's exit code. + +## Interactive workflow testing + +Create and config tests inject a `PromptPort` and scripted input/output streams into the existing workflows. Parameterized scenarios cover feature selection, feature options, secrets, cancellation, review, and apply behavior, then assert the resulting `cordis.yml` and other project files. The stable product assertion is the generated project state, not clack's ANSI redraw sequence. + +One or two optional real-PTY smoke tests may cover the shipped binary and TTY guard that injection cannot reproduce. Native PTY tooling does not belong on the required path unless it is reliable across the repository's supported Node and host versions. + +## Deferred work + +- Extend the headless create specification to express local `plugin` or `tool` scaffolding instead of defaulting that interactive choice to none. +- Expose the telemetry opt-out in create and config while preserving the consent representation in which only a disabled telemetry entry is written. +- Define whether GitHub source dependencies must be prebuilt or may run package-manager-controlled preparation scripts, and surface the policy before installation. +- Replace the telemetry package's `.invalid` endpoint placeholder with the production endpoint before release. + +## Alternatives considered + +**Build a separate headless creation engine.** This would duplicate questions, feature requirements, configuration behavior, and project-editing rules. Reusing the prompt and edit-session boundaries keeps one implementation of project semantics. + +**Make a specification file the primary automation interface.** Agents can pass the same typed JSON object inline, while people and CI may still use a file. A file-only protocol adds persistence and cleanup without adding semantics. + +**Use `npx skills add` as the project creator.** The skills CLI installs Markdown skills; it does not create SDK projects or install npm packages. The agent skill therefore drives the SDK initializer instead of replacing it. + +**Fetch GitHub and npm sources through giget or pacote.** A second fetch layer would duplicate package-manager resolution, integrity, lockfile, and lifecycle policy. Native dependency specifiers keep those decisions in the selected package manager. + +**Implement telemetry as a Cordis runtime plugin.** Create and build do not necessarily boot Cordis, so a runtime plugin cannot observe the complete developer command cycle. The launcher is the boundary shared by those commands. + +**Derive the anonymous identifier from git metadata.** Repository remotes can identify a project or organization. A random per-user identifier supports aggregation without encoding repository identity. + +**Collect only aggregate counters.** Aggregate-only events reduce exposure but cannot answer which plugins, dependencies, and configuration shapes developers actually use. This proposal accepts collection of redacted project text and makes that exposure explicit. + +**Use real PTYs and transcript snapshots as the primary test strategy.** Native PTY dependencies and terminal repaint sequences add platform and rendering instability while mostly testing clack. Injected interaction plus generated-file assertions tests the SDK-owned behavior directly. + +## Acceptance criteria + +- Create runs without a TTY from a complete structured input, emits only NDJSON on stdout under `--json`, and reports missing required input as `action-required` without writing a partial project. +- Create and config resolve the same feature-plan contract through the shared question, feature-configuration, and project-editing code paths. +- `dsh-sdk create ` uses the selected project package manager, mounts the dependency name that operation actually added, and fails loudly when no new dependency can be identified. +- Every `dsh-sdk` command reaches one best-effort telemetry completion path; an explicit disabled entry, `DO_NOT_TRACK`, or CI prevents delivery, and telemetry failures never change the command result. +- Telemetry never reads `.env`, withholds unrelated `package.json` content when no `cordis.yml` exists, redacts both eligible text payloads, and uses an identifier unrelated to git metadata. +- Interactive tests cover create and config decisions through injected interaction and assert committed project files; any real-PTY coverage remains a narrow smoke layer. +- The agent skill documents the public structured-input and event contracts without depending on private package exports. + +## Risks + +- Full redacted `cordis.yml` and `package.json` text still reveals plugin and dependency names, URLs, paths, and configuration values to the endpoint operator, and heuristic redaction can miss a secret. +- Default-on reporting may surprise developers when no telemetry entry exists; the CLI must make the opt-out discoverable before release. +- A package-manager add can change `package.json`, the lockfile, and installed files before `ProjectEditSession` mounts the plugin, so a later mount failure can leave dependency changes that require manual recovery. +- GitHub dependencies may execute preparation or lifecycle code according to package-manager policy; an unresolved build policy is a supply-chain and reproducibility risk. +- Injected prompt tests do not prove raw-mode, signal, or repaint behavior in a real terminal; the optional smoke layer must cover only those residual contracts. + +## References + +- [Vercel Eve](https://github.com/vercel/eve) and [Vercel Labs Skills](https://github.com/vercel-labs/skills) for the distinction between a headless initializer and skill distribution. +- [npm package specifications](https://docs.npmjs.com/cli/v11/using-npm/package-spec), [pnpm add](https://pnpm.io/cli/add), and [Yarn add](https://yarnpkg.com/cli/add) for package-manager-native sources. +- [Console Do Not Track](https://consoledonottrack.com/) for the environment-level opt-out convention. +- [Clack](https://github.com/bombshell-dev/clack) and [Vitest snapshots](https://vitest.dev/guide/snapshot) for injected prompts and generated-file assertions. diff --git a/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md b/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md new file mode 100644 index 0000000000..b6cd56ac31 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md @@ -0,0 +1,118 @@ +# RFC: SDK 后续功能 + +Status: proposed + +[English](2026-07-17-sdk-follow-up-capabilities.md) | 中文 + +## 问题 + +首个 SDK 版本通过[开发者工程 RFC](2026-07-14-sdk-developer-projects.md) 和 [SDK 工程编辑架构](../architecture/2026-07-15-sdk-project-editing-architecture.md)定义的共享模型创建和编辑开发者拥有的 Cordis 工程。create 和 config 工作流仅支持交互调用,接入外部 Cordis 插件需要手工修改依赖和配置,命令行遥测没有明确的所属边界,交互分支也缺少稳定的测试策略。 + +这些缺口彼此关联。create 和 config 已经共享问题、功能配置和 `ProjectEditSession`;若另建自动化路径,就会复制领域逻辑。安装外部插件必须同时修改包管理器文件和 `cordis.yml`。遥测需要观察 create、build 等不会启动 Cordis 的命令。交互测试需要覆盖 Harness 自身行为,同时避免把终端渲染固化成脆弱的产品契约。 + +## 提案 + +SDK 扩展现有提示词与工程编辑边界,不另建平行工作流。非交互式 `PromptPort` 实现和结构化功能计划驱动 create 与 config;`dsh-sdk create ` 先把依赖解析交给工程的包管理器,再通过 `ProjectEditSession` 挂载解析所得的包;`dsh-sdk` 启动器侧的遥测包住每个命令;交互测试主要通过注入的提示词输入输出流完成。 + +| 功能 | 产品入口 | 所属机制 | 必须达到的结果 | +|---|---|---|---| +| Headless 工程创建 | `create-sdk --config ` 或 `--config-json `,可搭配 `--json` | `HeadlessPromptPort`、结构化工程答案和完整功能计划 | 不阻塞等待终端;明确报告缺失的必答输入 | +| 外部 Cordis 插件安装 | `dsh-sdk create ` | 包管理器原生 `add` 加 `ProjectEditSession` | 依赖和 `cordis.yml` 配置项指向包管理器解析出的包 | +| 开发周期遥测 | 每个 `dsh-sdk` 命令 | 启动器侧的上报条件判断、遥测内容构建、脱敏、匿名身份和传输服务 | 上报采用尽力而为语义,不能改变命令结果 | +| 交互回归覆盖 | create 和 config 测试 | 注入的 `PromptPort` 输入输出和文件系统断言 | 测试覆盖 Harness 决策与生成文件,不快照终端重绘 | + +## 共享 headless 工作流 + +### 结构化输入和生命周期事件 + +Headless create 通过 `--config-json` 接收内联 JSON 对象,或通过 `--config` 从文件读取。标量字段提供普通 create 答案,`features` 提供完整的已选功能、功能选项、secret(密钥)和专用值。只有所属问题明确声明的默认值才有效;headless 路径绝不为必答问题臆造答案。 + +使用 `--json` 时,stdout 是 NDJSON 事件流。`done` 表示创建及要求执行的安装和构建均已完成,`action-required` 指明一个尚未回答的必答问题,`error` 报告其他失败。面向人的进度信息和包管理器输出写入 stderr,确保 stdout 每一行都能解析成一个事件。调用方收到 `action-required` 后补充缺失值,再次运行命令。 + +Create 和 config 使用相同的功能计划形状。create 通过上述命令行输入公开该形状;config 在共享工作流边界使用同一形状,使后续自动化入口无需另建功能选择模型。 + +### Prompt 与工程编辑边界 + +`PromptPort` 仍是 SDK 问题与交互实现之间的唯一边界。`ClackPromptPort` 负责终端交互。`HeadlessPromptPort` 使用问题契约公开的默认值,否则通过未回答问题快速失败;预填值通常会让流程根本不调用该 port。 + +两条路径使用相同的 `Question` 对象、`FeatureConfigurator`、`SdkProject` 和 `ProjectEditSession`。因此,headless 路径只改变答案的到达方式,不改变功能解释或文件提交方式。 + +### Agent skill + +仓库提供一份轻量 `SKILL.md`,指导 agent skill(智能体技能)构造结构化输入、请求 NDJSON、补充 `action-required` 指明的值并重试。该 skill 调用公开 CLI,不导入 SDK 内部 API,也不引入另一套工程规格。 + +## 外部 Cordis 插件安装 + +`dsh-sdk create ` 接受包管理器原生的 npm package specifier,例如 `pkg@version`,也接受 `github:owner/repo#ref` 等 GitHub package specifier。用户确认后,命令要求工程包管理器添加来源,对比操作前后的直接依赖名,重新打开工程,再通过 `ProjectEditSession` 把每个新增且已解析的包挂载进 `cordis.yml`。 + +包管理器负责来源解析、版本或 commit 解析、`integrity` 数据、lockfile 更新和构建策略。SDK 不再通过 giget 或 pacote 下载、解压第二份副本。外部插件是 `node_modules` 下的依赖;本地插件脚手架仍属于独立的工程创建问题。 + +## Launcher 遥测 + +### Consent 与采集 + +遥测包住 `dsh-sdk` launcher 的命令生命周期,因为 create 和 build 不会稳定地启动 Cordis。每个事件记录命令名、时长、成败、随机生成的用户级匿名标识符,以及符合条件时经过脱敏的 `cordis.yml` 与 `package.json` 文本。 + +除非当前存在的遥测配置项被明确禁用,否则允许上报。`DO_NOT_TRACK` 和 CI 无论工程配置如何都禁止上报。缺少 `cordis.yml` 本身不会禁止事件,但只有 `cordis.yml` 能证明目录是 SDK 工程时,遥测内容才包含 `package.json` 文本。 + +### 安全与传输 + +Payload 构建器绝不读取 `.env`。它会脱敏两个符合条件的文本文件中的疑似密钥键和值、已知 token 形式、PEM 块、URL 凭据和高熵不透明字符串。脱敏只是安全兜底,不能提供绝对保证;SDK 工程必须把凭据放进 `.env`。 + +`TelemetryReporter` 使用固定 endpoint,每条发送路径都会正常结束且不抛错。命令分发通过 `finally` 路径记录成败,在命令结果已确定后启动上报,并在有界时间内等待传输结束。只有遥测边界会吞掉上报条件解析、遥测内容构建、存储或网络错误,这些错误绝不改变命令退出码。 + +## 交互工作流测试 + +Create 和 config 测试向现有工作流注入 `PromptPort` 和脚本化输入输出流。参数化场景覆盖功能选择、功能选项、secret、取消、评审和应用行为,再断言最终的 `cordis.yml` 及其他工程文件。稳定的产品断言是生成后的工程状态,不是 clack 的 ANSI 重绘序列。 + +可以用一到两个可选的真实 PTY 冒烟测试覆盖注入无法复现的发布二进制和 TTY 检查。除非原生 PTY 工具在仓库支持的 Node 与宿主版本上足够可靠,否则它不进入必跑路径。 + +## 延后工作 + +- 扩展 headless create 规格,使其能表达本地 `plugin` 或 `tool` 脚手架,而不是把该交互选择默认为 none。 +- 在 create 和 config 中公开遥测关闭选项,同时保留只有禁用时才写入遥测配置项的上报许可表示。 +- 明确 GitHub 来源依赖必须预先构建,还是允许运行由包管理器控制的 preparation script(准备脚本),并在安装前向用户展示该策略。 +- 发布前把遥测包中的 `.invalid` endpoint 占位符替换为生产端点。 + +## 曾考虑的替代方案 + +**另建 headless 创建引擎。** 该方案会复制问题、功能依赖、配置行为和工程编辑规则。复用提示词与编辑会话边界,可以保证工程语义只有一份实现。 + +**把规格文件作为主要自动化接口。** Agent 可以内联传入相同的类型化 JSON 对象,人和 CI 仍可选用文件。文件专用协议会增加持久化与清理工作,却不增加语义。 + +**使用 `npx skills add` 创建工程。** Skills CLI 只安装 Markdown skill,不创建 SDK 工程,也不安装 npm 包。因此,agent skill 驱动 SDK 初始化命令,而不是取代它。 + +**通过 giget 或 pacote 获取 GitHub 与 npm 来源。** 第二套获取层会复制包管理器的解析、完整性、lockfile 和生命周期策略。原生 package specifier 让这些决策留在所选包管理器中。 + +**把遥测实现成 Cordis 运行时插件。** Create 和 build 不一定启动 Cordis,因此运行时插件无法观察完整的开发命令周期。Launcher 是这些命令共用的边界。 + +**从 git 元数据派生匿名标识符。** 仓库的 git remote 可能识别工程或组织。随机的用户级标识符能够支持聚合,同时不编码仓库身份。 + +**只采集聚合计数。** 仅聚合事件可以降低暴露,但无法回答开发者实际使用哪些插件、依赖和配置形状。本提案接受采集脱敏后的工程文本,并明确记录这项暴露。 + +**把真实 PTY 和 transcript(文本记录)快照作为主要测试策略。** 原生 PTY 依赖与终端重绘序列会带来平台和渲染不稳定性,而且主要是在测试 clack。注入交互并断言生成文件,可以直接测试 SDK 拥有的行为。 + +## 验收标准 + +- Create 能依据完整结构化输入在没有 TTY 时运行;使用 `--json` 时 stdout 只输出 NDJSON;缺少必答输入时通过 `action-required` 报告,且不写入部分工程。 +- Create 和 config 通过共享的问题、功能配置和工程编辑代码路径解析相同的功能计划契约。 +- `dsh-sdk create ` 使用工程选定的包管理器,挂载该操作实际新增的依赖名;无法识别新增依赖时快速失败。 +- 每个 `dsh-sdk` 命令都进入同一条尽力而为的遥测收尾路径;明确禁用的配置项、`DO_NOT_TRACK` 或 CI 会阻止传输,遥测失败绝不改变命令结果。 +- 遥测绝不读取 `.env`;没有 `cordis.yml` 时不发送无关的 `package.json` 内容;两个符合条件的文本都经过脱敏;匿名标识符与 git 元数据无关。 +- 交互测试通过注入交互覆盖 create 和 config 决策,并断言已提交的工程文件;真实 PTY 覆盖只作为窄范围冒烟层。 +- Agent skill 说明公开的结构化输入与事件契约,不依赖包的私有导出。 + +## 风险 + +- 即使经过脱敏,完整的 `cordis.yml` 与 `package.json` 文本仍会向 endpoint 运营方暴露插件名、依赖名、URL、路径和配置值;启发式脱敏也可能漏掉 secret。 +- 没有遥测配置项时默认上报可能让开发者意外;发布前 CLI 必须让关闭方法易于发现。 +- 在 `ProjectEditSession` 挂载插件前,包管理器的 add 操作已经可能修改 `package.json`、lockfile 和安装文件;后续挂载失败会留下需要手工恢复的依赖改动。 +- GitHub 依赖可能按包管理器策略执行 preparation 或 lifecycle script;尚未解决的构建策略会带来供应链与可复现性风险。 +- 注入提示词交互的测试无法证明真实终端中的 raw mode、signal 或重绘行为;可选冒烟层只应覆盖这些残余契约。 + +## 参考资料 + +- [Vercel Eve](https://github.com/vercel/eve) 与 [Vercel Labs Skills](https://github.com/vercel-labs/skills) 用于区分 headless 初始化命令与 skill 分发。 +- [npm package specifications](https://docs.npmjs.com/cli/v11/using-npm/package-spec)、[pnpm add](https://pnpm.io/cli/add)和 [Yarn add](https://yarnpkg.com/cli/add)说明包管理器原生来源。 +- [Console Do Not Track](https://consoledonottrack.com/)定义环境级关闭约定。 +- [Clack](https://github.com/bombshell-dev/clack) 和 [Vitest snapshots](https://vitest.dev/guide/snapshot) 说明注入提示词交互与生成文件断言。 diff --git a/docs/sdk-后续工作-设计.md b/docs/sdk-后续工作-设计.md deleted file mode 100644 index 5f968c27ee..0000000000 --- a/docs/sdk-后续工作-设计.md +++ /dev/null @@ -1,196 +0,0 @@ -# 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 ` 加依赖并挂载 | 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 | - -**节奏**:先做地基(`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 包当**依赖**加进现有项目并挂载;用包管理器原生能力,**不引 giget/pacote**。 - -**设计(PM 原生依赖 + cordis 挂载)**: - -- **来源**: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) - -**目标**:每次 `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(对用户可见、随项目)。 - -> **接线现状(读码修正)**: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) - -**目标**: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 建插件 | 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 | - -## 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 38c2b5a6ecb145b5efca98e2a4782af395a70072 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:49:41 +0800 Subject: [PATCH 27/29] docs(rfc): preserve SDK telemetry scope --- .../2026-07-17-sdk-follow-up-capabilities.i18n.yaml | 4 ++-- .../feature/2026-07-17-sdk-follow-up-capabilities.md | 10 +++++----- .../2026-07-17-sdk-follow-up-capabilities.zh.md | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml b/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml index 0982e51461..933cff9cb5 100644 --- a/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml +++ b/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.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 -2026-07-17-sdk-follow-up-capabilities.md: 8fa83018b3eace1844148ed02cc77ac67571a67c -2026-07-17-sdk-follow-up-capabilities.zh.md: b6cd56ac315ff758c128eade671544ae3820e8e9 +2026-07-17-sdk-follow-up-capabilities.md: dac859f95e4ee4c628c50be72b3a18720f2b19fb +2026-07-17-sdk-follow-up-capabilities.zh.md: 55648a368b4f9aa865129f3e0501e417d15f09ed diff --git a/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md b/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md index 8fa83018b3..dac859f95e 100644 --- a/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md +++ b/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md @@ -12,13 +12,13 @@ These gaps are coupled. Create and config already share questions, feature confi ## Proposal -The SDK extends the existing prompt and project-editing boundaries instead of creating parallel workflows. A non-interactive prompt port and structured feature plan drive create and config, `dsh-sdk create ` delegates dependency resolution to the project package manager before mounting the resolved package through `ProjectEditSession`, launcher-side telemetry wraps every `dsh-sdk` command, and injected prompt streams provide the primary interactive-test seam. +The SDK extends the existing prompt and project-editing boundaries instead of creating parallel workflows. A non-interactive prompt port and structured feature plan drive create and config, `dsh-sdk create ` delegates dependency resolution to the project package manager before mounting the resolved package through `ProjectEditSession`, launcher-side telemetry wraps `create-sdk` and every `dsh-sdk` command, and injected prompt streams provide the primary interactive-test seam. | Capability | Product entrypoint | Owning mechanism | Required outcome | |---|---|---|---| | Headless project creation | `create-sdk --config ` or `--config-json ` with optional `--json` | `HeadlessPromptPort`, structured project answers, and a complete feature plan | No terminal blocking; missing required input is explicit | | External Cordis plugin installation | `dsh-sdk create ` | Native package-manager `add` plus `ProjectEditSession` | The dependency and `cordis.yml` entry identify the package manager's resolved package | -| Developer-cycle telemetry | Every `dsh-sdk` command | Launcher-side consent, payload, redaction, anonymous identity, and delivery services | Reporting is best-effort and cannot change the command result | +| Developer-cycle telemetry | `create-sdk` and every `dsh-sdk` command | Launcher-side consent, payload, redaction, anonymous identity, and delivery services | Reporting is best-effort and cannot change the command result | | Interactive regression coverage | Create and config tests | Injected `PromptPort` input/output and filesystem assertions | Tests cover Harness decisions and generated files without snapshotting terminal repainting | ## Shared headless workflow @@ -51,7 +51,7 @@ The package manager owns source parsing, version or commit resolution, integrity ### Consent and collection -Telemetry wraps the `dsh-sdk` launcher command lifecycle because create and build do not reliably boot Cordis. One event records the command name, duration, success, a random per-user anonymous identifier, and redacted `cordis.yml` and `package.json` text when those project files are eligible. +Telemetry wraps the `create-sdk` initializer and the `dsh-sdk` launcher command lifecycle because project initialization, plugin creation, and build do not reliably boot Cordis. One event records the command name, duration, success, a random per-user anonymous identifier, and redacted `cordis.yml` and `package.json` text when those project files are eligible. Reporting is enabled unless a present telemetry config entry is explicitly disabled. `DO_NOT_TRACK` and CI deny reporting regardless of project configuration. A missing `cordis.yml` does not itself deny the event, but `package.json` content is included only when `cordis.yml` establishes that the directory is an SDK project. @@ -97,7 +97,7 @@ One or two optional real-PTY smoke tests may cover the shipped binary and TTY gu - Create runs without a TTY from a complete structured input, emits only NDJSON on stdout under `--json`, and reports missing required input as `action-required` without writing a partial project. - Create and config resolve the same feature-plan contract through the shared question, feature-configuration, and project-editing code paths. - `dsh-sdk create ` uses the selected project package manager, mounts the dependency name that operation actually added, and fails loudly when no new dependency can be identified. -- Every `dsh-sdk` command reaches one best-effort telemetry completion path; an explicit disabled entry, `DO_NOT_TRACK`, or CI prevents delivery, and telemetry failures never change the command result. +- The initializer and every `dsh-sdk` command reach one best-effort telemetry completion path; an explicit disabled entry, `DO_NOT_TRACK`, or CI prevents delivery, and telemetry failures never change the command result. - Telemetry never reads `.env`, withholds unrelated `package.json` content when no `cordis.yml` exists, redacts both eligible text payloads, and uses an identifier unrelated to git metadata. - Interactive tests cover create and config decisions through injected interaction and assert committed project files; any real-PTY coverage remains a narrow smoke layer. - The agent skill documents the public structured-input and event contracts without depending on private package exports. @@ -114,5 +114,5 @@ One or two optional real-PTY smoke tests may cover the shipped binary and TTY gu - [Vercel Eve](https://github.com/vercel/eve) and [Vercel Labs Skills](https://github.com/vercel-labs/skills) for the distinction between a headless initializer and skill distribution. - [npm package specifications](https://docs.npmjs.com/cli/v11/using-npm/package-spec), [pnpm add](https://pnpm.io/cli/add), and [Yarn add](https://yarnpkg.com/cli/add) for package-manager-native sources. -- [Console Do Not Track](https://consoledonottrack.com/) for the environment-level opt-out convention. +- [`DO_NOT_TRACK`](https://donottrack.sh/) for the environment-level opt-out convention. - [Clack](https://github.com/bombshell-dev/clack) and [Vitest snapshots](https://vitest.dev/guide/snapshot) for injected prompts and generated-file assertions. diff --git a/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md b/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md index b6cd56ac31..55648a368b 100644 --- a/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md +++ b/docs/rfc/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md @@ -12,13 +12,13 @@ Status: proposed ## 提案 -SDK 扩展现有提示词与工程编辑边界,不另建平行工作流。非交互式 `PromptPort` 实现和结构化功能计划驱动 create 与 config;`dsh-sdk create ` 先把依赖解析交给工程的包管理器,再通过 `ProjectEditSession` 挂载解析所得的包;`dsh-sdk` 启动器侧的遥测包住每个命令;交互测试主要通过注入的提示词输入输出流完成。 +SDK 扩展现有提示词与工程编辑边界,不另建平行工作流。非交互式 `PromptPort` 实现和结构化功能计划驱动 create 与 config;`dsh-sdk create ` 先把依赖解析交给工程的包管理器,再通过 `ProjectEditSession` 挂载解析所得的包;启动器侧遥测包住 `create-sdk` 和每个 `dsh-sdk` 命令;交互测试主要通过注入的提示词输入输出流完成。 | 功能 | 产品入口 | 所属机制 | 必须达到的结果 | |---|---|---|---| | Headless 工程创建 | `create-sdk --config ` 或 `--config-json `,可搭配 `--json` | `HeadlessPromptPort`、结构化工程答案和完整功能计划 | 不阻塞等待终端;明确报告缺失的必答输入 | | 外部 Cordis 插件安装 | `dsh-sdk create ` | 包管理器原生 `add` 加 `ProjectEditSession` | 依赖和 `cordis.yml` 配置项指向包管理器解析出的包 | -| 开发周期遥测 | 每个 `dsh-sdk` 命令 | 启动器侧的上报条件判断、遥测内容构建、脱敏、匿名身份和传输服务 | 上报采用尽力而为语义,不能改变命令结果 | +| 开发周期遥测 | `create-sdk` 和每个 `dsh-sdk` 命令 | 启动器侧的上报条件判断、遥测内容构建、脱敏、匿名身份和传输服务 | 上报采用尽力而为语义,不能改变命令结果 | | 交互回归覆盖 | create 和 config 测试 | 注入的 `PromptPort` 输入输出和文件系统断言 | 测试覆盖 Harness 决策与生成文件,不快照终端重绘 | ## 共享 headless 工作流 @@ -51,7 +51,7 @@ Create 和 config 使用相同的功能计划形状。create 通过上述命令 ### Consent 与采集 -遥测包住 `dsh-sdk` launcher 的命令生命周期,因为 create 和 build 不会稳定地启动 Cordis。每个事件记录命令名、时长、成败、随机生成的用户级匿名标识符,以及符合条件时经过脱敏的 `cordis.yml` 与 `package.json` 文本。 +遥测包住 `create-sdk` 初始化命令与 `dsh-sdk` launcher 的命令生命周期,因为工程初始化、插件创建和 build 都不会稳定地启动 Cordis。每个事件记录命令名、时长、成败、随机生成的用户级匿名标识符,以及符合条件时经过脱敏的 `cordis.yml` 与 `package.json` 文本。 除非当前存在的遥测配置项被明确禁用,否则允许上报。`DO_NOT_TRACK` 和 CI 无论工程配置如何都禁止上报。缺少 `cordis.yml` 本身不会禁止事件,但只有 `cordis.yml` 能证明目录是 SDK 工程时,遥测内容才包含 `package.json` 文本。 @@ -97,7 +97,7 @@ Create 和 config 测试向现有工作流注入 `PromptPort` 和脚本化输入 - Create 能依据完整结构化输入在没有 TTY 时运行;使用 `--json` 时 stdout 只输出 NDJSON;缺少必答输入时通过 `action-required` 报告,且不写入部分工程。 - Create 和 config 通过共享的问题、功能配置和工程编辑代码路径解析相同的功能计划契约。 - `dsh-sdk create ` 使用工程选定的包管理器,挂载该操作实际新增的依赖名;无法识别新增依赖时快速失败。 -- 每个 `dsh-sdk` 命令都进入同一条尽力而为的遥测收尾路径;明确禁用的配置项、`DO_NOT_TRACK` 或 CI 会阻止传输,遥测失败绝不改变命令结果。 +- 初始化命令与每个 `dsh-sdk` 命令都进入同一条尽力而为的遥测收尾路径;明确禁用的配置项、`DO_NOT_TRACK` 或 CI 会阻止传输,遥测失败绝不改变命令结果。 - 遥测绝不读取 `.env`;没有 `cordis.yml` 时不发送无关的 `package.json` 内容;两个符合条件的文本都经过脱敏;匿名标识符与 git 元数据无关。 - 交互测试通过注入交互覆盖 create 和 config 决策,并断言已提交的工程文件;真实 PTY 覆盖只作为窄范围冒烟层。 - Agent skill 说明公开的结构化输入与事件契约,不依赖包的私有导出。 @@ -114,5 +114,5 @@ Create 和 config 测试向现有工作流注入 `PromptPort` 和脚本化输入 - [Vercel Eve](https://github.com/vercel/eve) 与 [Vercel Labs Skills](https://github.com/vercel-labs/skills) 用于区分 headless 初始化命令与 skill 分发。 - [npm package specifications](https://docs.npmjs.com/cli/v11/using-npm/package-spec)、[pnpm add](https://pnpm.io/cli/add)和 [Yarn add](https://yarnpkg.com/cli/add)说明包管理器原生来源。 -- [Console Do Not Track](https://consoledonottrack.com/)定义环境级关闭约定。 +- [`DO_NOT_TRACK`](https://donottrack.sh/)定义环境级关闭约定。 - [Clack](https://github.com/bombshell-dev/clack) 和 [Vitest snapshots](https://vitest.dev/guide/snapshot) 说明注入提示词交互与生成文件断言。 From c225b4956eacb4e9ccdfbfcd81806eb2a43b25e9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:53:31 +0800 Subject: [PATCH 28/29] docs(telemetry): document cache effect --- packages/sdk/telemetry/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/sdk/telemetry/README.md b/packages/sdk/telemetry/README.md index 8ec3da2c78..01a2ee6c2d 100644 --- a/packages/sdk/telemetry/README.md +++ b/packages/sdk/telemetry/README.md @@ -18,6 +18,10 @@ The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); its `.in None, as the reporter sends developer-cycle telemetry from the launcher and never reaches a model request. +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + ## Known Limitations and Deferred Work - **Placeholder endpoint** — `DSH_TELEMETRY_ENDPOINT` points at `.invalid` until the real endpoint is set. From a1c85447b86d78e85f4e79e88f6743a1bfb58fab Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:19:42 +0800 Subject: [PATCH 29/29] refactor(examples): rename DSBench composition to jsonrpc-agent --- examples/README.md | 4 ++-- examples/dsbench-coding-agent/package.json | 7 ------- .../{dsbench-coding-agent => jsonrpc-agent}/README.md | 10 +++++----- .../{dsbench-coding-agent => jsonrpc-agent}/cordis.yml | 4 ++-- examples/jsonrpc-agent/package.json | 7 +++++++ .../tests/keyless-smoke.e2e.ts | 6 +++--- python/sdk/README.i18n.yaml | 4 ++-- python/sdk/README.md | 2 +- python/sdk/README.zh.md | 2 +- 9 files changed, 23 insertions(+), 23 deletions(-) delete mode 100644 examples/dsbench-coding-agent/package.json rename examples/{dsbench-coding-agent => jsonrpc-agent}/README.md (60%) rename examples/{dsbench-coding-agent => jsonrpc-agent}/cordis.yml (94%) create mode 100644 examples/jsonrpc-agent/package.json rename examples/{dsbench-coding-agent => jsonrpc-agent}/tests/keyless-smoke.e2e.ts (96%) diff --git a/examples/README.md b/examples/README.md index 620800488d..c06bb791df 100644 --- a/examples/README.md +++ b/examples/README.md @@ -33,9 +33,9 @@ The full-screen terminal sibling of `repl-agent`: it reuses the same coding back Run with: `pnpm run demo:tui` (needs `DEEPSEEK_API_KEY`). See [tui-agent/README.md](tui-agent/README.md) for controls and composition. -## dsbench-coding-agent +## jsonrpc-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). +An unattended coding agent driven through the Python SDK: 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 [jsonrpc-agent/README.md](jsonrpc-agent/README.md). ## cordis-agent diff --git a/examples/dsbench-coding-agent/package.json b/examples/dsbench-coding-agent/package.json deleted file mode 100644 index 900c88d69b..0000000000 --- a/examples/dsbench-coding-agent/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "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/README.md b/examples/jsonrpc-agent/README.md similarity index 60% rename from examples/dsbench-coding-agent/README.md rename to examples/jsonrpc-agent/README.md index ff45ba36cf..cfbf6787b1 100644 --- a/examples/dsbench-coding-agent/README.md +++ b/examples/jsonrpc-agent/README.md @@ -1,6 +1,6 @@ -# dsbench-coding-agent +# jsonrpc-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 unattended coding-agent 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 turns are driven by the SDK. The model-facing tools are: @@ -9,7 +9,7 @@ The model-facing tools are: - `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. +The surrounding runtime also loads JSONL session persistence and automatic context compaction. `maxTokensAsSuccess` keeps a token-limited model turn as an accepted evaluation result while preserving its `max-tokens` reason. ## Runtime environment @@ -17,8 +17,8 @@ The surrounding runtime also loads JSONL session persistence and automatic conte |---|---| | `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_CWD` | Agent workspace for bash and filesystem tools | | `DSH_SESSION_ROOT` | JSONL trajectory directory | -| `DSH_SYSTEM_PROMPT` | DSBench-provided coding persona | +| `DSH_SYSTEM_PROMPT` | Deployment-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/jsonrpc-agent/cordis.yml similarity index 94% rename from examples/dsbench-coding-agent/cordis.yml rename to examples/jsonrpc-agent/cordis.yml index 4d970c8b47..de19b3da94 100644 --- a/examples/dsbench-coding-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -1,4 +1,4 @@ -# DSBench deployment for the bundled dsh-jsonrpc-agent runtime. +# Unattended coding-agent 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 @@ -21,7 +21,7 @@ - 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.' + persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a coding agent.' workspaceContext: false skills: enabled: false diff --git a/examples/jsonrpc-agent/package.json b/examples/jsonrpc-agent/package.json new file mode 100644 index 0000000000..080b0649a6 --- /dev/null +++ b/examples/jsonrpc-agent/package.json @@ -0,0 +1,7 @@ +{ + "name": "jsonrpc-agent-example", + "private": true, + "version": "0.0.1", + "type": "module", + "description": "Unattended JSON-RPC coding-agent composition" +} diff --git a/examples/dsbench-coding-agent/tests/keyless-smoke.e2e.ts b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts similarity index 96% rename from examples/dsbench-coding-agent/tests/keyless-smoke.e2e.ts rename to examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts index f5e2d7b938..41afb03574 100644 --- a/examples/dsbench-coding-agent/tests/keyless-smoke.e2e.ts +++ b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts @@ -28,7 +28,7 @@ function waitForLine( return } } catch { - reject(new Error(`non-JSON stdout from DSBench runtime: ${line}`)) + reject(new Error(`non-JSON stdout from JSON-RPC agent runtime: ${line}`)) return } } @@ -42,9 +42,9 @@ function waitForLine( }) } -describe('dsbench-coding-agent keyless smoke', () => { +describe('jsonrpc-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 root = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-agent-smoke-')) const modelRequests: Record[] = [] const modelServer = createServer((request, response) => { let body = '' diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index bb012a90db..181df4986e 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.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 -README.md: 80c9d1f50d26fc4f4670800fd7d7f5ea442ad891 -README.zh.md: ffedb5eb30f17388fe589863dbc654b22716b40c +README.md: 5fd1bc7cd89152a28d3da17100fd62eed4f8cb14 +README.zh.md: 247a2ca5ea5c1c3afc19335a6bbcba356c823211 diff --git a/python/sdk/README.md b/python/sdk/README.md index 80c9d1f50d..5fd1bc7cd8 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -27,7 +27,7 @@ from deepseek_harness import DeepSeekHarness with DeepSeekHarness( provider="deepseek", model="deepseek-v4-flash", - cordis="examples/dsbench-coding-agent/cordis.yml", + cordis="examples/jsonrpc-agent/cordis.yml", ) as harness: result = harness.run("Make the requested code change.") ``` diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index ffedb5eb30..247a2ca5ea 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -23,7 +23,7 @@ from deepseek_harness import DeepSeekHarness with DeepSeekHarness( provider="deepseek", model="deepseek-v4-flash", - cordis="examples/dsbench-coding-agent/cordis.yml", + cordis="examples/jsonrpc-agent/cordis.yml", ) as harness: result = harness.run("Make the requested code change.") ```