$DSH_HOME/config.yaml was an implicit composition layer: if the file existed, every launch applied an arbitrary Loader patch graph over the shipped tree, kept live by a dedicated HMR watcher. Three costs came from the implicitness, not the capability. A patch replaces its target row's whole config, so a file written months ago pins that row to the field set it knew and every default the shipped tree later adds silently stops applying. It competed with the typed settings namespaces llm-deepseek and llm-pi-ai already register, so which one wins was a function of layer order rather than meaning. And the explicit escape hatch it was supposedly redundant with did not exist on every surface: dsh -p, dsh meta, and dsh upgrade all rejected --config, so for them the implicit file was the only composition route at all. Complete the explicit layer first: --config and --config-replace now work on every booting surface. A headless --config-replace tree must still mount a webserver row, because that surface reaches its own agent over the same HTTP gateway the browser uses; AppCLIEntry names that contract in the failure instead of reporting a bare missing service. Then delete the implicit one. PERSONAL_CONFIG_FILENAME, loadPersonalPatches, watchPersonalPatches, and the config-only HMR row mounted for it are gone; a file left at that path is inert, and --dump-config no longer reads the Harness home. --config therefore stops *replacing* the personal overlay and simply *is* the user overlay. No migration: a user who wants the old behavior names the same file (dsh --config ~/.dsh/config.yaml), which a shell alias makes permanent.
150 lines
7.0 KiB
TypeScript
150 lines
7.0 KiB
TypeScript
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { execa } from 'execa'
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
|
|
/**
|
|
* Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under
|
|
* plain Node (no tsx) with PIPED stdio and assert the TUI refuses to boot.
|
|
* `dsh` is the sole terminal front door; the TUI owns no non-TTY fallback, so a
|
|
* piped launch must exit nonzero with a stderr pointer at the one-shot `-p`
|
|
* mode. The guard fires inside `runTui` BEFORE the Loader resolves the config
|
|
* tree — a compose-time throw inside the tree is logged per-entry, not
|
|
* rethrown, so without this guard a piped launch would settle into an idle
|
|
* UI-less process. The bin resolves its workspace deps through the repo's
|
|
* node_modules, so no external consumer is assembled; missing-config fail-loud
|
|
* and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's
|
|
* built-bin suite, and interactive TTY behavior is PTY-covered by
|
|
* apps/cli/tests. Skips before the bin is built.
|
|
*/
|
|
|
|
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
|
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
|
|
|
/**
|
|
* Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output
|
|
* + exit code. `env` isolates the Harness home for surfaces that read it.
|
|
*/
|
|
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 }
|
|
}
|
|
|
|
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
|
|
it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => {
|
|
const { stdout, code, stderr } = await runBuiltBin()
|
|
expect(code).not.toBe(0)
|
|
expect(stderr).toContain('requires stdin and stdout to be interactive TTYs')
|
|
expect(stderr).toContain('dsh -p')
|
|
// The refusal happens before any plugin mounts: stdout stays silent.
|
|
expect(stdout).toBe('')
|
|
}, 30_000)
|
|
|
|
describe('experimental subcommand gate', () => {
|
|
// The gate has two halves: a per-invocation --experimental flag parsed by
|
|
// Commander and an env opt-in read by bin.ts as exactly '1'. Passing the
|
|
// gate is proven by reaching the NEXT failure — the TUI's piped-stdio
|
|
// refusal — instead of the gate diagnostic.
|
|
it('rejects bare `meta`/`upgrade` LOUD, naming both opt-ins', async () => {
|
|
for (const command of ['meta', 'upgrade']) {
|
|
const { code, stderr } = await runBuiltBin([command], { DSH_EXPERIMENTAL: '' })
|
|
expect(code).toBe(1)
|
|
expect(stderr).toContain(`${command} is experimental; pass --experimental or set DSH_EXPERIMENTAL=1`)
|
|
}
|
|
}, 30_000)
|
|
|
|
it('admits --experimental and DSH_EXPERIMENTAL=1, but not other env values', async () => {
|
|
const flagged = await runBuiltBin(['meta', '--experimental'], { DSH_EXPERIMENTAL: '' })
|
|
expect(flagged.stderr).toContain('requires stdin and stdout to be interactive TTYs')
|
|
const env = await runBuiltBin(['meta'], { DSH_EXPERIMENTAL: '1' })
|
|
expect(env.stderr).toContain('requires stdin and stdout to be interactive TTYs')
|
|
// The env opt-in is exact: '0' (or any other value) does not enable.
|
|
const zero = await runBuiltBin(['meta'], { DSH_EXPERIMENTAL: '0' })
|
|
expect(zero.code).toBe(1)
|
|
expect(zero.stderr).toContain('meta is experimental')
|
|
}, 30_000)
|
|
})
|
|
|
|
describe('dsh --dump-config', () => {
|
|
let home: string
|
|
beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })
|
|
afterEach(() => { rmSync(home, { recursive: true, force: true }) })
|
|
|
|
it('prints the shipped TUI composition without booting or needing a TTY', async () => {
|
|
const { stdout, code, stderr } = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home })
|
|
expect(code).toBe(0)
|
|
expect(stderr).toBe('')
|
|
// Base rows composed with the TUI overlay's surface values, `!!js`
|
|
// expressions verbatim (unevaluated), and TUI-only inserted rows present.
|
|
expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'")
|
|
expect(stdout).toContain('model: deepseek-v4-pro')
|
|
expect(stdout).toContain('cwd: !!js process.cwd()')
|
|
expect(stdout).toContain("name: '@deepseek-ai/dsh-tui'")
|
|
expect(stdout).toContain([
|
|
'- id: tool-web',
|
|
" name: '@deepseek-ai/dsh-tool-web'",
|
|
' config:',
|
|
' fetch: false',
|
|
' searchTimeoutMs: 60000',
|
|
].join('\n'))
|
|
// Provenance comment separators name each section's source file.
|
|
expect(stdout).toContain('# == base.cordis.yml')
|
|
expect(stdout).toContain('# == base.cordis.yml, patched by tui.cordis.yml')
|
|
expect(stdout).toContain('# == tui.cordis.yml')
|
|
}, 30_000)
|
|
|
|
it('layers a --config overlay in --dump-config and reports an unmatched patch on stderr', async () => {
|
|
const overlay = join(home, 'overlay.yml')
|
|
writeFileSync(overlay, [
|
|
'- id: agent-loop',
|
|
' config:',
|
|
' agents:',
|
|
' - id: main',
|
|
' provider: custom-provider',
|
|
' model: custom-model',
|
|
'- id: only-on-web',
|
|
' config:',
|
|
' value: 1',
|
|
'',
|
|
].join('\n'))
|
|
const { stdout, code, stderr } = await runBuiltBin(['--dump-config', '--config', overlay], { DSH_HOME: home })
|
|
expect(code).toBe(0)
|
|
expect(stdout).toContain('provider: custom-provider')
|
|
expect(stdout).not.toContain('model: deepseek-v4-pro')
|
|
// The named layer appears in the patched row's provenance and the
|
|
// skipped-patch warning carries its label.
|
|
expect(stdout).toContain(`patched by tui.cordis.yml, ${overlay}`)
|
|
expect(stderr).toContain('patch: entry "only-on-web" not found')
|
|
|
|
// An unnamed dump composes the shipped tree only: a file sitting in the
|
|
// Harness home is not a layer any more.
|
|
const unnamed = await runBuiltBin(['--dump-config'], { DSH_HOME: home })
|
|
expect(unnamed.stdout).not.toContain('custom-provider')
|
|
const shipped = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home })
|
|
expect(shipped.stdout).not.toContain('custom-provider')
|
|
expect(shipped.stdout).toContain('model: deepseek-v4-pro')
|
|
}, 30_000)
|
|
|
|
it('composes the web overlay for `dsh web --dump-config`', async () => {
|
|
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).not.toContain("name: '@deepseek-ai/dsh-tui'")
|
|
}, 30_000)
|
|
})
|
|
})
|