scripts.spec.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538
  1. import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { dirname, join } from 'node:path'
  4. import { PassThrough, Writable } from 'node:stream'
  5. import { fileURLToPath, pathToFileURL } from 'node:url'
  6. import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
  7. import {
  8. LocalPluginBlueprint,
  9. NpmPackageManager,
  10. SdkProject,
  11. featureId,
  12. createBuiltinRegistry,
  13. type CommandRunner,
  14. type NestedMultiSelectValue,
  15. type ProjectCreationRequest,
  16. type PromptPort,
  17. } from '@deepseek-ai/dsh-helper'
  18. import type {
  19. ConfirmPromptRequest,
  20. MultiSelectPromptRequest,
  21. NestedMultiSelectRequest,
  22. PromptOutcome,
  23. SecretPromptRequest,
  24. SelectPromptRequest,
  25. TextPromptRequest,
  26. } from '../../helper/src/questions/prompt-port.ts'
  27. import { runSDK, startSDK } from '@deepseek-ai/dsh-scripts'
  28. import { parseDshSdkArgs, parseSdkBootArgs } from '../src/args.ts'
  29. import { PluginBuild, ProjectBuild, runProjectBuild } from '../src/build.ts'
  30. import { runDshSdkCommand, type DshSdkCommandContext } from '../src/command.ts'
  31. import { runConfigCommand } from '../src/config.ts'
  32. import { ConfigWorkflow } from '../src/config/config-workflow.ts'
  33. import { initialize, resolve as resolveLocalPlugin } from '../src/local-plugin-loader-hooks.ts'
  34. const temporary: string[] = []
  35. afterEach(async () => {
  36. await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
  37. })
  38. class QueuePort implements PromptPort {
  39. readonly #answers: unknown[]
  40. constructor(answers: unknown[]) { this.#answers = [...answers] }
  41. next<T>(): Promise<PromptOutcome<T>> {
  42. return Promise.resolve({ status: 'answered', value: this.#answers.shift() as T })
  43. }
  44. text(_request: TextPromptRequest): Promise<PromptOutcome<string>> { return this.next() }
  45. secret(_request: SecretPromptRequest): Promise<PromptOutcome<string>> { return this.next() }
  46. select<T>(_request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> { return this.next() }
  47. multiselect<T>(_request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> { return this.next() }
  48. confirm(_request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> { return this.next() }
  49. nestedMultiselect<TValue, TChoice>(
  50. _request: NestedMultiSelectRequest<TValue, TChoice>,
  51. ): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> { return this.next() }
  52. }
  53. function outputBuffer(): { stream: Writable; read: () => string } {
  54. let text = ''
  55. return {
  56. stream: new Writable({ write(chunk, _encoding, callback) { text += String(chunk); callback() } }),
  57. read: () => text,
  58. }
  59. }
  60. function commandContext(cwd: string): DshSdkCommandContext & { readStdout: () => string; readStderr: () => string } {
  61. let stdout = ''
  62. let stderr = ''
  63. const stdin = Object.assign(new PassThrough(), { isTTY: true }) as unknown as NodeJS.ReadStream
  64. const output = Object.assign(new Writable({
  65. write(chunk, _encoding, callback) { stdout += String(chunk); callback() },
  66. }), { isTTY: true }) as unknown as NodeJS.WriteStream
  67. const error = new Writable({
  68. write(chunk, _encoding, callback) { stderr += String(chunk); callback() },
  69. }) as unknown as NodeJS.WriteStream
  70. return {
  71. cwd, stdin, stdout: output, stderr: error,
  72. readStdout: () => stdout,
  73. readStderr: () => stderr,
  74. }
  75. }
  76. function creation(
  77. extra: ProjectCreationRequest['features'] = [],
  78. localPlugins: readonly LocalPluginBlueprint[] = [],
  79. app: 'acp' | 'stdio' | 'embed' = 'embed',
  80. ): ProjectCreationRequest {
  81. return {
  82. name: 'config-agent',
  83. description: 'config test',
  84. runtime: { model: 'deepseek-v4-flash' },
  85. packageManager: new NpmPackageManager('10.0.0'),
  86. releaseVersion: '0.0.1',
  87. features: [
  88. { id: featureId('provider'), options: ['deepseek'], secrets: { apiKey: 'key' } },
  89. { id: featureId('bash'), options: ['local'] },
  90. { id: featureId('app'), options: [app] },
  91. { id: featureId('persistence'), options: ['jsonl'] },
  92. ...extra,
  93. ],
  94. localPlugins,
  95. }
  96. }
  97. async function committedProject(
  98. extra: ProjectCreationRequest['features'] = [],
  99. localPlugins: readonly LocalPluginBlueprint[] = [],
  100. app: 'acp' | 'stdio' | 'embed' = 'embed',
  101. ): Promise<SdkProject> {
  102. const root = await mkdtemp(join(tmpdir(), 'dsh-config-workflow-'))
  103. temporary.push(root)
  104. const request = creation(extra, localPlugins, app)
  105. const project = SdkProject.create(root, request)
  106. const registry = createBuiltinRegistry(project.profile)
  107. const edit = project.edit(registry)
  108. for (const item of request.features) edit.installFeature(registry.get(item.id), item)
  109. for (const plugin of localPlugins) edit.addPlugin(plugin)
  110. return (await edit.commit()).project
  111. }
  112. describe('Commander launcher arguments', () => {
  113. it('parses real subcommands and forwards arbitrary build options', () => {
  114. expect(parseDshSdkArgs([])).toMatchObject({ help: true })
  115. expect(parseDshSdkArgs(['start', 'index.js'])).toMatchObject({ command: 'start', target: 'index.js' })
  116. expect(parseDshSdkArgs(['dev'])).toEqual({ command: 'dev', forwarded: [], help: false })
  117. expect(parseDshSdkArgs(['build', '--watch', '--minify'])).toMatchObject({
  118. command: 'build', forwarded: ['--watch', '--minify'],
  119. })
  120. expect(parseDshSdkArgs(['start', 'index.js', '--', '--resume', 'session-1'])).toMatchObject({
  121. command: 'start', target: 'index.js', forwarded: ['--resume', 'session-1'],
  122. })
  123. expect(parseDshSdkArgs(['config'])).toMatchObject({ command: 'config' })
  124. expect(parseDshSdkArgs(['start'])).toEqual({ command: 'start', forwarded: [], help: false })
  125. expect(parseDshSdkArgs(['dev', 'index.ts'])).toMatchObject({ command: 'dev', target: 'index.ts' })
  126. expect(parseDshSdkArgs(['-h'])).toMatchObject({ help: true })
  127. expect(() => parseDshSdkArgs(['unknown'])).toThrow()
  128. expect(() => parseDshSdkArgs(['config', 'extra'])).toThrow()
  129. expect(() => parseDshSdkArgs(['config', '--', 'extra'])).toThrow('does not accept forwarded')
  130. expect(parseSdkBootArgs([
  131. '--model=mock', '--resume=session-1', '--custom=value', '--verbose', '--no-cache', '--max-depth=-1',
  132. ])).toEqual({
  133. model: 'mock', resume: 'session-1', custom: 'value', verbose: true, cache: false, 'max-depth': '-1',
  134. })
  135. })
  136. it('dispatches every command and maps failures to exit codes', async () => {
  137. const root = await mkdtemp(join(tmpdir(), 'dsh-command-'))
  138. temporary.push(root)
  139. const context = commandContext(root)
  140. const calls: unknown[] = []
  141. context.run = async (target, options) => { calls.push(['run', target, options]); return undefined }
  142. context.build = async (args, cwd) => { calls.push(['build', args, cwd]) }
  143. context.config = async () => { calls.push(['config']); return {} }
  144. await expect(runDshSdkCommand(['start', 'index.js', '--', '--resume', 'session-1'], context)).resolves.toBe(0)
  145. await expect(runDshSdkCommand(['dev', 'index.ts'], context)).resolves.toBe(0)
  146. await expect(runDshSdkCommand(['build', '--watch'], context)).resolves.toBe(0)
  147. await expect(runDshSdkCommand(['config'], context)).resolves.toBe(0)
  148. expect(calls).toHaveLength(4)
  149. expect(calls[0]).toEqual(['run', 'index.js', { cwd: root, argv: ['--resume', 'session-1'] }])
  150. expect(calls[1]).toEqual(['run', 'index.ts', { cwd: root, dev: true, argv: [] }])
  151. context.config = async () => ({ installError: new Error('offline') })
  152. await expect(runDshSdkCommand(['config'], context)).resolves.toBe(1)
  153. context.config = async () => { throw 'broken' }
  154. await expect(runDshSdkCommand(['config'], context)).resolves.toBe(1)
  155. expect(context.readStderr()).toContain('broken')
  156. await expect(runDshSdkCommand(['unknown'], context)).resolves.toBe(1)
  157. await expect(runDshSdkCommand([], context)).resolves.toBe(0)
  158. expect(context.readStdout()).toContain('Usage: dsh-sdk')
  159. const defaults = commandContext(root)
  160. await writeFile(join(root, 'main.mjs'), 'export function main() { return "ok" }\n')
  161. await expect(runDshSdkCommand(['start', 'main.mjs'], defaults)).resolves.toBe(0)
  162. await expect(runDshSdkCommand(['build'], defaults)).resolves.toBe(0)
  163. defaults.port = new QueuePort([[]])
  164. await expect(runDshSdkCommand(['config'], defaults)).resolves.toBe(1)
  165. })
  166. })
  167. describe('build profiles and invocation', () => {
  168. it('discovers root and plugin targets and creates independent profiles', async () => {
  169. const root = await mkdtemp(join(tmpdir(), 'dsh-build-profile-'))
  170. temporary.push(root)
  171. await mkdir(join(root, 'plugins', 'one', 'src'), { recursive: true })
  172. await writeFile(join(root, 'index.ts'), 'export {}\n')
  173. await writeFile(join(root, 'plugins', 'one', 'package.json'), '{"name":"one"}\n')
  174. await writeFile(join(root, 'plugins', 'one', 'src', 'index.ts'), 'export {}\n')
  175. expect(ProjectBuild({ cwd: root, entry: ['index.ts'] })).toEqual([
  176. { cwd: root, entry: ['index.ts'] },
  177. { workspace: { include: ['plugins/*'] } },
  178. ])
  179. expect(PluginBuild({ entry: ['src/index.ts'], dts: true })).toEqual({ entry: ['src/index.ts'], dts: true })
  180. expect(() => ProjectBuild({ workspace: true })).toThrow('owns workspace discovery')
  181. expect(() => PluginBuild({ workspace: true })).toThrow('does not accept nested workspace')
  182. expect(ProjectBuild({ cwd: join(root, 'empty'), entry: ['index.ts'] })).toEqual([
  183. { cwd: join(root, 'empty'), entry: ['index.ts'] },
  184. ])
  185. expect(ProjectBuild({ entry: ['index.ts'] })[0]).toMatchObject({ entry: ['index.ts'] })
  186. })
  187. it('runs the project-installed tsdown and reports child failure', async () => {
  188. const root = await mkdtemp(join(tmpdir(), 'dsh-build-run-'))
  189. temporary.push(root)
  190. await writeFile(join(root, 'package.json'), '{"type":"module"}\n')
  191. await writeFile(join(root, 'index.ts'), 'export {}\n')
  192. await writeFile(join(root, 'tsdown.config.ts'), 'export default {}\n')
  193. await mkdir(join(root, 'node_modules'), { recursive: true })
  194. const manifest = fileURLToPath(import.meta.resolve('tsdown/package.json'))
  195. await symlink(dirname(manifest), join(root, 'node_modules', 'tsdown'))
  196. const calls: string[][] = []
  197. const runner: CommandRunner = {
  198. run: async (command, args) => {
  199. calls.push([command, ...args])
  200. return { exitCode: 0, signal: null }
  201. },
  202. }
  203. await runProjectBuild(['--watch'], root, runner)
  204. expect(calls[0]?.[0]).toBe(process.execPath)
  205. expect(calls[0]?.at(-1)).toBe('--watch')
  206. const failed: CommandRunner = { run: async () => ({ exitCode: 2, signal: null }) }
  207. await expect(runProjectBuild([], root, failed)).rejects.toThrow('exited with code 2')
  208. const killed: CommandRunner = { run: async () => ({ exitCode: null, signal: 'SIGTERM' }) }
  209. await expect(runProjectBuild([], root, killed)).rejects.toThrow('killed by SIGTERM')
  210. })
  211. it('recognizes every tsdown config source', async () => {
  212. const manifest = fileURLToPath(import.meta.resolve('tsdown/package.json'))
  213. for (const extension of ['cts', 'cjs', 'json']) {
  214. const root = await mkdtemp(join(tmpdir(), `dsh-build-${extension}-`))
  215. temporary.push(root)
  216. await writeFile(join(root, 'package.json'), '{"type":"module"}\n')
  217. await writeFile(join(root, `tsdown.config.${extension}`), '{}\n')
  218. await mkdir(join(root, 'node_modules'), { recursive: true })
  219. await symlink(dirname(manifest), join(root, 'node_modules', 'tsdown'))
  220. let called = false
  221. await runProjectBuild([], root, {
  222. run: async () => { called = true; return { exitCode: 0, signal: null } },
  223. })
  224. expect(called).toBe(true)
  225. }
  226. const root = await mkdtemp(join(tmpdir(), 'dsh-build-package-json-'))
  227. temporary.push(root)
  228. await writeFile(join(root, 'package.json'), '{"type":"module","tsdown":{}}\n')
  229. await mkdir(join(root, 'node_modules'), { recursive: true })
  230. await symlink(dirname(manifest), join(root, 'node_modules', 'tsdown'))
  231. let called = false
  232. await runProjectBuild([], root, {
  233. run: async () => { called = true; return { exitCode: 0, signal: null } },
  234. })
  235. expect(called).toBe(true)
  236. })
  237. it('reports missing and malformed project tsdown executables', async () => {
  238. const missing = await mkdtemp(join(tmpdir(), 'dsh-build-missing-'))
  239. temporary.push(missing)
  240. await writeFile(join(missing, 'package.json'), '{"type":"module"}')
  241. await writeFile(join(missing, 'tsdown.config.ts'), 'export default {}\n')
  242. await expect(runProjectBuild([], missing)).rejects.toThrow('requires tsdown')
  243. const malformed = await mkdtemp(join(tmpdir(), 'dsh-build-malformed-'))
  244. temporary.push(malformed)
  245. await writeFile(join(malformed, 'package.json'), '{"type":"module"}')
  246. await writeFile(join(malformed, 'tsdown.config.ts'), 'export default {}\n')
  247. await mkdir(join(malformed, 'node_modules', 'tsdown'), { recursive: true })
  248. await writeFile(join(malformed, 'node_modules', 'tsdown', 'package.json'), JSON.stringify({
  249. name: 'tsdown', version: '0.0.0', exports: { './package.json': './package.json' }, bin: {},
  250. }))
  251. await expect(runProjectBuild([], malformed)).rejects.toThrow('has no executable')
  252. await writeFile(join(malformed, 'node_modules', 'tsdown', 'package.json'), JSON.stringify({
  253. name: 'tsdown', version: '0.0.0', exports: { './package.json': './package.json' },
  254. }))
  255. await expect(runProjectBuild([], malformed)).rejects.toThrow('has no executable')
  256. const stringBin = await mkdtemp(join(tmpdir(), 'dsh-build-string-bin-'))
  257. temporary.push(stringBin)
  258. await writeFile(join(stringBin, 'package.json'), '{"type":"module"}')
  259. await writeFile(join(stringBin, 'tsdown.config.js'), 'export default {}\n')
  260. await mkdir(join(stringBin, 'node_modules', 'tsdown'), { recursive: true })
  261. await writeFile(join(stringBin, 'node_modules', 'tsdown', 'package.json'), JSON.stringify({
  262. name: 'tsdown', version: '0.0.0', exports: { './package.json': './package.json' }, bin: 'cli.js',
  263. }))
  264. await writeFile(join(stringBin, 'node_modules', 'tsdown', 'cli.js'), '')
  265. let command = ''
  266. await runProjectBuild([], stringBin, {
  267. run: async (_node, args) => { command = args[0] ?? ''; return { exitCode: 0, signal: null } },
  268. })
  269. expect(command).toContain('cli.js')
  270. })
  271. it('returns a no-op for a project with no build targets and hints on a missing start target', async () => {
  272. const root = await mkdtemp(join(tmpdir(), 'dsh-no-build-'))
  273. temporary.push(root)
  274. let called = false
  275. await runProjectBuild([], root, { run: async () => { called = true; return { exitCode: 0, signal: null } } })
  276. expect(called).toBe(false)
  277. const unreadableManifest = await mkdtemp(join(tmpdir(), 'dsh-build-unreadable-manifest-'))
  278. temporary.push(unreadableManifest)
  279. await mkdir(join(unreadableManifest, 'package.json'))
  280. await expect(runProjectBuild([], unreadableManifest)).rejects.toThrow()
  281. await expect(runSDK('index.js', { cwd: root })).rejects.toThrow('Run dsh-sdk build first')
  282. })
  283. it('invokes the target module main export and rejects passive modules', async () => {
  284. const root = await mkdtemp(join(tmpdir(), 'dsh-module-main-'))
  285. temporary.push(root)
  286. await writeFile(join(root, 'main.mjs'), 'export function main(context) { return context }\n')
  287. await writeFile(join(root, 'passive.mjs'), 'export const value = 1\n')
  288. await expect(runSDK('main.mjs', {
  289. cwd: root,
  290. argv: ['--model=mock', '--resume=session-1', 'custom'],
  291. })).resolves.toEqual({
  292. argv: ['--model=mock', '--resume=session-1', 'custom'],
  293. args: { model: 'mock', resume: 'session-1' }, cwd: root, mode: 'start',
  294. })
  295. await expect(runSDK('passive.mjs', { cwd: root })).rejects.toThrow('must export function main()')
  296. })
  297. it('boots empty Cordis configs and delegates targetless runs', async () => {
  298. expectTypeOf(runSDK).toBeCallableWith()
  299. const root = await mkdtemp(join(tmpdir(), 'dsh-start-sdk-'))
  300. temporary.push(root)
  301. await writeFile(join(root, 'cordis.yml'), '[]\n')
  302. const byUrl = await startSDK(pathToFileURL(join(root, 'cordis.yml')))
  303. await byUrl.fiber.dispose()
  304. const byRun = await runSDK(undefined, { cwd: root }) as import('cordis').Context
  305. await byRun.fiber.dispose()
  306. const dev = await startSDK('./cordis.yml', { cwd: root, dev: true })
  307. await dev.fiber.dispose()
  308. await expect(startSDK(new URL('https://example.invalid/cordis.yml'), { cwd: root })).rejects.toThrow()
  309. })
  310. it('validates local plugin metadata in dev mode', async () => {
  311. const malformed = await mkdtemp(join(tmpdir(), 'dsh-dev-malformed-'))
  312. temporary.push(malformed)
  313. await mkdir(join(malformed, 'plugins', 'bad'), { recursive: true })
  314. await expect(runSDK('missing.ts', { cwd: malformed, dev: true })).rejects.toThrow('cannot load local plugin metadata')
  315. const absent = await mkdtemp(join(tmpdir(), 'dsh-dev-absent-'))
  316. temporary.push(absent)
  317. await expect(runSDK('missing.ts', { cwd: absent, dev: true })).rejects.toThrow('cannot start missing target')
  318. const unnamed = await mkdtemp(join(tmpdir(), 'dsh-dev-unnamed-'))
  319. temporary.push(unnamed)
  320. await mkdir(join(unnamed, 'plugins', 'bad', 'src'), { recursive: true })
  321. await writeFile(join(unnamed, 'plugins', 'bad', 'package.json'), '{}')
  322. await writeFile(join(unnamed, 'plugins', 'bad', 'src/index.ts'), 'export {}\n')
  323. await expect(runSDK('missing.ts', { cwd: unnamed, dev: true })).rejects.toThrow('has no name')
  324. const duplicate = await mkdtemp(join(tmpdir(), 'dsh-dev-duplicate-'))
  325. temporary.push(duplicate)
  326. for (const name of ['one', 'two']) {
  327. await mkdir(join(duplicate, 'plugins', name, 'src'), { recursive: true })
  328. await writeFile(join(duplicate, 'plugins', name, 'package.json'), '{"name":"same"}')
  329. await writeFile(join(duplicate, 'plugins', name, 'src/index.ts'), 'export {}\n')
  330. }
  331. await expect(runSDK('missing.ts', { cwd: duplicate, dev: true })).rejects.toThrow('duplicate local plugin')
  332. const valid = await mkdtemp(join(tmpdir(), 'dsh-dev-valid-'))
  333. temporary.push(valid)
  334. await mkdir(join(valid, 'plugins', 'one', 'src'), { recursive: true })
  335. await writeFile(join(valid, 'plugins', 'README.md'), 'skip\n')
  336. await writeFile(join(valid, 'plugins', 'one', 'package.json'), '{"name":"local"}')
  337. await writeFile(join(valid, 'plugins', 'one', 'src/index.ts'), 'export {}\n')
  338. await writeFile(join(valid, 'main.ts'), 'export function main() { return "dev" }\n')
  339. await expect(runSDK('main.ts', { cwd: valid, dev: true })).resolves.toBe('dev')
  340. await expect(runSDK('missing.ts', { cwd: valid, dev: true })).rejects.toThrow('cannot start missing target')
  341. })
  342. it('maps only exact local package names through the loader hook', async () => {
  343. initialize({ mappings: { local: 'file:///tmp/local.ts' } })
  344. const next = async (specifier: string) => ({ url: specifier, format: 'module' as const })
  345. const context: import('node:module').ResolveHookContext = {
  346. conditions: [], importAttributes: {}, parentURL: undefined,
  347. }
  348. await expect(resolveLocalPlugin('local', context, next)).resolves.toMatchObject({ url: 'file:///tmp/local.ts' })
  349. await expect(resolveLocalPlugin('other', context, next)).resolves.toMatchObject({ url: 'other' })
  350. })
  351. })
  352. describe('ConfigWorkflow', () => {
  353. it('opens a project through the config command prompt seam', async () => {
  354. const project = await committedProject()
  355. const context = commandContext(project.root)
  356. context.port = new QueuePort([[]])
  357. context.install = async () => { throw new Error('install should not run') }
  358. await expect(runConfigCommand(context)).resolves.toEqual({})
  359. delete context.port
  360. delete context.install
  361. context.stdin.isTTY = false
  362. await expect(runConfigCommand(context)).rejects.toThrow('interactive TTY')
  363. context.stdin.isTTY = true
  364. context.stdout.isTTY = false
  365. await expect(runConfigCommand(context)).rejects.toThrow('interactive TTY')
  366. })
  367. it('accumulates a disable and commits only after Review & Apply', async () => {
  368. const project = await committedProject([{ id: featureId('todo'), options: ['default'] }])
  369. const registry = createBuiltinRegistry(project.profile)
  370. const output = outputBuffer()
  371. const workflow = new ConfigWorkflow(new QueuePort([
  372. [], true,
  373. ]), output.stream, async () => { throw new Error('install should not run') })
  374. const result = await workflow.run(project, registry)
  375. expect(result.commit?.project.cordis.entry('tool-todo')?.disabled).toBe(true)
  376. expect(output.read()).toContain('Disable feature: todo')
  377. })
  378. it('installs once after NPM dependency changes and keeps committed files on install failure', async () => {
  379. const project = await committedProject()
  380. const registry = createBuiltinRegistry(project.profile)
  381. const output = outputBuffer()
  382. let installs = 0
  383. const workflow = new ConfigWorkflow(new QueuePort([
  384. [{ value: 'feature:todo', choices: [] }], true,
  385. ]), output.stream, async () => {
  386. installs += 1
  387. throw new Error('offline')
  388. })
  389. const result = await workflow.run(project, registry)
  390. expect(installs).toBe(1)
  391. expect(result.installError?.message).toBe('offline')
  392. expect(result.commit?.project.cordis.entry('tool-todo')).toBeDefined()
  393. expect(output.read()).toContain('Changes were committed, but install failed')
  394. })
  395. it('cancels apply and enables a disabled feature without reinstalling', async () => {
  396. const project = await committedProject([{ id: featureId('todo'), options: ['default'] }])
  397. const registry = createBuiltinRegistry(project.profile)
  398. const cancelled = await new ConfigWorkflow(new QueuePort([[], false]), outputBuffer().stream).run(project, registry)
  399. expect(cancelled).toEqual({})
  400. const disable = project.edit(registry)
  401. disable.disableFeature(registry.get(featureId('todo')))
  402. const disabled = (await disable.commit()).project
  403. let installs = 0
  404. const enabled = await new ConfigWorkflow(new QueuePort([
  405. [{ value: 'feature:todo', choices: [] }], true,
  406. ]), outputBuffer().stream, async () => { installs += 1 }).run(disabled, createBuiltinRegistry(disabled.profile))
  407. expect(enabled.commit?.project.cordis.entry('tool-todo')?.disabled).toBeUndefined()
  408. expect(installs).toBe(0)
  409. })
  410. it('toggles custom Cordis config entries without changing NPM dependencies', async () => {
  411. const project = await committedProject([], [new LocalPluginBlueprint('sample', 'plugin')])
  412. await expect(new ConfigWorkflow(new QueuePort([
  413. [{ value: 'plugin:sample', choices: [] }],
  414. ]), outputBuffer().stream).run(project, createBuiltinRegistry(project.profile))).resolves.toEqual({})
  415. const output = outputBuffer()
  416. const disabled = await new ConfigWorkflow(new QueuePort([[], true]), output.stream).run(
  417. project, createBuiltinRegistry(project.profile),
  418. )
  419. expect(disabled.commit?.project.cordis.entry('sample')?.disabled).toBe(true)
  420. expect(output.read()).toContain('Disable custom plugin: sample')
  421. const next = disabled.commit?.project
  422. if (!next) throw new Error('custom toggle did not commit')
  423. const enabled = await new ConfigWorkflow(new QueuePort([
  424. [{ value: 'plugin:sample', choices: [] }], true,
  425. ]), outputBuffer().stream).run(next, createBuiltinRegistry(next.profile))
  426. expect(enabled.commit?.project.cordis.entry('sample')?.disabled).toBeUndefined()
  427. })
  428. it('shows inconsistent features as diagnostic-only rows', async () => {
  429. const complete = await committedProject()
  430. await writeFile(join(complete.root, 'cordis.yml'), `${await readFile(join(complete.root, 'cordis.yml'), 'utf8')}- id: web-search-exa
  431. name: '@deepseek-ai/dsh-web-search-exa'
  432. `)
  433. const project = await SdkProject.open(complete.root)
  434. const port = new QueuePort([[]])
  435. await expect(new ConfigWorkflow(port, outputBuffer().stream).run(project, createBuiltinRegistry(project.profile)))
  436. .resolves.toEqual({})
  437. })
  438. it('uses the default installer and normalizes non-Error install failures', async () => {
  439. const project = await committedProject()
  440. const install = vi.spyOn(NpmPackageManager.prototype, 'install').mockResolvedValue()
  441. await new ConfigWorkflow(new QueuePort([
  442. [{ value: 'feature:todo', choices: [] }], true,
  443. ])).run(project, createBuiltinRegistry(project.profile))
  444. expect(install).toHaveBeenCalledOnce()
  445. install.mockRestore()
  446. const next = await committedProject()
  447. const failed = await new ConfigWorkflow(new QueuePort([
  448. [{ value: 'feature:todo', choices: [] }], true,
  449. ]), outputBuffer().stream, async () => { throw 'offline-string' }).run(next, createBuiltinRegistry(next.profile))
  450. expect(failed.installError?.message).toBe('offline-string')
  451. })
  452. it('reconciles a child option selected in the feature tree', async () => {
  453. const project = await committedProject()
  454. const registry = createBuiltinRegistry(project.profile)
  455. let installs = 0
  456. const workflow = new ConfigWorkflow(new QueuePort([
  457. [{ value: 'feature:persistence', choices: ['sqlite'] }], true,
  458. ]), outputBuffer().stream, async () => { installs += 1 })
  459. const result = await workflow.run(project, registry)
  460. expect(result.commit?.project.cordis.entry('session-persistence')).toMatchObject({
  461. name: '@deepseek-ai/dsh-session-persistence-sqlite',
  462. config: { path: './.sessions/sessions.sqlite' },
  463. })
  464. expect(installs).toBe(1)
  465. })
  466. it('switches required provider and interface options', async () => {
  467. const project = await committedProject()
  468. const registry = createBuiltinRegistry(project.profile)
  469. const workflow = new ConfigWorkflow(new QueuePort([
  470. [
  471. { value: 'feature:provider', choices: ['custom'] },
  472. { value: 'feature:app', choices: ['stdio'] },
  473. { value: 'feature:persistence', choices: ['jsonl'] },
  474. ],
  475. 'https://provider.example/v1',
  476. 'custom-key',
  477. true,
  478. ]), outputBuffer().stream, async () => {})
  479. const result = await workflow.run(project, registry)
  480. const provider = result.commit?.project.cordis.entry('llm-pi-ai')
  481. expect(provider?.config?.apiKey).toBeDefined()
  482. expect(provider?.config?.baseURL).toBe('https://provider.example/v1')
  483. expect(result.commit?.project.cordis.entry('stdio')).toBeDefined()
  484. expect(result.commit?.project.cordis.entry('agent-loop')).toBeDefined()
  485. expect(result.commit?.project.cordis.entry('agent-core')).toBeUndefined()
  486. })
  487. it('disables ask-user when switching its app interface to embed', async () => {
  488. const project = await committedProject([
  489. { id: featureId('ask-user'), options: ['default'] },
  490. ], [], 'acp')
  491. const registry = createBuiltinRegistry(project.profile)
  492. const output = outputBuffer()
  493. const workflow = new ConfigWorkflow(new QueuePort([
  494. [
  495. { value: 'feature:provider', choices: ['deepseek'] },
  496. { value: 'feature:app', choices: ['embed'] },
  497. { value: 'feature:persistence', choices: ['jsonl'] },
  498. { value: 'feature:ask-user', choices: ['default'] },
  499. ],
  500. true,
  501. ]), output.stream, async () => {})
  502. const result = await workflow.run(project, registry)
  503. expect(result.commit?.project.profile.runInterface).toBe('embed')
  504. expect(result.commit?.project.cordis.entry('tool-ask-user')?.disabled).toBe(true)
  505. expect(output.read()).toContain('Disable feature: ask-user')
  506. })
  507. })