Delete packages/ui/stdio and examples/repl-agent; rename stdio-demo to @deepseek-ai/dsh-tui-demo (TUI-only, refuses pipes before Loader boot). tui-agent owns the coding composition inline; echo-agent and the CI demo smoke move to the one-shot cli-demo bin, which gains -p/--prompt. The UI-independent with-key e2es move verbatim to tui-agent. SDK wizard's 'stdio' interface becomes 'tui'. PTY testing stays confined to TUI surfaces; all other subprocess tests ride pipes. See .agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md
99 lines
4.4 KiB
TypeScript
99 lines
4.4 KiB
TypeScript
import { spawn } from 'node:child_process'
|
|
import { existsSync } from 'node:fs'
|
|
import { mkdtemp, mkdir, rm, symlink, readFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
|
|
/**
|
|
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer.
|
|
* The TUI app owns no non-TTY fallback, so the piped subprocess must refuse to boot with a
|
|
* nonzero exit and a stderr pointer at the one-shot CLI — the bin guards BEFORE the Loader
|
|
* because a compose-time throw inside the tree is logged per-entry, not rethrown. The consumer
|
|
* links only the bin's import chain (dsh-app-boot and its vendored Loader stack): the refusal
|
|
* fires before any config is read, so no plugin tree is needed. Missing-config fail-loud and
|
|
* full-boot coverage for the shared dsh-app-boot glue live in cli-demo's built-bin suite; it
|
|
* skips before build, and interactive TTY behavior is PTY-covered by examples/tui-agent (the
|
|
* one sanctioned PTY surface).
|
|
*/
|
|
|
|
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
|
const tuiBin = join(repoRoot, 'packages/examples/tui-demo/lib/bin.js')
|
|
|
|
// Symlink each package the bin imports at module load by package name so plain
|
|
// Node resolves its built `main`, matching an installed dependency rather than
|
|
// tsconfig paths.
|
|
const dshPackages = ['examples/tui-demo', 'ui/app-boot']
|
|
const vendorPackages = ['cordis', 'loader', 'include', 'schemastery', 'cosmokit']
|
|
|
|
async function pkgName(absDir: string): Promise<string> {
|
|
const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
|
|
return json.name
|
|
}
|
|
|
|
/** Build a temporary external consumer with built workspace/vendor links. */
|
|
async function makeConsumer(): Promise<string> {
|
|
const dir = await mkdtemp(join(tmpdir(), 'tui-built-bin-'))
|
|
const nm = join(dir, 'node_modules')
|
|
for (const rel of dshPackages) {
|
|
const abs = join(repoRoot, 'packages', rel)
|
|
const target = join(nm, await pkgName(abs))
|
|
await mkdir(dirname(target), { recursive: true })
|
|
await symlink(abs, target)
|
|
}
|
|
for (const v of vendorPackages) {
|
|
const abs = join(repoRoot, 'vendor', v)
|
|
const target = join(nm, await pkgName(abs))
|
|
await mkdir(dirname(target), { recursive: true })
|
|
await symlink(abs, target)
|
|
}
|
|
return dir
|
|
}
|
|
|
|
/** Run the built bin in `cwd` with PIPED stdio; resolve with output + exit code. */
|
|
function runBuiltBin(cwd: string): Promise<{ stdout: string; code: number; stderr: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
// NO tsx — this is the published `node lib/bin.js` path (`--expose-internals`
|
|
// matches the demo command; the guard fires before the Loader needs it).
|
|
const child = spawn(process.execPath, ['--expose-internals', tuiBin, './cordis.yml'], {
|
|
cwd,
|
|
env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
})
|
|
let stdout = ''
|
|
let stderr = ''
|
|
child.stdout.setEncoding('utf8')
|
|
child.stdout.on('data', (c: string) => { stdout += c })
|
|
child.stderr.setEncoding('utf8')
|
|
child.stderr.on('data', (c: string) => { stderr += c })
|
|
const timer = setTimeout(() => {
|
|
child.kill('SIGKILL')
|
|
reject(new Error(`built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
|
}, 25_000)
|
|
child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) })
|
|
child.on('error', (err) => { clearTimeout(timer); reject(err) })
|
|
child.stdin.end()
|
|
})
|
|
}
|
|
|
|
let consumer: string | undefined
|
|
|
|
afterEach(async () => {
|
|
// Windows can briefly retain released handles after exit; retry removal.
|
|
if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
|
consumer = undefined
|
|
})
|
|
|
|
describe.skipIf(!existsSync(tuiBin))('dsh-tui-demo BUILT bin (node lib/bin.js, no tsx)', () => {
|
|
it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => {
|
|
consumer = await makeConsumer()
|
|
const { stdout, code, stderr } = await runBuiltBin(consumer)
|
|
expect(code).not.toBe(0)
|
|
expect(stderr).toContain('requires stdin and stdout to be interactive TTYs')
|
|
expect(stderr).toContain('dsh-cli-demo')
|
|
// The refusal happens before any plugin mounts: stdout stays silent.
|
|
expect(stdout).toBe('')
|
|
}, 30_000)
|
|
})
|