built-bin.e2e.ts 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146
  1. import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { createInterface } from 'node:readline'
  5. import { Readable, Writable } from 'node:stream'
  6. import { fileURLToPath, pathToFileURL } from 'node:url'
  7. import {
  8. client as createAcpClientApp,
  9. methods,
  10. ndJsonStream,
  11. PROTOCOL_VERSION,
  12. type SessionNotification,
  13. } from '@agentclientprotocol/sdk'
  14. import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
  15. import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
  16. import { execa } from 'execa'
  17. import * as yaml from 'js-yaml'
  18. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  19. /** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
  20. const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
  21. // The dsh built bin cold-starts slowly on the contended self-hosted Windows pool; the
  22. // execa deadline, its error text, the outer vitest case budget, and waitForFile all
  23. // share this value so a widening cannot leave a stale 25s diagnostic behind.
  24. const SPAWN_TIMEOUT_MS = 60_000
  25. // The release version, including a prerelease such as 0.0.1-rc.1: `--version`
  26. // prints what this manifest carries, so no test may pin it to a literal.
  27. const cliVersion = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
  28. const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
  29. const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url))
  30. const webReadyExitHook = new URL('./fixtures/web-browser-open/register.mjs', import.meta.url).href
  31. async function runBuiltBin(
  32. args: readonly string[] = [],
  33. env: Readonly<Record<string, string | undefined>> = {},
  34. cwd?: string,
  35. ): Promise<{ stdout: string; code: number; stderr: string }> {
  36. const childEnv = Object.fromEntries(
  37. Object.entries({ ...process.env, ...env })
  38. .filter((entry): entry is [string, string] => entry[1] !== undefined),
  39. )
  40. const result = await execa(process.execPath, [dshBin, ...args], {
  41. input: '',
  42. timeout: SPAWN_TIMEOUT_MS,
  43. killSignal: 'SIGKILL',
  44. reject: false,
  45. env: childEnv,
  46. extendEnv: false,
  47. ...cwd === undefined ? {} : { cwd },
  48. })
  49. if (result.timedOut) {
  50. throw new Error(`dsh built bin did not exit within ${SPAWN_TIMEOUT_MS / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
  51. }
  52. return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr }
  53. }
  54. async function waitForFile(file: string): Promise<void> {
  55. const deadline = Date.now() + SPAWN_TIMEOUT_MS
  56. while (!existsSync(file)) {
  57. if (Date.now() >= deadline) throw new Error(`dsh profile lifecycle marker did not appear: ${file}`)
  58. await new Promise(resolve => setTimeout(resolve, 20))
  59. }
  60. }
  61. interface ProfileLifecycleFixture {
  62. home: string
  63. ready: string
  64. settled: string
  65. disposed: string
  66. interrupt: string
  67. }
  68. /**
  69. * A minimal custom profile: one lifecycle-marker plugin bundle listed in
  70. * dsh.profile.bundles, no dsh-base — proving out-of-box composition machinery without
  71. * booting the entire product tree.
  72. */
  73. function createProfileLifecycleFixture(): ProfileLifecycleFixture {
  74. const home = mkdtempSync(join(tmpdir(), 'dsh-profile-lifecycle-'))
  75. const ready = join(home, 'ready')
  76. const settled = join(home, 'settled')
  77. const disposed = join(home, 'disposed')
  78. const interrupt = join(home, 'interrupt')
  79. const bundleDir = join(home, 'lifecycle-bundle')
  80. mkdirSync(bundleDir, { recursive: true })
  81. writeFileSync(join(bundleDir, 'plugin.mjs'), [
  82. "import { existsSync, writeFileSync } from 'node:fs'",
  83. "import { join } from 'node:path'",
  84. "export const name = 'profile-lifecycle-fixture'",
  85. 'export function apply(ctx, config = {}) {',
  86. ' let active = true',
  87. ' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.',
  88. ' // Windows has no deliverable SIGTERM; the marker emits the same process event there.',
  89. ' let interrupted = false',
  90. ' const heartbeat = setInterval(() => {',
  91. ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
  92. ' interrupted = true',
  93. " process.emit('SIGTERM')",
  94. ' }, 20)',
  95. ' // Echo the mounted generation so the hot-reload e2e can assert both an',
  96. ' // applied override and its removal reverting to this bundle default.',
  97. " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
  98. " writeFileSync(process.env.RAW_READY_FILE, 'ready')",
  99. ' void ctx.loader.await().then(() => {',
  100. " if (active) writeFileSync(process.env.RAW_SETTLED_FILE, 'settled')",
  101. ' })',
  102. ' ctx.effect(() => () => {',
  103. ' active = false',
  104. ' clearInterval(heartbeat)',
  105. " writeFileSync(process.env.RAW_DISPOSED_FILE, 'disposed')",
  106. ' })',
  107. '}',
  108. '',
  109. ].join('\n'))
  110. writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
  111. '- insert:',
  112. ' - id: profile-lifecycle-fixture',
  113. ` name: ${pathToFileURL(join(bundleDir, 'plugin.mjs')).href}`,
  114. '',
  115. ].join('\n'))
  116. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
  117. name: 'dsh-lifecycle-bundle',
  118. version: '0.0.0',
  119. type: 'module',
  120. dsh: { bundle: { patch: './cordis.patch.yml' } },
  121. }, undefined, 2))
  122. const profileDir = join(home, 'profiles', 'lifecycle')
  123. mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
  124. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  125. name: 'dsh-profile-lifecycle',
  126. private: true,
  127. dependencies: {},
  128. dsh: { profile: { bundles: ['dsh-lifecycle-bundle'] } },
  129. }, undefined, 2))
  130. // Hand-place the "installed" bundle where profile resolution finds it.
  131. writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
  132. const linkTarget = join(profileDir, 'node_modules', 'dsh-lifecycle-bundle')
  133. mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
  134. try {
  135. rmSync(linkTarget, { recursive: true, force: true })
  136. } catch { /* fresh dir */ }
  137. // Copy-free: a package.json redirecting via a relative main is enough for require.resolve.
  138. mkdirSync(linkTarget, { recursive: true })
  139. for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) {
  140. writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file)))
  141. }
  142. return { home, ready, settled, disposed, interrupt }
  143. }
  144. function startProfileLifecycle(fixture: ProfileLifecycleFixture, args: readonly string[] = []) {
  145. return execa(process.execPath, [dshBin, '--profile', 'lifecycle', ...args], {
  146. cwd: fixture.home,
  147. input: '',
  148. timeout: SPAWN_TIMEOUT_MS,
  149. killSignal: 'SIGKILL',
  150. reject: false,
  151. env: {
  152. DSH_HOME: fixture.home,
  153. RAW_READY_FILE: fixture.ready,
  154. RAW_SETTLED_FILE: fixture.settled,
  155. RAW_DISPOSED_FILE: fixture.disposed,
  156. RAW_INTERRUPT_FILE: fixture.interrupt,
  157. },
  158. })
  159. }
  160. function requestProfileShutdown(
  161. child: Pick<ReturnType<typeof startProfileLifecycle>, 'kill'>,
  162. fixture: Pick<ProfileLifecycleFixture, 'interrupt'>,
  163. ): void {
  164. if (process.platform === 'win32') {
  165. writeFileSync(fixture.interrupt, 'interrupt')
  166. return
  167. }
  168. child.kill('SIGTERM')
  169. }
  170. function createEnvironmentProbeProfile(home: string, project: string): void {
  171. const pluginFile = join(project, 'environment-probe.mjs')
  172. writeFileSync(pluginFile, [
  173. "export const name = 'environment-probe'",
  174. "export const inject = ['llm']",
  175. 'export function apply(ctx) {',
  176. ' void ctx.loader.await().then(async () => {',
  177. " let text = ''",
  178. ' for await (const chunk of ctx.llm.stream({',
  179. " provider: 'deepseek-official',",
  180. " model: 'deepseek-v4-flash',",
  181. ' messages: [],',
  182. ' maxTokens: 32,',
  183. ' })) {',
  184. " if (chunk.type === 'text-delta') text += chunk.text",
  185. ' }',
  186. ' process.stdout.write(`${text}\\n`)',
  187. " if (process.platform === 'win32') process.emit('SIGTERM')",
  188. " else process.kill(process.pid, 'SIGTERM')",
  189. ' })',
  190. '}',
  191. '',
  192. ].join('\n'))
  193. const profileDir = join(home, 'profiles', 'environment-probe')
  194. mkdirSync(profileDir, { recursive: true })
  195. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  196. name: 'dsh-profile-environment-probe',
  197. private: true,
  198. dependencies: {},
  199. dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
  200. }, undefined, 2))
  201. writeFileSync(join(profileDir, 'cordis.patch.yml'), [
  202. '- insert:',
  203. ' - id: environment-probe',
  204. ` name: ${pathToFileURL(pluginFile).href}`,
  205. '',
  206. ].join('\n'))
  207. }
  208. interface StartupFixture {
  209. home: string
  210. ready: string
  211. echo: string
  212. interrupt: string
  213. /** An always-running row's echo, used to observe that a user patch reload landed. */
  214. witness: string
  215. }
  216. /**
  217. * A custom profile whose ordinary provider plugin injects `cmdlineArgs`, plus
  218. * a row that reads its app-owned service through a `!!js` config expression.
  219. * Both plugin modules resolve
  220. * `@deepseek-ai/dsh-cmdline` and `commander` through the profile module
  221. * fallback, exactly as an installed out-of-tree bundle does.
  222. */
  223. function createStartupFixture(): StartupFixture {
  224. const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-'))
  225. const profileDir = join(home, 'profiles', 'startup')
  226. // Written straight into the installed location: a row module resolves its
  227. // own imports from where it is installed, and only inside the profile does
  228. // Node's parent walk reach the installation fallback these plugins need.
  229. const bundleDir = join(profileDir, 'node_modules', 'dsh-startup-bundle')
  230. mkdirSync(bundleDir, { recursive: true })
  231. writeFileSync(join(bundleDir, 'startup.mjs'), [
  232. "import { Command } from 'commander'",
  233. "import { parseCmdline } from '@deepseek-ai/dsh-cmdline'",
  234. "export const name = 'fixture-startup'",
  235. "export const inject = ['cmdlineArgs']",
  236. 'export function apply(ctx) {',
  237. " const program = new Command().name('fixture').option('--generation <value>', 'echoed generation')",
  238. " program.action(() => ctx.provide('fixtureStartup', { generation: program.opts().generation }))",
  239. ' parseCmdline(ctx, program)',
  240. '}',
  241. '',
  242. ].join('\n'))
  243. writeFileSync(join(bundleDir, 'waiting.mjs'), [
  244. "import { existsSync, writeFileSync } from 'node:fs'",
  245. "import { join } from 'node:path'",
  246. "export const name = 'startup-fixture'",
  247. 'export function apply(ctx, config = {}) {',
  248. ' let interrupted = false',
  249. ' const heartbeat = setInterval(() => {',
  250. ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
  251. ' interrupted = true',
  252. " process.emit('SIGTERM')",
  253. ' }, 20)',
  254. " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
  255. " writeFileSync(process.env.RAW_READY_FILE, 'ready')",
  256. ' ctx.effect(() => () => { clearInterval(heartbeat) })',
  257. '}',
  258. '',
  259. ].join('\n'))
  260. writeFileSync(join(bundleDir, 'witness.mjs'), [
  261. "import { writeFileSync } from 'node:fs'",
  262. "import { join } from 'node:path'",
  263. "export const name = 'reload-witness'",
  264. 'export function apply(ctx, config = {}) {',
  265. " writeFileSync(join(process.env.DSH_HOME, 'witness'), String(config.generation ?? 'bundle-default'))",
  266. '}',
  267. '',
  268. ].join('\n'))
  269. writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
  270. '- insert:',
  271. ' - id: startup-fixture',
  272. ` name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`,
  273. ' inject: [fixtureStartup]',
  274. ' config:',
  275. // Lazy interpolation runs only after the provider's service is injected.
  276. " generation: !!js ctx.fixtureStartup.generation ?? 'bundle-default'",
  277. ' - id: fixture-startup',
  278. ` name: ${pathToFileURL(join(bundleDir, 'startup.mjs')).href}`,
  279. ' - id: reload-witness',
  280. ` name: ${pathToFileURL(join(bundleDir, 'witness.mjs')).href}`,
  281. '',
  282. ].join('\n'))
  283. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
  284. name: 'dsh-startup-bundle',
  285. version: '0.0.0',
  286. type: 'module',
  287. dsh: { bundle: { patch: './cordis.patch.yml' } },
  288. }, undefined, 2))
  289. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  290. name: 'dsh-profile-startup',
  291. private: true,
  292. dependencies: {},
  293. dsh: { profile: { bundles: ['dsh-startup-bundle'] } },
  294. }, undefined, 2))
  295. writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
  296. return {
  297. home,
  298. ready: join(home, 'ready'),
  299. echo: join(home, 'config-echo'),
  300. interrupt: join(home, 'interrupt'),
  301. witness: join(home, 'witness'),
  302. }
  303. }
  304. function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
  305. return execa(process.execPath, [dshBin, '--profile', 'startup', ...args], {
  306. cwd: fixture.home,
  307. input: '',
  308. reject: false,
  309. timeout: SPAWN_TIMEOUT_MS,
  310. killSignal: 'SIGKILL',
  311. env: {
  312. DSH_HOME: fixture.home,
  313. RAW_READY_FILE: fixture.ready,
  314. RAW_INTERRUPT_FILE: fixture.interrupt,
  315. },
  316. })
  317. }
  318. describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
  319. it('requires --profile and rejects removed commands', async () => {
  320. const bare = await runBuiltBin()
  321. expect(bare.code).toBe(1)
  322. expect(bare.stdout).toBe('')
  323. expect(bare.stderr).toContain('--profile <name> is required')
  324. const help = await runBuiltBin(['--help'])
  325. expect(help.code).toBe(0)
  326. expect(help.stdout).toContain('dsh --profile web')
  327. expect(help.stdout).toContain('dsh plugin --profile')
  328. expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
  329. for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['run', 'task']]) {
  330. const result = await runBuiltBin(removed)
  331. expect(result.code).toBe(1)
  332. }
  333. }, SPAWN_TIMEOUT_MS * 3 + 30_000)
  334. it('routes help and usage errors without activating startup-dependent rows', async () => {
  335. const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-'))
  336. try {
  337. const web = await runBuiltBin(['--profile', 'web', '--help'], {
  338. DSH_HOME: home,
  339. DSH_TELEMETRY_DISABLED: '1',
  340. })
  341. expect(web.code).toBe(0)
  342. expect(web.stderr).toBe('')
  343. expect(web.stdout).toContain('Usage: dsh --profile web')
  344. expect(web.stdout).toContain('--port <port>')
  345. expect(web.stdout).not.toContain('dsh web: http://')
  346. const wildcardHost = await runBuiltBin(['web', '--host', '0.0.0.0'], {
  347. DSH_HOME: home,
  348. DSH_TELEMETRY_DISABLED: '1',
  349. })
  350. expect(wildcardHost.code).toBe(1)
  351. expect(wildcardHost.stdout).toBe('')
  352. expect(wildcardHost.stderr).toContain('--host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead')
  353. expect(wildcardHost.stderr).not.toContain('dsh web: http://')
  354. const headlessHelp = await runBuiltBin(['--profile', 'headless', '--help'], {
  355. DSH_HOME: home,
  356. DSH_TELEMETRY_DISABLED: '1',
  357. })
  358. expect(headlessHelp.code).toBe(0)
  359. expect(headlessHelp.stderr).toBe('')
  360. expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless')
  361. const sdkHelp = await runBuiltBin(['--profile', 'sdk', '--help'], {
  362. DSH_HOME: home,
  363. DSH_TELEMETRY_DISABLED: '1',
  364. })
  365. expect(sdkHelp.code).toBe(0)
  366. expect(sdkHelp.stderr).toBe('')
  367. expect(sdkHelp.stdout).toContain('Usage: dsh --profile sdk')
  368. const acpHelp = await runBuiltBin(['--profile', 'acp', '--help'], {
  369. DSH_HOME: home,
  370. DSH_TELEMETRY_DISABLED: '1',
  371. })
  372. expect(acpHelp.code).toBe(0)
  373. expect(acpHelp.stderr).toBe('')
  374. expect(acpHelp.stdout).toContain('Usage: dsh --profile acp')
  375. const missingTask = await runBuiltBin(['--profile', 'headless'], {
  376. DSH_HOME: home,
  377. DSH_TELEMETRY_DISABLED: '1',
  378. })
  379. expect(missingTask.code).toBe(1)
  380. expect(missingTask.stderr).toContain('a task is required')
  381. } finally {
  382. rmSync(home, { recursive: true, force: true })
  383. }
  384. }, SPAWN_TIMEOUT_MS * 3 + 30_000)
  385. it('ignores an optional SDK plugin import failure before stdin reaches EOF', async () => {
  386. const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-'))
  387. const patch = join(home, 'broken-sdk.cordis.yml')
  388. writeFileSync(patch, [
  389. '- insert:',
  390. ' - id: missing-sdk-startup-plugin',
  391. ' name: "@deepseek-ai/dsh-missing-sdk-startup-plugin"',
  392. '',
  393. ].join('\n'))
  394. try {
  395. const result = await runBuiltBin(['--profile', 'sdk', '--patch', patch], {
  396. DSH_HOME: home,
  397. DSH_TELEMETRY_DISABLED: '1',
  398. DEEPSEEK_API_KEY: 'built-sdk-startup-failure-no-call',
  399. }, home)
  400. expect(result.code).toBe(0)
  401. expect(result.stdout).toBe('')
  402. expect(result.stderr).toContain('warning: 1 entry did not activate')
  403. expect(result.stderr).toContain('@deepseek-ai/dsh-missing-sdk-startup-plugin')
  404. } finally {
  405. rmSync(home, { recursive: true, force: true })
  406. }
  407. }, SPAWN_TIMEOUT_MS + 30_000)
  408. it('serves the SDK protocol with an absolute-path overlay plugin and exits after shutdown', async () => {
  409. const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-'))
  410. const pluginPath = join(home, 'plugin #100%.mjs')
  411. const marker = join(home, 'plugin-loaded')
  412. writeFileSync(pluginPath, [
  413. "import { writeFileSync } from 'node:fs'",
  414. 'export function apply(ctx, config) { writeFileSync(config.marker, "loaded") }',
  415. '',
  416. ].join('\n'))
  417. const patch = join(home, 'absolute.patch.yml')
  418. writeFileSync(patch, JSON.stringify([{ insert: [
  419. { id: 'absolute-plugin', name: pluginPath, config: { marker } },
  420. ] }]))
  421. const child = execa(process.execPath, [dshBin, '--profile', 'sdk', '--patch', patch], {
  422. cwd: home,
  423. reject: false,
  424. timeout: SPAWN_TIMEOUT_MS,
  425. killSignal: 'SIGKILL',
  426. env: {
  427. ...process.env,
  428. DSH_HOME: home,
  429. DSH_TELEMETRY_DISABLED: '1',
  430. DEEPSEEK_API_KEY: 'built-sdk-profile-no-call',
  431. },
  432. extendEnv: false,
  433. })
  434. const stdoutLines = createInterface({ input: child.stdout, crlfDelay: Infinity })[Symbol.asyncIterator]()
  435. let stderr = ''
  436. child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
  437. const response = async (id: number): Promise<Record<string, unknown>> => {
  438. for (;;) {
  439. const line = await stdoutLines.next()
  440. if (line.done) throw new Error(`SDK profile stdout closed before response ${String(id)}; stderr=${stderr}`)
  441. let value: Record<string, unknown>
  442. try {
  443. value = JSON.parse(line.value) as Record<string, unknown>
  444. } catch {
  445. throw new Error(`SDK profile wrote non-JSON stdout: ${line.value}`)
  446. }
  447. if (value.id === id) return value
  448. }
  449. }
  450. try {
  451. child.stdin.write(`${JSON.stringify({
  452. jsonrpc: '2.0',
  453. id: 1,
  454. method: 'initialize',
  455. params: { cwd: home, provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  456. })}\n`)
  457. const initialized = await response(1)
  458. expect(initialized, `${JSON.stringify(initialized)}\n${stderr}`).toMatchObject({
  459. jsonrpc: '2.0',
  460. id: 1,
  461. result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } },
  462. })
  463. child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'shutdown' })}\n`)
  464. expect(await response(2)).toEqual({ jsonrpc: '2.0', id: 2, result: {} })
  465. const result = await child
  466. expect(result.timedOut, stderr).toBe(false)
  467. expect(result.signal, stderr).toBeUndefined()
  468. expect(result.exitCode, stderr).toBe(0)
  469. expect(stderr).toBe('')
  470. expect(readFileSync(marker, 'utf8')).toBe('loaded')
  471. } finally {
  472. child.kill('SIGKILL')
  473. await child
  474. rmSync(home, { recursive: true, force: true })
  475. }
  476. }, SPAWN_TIMEOUT_MS + 30_000)
  477. it('runs a mock-backed ACP turn through the acp profile and exits on disconnect', async () => {
  478. const apiKey = 'built-acp-profile-key'
  479. const server = await startMockLlmServer({
  480. sequence: ['success'],
  481. apiKey,
  482. successText: 'ACP BUILT PROFILE OK',
  483. })
  484. const home = mkdtempSync(join(tmpdir(), 'dsh-built-acp-'))
  485. const child = execa(process.execPath, [dshBin, '--profile', 'acp'], {
  486. cwd: home,
  487. reject: false,
  488. timeout: SPAWN_TIMEOUT_MS,
  489. killSignal: 'SIGKILL',
  490. env: {
  491. ...process.env,
  492. DSH_HOME: home,
  493. DSH_TELEMETRY_DISABLED: '1',
  494. DEEPSEEK_API_KEY: apiKey,
  495. DEEPSEEK_BASE_URL: server.baseURL,
  496. DSH_PERMISSION_MODE: 'danger-full-access',
  497. },
  498. extendEnv: false,
  499. })
  500. const rawOut: string[] = []
  501. const passthrough = new Readable({ read() {} })
  502. child.stdout.on('data', (chunk: Buffer) => {
  503. rawOut.push(chunk.toString('utf8'))
  504. passthrough.push(chunk)
  505. })
  506. child.stdout.on('end', () => { passthrough.push(null) })
  507. const stream = ndJsonStream(
  508. Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
  509. Readable.toWeb(passthrough) as ReadableStream<Uint8Array>,
  510. )
  511. const updates: SessionNotification['update'][] = []
  512. const clientApp = createAcpClientApp({ name: 'dsh-built-acp-profile' })
  513. .onNotification(methods.client.session.update, ({ params }) => {
  514. updates.push(params.update)
  515. return Promise.resolve()
  516. })
  517. .onRequest(methods.client.session.requestPermission, () => {
  518. return Promise.resolve({ outcome: { outcome: 'cancelled' } })
  519. })
  520. const client = clientApp.connect(stream).agent
  521. try {
  522. const initialized = await client.request(methods.agent.initialize, {
  523. protocolVersion: PROTOCOL_VERSION,
  524. clientCapabilities: {},
  525. })
  526. expect(initialized.agentInfo).toMatchObject({ name: 'deepseek-harness-acp' })
  527. expect(initialized.agentCapabilities).toEqual({
  528. mcpCapabilities: { http: true },
  529. promptCapabilities: { image: false, audio: false, embeddedContext: false },
  530. sessionCapabilities: { close: {}, list: {}, resume: {} },
  531. })
  532. expect('_meta' in initialized).toBe(false)
  533. const session = await client.request(methods.agent.session.new, { cwd: home, mcpServers: [] })
  534. expect(session.sessionId).toBeTruthy()
  535. expect(await client.request(methods.agent.session.prompt, {
  536. sessionId: session.sessionId,
  537. prompt: [{ type: 'text', text: 'reply from the built ACP profile' }],
  538. })).toEqual({ stopReason: 'end_turn' })
  539. expect(updates).toContainEqual(expect.objectContaining({
  540. sessionUpdate: 'agent_message_chunk',
  541. content: { type: 'text', text: 'ACP BUILT PROFILE OK' },
  542. }))
  543. const message = updates.find(update => update.sessionUpdate === 'agent_message_chunk')
  544. expect(message !== undefined && 'messageId' in message && typeof message.messageId === 'string').toBe(true)
  545. expect(server.requests).toHaveLength(1)
  546. child.stdin.end()
  547. const result = await child
  548. expect(result.exitCode, `signal=${String(result.signal)}; stderr=${result.stderr}`).toBe(0)
  549. expect(result.stderr).toBe('')
  550. for (const line of rawOut.join('').split('\n').filter(value => value.trim() !== '')) {
  551. expect(() => JSON.parse(line) as unknown).not.toThrow()
  552. }
  553. } finally {
  554. child.kill('SIGKILL')
  555. await child
  556. await server.close()
  557. rmSync(home, { recursive: true, force: true })
  558. }
  559. }, SPAWN_TIMEOUT_MS + 30_000)
  560. it('runs the headless profile through its app-owned task positional', async () => {
  561. const apiKey = 'built-dsh-headless-key'
  562. const server = await startMockLlmServer({
  563. sequence: ['reasoning_success'],
  564. apiKey,
  565. reasoningText: 'Inspecting the published entry.',
  566. successText: 'published headless profile reached the mock',
  567. })
  568. const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-'))
  569. try {
  570. const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], {
  571. DSH_HOME: home,
  572. DSH_TELEMETRY_DISABLED: '1',
  573. DEEPSEEK_API_KEY: apiKey,
  574. DEEPSEEK_BASE_URL: server.baseURL,
  575. })
  576. expect(result.code, result.stderr).toBe(0)
  577. expect(result.stdout).toBe('published headless profile reached the mock')
  578. expect(result.stderr).toBe('dsh: reasoning:\nInspecting the published entry.')
  579. expect(server.requests.length).toBeGreaterThan(0)
  580. expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true)
  581. expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry')
  582. } finally {
  583. await server.close()
  584. rmSync(home, { recursive: true, force: true })
  585. }
  586. }, SPAWN_TIMEOUT_MS + 30_000)
  587. it('does not load a project environment for --version', async () => {
  588. const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-'))
  589. writeFileSync(join(project, '.env'), 'PATH=/project-only-path\n')
  590. try {
  591. const result = await runBuiltBin(['--version'], {}, project)
  592. expect(result).toEqual({ code: 0, stdout: cliVersion, stderr: '' })
  593. } finally {
  594. rmSync(project, { recursive: true, force: true })
  595. }
  596. })
  597. it.skipIf(process.platform === 'win32')('runs through an installed-style symlink', async () => {
  598. const installation = mkdtempSync(join(tmpdir(), 'dsh-bin-link-'))
  599. const installedBin = join(installation, 'dsh')
  600. symlinkSync(dshBin, installedBin)
  601. try {
  602. const result = await execa(process.execPath, [installedBin, '--version'], {
  603. input: '',
  604. timeout: SPAWN_TIMEOUT_MS,
  605. killSignal: 'SIGKILL',
  606. reject: false,
  607. })
  608. expect(result.exitCode).toBe(0)
  609. expect(result.stdout).toBe(cliVersion)
  610. expect(result.stderr).toBe('')
  611. } finally {
  612. rmSync(installation, { recursive: true, force: true })
  613. }
  614. })
  615. it('fails loud on a nonexistent profile with the plugin-command hint', async () => {
  616. const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-'))
  617. try {
  618. const result = await runBuiltBin(['--profile', 'nope'], { DSH_HOME: home })
  619. expect(result.code).toBe(1)
  620. expect(result.stderr).toContain('profile "nope" does not exist')
  621. expect(result.stderr).toContain('dsh plugin --profile nope add')
  622. } finally {
  623. rmSync(home, { recursive: true, force: true })
  624. }
  625. }, SPAWN_TIMEOUT_MS + 30_000)
  626. it('creates a custom profile from the shipped web template before booting it', async () => {
  627. const home = mkdtempSync(join(tmpdir(), 'dsh-from-default-profile-'))
  628. try {
  629. const created = await runBuiltBin(
  630. ['--profile', 'rescue', '--from-default-profile', 'web', '--help'],
  631. { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
  632. )
  633. expect(created.code).toBe(0)
  634. expect(created.stderr).toBe('')
  635. expect(created.stdout).toContain('Usage: dsh --profile web')
  636. const dir = join(home, 'profiles', 'rescue')
  637. const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as {
  638. dependencies: Record<string, string>
  639. dsh: { profile: { bundles: string[]; patchReload: string } }
  640. }
  641. expect(manifest.dependencies).toEqual({})
  642. expect(manifest.dsh.profile).toEqual({
  643. bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
  644. patchReload: 'live',
  645. })
  646. expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).toContain('[]')
  647. expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
  648. const repeated = await runBuiltBin(
  649. ['--profile', 'rescue', '--from-default-profile', 'web', '--help'],
  650. { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
  651. )
  652. expect(repeated.code).toBe(1)
  653. expect(repeated.stdout).toBe('')
  654. expect(repeated.stderr).toContain('profile "rescue" already exists')
  655. expect(repeated.stderr).toContain('omit --from-default-profile to use it')
  656. const reopened = await runBuiltBin(
  657. ['--profile', 'rescue', '--help'],
  658. { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
  659. )
  660. expect(reopened.code).toBe(0)
  661. expect(reopened.stderr).toBe('')
  662. expect(reopened.stdout).toContain('Usage: dsh --profile web')
  663. } finally {
  664. rmSync(home, { recursive: true, force: true })
  665. }
  666. }, SPAWN_TIMEOUT_MS * 3 + 30_000)
  667. it('keeps a newly created profile when application boot rejects its arguments', async () => {
  668. const home = mkdtempSync(join(tmpdir(), 'dsh-from-default-profile-failed-boot-'))
  669. try {
  670. const failed = await runBuiltBin(
  671. ['--profile', 'rescue', '--from-default-profile', 'web', '--port', 'not-a-number'],
  672. { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
  673. )
  674. expect(failed.code).toBe(1)
  675. expect(failed.stderr).toContain('--port must be a number')
  676. expect(existsSync(join(home, 'profiles', 'rescue', 'package.json'))).toBe(true)
  677. const retried = await runBuiltBin(
  678. ['--profile', 'rescue', '--help'],
  679. { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
  680. )
  681. expect(retried.code).toBe(0)
  682. expect(retried.stderr).toBe('')
  683. expect(retried.stdout).toContain('Usage: dsh --profile web')
  684. } finally {
  685. rmSync(home, { recursive: true, force: true })
  686. }
  687. }, SPAWN_TIMEOUT_MS * 2 + 30_000)
  688. it('uses the launching endpoint and managed credential through the published entry', async () => {
  689. const apiKey = 'built-home-layer-key'
  690. const server = await startMockLlmServer({
  691. sequence: ['success'],
  692. apiKey,
  693. successText: 'launching endpoint reached the mock',
  694. })
  695. const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-'))
  696. const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-'))
  697. writeFileSync(join(home, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
  698. createEnvironmentProbeProfile(home, project)
  699. try {
  700. const result = await runBuiltBin(
  701. ['--profile', 'environment-probe'],
  702. {
  703. DSH_HOME: home,
  704. DSH_TELEMETRY_DISABLED: '1',
  705. DEEPSEEK_API_KEY: undefined,
  706. DEEPSEEK_BASE_URL: server.baseURL,
  707. },
  708. project,
  709. )
  710. expect(
  711. result.code,
  712. `${result.stderr}\nstdout:\n${result.stdout}\nmock requests: ${String(server.requests.length)}`,
  713. ).toBe(0)
  714. expect(result.stdout).toBe('launching endpoint reached the mock')
  715. expect(result.stdout).not.toContain(apiKey)
  716. expect(result.stderr).not.toContain(apiKey)
  717. expect(server.requests).toHaveLength(1)
  718. expect(server.requests[0]?.path).toBe('/chat/completions')
  719. expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`)
  720. expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey)
  721. } finally {
  722. await server.close()
  723. rmSync(home, { recursive: true, force: true })
  724. rmSync(project, { recursive: true, force: true })
  725. }
  726. }, SPAWN_TIMEOUT_MS + 30_000)
  727. it('keeps serving when an optional patch-overlay plugin fails', async () => {
  728. const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
  729. try {
  730. const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider, '--port', '0', '--no-open'], {
  731. DSH_HOME: home,
  732. DSH_BROWSER_OPEN_TEST_EXIT_ON_READY: '1',
  733. DEEPSEEK_API_KEY: 'keyless-invalid-config',
  734. DSH_TELEMETRY_DISABLED: '1',
  735. NODE_OPTIONS: `--import=${webReadyExitHook}`,
  736. })
  737. expect(result.code, result.stderr).toBe(0)
  738. expect(result.stdout).toMatch(/^dsh web: http:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+$/u)
  739. expect(result.stderr).toContain('llm-pi-ai')
  740. } finally {
  741. rmSync(home, { recursive: true, force: true })
  742. }
  743. }, SPAWN_TIMEOUT_MS + 30_000)
  744. it('lets a profile without a parser ignore app arguments and dispose on a startup-time signal', async () => {
  745. const fixture = createProfileLifecycleFixture()
  746. const child = startProfileLifecycle(fixture, ['--unclaimed'])
  747. try {
  748. await waitForFile(fixture.ready)
  749. requestProfileShutdown(child, fixture)
  750. const result = await child
  751. expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
  752. expect(result.signal).toBeUndefined()
  753. expect(existsSync(fixture.disposed)).toBe(true)
  754. } finally {
  755. child.kill('SIGKILL')
  756. rmSync(fixture.home, { recursive: true, force: true })
  757. }
  758. }, SPAWN_TIMEOUT_MS + 30_000)
  759. it('fully settles a custom profile, hot-reloads its patch layer with removal reverting, and disposes on a signal', async () => {
  760. const fixture = createProfileLifecycleFixture()
  761. const child = startProfileLifecycle(fixture)
  762. const profilePatch = join(fixture.home, 'profiles', 'lifecycle', 'cordis.patch.yml')
  763. const configFile = join(fixture.home, 'config-echo')
  764. try {
  765. await waitForFile(fixture.settled)
  766. // The live profile layer: even without an hmr row in the composition,
  767. // the launcher mounts a config-only watcher, so an edited
  768. // cordis.patch.yml lands in the running tree (the reload disposes the
  769. // patched row's old fiber — observable as the disposed marker — and
  770. // mounts the new config, which echoes its generation and re-writes the
  771. // ready marker).
  772. rmSync(fixture.ready)
  773. writeFileSync(profilePatch, [
  774. '- id: profile-lifecycle-fixture',
  775. ' config:',
  776. ' generation: 2',
  777. '',
  778. ].join('\n'))
  779. await waitForFile(fixture.ready)
  780. expect(readFileSync(configFile, 'utf8')).toBe('2')
  781. // Unlink exercises layer removal without racing Chokidar's change-event
  782. // suppression window after the preceding edit. The bundle default must return.
  783. rmSync(fixture.ready)
  784. rmSync(profilePatch)
  785. await waitForFile(fixture.ready)
  786. expect(existsSync(profilePatch)).toBe(false)
  787. expect(readFileSync(configFile, 'utf8')).toBe('bundle-default')
  788. // The home-level user layer ($DSH_HOME/cordis.patch.yml) is live too
  789. // and outranks the per-profile layer.
  790. rmSync(fixture.ready)
  791. writeFileSync(join(fixture.home, 'cordis.patch.yml'), [
  792. '- id: profile-lifecycle-fixture',
  793. ' config:',
  794. ' generation: home',
  795. '',
  796. ].join('\n'))
  797. await waitForFile(fixture.ready)
  798. expect(readFileSync(configFile, 'utf8')).toBe('home')
  799. requestProfileShutdown(child, fixture)
  800. const result = await child
  801. expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
  802. expect(result.signal).toBeUndefined()
  803. expect(existsSync(fixture.disposed)).toBe(true)
  804. } finally {
  805. child.kill('SIGKILL')
  806. await child
  807. rmSync(fixture.home, { recursive: true, force: true })
  808. }
  809. }, SPAWN_TIMEOUT_MS + 30_000)
  810. it('hands the app arguments to the profile, which applies them before its rows start', async () => {
  811. const fixture = createStartupFixture()
  812. const child = startStartupProfile(fixture, ['--generation', 'flagged'])
  813. try {
  814. await waitForFile(fixture.ready)
  815. // The consumer started once, already carrying the flag value: the
  816. // launcher never saw --generation, and the app provider resolved it first.
  817. expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
  818. requestProfileShutdown(child, fixture)
  819. expect((await child).exitCode).toBe(0)
  820. } finally {
  821. child.kill('SIGKILL')
  822. rmSync(fixture.home, { recursive: true, force: true })
  823. }
  824. }, SPAWN_TIMEOUT_MS + 30_000)
  825. it('starts a consumer on its composed value when the invocation carries no app arguments', async () => {
  826. const fixture = createStartupFixture()
  827. const child = startStartupProfile(fixture, [])
  828. try {
  829. await waitForFile(fixture.ready)
  830. expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default')
  831. requestProfileShutdown(child, fixture)
  832. expect((await child).exitCode).toBe(0)
  833. } finally {
  834. child.kill('SIGKILL')
  835. rmSync(fixture.home, { recursive: true, force: true })
  836. }
  837. }, SPAWN_TIMEOUT_MS + 30_000)
  838. it('keeps the app arguments across a user patch reload', async () => {
  839. // A live edit recomposes every row while the provider service remains
  840. // active, so each config expression reads the same invocation value (a
  841. // served port does not move back to its composed fallback).
  842. const fixture = createStartupFixture()
  843. const profilePatch = join(fixture.home, 'profiles', 'startup', 'cordis.patch.yml')
  844. const child = startStartupProfile(fixture, ['--generation', 'flagged'])
  845. try {
  846. // Both rows: the waiting one carries the flag value, and the witness is
  847. // what a reload will re-mount. They start independently, so neither
  848. // marker implies the other.
  849. await waitForFile(fixture.ready)
  850. await waitForFile(fixture.witness)
  851. expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
  852. // An edit to an unrelated row: the witness re-mounts, which is how this
  853. // test knows the whole tree was recomposed.
  854. rmSync(fixture.witness)
  855. writeFileSync(profilePatch, [
  856. '- id: reload-witness',
  857. ' config:',
  858. ' generation: reloaded',
  859. '',
  860. ].join('\n'))
  861. await waitForFile(fixture.witness)
  862. expect(readFileSync(fixture.witness, 'utf8')).toBe('reloaded')
  863. expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
  864. requestProfileShutdown(child, fixture)
  865. expect((await child).exitCode).toBe(0)
  866. } finally {
  867. child.kill('SIGKILL')
  868. rmSync(fixture.home, { recursive: true, force: true })
  869. }
  870. }, SPAWN_TIMEOUT_MS + 30_000)
  871. it("prints the app's own help, starts none of its rows, and exits", async () => {
  872. const fixture = createStartupFixture()
  873. try {
  874. const result = await startStartupProfile(fixture, ['--help'])
  875. expect(result.exitCode).toBe(0)
  876. expect(result.stdout).toContain('Usage: fixture')
  877. expect(result.stdout).toContain('--generation')
  878. expect(existsSync(fixture.ready)).toBe(false)
  879. } finally {
  880. rmSync(fixture.home, { recursive: true, force: true })
  881. }
  882. }, SPAWN_TIMEOUT_MS + 30_000)
  883. it('anchors a relative add spec to the invoking directory, not the profile', async () => {
  884. // `dsh plugin --profile x add .` from a plugin checkout must install THAT
  885. // checkout — pnpm's cwd is the profile directory, so an un-anchored `.`
  886. // would self-link the profile.
  887. const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-anchor-'))
  888. const checkout = mkdtempSync(join(tmpdir(), 'dsh-plugin-checkout-'))
  889. try {
  890. writeFileSync(join(checkout, 'package.json'), JSON.stringify({
  891. name: 'anchored-bundle',
  892. version: '1.0.0',
  893. dsh: { bundle: { patch: './cordis.patch.yml' } },
  894. }))
  895. writeFileSync(join(checkout, 'cordis.patch.yml'), '[]\n')
  896. const result = await execa(process.execPath, [dshBin, 'plugin', '--profile', 'anchor', 'add', '.'], {
  897. cwd: checkout,
  898. input: '',
  899. timeout: SPAWN_TIMEOUT_MS,
  900. killSignal: 'SIGKILL',
  901. reject: false,
  902. env: { DSH_HOME: home },
  903. })
  904. expect(result.exitCode).toBe(0)
  905. const manifest = JSON.parse(readFileSync(join(home, 'profiles', 'anchor', 'package.json'), 'utf8')) as {
  906. dependencies: Record<string, string>
  907. dsh: { profile: { bundles: string[] } }
  908. }
  909. expect(Object.keys(manifest.dependencies)).toEqual(['anchored-bundle'])
  910. expect(manifest.dsh.profile.bundles).toContain('anchored-bundle')
  911. const removed = await runBuiltBin(
  912. ['plugin', '--profile', 'anchor', 'remove', 'anchored-bundle'],
  913. { DSH_HOME: home },
  914. checkout,
  915. )
  916. expect(removed.code).toBe(0)
  917. const afterRemove = JSON.parse(
  918. readFileSync(join(home, 'profiles', 'anchor', 'package.json'), 'utf8'),
  919. ) as {
  920. dependencies?: Record<string, string>
  921. dsh: { profile: { bundles: string[] } }
  922. }
  923. expect(Object.keys(afterRemove.dependencies ?? {})).toEqual([])
  924. expect(afterRemove.dsh.profile.bundles).not.toContain('anchored-bundle')
  925. } finally {
  926. rmSync(home, { recursive: true, force: true })
  927. rmSync(checkout, { recursive: true, force: true })
  928. }
  929. }, SPAWN_TIMEOUT_MS * 2 + 30_000)
  930. it('activates a dependency that gained dsh.bundle in a later update', async () => {
  931. // Reconcile runs against the INSTALLED state on every successful pnpm
  932. // run, so `update` (not only `add`) activates a package whose newer
  933. // version declares dsh.bundle. Simulated without a registry: hand-place
  934. // the installed package, flip its manifest, and run a benign pnpm verb.
  935. const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-update-'))
  936. try {
  937. const profileDir = join(home, 'profiles', 'up')
  938. const installed = join(profileDir, 'node_modules', 'late-bundle')
  939. mkdirSync(installed, { recursive: true })
  940. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  941. name: 'dsh-profile-up',
  942. private: true,
  943. dependencies: { 'late-bundle': 'file:./late-bundle' },
  944. dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
  945. }))
  946. writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
  947. // v1: no dsh manifest — a plain dependency.
  948. writeFileSync(join(installed, 'package.json'), JSON.stringify({ name: 'late-bundle', version: '1.0.0' }))
  949. const first = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
  950. expect(first.code).toBe(0)
  951. let manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
  952. expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base'])
  953. // v2: the installed package now declares dsh.bundle (an update landed).
  954. writeFileSync(join(installed, 'package.json'), JSON.stringify({
  955. name: 'late-bundle', version: '2.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } },
  956. }))
  957. writeFileSync(join(installed, 'cordis.patch.yml'), '[]\n')
  958. const second = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
  959. expect(second.code).toBe(0)
  960. manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
  961. expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base', 'late-bundle'])
  962. } finally {
  963. rmSync(home, { recursive: true, force: true })
  964. }
  965. }, SPAWN_TIMEOUT_MS * 2 + 30_000)
  966. describe('config dump', () => {
  967. let home: string
  968. beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })
  969. afterEach(() => { rmSync(home, { recursive: true, force: true }) })
  970. it('prints the web profile bundle layers without a user layer', async () => {
  971. const { stdout, code, stderr } = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
  972. expect(code).toBe(0)
  973. expect(stderr).toBe('')
  974. expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'")
  975. expect(stdout).toContain('agents: []')
  976. expect(stdout).toContain('# == @deepseek-ai/dsh-base')
  977. expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
  978. expect(existsSync(join(home, 'profiles', 'node_modules'))).toBe(false)
  979. }, SPAWN_TIMEOUT_MS + 30_000)
  980. it('creates a custom profile from a shipped template before printing it', async () => {
  981. const { stdout, code, stderr } = await runBuiltBin(
  982. ['--profile', 'rescue', '--from-default-profile', 'web', '--dump-default-config'],
  983. { DSH_HOME: home },
  984. )
  985. expect(code).toBe(0)
  986. expect(stderr).toBe('')
  987. expect(stdout).toContain('# == @deepseek-ai/dsh-web-app')
  988. expect(existsSync(join(home, 'profiles', 'rescue', 'package.json'))).toBe(true)
  989. }, SPAWN_TIMEOUT_MS + 30_000)
  990. it('rejects an unknown source before creating the target profile', async () => {
  991. const { stdout, code, stderr } = await runBuiltBin(
  992. ['--profile', 'rescue', '--from-default-profile', 'unknown', '--dump-default-config'],
  993. { DSH_HOME: home },
  994. )
  995. expect(code).toBe(1)
  996. expect(stdout).toBe('')
  997. expect(stderr).toContain('unknown default profile "unknown"')
  998. expect(stderr).toContain('"web"')
  999. expect(existsSync(join(home, 'profiles', 'rescue'))).toBe(false)
  1000. }, SPAWN_TIMEOUT_MS + 30_000)
  1001. it('prints the headless profile without Host or browser layers', async () => {
  1002. const { stdout, code, stderr } = await runBuiltBin(
  1003. ['--profile', 'headless', '--dump-default-config'],
  1004. { DSH_HOME: home },
  1005. )
  1006. expect(code).toBe(0)
  1007. expect(stderr).toBe('')
  1008. expect(stdout).toContain("name: '@deepseek-ai/dsh-headless'")
  1009. expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-host-/)
  1010. expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'")
  1011. expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/)
  1012. }, SPAWN_TIMEOUT_MS + 30_000)
  1013. it('prints the exact standalone sdk-minimal tree without dsh-base', async () => {
  1014. const { stdout, code, stderr } = await runBuiltBin(
  1015. ['--profile', 'sdk-minimal', '--dump-default-config'],
  1016. { DSH_HOME: home },
  1017. )
  1018. expect(code).toBe(0)
  1019. expect(stderr).toBe('')
  1020. const rows = yaml.load(stdout, { schema: entryListSchema }) as Array<{ id?: string; name?: string }>
  1021. expect(rows.map(row => [row.id, row.name])).toEqual([
  1022. ['sdk-app-startup', '@deepseek-ai/dsh-sdk-app'],
  1023. ['sdk-jsonrpc-server', '@deepseek-ai/dsh-sdk-jsonrpc-server'],
  1024. ['deepseek-llm-api-extensions', '@deepseek-ai/dsh-deepseek-llm-api-extensions'],
  1025. ['session-log-deepseek', '@deepseek-ai/dsh-session-log-deepseek'],
  1026. ['plugin-package-inventory-deepseek', '@deepseek-ai/dsh-plugin-package-inventory-deepseek'],
  1027. ['llm-deepseek', '@deepseek-ai/dsh-llm-deepseek'],
  1028. ['sandbox', '@deepseek-ai/dsh-sandbox-local'],
  1029. ['session-projection', '@deepseek-ai/dsh-session-projection'],
  1030. ['sandbox-policy', '@deepseek-ai/dsh-sandbox-policy'],
  1031. ['subprocess', '@deepseek-ai/dsh-subprocess-local'],
  1032. ['pty', '@deepseek-ai/dsh-terminal'],
  1033. ['terminal-bash', '@deepseek-ai/dsh-terminal-bash'],
  1034. ['terminal-pwsh', '@deepseek-ai/dsh-terminal-bash'],
  1035. ['timer', '@deepseek-ai/cordis-plugin-timer'],
  1036. ['llm', '@deepseek-ai/dsh-llm'],
  1037. ['session', '@deepseek-ai/dsh-session'],
  1038. ['session-title', '@deepseek-ai/dsh-session-title'],
  1039. ['system-prompt', '@deepseek-ai/dsh-system-prompt'],
  1040. ['tools', '@deepseek-ai/dsh-tools'],
  1041. ['agent', '@deepseek-ai/dsh-agent'],
  1042. ['llm-retry', '@deepseek-ai/dsh-llm-retry'],
  1043. ['jobs', '@deepseek-ai/dsh-jobs-local'],
  1044. ['invariants', '@deepseek-ai/dsh-invariants'],
  1045. ['session-invariant', '@deepseek-ai/dsh-session/invariant'],
  1046. ['agent-invariant', '@deepseek-ai/dsh-agent/invariant'],
  1047. ['scope-invariant', '@deepseek-ai/dsh-scope/invariant'],
  1048. ['agent-loop-invariant', '@deepseek-ai/dsh-agent-loop/invariant'],
  1049. ['agent-loop', '@deepseek-ai/dsh-agent-loop'],
  1050. ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'],
  1051. ['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'],
  1052. ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'],
  1053. ])
  1054. expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal')
  1055. expect(stdout).not.toContain('@deepseek-ai/dsh-base')
  1056. expect(stdout).not.toContain('@deepseek-ai/dsh-web-app')
  1057. }, SPAWN_TIMEOUT_MS * 2 + 30_000)
  1058. it('composes the profile user layer and a --patch overlay in order', async () => {
  1059. // Auto-init the web profile first, then write its user layer.
  1060. const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
  1061. expect(init.code).toBe(0)
  1062. const profilePatch = join(home, 'profiles', 'web', 'cordis.patch.yml')
  1063. writeFileSync(profilePatch, [
  1064. '- id: agent-loop',
  1065. ' config:',
  1066. ' agents:',
  1067. ' - id: personal',
  1068. ' provider: personal-provider',
  1069. ' model: personal-model',
  1070. '- id: absent-row',
  1071. ' config:',
  1072. ' x: 1',
  1073. '',
  1074. ].join('\n'))
  1075. const overlay = join(home, 'overlay.cordis.yml')
  1076. writeFileSync(overlay, [
  1077. '- id: agent-loop',
  1078. ' config:',
  1079. ' agents:',
  1080. ' - id: configured',
  1081. ' provider: configured-provider',
  1082. ' model: configured-model',
  1083. '',
  1084. ].join('\n'))
  1085. const { stdout, code, stderr } = await runBuiltBin(
  1086. ['--profile', 'web', '--patch', overlay, '--dump-config'],
  1087. { DSH_HOME: home },
  1088. )
  1089. expect(code).toBe(0)
  1090. expect(stdout).toContain('provider: configured-provider')
  1091. expect(stdout).not.toContain('personal-provider')
  1092. // Both layers patched the row; the comment lists them in application order.
  1093. expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`)
  1094. expect(stderr).toContain('patch: entry "absent-row" not found')
  1095. }, SPAWN_TIMEOUT_MS * 2 + 30_000)
  1096. })
  1097. })