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.
253 lines
9.8 KiB
TypeScript
253 lines
9.8 KiB
TypeScript
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 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')
|
|
|
|
async function runBuiltBin(
|
|
args: readonly string[] = [],
|
|
env: Record<string, string> = {},
|
|
): Promise<{ stdout: string; code: number; stderr: string }> {
|
|
const result = await execa(process.execPath, [dshBin, ...args], {
|
|
input: '',
|
|
timeout: 25_000,
|
|
killSignal: 'SIGKILL',
|
|
reject: false,
|
|
env,
|
|
})
|
|
if (result.timedOut) {
|
|
throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
|
}
|
|
return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr }
|
|
}
|
|
|
|
async function waitForFile(file: string): Promise<void> {
|
|
const deadline = Date.now() + 20_000
|
|
while (!existsSync(file)) {
|
|
if (Date.now() >= deadline) throw new Error(`dsh profile lifecycle marker did not appear: ${file}`)
|
|
await new Promise(resolve => setTimeout(resolve, 20))
|
|
}
|
|
}
|
|
|
|
interface ProfileLifecycleFixture {
|
|
home: string
|
|
ready: string
|
|
settled: string
|
|
disposed: string
|
|
}
|
|
|
|
/**
|
|
* 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 bundleDir = join(home, 'lifecycle-bundle')
|
|
mkdirSync(bundleDir, { recursive: true })
|
|
writeFileSync(join(bundleDir, 'plugin.mjs'), [
|
|
"import { writeFileSync } from 'node:fs'",
|
|
"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(join(bundleDir, 'cordis.patch.yml'), [
|
|
'- insert:',
|
|
' - id: profile-lifecycle-fixture',
|
|
` name: ${pathToFileURL(join(bundleDir, 'plugin.mjs')).href}`,
|
|
'',
|
|
].join('\n'))
|
|
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 startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
|
return execa(process.execPath, [dshBin, '--profile', 'lifecycle'], {
|
|
cwd: fixture.home,
|
|
input: '',
|
|
reject: false,
|
|
env: {
|
|
DSH_HOME: fixture.home,
|
|
RAW_READY_FILE: fixture.ready,
|
|
RAW_SETTLED_FILE: fixture.settled,
|
|
RAW_DISPOSED_FILE: fixture.disposed,
|
|
},
|
|
})
|
|
}
|
|
|
|
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
|
|
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('--profile <name> is required')
|
|
const help = await runBuiltBin(['--help'])
|
|
expect(help.code).toBe(0)
|
|
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 removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task']]) {
|
|
const result = await runBuiltBin(removed)
|
|
expect(result.code).toBe(1)
|
|
}
|
|
}, 30_000)
|
|
|
|
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 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')
|
|
const result = await child
|
|
expect(result.exitCode).toBe(0)
|
|
expect(result.signal).toBeUndefined()
|
|
expect(existsSync(fixture.disposed)).toBe(true)
|
|
} finally {
|
|
child.kill('SIGKILL')
|
|
rmSync(fixture.home, { recursive: true, force: true })
|
|
}
|
|
}, 30_000)
|
|
|
|
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)
|
|
expect(result.signal).toBeUndefined()
|
|
expect(existsSync(fixture.disposed)).toBe(true)
|
|
} finally {
|
|
child.kill('SIGKILL')
|
|
rmSync(fixture.home, { recursive: true, force: true })
|
|
}
|
|
}, 30_000)
|
|
|
|
describe('config dump', () => {
|
|
let home: string
|
|
beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })
|
|
afterEach(() => { rmSync(home, { recursive: true, force: true }) })
|
|
|
|
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('# == @deepseek-ai/dsh-base')
|
|
expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
|
|
}, 30_000)
|
|
|
|
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(
|
|
['--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')
|
|
// 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)
|
|
})
|
|
})
|