feat(cli)!: dsh boots profiles; plugin subcommand manages them via pnpm

dsh --profile <name> replaces the fixed entry modes: --config and -p are
removed, --patch adds overlays over the composed profile, a positional task
selects one-shot mode (requires the headless-runner row), and dsh web stays as
the alias for --profile web carrying the Web flag family as patches. dsh
plugin --profile <name> forwards verbatim to pnpm in the profile directory,
initializes on first use, and reconciles the dsh.plugins layer list after
add/remove (patch-less packages warn and stay plain dependencies). Config
dumps and the keyless web e2e scaffold compose the same bundle layers over the
same empty root as the boot.
This commit is contained in:
Turtle
2026-08-06 04:40:32 +08:00
parent 9235d0f90f
commit cd6b4ee3c9
44 changed files with 1126 additions and 1845 deletions

View File

@@ -1,15 +1,13 @@
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
/** Published-entry acceptance for raw argument errors and boot-free config dumps. */
/** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
const rawOverlay = fileURLToPath(new URL('./fixtures/raw-overlay.cordis.yml', import.meta.url))
const rawInvalidProvider = fileURLToPath(new URL('./fixtures/raw-invalid-provider.cordis.yml', import.meta.url))
async function runBuiltBin(
args: readonly string[] = [],
@@ -31,60 +29,91 @@ async function runBuiltBin(
async function waitForFile(file: string): Promise<void> {
const deadline = Date.now() + 20_000
while (!existsSync(file)) {
if (Date.now() >= deadline) throw new Error(`dsh raw lifecycle marker did not appear: ${file}`)
if (Date.now() >= deadline) throw new Error(`dsh profile lifecycle marker did not appear: ${file}`)
await new Promise(resolve => setTimeout(resolve, 20))
}
}
interface RawLifecycleFixture {
interface ProfileLifecycleFixture {
home: string
ready: string
settled: string
disposed: string
overlay: string
}
function createRawLifecycleFixture(): RawLifecycleFixture {
const home = mkdtempSync(join(tmpdir(), 'dsh-raw-lifecycle-'))
/**
* A minimal custom profile: one lifecycle-marker plugin bundle listed in
* dsh.plugins, no dsh-base — proving out-of-box composition machinery without
* booting the entire product tree.
*/
function createProfileLifecycleFixture(): ProfileLifecycleFixture {
const home = mkdtempSync(join(tmpdir(), 'dsh-profile-lifecycle-'))
const ready = join(home, 'ready')
const settled = join(home, 'settled')
const disposed = join(home, 'disposed')
const plugin = join(home, 'lifecycle.mjs')
const overlay = join(home, 'overlay.cordis.yml')
writeFileSync(plugin, [
const bundleDir = join(home, 'lifecycle-bundle')
mkdirSync(bundleDir, { recursive: true })
writeFileSync(join(bundleDir, 'plugin.mjs'), [
"import { writeFileSync } from 'node:fs'",
"export const name = 'raw-lifecycle-fixture'",
"export const inject = ['sessionQuery']",
"export const name = 'profile-lifecycle-fixture'",
'export function apply(ctx) {',
' let active = true',
' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.',
' const heartbeat = setInterval(() => {}, 1000)',
" writeFileSync(process.env.RAW_READY_FILE, 'ready')",
' void ctx.loader.await().then(() => {',
" if (active) writeFileSync(process.env.RAW_SETTLED_FILE, 'settled')",
' })',
' ctx.effect(() => () => {',
' active = false',
' clearInterval(heartbeat)',
" writeFileSync(process.env.RAW_DISPOSED_FILE, 'disposed')",
' })',
'}',
'',
].join('\n'))
writeFileSync(overlay, [
writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
'- insert:',
' - id: raw-lifecycle-fixture',
` name: ${pathToFileURL(plugin).href}`,
' - id: profile-lifecycle-fixture',
` name: ${pathToFileURL(join(bundleDir, 'plugin.mjs')).href}`,
'',
].join('\n'))
return { home, ready, settled, disposed, overlay }
writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
name: 'dsh-lifecycle-bundle',
version: '0.0.0',
type: 'module',
dsh: { patch: './cordis.patch.yml' },
}, undefined, 2))
const profileDir = join(home, 'profiles', 'lifecycle')
mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
name: 'dsh-profile-lifecycle',
private: true,
dependencies: {},
dsh: { plugins: ['dsh-lifecycle-bundle'] },
}, undefined, 2))
// Hand-place the "installed" bundle where profile resolution finds it.
writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
const linkTarget = join(profileDir, 'node_modules', 'dsh-lifecycle-bundle')
mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
try {
rmSync(linkTarget, { recursive: true, force: true })
} catch { /* fresh dir */ }
// Copy-free: a package.json redirecting via a relative main is enough for require.resolve.
mkdirSync(linkTarget, { recursive: true })
for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) {
writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file)))
}
return { home, ready, settled, disposed }
}
function startRawLifecycle(fixture: RawLifecycleFixture) {
return execa(process.execPath, [dshBin, '--config', fixture.overlay], {
function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
return execa(process.execPath, [dshBin, '--profile', 'lifecycle'], {
cwd: fixture.home,
input: '',
reject: false,
env: {
DSH_HOME: fixture.home,
DSH_TELEMETRY_DISABLED: '1',
RAW_READY_FILE: fixture.ready,
RAW_SETTLED_FILE: fixture.settled,
RAW_DISPOSED_FILE: fixture.disposed,
@@ -93,35 +122,37 @@ function startRawLifecycle(fixture: RawLifecycleFixture) {
}
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
it('requires --config for the raw command and rejects removed commands', async () => {
it('requires --profile and rejects removed commands', async () => {
const bare = await runBuiltBin()
expect(bare.code).toBe(1)
expect(bare.stdout).toBe('')
expect(bare.stderr).toContain('--config <path> is required')
expect(bare.stderr).toContain('--profile <name> is required')
const help = await runBuiltBin(['--help'])
expect(help.code).toBe(0)
expect(help.stdout).toContain('dsh --config ./app.cordis.yml')
expect(help.stdout).toContain('dsh --profile web')
expect(help.stdout).toContain('dsh plugin --profile')
expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
for (const command of ['tui', 'meta', 'upgrade']) {
const removed = await runBuiltBin([command])
expect(removed.code).toBe(1)
expect(removed.stderr).not.toContain('experimental')
for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task']]) {
const result = await runBuiltBin(removed)
expect(result.code).toBe(1)
}
}, 30_000)
it('reports a raw overlay boot failure without hanging', async () => {
const result = await runBuiltBin(['--config', rawInvalidProvider], {
DEEPSEEK_API_KEY: 'keyless-invalid-config',
DSH_TELEMETRY_DISABLED: '1',
})
expect(result.code).toBe(1)
expect(result.stdout).toBe('')
expect(result.stderr).toContain('llm-pi-ai')
it('fails loud on a nonexistent profile with the plugin-command hint', async () => {
const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-'))
try {
const result = await runBuiltBin(['--profile', 'nope'], { DSH_HOME: home })
expect(result.code).toBe(1)
expect(result.stderr).toContain('profile "nope" does not exist')
expect(result.stderr).toContain('dsh plugin --profile nope add')
} finally {
rmSync(home, { recursive: true, force: true })
}
}, 30_000)
it('applies an inserted raw plugin and disposes it on a startup-time signal', async () => {
const fixture = createRawLifecycleFixture()
const child = startRawLifecycle(fixture)
it('applies a custom profile bundle and disposes it on a startup-time signal', async () => {
const fixture = createProfileLifecycleFixture()
const child = startProfileLifecycle(fixture)
try {
await waitForFile(fixture.ready)
child.kill('SIGTERM')
@@ -135,11 +166,24 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
}
}, 30_000)
it('fully settles a valid raw overlay and disposes it on a signal', async () => {
const fixture = createRawLifecycleFixture()
const child = startRawLifecycle(fixture)
it('fully settles a custom profile, hot-reloads its patch layer, and disposes on a signal', async () => {
const fixture = createProfileLifecycleFixture()
const child = startProfileLifecycle(fixture)
try {
await waitForFile(fixture.settled)
// The live profile layer: even without an hmr row in the composition,
// the launcher mounts a config-only watcher, so an edited
// cordis.patch.yml lands in the running tree (the reload disposes the
// patched row's old fiber — observable as the disposed marker — and
// mounts the new config, which re-writes the ready marker).
rmSync(fixture.ready)
writeFileSync(join(fixture.home, 'profiles', 'lifecycle', 'cordis.patch.yml'), [
'- id: profile-lifecycle-fixture',
' config:',
' generation: 2',
'',
].join('\n'))
await waitForFile(fixture.ready)
child.kill('SIGTERM')
const result = await child
expect(result.exitCode).toBe(0)
@@ -156,50 +200,53 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })
afterEach(() => { rmSync(home, { recursive: true, force: true }) })
it('prints the shipped base without a user layer', async () => {
const { stdout, code, stderr } = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home })
it('prints the web profile bundle layers without a user layer', async () => {
const { stdout, code, stderr } = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
expect(code).toBe(0)
expect(stderr).toBe('')
expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'")
expect(stdout).toContain('agents: []')
expect(stdout).toContain('# == base.cordis.yml')
expect(stdout).toContain('# == @deepseek-ai/dsh-base')
expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
}, 30_000)
it('composes the required raw overlay directly over the base', async () => {
writeFileSync(join(home, 'config.yaml'), [
it('composes the profile user layer and a --patch overlay in order', async () => {
// Auto-init the web profile first, then write its user layer.
const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
expect(init.code).toBe(0)
const profilePatch = join(home, 'profiles', 'web', 'cordis.patch.yml')
writeFileSync(profilePatch, [
'- id: agent-loop',
' config:',
' agents:',
' - id: personal',
' provider: personal-provider',
' model: personal-model',
'- id: absent-row',
' config:',
' x: 1',
'',
].join('\n'))
const overlay = join(home, 'overlay.cordis.yml')
writeFileSync(overlay, [
'- id: agent-loop',
' config:',
' agents:',
' - id: configured',
' provider: configured-provider',
' model: configured-model',
'',
].join('\n'))
const { stdout, code, stderr } = await runBuiltBin(
['--config', rawOverlay, '--dump-config'],
['--profile', 'web', '--patch', overlay, '--dump-config'],
{ DSH_HOME: home },
)
expect(code).toBe(0)
expect(stdout).toContain('provider: configured-provider')
expect(stdout).not.toContain('personal-provider')
expect(stdout).toContain(`patched by ${rawOverlay}`)
// Both layers patched the row; provenance lists them in application order.
expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`)
expect(stderr).toContain('patch: entry "absent-row" not found')
}, 30_000)
it('keeps the Web overlay and personal layer on the Web command', async () => {
writeFileSync(join(home, 'config.yaml'), [
'- id: agent-loop',
' config:',
' agents:',
' - id: personal',
' provider: personal-provider',
' model: personal-model',
'',
].join('\n'))
const { stdout, code } = await runBuiltBin(['web', '--dump-config'], { DSH_HOME: home })
expect(code).toBe(0)
expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
expect(stdout).toContain('provider: personal-provider')
}, 30_000)
})
})