built-bin.e2e.ts 58 KB

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