project.spec.ts 54 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013
  1. import { chmod, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { fileURLToPath } from 'node:url'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import {
  7. FeatureOption,
  8. ExclusiveOptionFeature,
  9. MultiOptionFeature,
  10. FixedFeature,
  11. type FeatureProjectView,
  12. } from '../src/features/feature.ts'
  13. import { createBuiltinRegistry } from '../src/features/builtin/index.ts'
  14. import {
  15. npmCordisConfigEntry,
  16. cordisConfigEntry,
  17. environment as environmentResource,
  18. optionalString,
  19. ownedTextFile,
  20. requiredString,
  21. stringArray,
  22. } from '../src/features/builtin/helpers.ts'
  23. import { defineFeatures, defineFeature } from '../src/features/define-feature.ts'
  24. import { FeatureRegistry } from '../src/features/registry.ts'
  25. import { ProjectContribution } from '../src/features/resources.ts'
  26. import type { CordisConfigEntryResource, ProjectResource } from '../src/features/resources.ts'
  27. import type { CordisConfigEntry } from '../src/documents/cordis-yaml-file.ts'
  28. import { PackageJsonFile } from '../src/documents/package-json-file.ts'
  29. import { TextProjectFile } from '../src/documents/project-file.ts'
  30. import { featureId, resourceKey } from '../src/ids.ts'
  31. import { NpmPackageManager } from '../src/package-managers/package-manager.ts'
  32. import { LocalPluginBlueprint } from '../src/plugins/local-plugin-blueprint.ts'
  33. import { SdkProject } from '../src/project/sdk-project.ts'
  34. import type {
  35. FeatureSelection,
  36. ProjectCreationRequest,
  37. ProjectProfile,
  38. } from '../src/project/types.ts'
  39. const temporary: string[] = []
  40. const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
  41. afterEach(async () => {
  42. await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
  43. })
  44. function selection(id: string, options: readonly string[], secrets?: Record<string, string>): FeatureSelection {
  45. return { id: featureId(id), options, ...secrets ? { secrets } : {} }
  46. }
  47. function request(
  48. extra: readonly FeatureSelection[] = [],
  49. plugins: readonly LocalPluginBlueprint[] = [],
  50. app: 'acp' | 'tui' | 'embed' = 'tui',
  51. bash: 'local' | 'sandbox' = 'local',
  52. ): ProjectCreationRequest {
  53. return {
  54. name: 'test-agent',
  55. description: 'test project',
  56. runtime: { model: 'deepseek-v4-flash' },
  57. packageManager: new NpmPackageManager('10.0.0'),
  58. releaseVersion: '0.0.1',
  59. features: [
  60. selection('provider', ['deepseek'], { apiKey: 'test-key' }),
  61. selection('bash', [bash]),
  62. selection('app', [app]),
  63. selection('persistence', ['jsonl']),
  64. ...extra,
  65. ],
  66. localPlugins: plugins,
  67. }
  68. }
  69. async function createCommitted(
  70. extra: readonly FeatureSelection[] = [],
  71. plugins: readonly LocalPluginBlueprint[] = [],
  72. ): Promise<SdkProject> {
  73. const root = await mkdtemp(join(tmpdir(), 'dsh-project-domain-'))
  74. temporary.push(root)
  75. const creation = request(extra, plugins)
  76. const project = SdkProject.create(root, creation)
  77. const registry = createBuiltinRegistry(project.profile)
  78. const edit = project.edit(registry)
  79. for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
  80. for (const plugin of plugins) edit.addPlugin(plugin)
  81. return (await edit.commit()).project
  82. }
  83. describe('SdkProject and ProjectEditSession', () => {
  84. it('derives existing-project profiles and tolerates malformed optional documents', async () => {
  85. const make = async (
  86. name: string,
  87. manifest: Record<string, unknown>,
  88. cordis: string,
  89. extras: Record<string, string> = {},
  90. ): Promise<SdkProject> => {
  91. const root = await mkdtemp(join(tmpdir(), `${name}-`))
  92. temporary.push(root)
  93. await writeFile(join(root, 'package.json'), JSON.stringify(manifest))
  94. await writeFile(join(root, 'cordis.yml'), cordis)
  95. for (const [path, text] of Object.entries(extras)) await writeFile(join(root, path), text)
  96. return SdkProject.open(root)
  97. }
  98. const acp = await make('dsh-open-acp', {
  99. name: 'acp', description: 'ACP', packageManager: 'pnpm@10.1.0',
  100. dependencies: { '@deepseek-ai/dsh-scripts': '^1.2.3' },
  101. }, `- id: acp
  102. name: '@deepseek-ai/dsh-acp'
  103. config: { model: app-model }
  104. `, { '.env': 'KEY=value\n', 'tsconfig.json': '{bad', 'pnpm-workspace.yaml': 'bad' })
  105. expect(acp.profile).toMatchObject({
  106. runInterface: 'acp', runtime: { model: 'app-model' }, releaseVersion: '1.2.3', description: 'ACP',
  107. })
  108. expect(acp.profile.packageManager.name).toBe('pnpm')
  109. expect(acp.readEnvironment('.env', 'KEY')).toBe('value')
  110. expect(() => acp.readEnvironment('.env.example', 'KEY')).not.toThrow()
  111. expect(acp.document('tsconfig.json')).toBeInstanceOf(TextProjectFile)
  112. const tui = await make('dsh-open-tui', {}, `- id: provider
  113. name: '@deepseek-ai/dsh-llm-deepseek'
  114. config: { models: [provider-model] }
  115. - id: tui
  116. name: '@deepseek-ai/dsh-tui'
  117. `, { 'yarn.lock': '' })
  118. expect(tui.profile.runInterface).toBe('tui')
  119. expect(tui.profile.runtime.model).toBe('provider-model')
  120. expect(tui.profile.packageManager.name).toBe('yarn')
  121. expect(tui.profile.name).toBe(tui.root.split('/').at(-1))
  122. const pnpm = await make('dsh-open-pnpm', { name: 'pnpm' }, '[]\n', { 'pnpm-lock.yaml': '' })
  123. expect(pnpm.profile.packageManager.name).toBe('pnpm')
  124. const defaults = await make('dsh-open-default', { name: 'default', packageManager: 'npm@10.0.0' }, '[]\n')
  125. expect(defaults.profile).toMatchObject({
  126. runInterface: 'embed', runtime: { model: 'deepseek-v4-flash' }, releaseVersion: '0.0.1',
  127. })
  128. expect(() => SdkProject.create(defaults.root, { ...request(), features: [] })).toThrow('requires one app')
  129. await expect(make('dsh-open-invalid-manager', { name: 'bad', packageManager: 'bad' }, '[]\n'))
  130. .rejects.toThrow('invalid packageManager field')
  131. const providerFallback = await make('dsh-open-provider-fallback', { name: 'fallback' }, `- id: tui
  132. name: '@deepseek-ai/dsh-tui'
  133. config: { model: '' }
  134. - id: provider
  135. name: '@deepseek-ai/dsh-llm-deepseek'
  136. config: { models: [fallback-model] }
  137. `)
  138. expect(providerFallback.profile.runtime.model).toBe('fallback-model')
  139. const pnpmRequest = { ...request(), packageManager: new (await import('../src/package-managers/package-manager.ts')).PnpmPackageManager('10.0.0') }
  140. expect(SdkProject.create(join(defaults.root, 'pnpm'), pnpmRequest).hasDocument('pnpm-workspace.yaml')).toBe(true)
  141. })
  142. it('commits a complete blueprint and round-trips every installed feature', async () => {
  143. const project = await createCommitted([
  144. selection('hmr', ['default']),
  145. selection('fs', ['local']),
  146. selection('todo', ['default']),
  147. selection('web', ['exa'], { apiKey: 'exa-key' }),
  148. selection('subagent', ['fork']),
  149. selection('workflow', ['workerthread']),
  150. selection('hooks', ['claude', 'codex']),
  151. ], [new LocalPluginBlueprint('sample', 'plugin'), new LocalPluginBlueprint('lookup', 'tool')])
  152. const registry = createBuiltinRegistry(project.profile)
  153. const inspections = registry.inspect(project)
  154. expect(inspections.filter(item => item.state === 'enabled').map(item => item.id)).toEqual([
  155. 'provider', 'spine', 'bash', 'app', 'persistence', 'hmr', 'fs', 'todo', 'web', 'subagent', 'workflow', 'hooks',
  156. ])
  157. expect(inspections.find(item => item.id === 'subagent')?.options).toEqual(['spawn', 'fork'])
  158. expect(project.cordisConfigEntries().map(entry => entry.id)).toContain('lookup')
  159. const index = await readFile(join(project.root, 'index.ts'), 'utf8')
  160. expect(index).toContain('SdkBootContext')
  161. expect(index).toContain('agents.create')
  162. expect(index).toContain('boot.args.resume')
  163. expect(index).not.toContain('AgentId')
  164. expect(index).toContain('const sessionId = SessionId(resume ?? `main-session-${randomUUID()}`)')
  165. expect(index).toContain('process.env.DSH_SDK_SESSION_ID = sessionId')
  166. expect(index).toContain('resumeSessionId: sessionId')
  167. expect(index).toContain('await ctx.fiber.dispose()')
  168. expect(index).toContain("new AggregateError([error, disposeError], 'TUI startup and cleanup failed')")
  169. expect(project.packageManifest().scripts).toEqual({
  170. dev: 'dsh-sdk dev index.ts -- --model="deepseek-v4-flash"',
  171. build: 'dsh-sdk build',
  172. typecheck: 'tsc -b',
  173. start: 'dsh-sdk start index.js -- --model="deepseek-v4-flash"',
  174. config: 'dsh-sdk config',
  175. })
  176. expect(await readFile(join(project.root, '.env.example'), 'utf8')).toContain('EXA_API_KEY=')
  177. expect(project.cordis.entry('tui')?.config?.sessionId).toMatchObject({
  178. source: 'process.env.DSH_SDK_SESSION_ID',
  179. })
  180. expect(await readFile(join(project.root, 'cordis.yml'), 'utf8'))
  181. .toContain('sessionId: !!js process.env.DSH_SDK_SESSION_ID')
  182. expect(project.cordis.entry('tui')?.config).not.toHaveProperty('model')
  183. expect(project.cordis.entry('tui-prompt')?.name).toBe('@deepseek-ai/dsh-tui/prompt')
  184. expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] })
  185. expect(project.cordis.entry('session-invariant')?.name).toBe('@deepseek-ai/dsh-session/invariant')
  186. expect(project.cordis.entry('agent-invariant')?.name).toBe('@deepseek-ai/dsh-agent/invariant')
  187. expect(project.cordis.entry('scope-invariant')?.name).toBe('@deepseek-ai/dsh-scope/invariant')
  188. expect(project.cordis.entry('agent-loop-invariant')?.name).toBe('@deepseek-ai/dsh-agent-loop/invariant')
  189. expect(project.cordis.entry('system-prompt')?.config?.persona).toContain('{{cwd}}')
  190. expect(project.packageManifest().dependencies?.['@cordisjs/plugin-timer']).toBe('^1.1.2')
  191. expect(project.packageManifest().dependencies?.['@cordisjs/plugin-hmr']).toBe('^1.0.15')
  192. expect(project.packageManifest().dependencies?.['@deepseek-ai/dsh-scope']).toBe('^0.0.1')
  193. expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-scope/invariant')
  194. expect(project.packageManifest().dependencies).not.toHaveProperty('node-addon-require-builtin')
  195. expect(project.cordis.entry('hmr')).toMatchObject({ name: '@cordisjs/plugin-hmr' })
  196. expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('baseURL')
  197. expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('models')
  198. })
  199. it('round-trips embed app projects without a front-door Cordis config entry', async () => {
  200. const root = await mkdtemp(join(tmpdir(), 'dsh-embed-app-'))
  201. temporary.push(root)
  202. const creation = request([], [], 'embed')
  203. const project = SdkProject.create(root, creation)
  204. const registry = createBuiltinRegistry(project.profile)
  205. const edit = project.edit(registry)
  206. for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
  207. const committed = (await edit.commit()).project
  208. const app = createBuiltinRegistry(committed.profile).get(featureId('app')).inspect(committed)
  209. expect(app).toMatchObject({ state: 'enabled', options: ['embed'] })
  210. expect(app.selection).toEqual(selection('app', ['embed']))
  211. expect(committed.cordis.entry('agent-loop')?.config).toEqual({ agents: [] })
  212. expect(committed.cordis.entry('acp')).toBeUndefined()
  213. expect(committed.cordis.entry('tui')).toBeUndefined()
  214. })
  215. it('emits the sandbox workspace-write example as inactive Cordis config', async () => {
  216. const root = await mkdtemp(join(tmpdir(), 'dsh-sandbox-bash-'))
  217. temporary.push(root)
  218. const creation = request([], [], 'tui', 'sandbox')
  219. const project = SdkProject.create(root, creation)
  220. const registry = createBuiltinRegistry(project.profile)
  221. const edit = project.edit(registry)
  222. for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
  223. await edit.commit()
  224. const cordis = await readFile(join(root, 'cordis.yml'), 'utf8')
  225. expect(cordis).toContain(`- id: bash
  226. name: "@deepseek-ai/dsh-bash-sandbox"
  227. # Uncomment to allow writes under the project workspace.
  228. # config:
  229. # mode: workspace-write
  230. # workspaceRoot: !!js process.cwd()`)
  231. })
  232. it('round-trips the custom pi-ai provider with explicit endpoint and default model', async () => {
  233. const root = await mkdtemp(join(tmpdir(), 'dsh-custom-provider-'))
  234. temporary.push(root)
  235. const base = request()
  236. const creation: ProjectCreationRequest = {
  237. ...base,
  238. features: [
  239. {
  240. id: featureId('provider'),
  241. options: ['custom'],
  242. values: { baseURL: 'https://custom.example/v1' },
  243. secrets: { apiKey: 'custom-key' },
  244. },
  245. ...base.features.filter(item => item.id !== 'provider'),
  246. ],
  247. }
  248. const project = SdkProject.create(root, creation)
  249. const registry = createBuiltinRegistry(project.profile)
  250. const edit = project.edit(registry)
  251. for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
  252. const committed = (await edit.commit()).project
  253. expect(committed.cordis.entry('llm-pi-ai')).toMatchObject({
  254. name: '@deepseek-ai/dsh-llm-pi-ai',
  255. config: { baseURL: 'https://custom.example/v1' },
  256. })
  257. expect(committed.cordis.entry('llm-pi-ai')?.config).not.toHaveProperty('models')
  258. expect(createBuiltinRegistry(committed.profile).get(featureId('provider')).inspect(committed)).toMatchObject({
  259. state: 'enabled', options: ['custom'],
  260. })
  261. })
  262. it('switches exclusive options and refuses disabling a required feature', async () => {
  263. const project = await createCommitted([selection('subagent', ['spawn']), selection('workflow', ['workerthread'])])
  264. const registry = createBuiltinRegistry(project.profile)
  265. const edit = project.edit(registry)
  266. const persistence = registry.get(featureId('persistence'))
  267. edit.configureFeature(persistence, selection('persistence', ['sqlite']))
  268. expect(() => { edit.disableFeature(registry.get(featureId('subagent'))) }).toThrow('required by workflow')
  269. expect(() => { edit.disableFeature(registry.get(featureId('app'))) }).toThrow('required feature')
  270. const committed = await edit.commit()
  271. expect(committed.project.cordis.entry('session-persistence')?.name).toContain('sqlite')
  272. expect(committed.changes.npmDependenciesChanged).toBe(true)
  273. })
  274. it('switches app-owned files and scripts while protecting user edits', async () => {
  275. const project = await createCommitted()
  276. const registry = createBuiltinRegistry(project.profile)
  277. const edit = project.edit(registry)
  278. edit.configureFeature(registry.get(featureId('app')), selection('app', ['acp']))
  279. const acp = (await edit.commit()).project
  280. expect(acp.profile.runInterface).toBe('acp')
  281. expect(acp.cordis.entry('commands')).toBeUndefined()
  282. expect(acp.cordis.entry('user-interaction')).toBeUndefined()
  283. expect(acp.packageManifest().scripts).toMatchObject({
  284. dev: 'dsh-sdk dev index.ts',
  285. start: 'dsh-sdk start index.js',
  286. })
  287. expect(await readFile(join(acp.root, 'README.md'), 'utf8')).toContain('Run as an ACP automation server')
  288. expect(await readFile(join(acp.root, 'index.ts'), 'utf8')).not.toContain('agents.create')
  289. const acpRegistry = createBuiltinRegistry(acp.profile)
  290. const embedEdit = acp.edit(acpRegistry)
  291. embedEdit.configureFeature(acpRegistry.get(featureId('app')), selection('app', ['embed']))
  292. const embed = (await embedEdit.commit()).project
  293. expect(embed.profile.runInterface).toBe('embed')
  294. expect(await readFile(join(embed.root, 'README.md'), 'utf8')).toContain('Embed the harness')
  295. const embedIndex = await readFile(join(embed.root, 'index.ts'), 'utf8')
  296. expect(embedIndex).toContain('agents.create')
  297. expect(embedIndex).toContain("import { SessionId } from '@deepseek-ai/dsh-session'")
  298. expect(embedIndex).not.toContain('AgentId')
  299. await writeFile(join(embed.root, 'README.md'), '# Custom README\n')
  300. const modified = await SdkProject.open(embed.root)
  301. const modifiedRegistry = createBuiltinRegistry(modified.profile)
  302. expect(() => { modified.edit(modifiedRegistry).configureFeature(
  303. modifiedRegistry.get(featureId('app')),
  304. selection('app', ['tui']),
  305. ) }).toThrow('feature-owned file was modified: README.md')
  306. const manifest = PackageJsonFile.parse(await readFile(join(embed.root, 'package.json'), 'utf8'))
  307. manifest.removeScript('dev')
  308. await writeFile(join(embed.root, 'package.json'), manifest.serialize())
  309. const incomplete = await SdkProject.open(embed.root)
  310. expect(createBuiltinRegistry(incomplete.profile).get(featureId('app')).inspect(incomplete).diagnostics)
  311. .toContain('missing package.json script dev')
  312. })
  313. it('rejects ask-user on non-interactive app interfaces', async () => {
  314. const project = await createCommitted([selection('ask-user', ['default'])])
  315. const registry = createBuiltinRegistry(project.profile)
  316. const embed = project.edit(registry)
  317. embed.configureFeature(registry.get(featureId('app')), selection('app', ['embed']))
  318. await expect(embed.commit()).rejects.toThrow('feature ask-user is not available for embed')
  319. const acp = project.edit(registry)
  320. acp.configureFeature(registry.get(featureId('app')), selection('app', ['acp']))
  321. await expect(acp.commit()).rejects.toThrow('feature ask-user is not available for acp')
  322. })
  323. it('supports disabled feature reconfiguration and rejects invalid state operations', async () => {
  324. const project = await createCommitted([selection('todo', ['default'])])
  325. const registry = createBuiltinRegistry(project.profile)
  326. const edit = project.edit(registry)
  327. expect(edit.inspections()).not.toHaveLength(0)
  328. const todo = registry.get(featureId('todo'))
  329. edit.configureFeature(registry.get(featureId('web')), selection('web', ['deepseek']))
  330. edit.disableFeature(todo)
  331. edit.configureFeature(todo, selection('todo', ['default']))
  332. edit.enableFeature(todo)
  333. expect(() => { edit.enableFeature(registry.get(featureId('ask-user'))) }).toThrow('not installed')
  334. expect(() => { edit.disableFeature(registry.get(featureId('ask-user'))) }).toThrow('not installed')
  335. expect(() => { edit.setCustomPluginDisabled('missing', true) }).toThrow('does not exist')
  336. const committed = await edit.commit()
  337. expect(committed.changes.enabledFeatures).toContain('todo')
  338. expect(() => { edit.enableFeature(todo) }).toThrow('already committed')
  339. })
  340. it('preserves custom entries and toggles only their Loader disabled state', async () => {
  341. const project = await createCommitted([], [new LocalPluginBlueprint('sample', 'plugin')])
  342. const registry = createBuiltinRegistry(project.profile)
  343. const edit = project.edit(registry)
  344. edit.setCustomPluginDisabled('sample', true)
  345. expect(edit.cordisConfigEntries().find(entry => entry.id === 'sample')?.disabled).toBe(true)
  346. expect(() => { edit.setCustomPluginDisabled('tui', true) }).toThrow('builtin feature')
  347. const next = (await edit.commit()).project
  348. const enable = next.edit(createBuiltinRegistry(next.profile))
  349. enable.setCustomPluginDisabled('sample', false)
  350. expect((await enable.commit()).project.cordis.entry('sample')?.disabled).toBeUndefined()
  351. })
  352. it('rejects local plugin collisions and invalid optional document shapes', async () => {
  353. const project = await createCommitted([], [new LocalPluginBlueprint('sample', 'plugin')])
  354. const registry = createBuiltinRegistry(project.profile)
  355. const npmDependencyConflict = project.edit(registry)
  356. expect(() => { npmDependencyConflict.addPlugin(new LocalPluginBlueprint('sample', 'plugin')) })
  357. .toThrow('NPM dependency already exists')
  358. await writeFile(join(project.root, 'tsconfig.json'), 'not-json\n')
  359. const malformed = await SdkProject.open(project.root)
  360. expect(() => { malformed.edit(createBuiltinRegistry(malformed.profile)).addPlugin(
  361. new LocalPluginBlueprint('other', 'plugin'),
  362. ) }).toThrow('requires a valid tsconfig')
  363. const entryProject = await createCommitted()
  364. const entryEdit = entryProject.edit(createBuiltinRegistry(entryProject.profile))
  365. ;(entryEdit as unknown as { cordis(): { addEntry(entry: CordisConfigEntry): void } }).cordis()
  366. .addEntry({ id: 'sample', name: 'manual' })
  367. expect(() => { entryEdit.addPlugin(new LocalPluginBlueprint('sample', 'plugin')) }).toThrow('entry already exists')
  368. const fileEdit = entryProject.edit(createBuiltinRegistry(entryProject.profile))
  369. ;(fileEdit as unknown as { documents: Map<string, TextProjectFile> }).documents
  370. .set('plugins/other/package.json', new TextProjectFile('plugins/other/package.json', '{}'))
  371. expect(() => { fileEdit.addPlugin(new LocalPluginBlueprint('other', 'plugin')) }).toThrow('file already exists')
  372. })
  373. it('reinstalls existing/disabled features and detects requirement cycles', async () => {
  374. const project = await createCommitted([selection('todo', ['default']), selection('subagent', ['spawn'])])
  375. const registry = createBuiltinRegistry(project.profile)
  376. const edit = project.edit(registry)
  377. const todo = registry.get(featureId('todo'))
  378. edit.installFeature(todo, selection('todo', ['default']))
  379. edit.disableFeature(todo)
  380. edit.installFeature(todo, selection('todo', ['default']))
  381. const subagent = registry.get(featureId('subagent'))
  382. edit.disableFeature(subagent)
  383. edit.installFeature(registry.get(featureId('workflow')), selection('workflow', ['workerthread']))
  384. expect(edit.cordisConfigEntries().find(entry => entry.id === 'subagent-spawn')?.disabled).toBeUndefined()
  385. class Cyclic extends FixedFeature {
  386. override readonly summary = 'cyclic'
  387. override readonly options = [new (class extends FeatureOption {
  388. override readonly id = 'one'
  389. override readonly label = 'One'
  390. override contribution(): ProjectContribution { return new ProjectContribution([]) }
  391. })()]
  392. override readonly id
  393. override readonly requires
  394. constructor(id: string, required: string) {
  395. super()
  396. this.id = featureId(id)
  397. this.requires = [featureId(required)]
  398. }
  399. }
  400. const one = new Cyclic('cycle-one', 'cycle-two')
  401. const two = new Cyclic('cycle-two', 'cycle-one')
  402. const cycleRegistry = new FeatureRegistry([one, two], project.profile)
  403. expect(() => { project.edit(cycleRegistry).installFeature(one, selection('cycle-one', ['one'])) })
  404. .toThrow('cyclic feature requirement')
  405. })
  406. it('removes clean owned files and detects files disappearing before commit', async () => {
  407. const project = await createCommitted([selection('hooks', ['claude', 'codex']), selection('todo', ['default'])])
  408. const registry = createBuiltinRegistry(project.profile)
  409. const remove = project.edit(registry)
  410. remove.configureFeature(registry.get(featureId('hooks')), selection('hooks', ['codex']))
  411. const committed = await remove.commit()
  412. expect(committed.changes.changedFiles).toContain('hooks.json')
  413. const edit = committed.project.edit(createBuiltinRegistry(committed.project.profile))
  414. edit.disableFeature(createBuiltinRegistry(committed.project.profile).get(featureId('todo')))
  415. await rm(join(committed.project.root, 'cordis.yml'))
  416. await expect(edit.commit()).rejects.toThrow('cannot verify project file cordis.yml')
  417. })
  418. it('guards internal resource collisions and malformed aggregate documents', async () => {
  419. const project = await createCommitted()
  420. const registry = createBuiltinRegistry(project.profile)
  421. const edit = project.edit(registry)
  422. type Internals = {
  423. documents: Map<string, TextProjectFile>
  424. states: Map<ReturnType<typeof featureId>, unknown>
  425. applyResource(resource: ProjectResource, previous: ProjectResource | undefined): void
  426. removeResource(resource: ProjectResource): void
  427. replaceContribution(previous: ProjectContribution | undefined, next: ProjectContribution): void
  428. finalProfile(): ProjectProfile
  429. manifest(): unknown
  430. cordis(): unknown
  431. environment(path: '.env' | '.env.example'): unknown
  432. state(feature: FixedFeature): unknown
  433. }
  434. const internals = edit as unknown as Internals
  435. const collidingEntry: ProjectResource = {
  436. kind: 'cordis-config-entry', key: resourceKey('cordis-config-entry:tui'),
  437. entry: { id: 'tui', name: 'other-package' }, ownedConfigKeys: [],
  438. }
  439. expect(() => { internals.applyResource(collidingEntry, undefined) }).toThrow('is owned by')
  440. const existingFile: ProjectResource = {
  441. kind: 'owned-file', key: resourceKey('file:tsconfig.json'),
  442. document: new TextProjectFile('tsconfig.json', 'replacement'), removeOnlyWhenUnchanged: true,
  443. }
  444. expect(() => { internals.applyResource(existingFile, undefined) }).toThrow('already exists')
  445. internals.documents.set('owned.txt', new TextProjectFile('owned.txt', 'old'))
  446. const previousFile: ProjectResource = {
  447. ...existingFile, key: resourceKey('file:owned.txt'), document: new TextProjectFile('owned.txt', 'old'),
  448. }
  449. const nextFile: ProjectResource = {
  450. ...existingFile, key: resourceKey('file:owned.txt'), document: new TextProjectFile('owned.txt', 'replacement'),
  451. }
  452. internals.applyResource(nextFile, previousFile)
  453. expect(internals.documents.get('owned.txt')?.serialize()).toBe('replacement\n')
  454. internals.documents.set('owned.txt', new TextProjectFile('owned.txt', 'user edit'))
  455. expect(() => { internals.applyResource(nextFile, previousFile) }).toThrow('was modified')
  456. const existingScript: ProjectResource = {
  457. kind: 'package-script', key: resourceKey('package-script:build'),
  458. name: 'build', command: 'other build', removeOnlyWhenUnchanged: true,
  459. }
  460. expect(() => { internals.applyResource(existingScript, undefined) }).toThrow('script already exists')
  461. const transientScript: ProjectResource = {
  462. kind: 'package-script', key: resourceKey('package-script:transient'),
  463. name: 'transient', command: 'first', removeOnlyWhenUnchanged: true,
  464. }
  465. internals.applyResource(transientScript, undefined)
  466. const nextScript: ProjectResource = { ...transientScript, command: 'second' }
  467. internals.applyResource(nextScript, transientScript)
  468. internals.applyResource(nextScript, transientScript)
  469. ;(internals.manifest() as PackageJsonFile).setScript('transient', 'user edit')
  470. expect(() => { internals.applyResource(transientScript, nextScript) }).toThrow('script was modified')
  471. expect(() => { internals.removeResource(nextScript) }).toThrow('script was modified')
  472. ;(internals.manifest() as PackageJsonFile).setScript('transient', 'second')
  473. internals.removeResource(nextScript)
  474. expect(() => { internals.removeResource(nextScript) }).toThrow('script is missing')
  475. expect(() => { internals.removeResource({
  476. ...existingFile, key: resourceKey('file:missing.txt'), document: new TextProjectFile('missing.txt', 'missing'),
  477. }) }).toThrow('owned file is missing')
  478. expect(() => { internals.removeResource({
  479. kind: 'cordis-config-entry', key: resourceKey('cordis-config-entry:missing'),
  480. entry: { id: 'missing', name: 'missing' }, ownedConfigKeys: [],
  481. }) }).toThrow('cannot confirm old Cordis resource')
  482. const transient: ProjectResource = {
  483. kind: 'owned-file', key: resourceKey('file:transient.txt'),
  484. document: new TextProjectFile('transient.txt', 'transient'), removeOnlyWhenUnchanged: true,
  485. }
  486. internals.applyResource(transient, undefined)
  487. internals.removeResource(transient)
  488. internals.replaceContribution(
  489. new ProjectContribution([{ kind: 'npm-dependency', key: resourceKey('shared'), name: 'cordis', section: 'dependencies' }]),
  490. new ProjectContribution([{
  491. kind: 'cordis-config-entry', key: resourceKey('shared'), entry: { id: 'new', name: 'new' }, ownedConfigKeys: [],
  492. }]),
  493. )
  494. internals.replaceContribution(
  495. new ProjectContribution([{
  496. kind: 'environment', key: resourceKey('environment:SAME'), name: 'SAME', value: 'old', exampleValue: '',
  497. }]),
  498. new ProjectContribution([{
  499. kind: 'environment', key: resourceKey('environment:SAME'), name: 'SAME', value: 'new', exampleValue: '',
  500. }]),
  501. )
  502. internals.documents.set('.env', new TextProjectFile('.env', 'bad'))
  503. expect(() => edit.readEnvironment('.env', 'KEY')).toThrow('not an environment document')
  504. expect(() => { internals.environment('.env') }).toThrow('not an environment document')
  505. internals.documents.delete('package.json')
  506. expect(() => { internals.manifest() }).toThrow('package.json is missing')
  507. internals.documents.delete('cordis.yml')
  508. expect(() => { internals.cordis() }).toThrow('cordis.yml is missing')
  509. class Foreign extends FixedFeature {
  510. override readonly id = featureId('foreign')
  511. override readonly summary = 'foreign'
  512. override readonly options = []
  513. }
  514. expect(() => { internals.state(new Foreign()) }).toThrow('not applicable')
  515. internals.states.delete(featureId('app'))
  516. expect(internals.finalProfile()).toBe(project.profile)
  517. const sourceDocuments = (project as unknown as { documents: Map<string, TextProjectFile> }).documents
  518. sourceDocuments.set('.env', new TextProjectFile('.env', 'bad'))
  519. expect(() => project.readEnvironment('.env', 'KEY')).toThrow('not an environment document')
  520. sourceDocuments.delete('package.json')
  521. expect(() => project.packageJson).toThrow('package.json is missing or invalid')
  522. sourceDocuments.delete('cordis.yml')
  523. expect(() => project.cordis).toThrow('cordis.yml is missing or invalid')
  524. })
  525. it('rejects external edits before writing any affected file', async () => {
  526. const project = await createCommitted([selection('todo', ['default'])])
  527. const registry = createBuiltinRegistry(project.profile)
  528. const edit = project.edit(registry)
  529. edit.disableFeature(registry.get(featureId('todo')))
  530. const manifestBefore = await readFile(join(project.root, 'package.json'), 'utf8')
  531. await writeFile(join(project.root, 'cordis.yml'), '# external\n[]\n')
  532. await expect(edit.commit()).rejects.toThrow('changed outside this edit session')
  533. expect(await readFile(join(project.root, 'package.json'), 'utf8')).toBe(manifestBefore)
  534. })
  535. it('rejects a create target file that appeared after the edit session opened', async () => {
  536. const root = await mkdtemp(join(tmpdir(), 'dsh-create-conflict-'))
  537. temporary.push(root)
  538. const creation = request()
  539. const project = SdkProject.create(root, creation)
  540. const registry = createBuiltinRegistry(project.profile)
  541. const edit = project.edit(registry)
  542. for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
  543. await writeFile(join(root, 'README.md'), 'external\n')
  544. await expect(edit.commit()).rejects.toThrow('changed outside this edit session: README.md')
  545. })
  546. it('uses Cordis config entries as the installation anchor and rejects partial resources', async () => {
  547. const root = await mkdtemp(join(tmpdir(), 'dsh-inconsistent-'))
  548. temporary.push(root)
  549. await writeFile(join(root, 'package.json'), JSON.stringify({
  550. name: 'partial', dependencies: { '@deepseek-ai/dsh-llm-deepseek': '^0.0.1' },
  551. }))
  552. await writeFile(join(root, 'cordis.yml'), '[]\n')
  553. const project = await SdkProject.open(root)
  554. const registry = createBuiltinRegistry(project.profile)
  555. expect(registry.get(featureId('provider')).inspect(project)).toMatchObject({
  556. state: 'absent', diagnostics: [],
  557. })
  558. const partialRoot = await mkdtemp(join(tmpdir(), 'dsh-entry-partial-'))
  559. temporary.push(partialRoot)
  560. await writeFile(join(partialRoot, 'package.json'), JSON.stringify({ name: 'partial-entry' }))
  561. await writeFile(join(partialRoot, 'cordis.yml'), `- id: llm-deepseek
  562. name: '@deepseek-ai/dsh-llm-deepseek'
  563. config:
  564. apiKey: test
  565. `)
  566. const partial = await SdkProject.open(partialRoot)
  567. const installation = createBuiltinRegistry(partial.profile)
  568. .get(featureId('provider')).inspect(partial)
  569. expect(installation.state).toBe('inconsistent')
  570. expect(installation.diagnostics).toContain('missing package.json dependencies entry @deepseek-ai/dsh-llm-deepseek')
  571. const partialEdit = partial.edit(createBuiltinRegistry(partial.profile))
  572. const provider = createBuiltinRegistry(partial.profile).get(featureId('provider'))
  573. expect(() => { partialEdit.configureFeature(provider, selection('provider', ['deepseek'])) }).toThrow('inconsistent')
  574. expect(() => { partialEdit.enableFeature(provider) }).toThrow('inconsistent')
  575. expect(() => { partialEdit.disableFeature(provider) }).toThrow('required feature')
  576. })
  577. it('rejects inconsistent optional features and incompatible requirement options', async () => {
  578. const root = await mkdtemp(join(tmpdir(), 'dsh-optional-inconsistent-'))
  579. temporary.push(root)
  580. await writeFile(join(root, 'package.json'), '{"name":"partial"}')
  581. await writeFile(join(root, 'cordis.yml'), `- id: web-search-exa
  582. name: '@deepseek-ai/dsh-web-search-exa'
  583. `)
  584. const project = await SdkProject.open(root)
  585. const builtin = createBuiltinRegistry(project.profile)
  586. const edit = project.edit(builtin)
  587. const web = builtin.get(featureId('web'))
  588. expect(() => { edit.disableFeature(web) }).toThrow('inconsistent')
  589. expect(() => { edit.installFeature(web, selection('web', ['deepseek'])) }).toThrow('inconsistent')
  590. class RequiresWeb extends FixedFeature {
  591. override readonly id = featureId('requires-web')
  592. override readonly summary = 'requires web'
  593. override readonly requires = [featureId('web')]
  594. override readonly options = [new (class extends FeatureOption {
  595. override readonly id = 'one'
  596. override readonly label = 'One'
  597. override contribution(): ProjectContribution { return new ProjectContribution([]) }
  598. })()]
  599. }
  600. const requiresWeb = new RequiresWeb()
  601. const webRegistry = new FeatureRegistry([web, requiresWeb], project.profile)
  602. expect(() => { project.edit(webRegistry).installFeature(requiresWeb, selection('requires-web', ['one'])) })
  603. .toThrow('required feature web is inconsistent')
  604. class RequiresAcp extends FixedFeature {
  605. override readonly id = featureId('requires-acp')
  606. override readonly summary = 'requires acp'
  607. override readonly options = [new (class extends FeatureOption {
  608. override readonly id = 'one'
  609. override readonly label = 'One'
  610. override contribution(): ProjectContribution { return new ProjectContribution([]) }
  611. })()]
  612. override requirements(): readonly [{ id: ReturnType<typeof featureId>; options: readonly string[] }] {
  613. return [{ id: featureId('app'), options: ['acp'] }]
  614. }
  615. }
  616. const requiring = new RequiresAcp()
  617. const complete = await createCommitted()
  618. const app = createBuiltinRegistry(complete.profile).get(featureId('app'))
  619. const registry = new FeatureRegistry([app, requiring], complete.profile)
  620. expect(() => { complete.edit(registry).installFeature(requiring, selection('requires-acp', ['one'])) })
  621. .toThrow('does not satisfy the option requirement')
  622. })
  623. it('removes obsolete environment resources when switching options', async () => {
  624. const project = await createCommitted([selection('web', ['exa'], { apiKey: 'exa' })])
  625. const registry = createBuiltinRegistry(project.profile)
  626. const edit = project.edit(registry)
  627. edit.configureFeature(registry.get(featureId('web')), selection('web', ['deepseek']))
  628. expect(edit.readEnvironment('.env.example', 'EXA_API_KEY')).toBeUndefined()
  629. })
  630. it('preserves duplicate and existing .env values while appending differently named secrets', async () => {
  631. const project = await createCommitted()
  632. if (process.platform !== 'win32') {
  633. expect((await stat(join(project.root, '.env'))).mode & 0o777).toBe(0o600)
  634. }
  635. const original = '# keep\nDEEPSEEK_API_KEY=first\nDEEPSEEK_API_KEY=second\n'
  636. await writeFile(join(project.root, '.env'), original)
  637. if (process.platform !== 'win32') await chmod(join(project.root, '.env'), 0o640)
  638. const reopened = await SdkProject.open(project.root)
  639. const registry = createBuiltinRegistry(reopened.profile)
  640. expect(registry.get(featureId('provider')).inspect(reopened)).toMatchObject({
  641. state: 'enabled', selection: { secrets: { apiKey: 'second' } },
  642. })
  643. const edit = reopened.edit(registry)
  644. edit.configureFeature(
  645. registry.get(featureId('provider')),
  646. selection('provider', ['deepseek'], { apiKey: 'replacement' }),
  647. )
  648. edit.installFeature(registry.get(featureId('web')), selection('web', ['exa'], { apiKey: 'exa-key' }))
  649. const withExa = (await edit.commit()).project
  650. expect(await readFile(join(withExa.root, '.env'), 'utf8')).toBe(`${original}EXA_API_KEY=exa-key\n`)
  651. if (process.platform !== 'win32') {
  652. expect((await stat(join(withExa.root, '.env'))).mode & 0o777).toBe(0o640)
  653. }
  654. const nextRegistry = createBuiltinRegistry(withExa.profile)
  655. const remove = withExa.edit(nextRegistry)
  656. remove.configureFeature(nextRegistry.get(featureId('web')), selection('web', ['deepseek']))
  657. await remove.commit()
  658. expect(await readFile(join(withExa.root, '.env'), 'utf8')).toBe(`${original}EXA_API_KEY=exa-key\n`)
  659. })
  660. it('refuses to remove a feature-owned file after user edits', async () => {
  661. const project = await createCommitted([selection('hooks', ['claude', 'codex'])])
  662. await writeFile(join(project.root, 'hooks.json'), '{"hooks":{}}\n')
  663. const reopened = await SdkProject.open(project.root)
  664. const registry = createBuiltinRegistry(reopened.profile)
  665. const edit = reopened.edit(registry)
  666. expect(() => { edit.configureFeature(
  667. registry.get(featureId('hooks')),
  668. selection('hooks', ['codex']),
  669. ) }).toThrow('owned file was modified: hooks.json')
  670. })
  671. it('does not mistake a linked NPM dependency closure for an installed feature', async () => {
  672. const root = await mkdtemp(join(tmpdir(), 'dsh-link-closure-inspection-'))
  673. temporary.push(root)
  674. const base = request([selection('hooks', ['claude'])])
  675. const creation: ProjectCreationRequest = { ...base, linkWorkspaceRoot: repoRoot }
  676. const project = SdkProject.create(root, creation)
  677. const registry = createBuiltinRegistry(project.profile)
  678. const edit = project.edit(registry)
  679. for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
  680. const committed = (await edit.commit()).project
  681. expect(committed.packageManifest().dependencies?.['@deepseek-ai/dsh-subagent']).toMatch(/^file:/)
  682. expect(createBuiltinRegistry(committed.profile).get(featureId('subagent')).inspect(committed).state).toBe('absent')
  683. })
  684. it('mounts an external plugin dependency and rejects missing deps or duplicate entries', async () => {
  685. const root = await mkdtemp(join(tmpdir(), 'dsh-external-plugin-'))
  686. temporary.push(root)
  687. const creation = request()
  688. const project = SdkProject.create(root, creation)
  689. const registry = createBuiltinRegistry(project.profile)
  690. const edit = project.edit(registry)
  691. for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
  692. await edit.commit()
  693. const manifestPath = join(root, 'package.json')
  694. const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { dependencies?: Record<string, string> }
  695. manifest.dependencies = { ...manifest.dependencies, 'ext-plugin': 'github:o/r#sha' }
  696. await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
  697. const reopened = await SdkProject.open(root)
  698. const edit2 = reopened.edit(createBuiltinRegistry(reopened.profile))
  699. edit2.addExternalPlugin('ext-plugin', 'ext-plugin')
  700. expect(() => { edit2.addExternalPlugin('ext-plugin', 'ext-plugin') }).toThrow('already exists')
  701. expect(() => { edit2.addExternalPlugin('missing', 'not-a-dep') }).toThrow('not installed')
  702. const commit = await edit2.commit()
  703. expect(commit.project.cordis.entry('ext-plugin')?.name).toBe('ext-plugin')
  704. })
  705. })
  706. describe('extension points', () => {
  707. const profile: ProjectProfile = {
  708. name: 'test', description: 'test', runtime: { model: 'm' }, runInterface: 'embed',
  709. packageManager: new NpmPackageManager('10.0.0'), releaseVersion: '0.0.1',
  710. }
  711. it('rejects cross-feature resource ownership conflicts at registry construction', () => {
  712. class TestOption extends FeatureOption {
  713. override readonly id = 'default'
  714. override readonly label = 'Default'
  715. override contribution(): ProjectContribution {
  716. return new ProjectContribution([{
  717. kind: 'npm-dependency', key: resourceKey('npm-dependency:shared'), name: 'shared', section: 'dependencies',
  718. }])
  719. }
  720. }
  721. class TestFeature extends FixedFeature {
  722. override readonly id
  723. override readonly summary = 'test'
  724. override readonly options = [new TestOption()]
  725. constructor(id: string) {
  726. super()
  727. this.id = featureId(id)
  728. }
  729. }
  730. expect(() => new FeatureRegistry([
  731. new TestFeature('one'), new TestFeature('two'),
  732. ], profile)).toThrow('declared by both one and two')
  733. expect(new TestFeature('one').defaultOptions()).toEqual(['default'])
  734. expect(() => new FeatureRegistry([
  735. new TestFeature('one'), new TestFeature('one'),
  736. ], profile)).toThrow('duplicate feature id')
  737. })
  738. it('validates selection modes and declarative feature definitions', () => {
  739. const option = { id: 'one', label: 'One', default: true, resources: [] } as const
  740. expect(() => defineFeature({ id: 'bad-single', summary: 'bad', mode: 'single', options: [] }))
  741. .toThrow('requires one default option')
  742. expect(() => defineFeature({
  743. id: 'bad-exclusive', summary: 'bad', mode: 'exclusive', options: [{ ...option, default: false }],
  744. })).toThrow('exactly one default option')
  745. expect(() => defineFeature({
  746. id: 'bad-multiple', summary: 'bad', mode: 'multiple', options: [{ ...option, default: false }],
  747. })).toThrow('at least one default option')
  748. const exclusive = defineFeature({
  749. id: 'defined', summary: 'Defined', mode: 'exclusive', supportedInterfaces: ['embed'],
  750. requires: [{ id: 'base' }], suggests: ['suggested'],
  751. baseResources: [{ kind: 'npm-dependency', name: 'base', section: 'devDependencies' }],
  752. options: [
  753. {
  754. id: 'one', label: 'One', default: true,
  755. requires: [{ id: 'option', options: ['required'] }],
  756. secrets: [{ id: 'token', environment: 'TOKEN', message: 'Token', required: true }],
  757. resources: [
  758. {
  759. kind: 'npm-cordis-config-entry', id: 'one', package: 'one-package',
  760. config: { nested: { value: 1 }, list: ['x'], nullable: null },
  761. },
  762. { kind: 'owned-file', path: 'one.txt', text: 'one', removeOnlyWhenUnchanged: false },
  763. ],
  764. },
  765. {
  766. id: 'two', label: 'Two', resources: [
  767. { kind: 'file-cordis-config-entry', id: 'two', path: './two.ts' },
  768. ],
  769. },
  770. ],
  771. })
  772. expect(exclusive.defaultOptions(profile)).toEqual(['one'])
  773. expect(exclusive.isApplicable(profile)).toBe(true)
  774. expect(exclusive.isApplicable({ ...profile, runInterface: 'tui' })).toBe(false)
  775. expect(exclusive.requirements(selection('defined', ['one']))).toEqual([
  776. { id: 'base' }, { id: 'option', options: ['required'] },
  777. ])
  778. const contribution = exclusive.contribution({
  779. id: featureId('defined'), options: ['one'], secrets: { token: 'secret' },
  780. }, profile)
  781. expect(contribution.resources.map(resource => resource.kind)).toEqual([
  782. 'npm-dependency', 'npm-dependency', 'cordis-config-entry', 'owned-file', 'environment',
  783. ])
  784. const entry = contribution.resources.find(resource => resource.kind === 'cordis-config-entry')
  785. expect(entry?.validateConfig?.({ nested: { value: 2 }, list: ['a', 'b'], nullable: null })).toEqual([])
  786. expect(entry?.validateConfig?.({ nested: [], list: 'bad' })).toHaveLength(3)
  787. expect(() => exclusive.normalizeSelection(selection('other', ['one']), profile)).toThrow('does not belong')
  788. expect(() => exclusive.normalizeSelection(selection('defined', ['one']), { ...profile, runInterface: 'tui' }))
  789. .toThrow('not available')
  790. expect(() => exclusive.normalizeSelection(selection('defined', ['missing']), profile)).toThrow('unknown')
  791. expect(() => exclusive.normalizeSelection(selection('defined', ['one', 'two']), profile)).toThrow('exactly one')
  792. expect(defineFeatures([exclusive, {
  793. id: 'fixed', summary: 'Fixed', mode: 'single', options: [option],
  794. }])).toHaveLength(2)
  795. expect(() => new FeatureRegistry([], profile).get(featureId('missing'))).toThrow('unknown feature')
  796. expect(new FeatureRegistry([exclusive], profile).ownerOfPackage('one-package', { ...profile, runInterface: 'tui' }))
  797. .toBeUndefined()
  798. class Unsupported extends FixedFeature {
  799. override readonly id = featureId('unsupported')
  800. override readonly summary = 'unsupported'
  801. override readonly options = [new (class extends FeatureOption {
  802. override readonly id = 'one'
  803. override readonly label = 'One'
  804. override contribution(): ProjectContribution { return new ProjectContribution([]) }
  805. })()]
  806. override readonly supportedInterfaces = []
  807. }
  808. expect(() => new FeatureRegistry([new Unsupported()], profile)).toThrow('supports no run interface')
  809. })
  810. it('covers feature base classes and resource conflict checks', () => {
  811. class EmptySimple extends FixedFeature {
  812. override readonly id = featureId('empty')
  813. override readonly summary = 'empty'
  814. override readonly options = []
  815. }
  816. expect(() => new EmptySimple().defaultOptions()).toThrow('has no option')
  817. class BadSimple extends FixedFeature {
  818. override readonly id = featureId('bad-simple')
  819. override readonly summary = 'bad'
  820. override readonly options = [new (class extends FeatureOption {
  821. override readonly id = 'one'
  822. override readonly label = 'One'
  823. override contribution(): ProjectContribution { return new ProjectContribution([]) }
  824. })(), new (class extends FeatureOption {
  825. override readonly id = 'two'
  826. override readonly label = 'Two'
  827. override contribution(): ProjectContribution { return new ProjectContribution([]) }
  828. })()]
  829. }
  830. expect(() => new BadSimple().normalizeSelection(selection('bad-simple', ['one']), profile)).toThrow('one fixed option')
  831. class EmptyMulti extends MultiOptionFeature {
  832. override readonly id = featureId('multi')
  833. override readonly summary = 'multi'
  834. override readonly options = []
  835. override defaultOptions(): readonly string[] { return [] }
  836. }
  837. expect(() => new EmptyMulti().normalizeSelection(selection('multi', []), profile)).toThrow('at least one')
  838. class EmptyExclusive extends ExclusiveOptionFeature {
  839. override readonly id = featureId('exclusive')
  840. override readonly summary = 'exclusive'
  841. override readonly options = []
  842. override defaultOptions(): readonly string[] { return [] }
  843. }
  844. expect(() => new EmptyExclusive().normalizeSelection(selection('exclusive', []), profile)).toThrow('exactly one')
  845. const resource = {
  846. kind: 'npm-dependency' as const, key: resourceKey('same'), name: 'one', section: 'dependencies' as const,
  847. }
  848. expect(() => new ProjectContribution([resource, resource])).toThrow('duplicate contribution')
  849. expect(() => ProjectContribution.merge(
  850. new ProjectContribution([resource]),
  851. new ProjectContribution([{ ...resource, name: 'two' }]),
  852. )).toThrow('conflicting definitions')
  853. expect(ProjectContribution.merge(new ProjectContribution([resource]), new ProjectContribution([resource])).byKey().size)
  854. .toBe(1)
  855. expect(ownedTextFile('owner', 'file.txt', 'text').document).toBeInstanceOf(TextProjectFile)
  856. expect(optionalString({ value: 1 }, 'value')).toHaveLength(1)
  857. expect(optionalString({}, 'value')).toEqual([])
  858. expect(requiredString({ value: 'x' }, 'value')).toEqual([])
  859. expect(stringArray({ value: ['a'] }, 'value')).toEqual([])
  860. expect(stringArray({ value: [1] }, 'value')).toHaveLength(1)
  861. expect(cordisConfigEntry('owner', { id: 'entry', name: 'pkg' }).ownedConfigKeys).toEqual([])
  862. expect(npmCordisConfigEntry('owner', { id: 'entry', name: 'pkg' })[1].ownedConfigKeys).toEqual([])
  863. expect(npmCordisConfigEntry('owner', { id: 'entry', name: '@scope/pkg/subpath' })[0].name).toBe('@scope/pkg')
  864. expect(npmCordisConfigEntry('owner', { id: 'entry', name: 'pkg/subpath' })[0].name).toBe('pkg')
  865. for (const invalid of ['', '@scope', '@scope/']) {
  866. expect(() => npmCordisConfigEntry('owner', { id: 'entry', name: invalid })).toThrow('invalid bare package specifier')
  867. }
  868. expect(environmentResource('owner', 'EMPTY', undefined)).not.toHaveProperty('value')
  869. const builtins = createBuiltinRegistry(profile)
  870. expect(builtins.get(featureId('app')).defaultOptions(profile)).toEqual(['embed'])
  871. expect(builtins.get(featureId('hmr')).defaultOptions(profile)).toEqual(['default'])
  872. const app = builtins.get(featureId('app'))
  873. const acpEntry = builtins.get(featureId('app')).contribution(selection('app', ['acp']), profile).resources
  874. .find((resource): resource is CordisConfigEntryResource =>
  875. resource.kind === 'cordis-config-entry' && resource.entry.id === 'acp')
  876. expect(acpEntry?.entry.id).toBe('acp')
  877. expect(acpEntry?.validateConfig?.({ model: '' })).toHaveLength(1)
  878. const tuiEntry = builtins.get(featureId('app')).contribution(selection('app', ['tui']), profile).resources
  879. .find((resource): resource is CordisConfigEntryResource =>
  880. resource.kind === 'cordis-config-entry' && resource.entry.id === 'tui')
  881. expect(tuiEntry?.validateConfig?.({ welcome: 'ready', sessionId: 1 })).toEqual([
  882. 'sessionId must be a non-empty string',
  883. ])
  884. const embedOption = app.options.find(option => option.id === 'embed')
  885. expect(embedOption?.markerConfigEntries(profile)).toEqual([])
  886. expect(embedOption?.contribution(profile, {}).resources.map(resource => resource.kind)).toEqual([
  887. 'owned-file', 'owned-file', 'package-script', 'package-script',
  888. ])
  889. expect(embedOption?.matchesConfigEntries([
  890. { id: 'agent-loop', name: '@deepseek-ai/dsh-agent-loop' },
  891. { id: 'tui', name: '@deepseek-ai/dsh-tui' },
  892. ], profile)).toBe(false)
  893. const spineAgentLoop = builtins.get(featureId('spine')).contribution(selection('spine', ['default']), profile).resources
  894. .find((resource): resource is CordisConfigEntryResource =>
  895. resource.kind === 'cordis-config-entry' && resource.entry.id === 'agent-loop')
  896. expect(spineAgentLoop?.validateConfig?.({ agents: 'main' })).toEqual(['agents must be an array'])
  897. expect(spineAgentLoop?.validateConfig?.({ agents: ['main'] })).toEqual(['agents must be empty'])
  898. expect(spineAgentLoop?.validateConfig?.({ agents: [] })).toEqual([])
  899. expect(builtins.get(featureId('provider')).defaultOptions(profile)).toEqual(['deepseek'])
  900. expect(() => builtins.get(featureId('provider')).contribution({
  901. id: featureId('provider'), options: ['custom'], values: { baseURL: 1 },
  902. }, profile)).toThrow('baseURL must be a string')
  903. const alternateModel = builtins.get(featureId('provider')).contribution({
  904. id: featureId('provider'), options: ['deepseek'],
  905. }, { ...profile, runtime: { model: 'other' } }).resources
  906. .find(resource => resource.kind === 'cordis-config-entry')
  907. expect(alternateModel?.entry.config?.models).toEqual(['other'])
  908. class RequiringSimple extends BadSimple {
  909. override readonly requires = [featureId('npm-dependency')]
  910. }
  911. expect(new RequiringSimple().requirements(selection('bad-simple', ['one']))).toEqual([{ id: 'npm-dependency' }])
  912. expect(builtins.get(featureId('bash')).defaultOptions(profile)).toEqual(['local'])
  913. })
  914. it('reports every inconsistent feature resource shape', () => {
  915. const feature = defineFeature({
  916. id: 'inspectable', summary: 'Inspectable', mode: 'single',
  917. baseResources: [{ kind: 'file-cordis-config-entry', id: 'base', path: 'pkg' }],
  918. options: [{
  919. id: 'one', label: 'One', default: true,
  920. secrets: [{ id: 'token', environment: 'TOKEN', message: 'Token', required: true }],
  921. resources: [
  922. { kind: 'file-cordis-config-entry', id: 'one', path: 'pkg', config: { value: 'x' } },
  923. { kind: 'npm-dependency', name: 'dep' },
  924. { kind: 'owned-file', path: 'owned.txt', text: 'owned' },
  925. ],
  926. }],
  927. })
  928. const view = (entries: readonly CordisConfigEntry[]): FeatureProjectView => ({
  929. profile,
  930. cordisConfigEntries: () => entries,
  931. packageManifest: () => ({}),
  932. hasDocument: () => false,
  933. readEnvironment: (path) => {
  934. if (path === '.env.example') throw new Error('bad env')
  935. return 'secret'
  936. },
  937. })
  938. expect(feature.inspect(view([])).state).toBe('absent')
  939. const inconsistent = feature.inspect(view([
  940. { id: 'one', name: 'pkg', config: { value: 1 } },
  941. { id: 'extra', name: 'pkg', disabled: true },
  942. ]))
  943. expect(inconsistent.state).toBe('inconsistent')
  944. expect(inconsistent.diagnostics.join('\n')).toContain('missing Cordis config entry base')
  945. expect(inconsistent.diagnostics.join('\n')).toContain('unexpected owned Cordis config entry extra')
  946. expect(inconsistent.diagnostics.join('\n')).toContain('missing package.json dependencies entry dep')
  947. expect(inconsistent.diagnostics.join('\n')).toContain('missing owned file owned.txt')
  948. expect(inconsistent.diagnostics.join('\n')).toContain('bad env')
  949. expect(inconsistent.diagnostics.join('\n')).toContain('mixed enabled states')
  950. expect(feature.inspect(view([{ id: 'unknown', name: 'pkg' }])).state).toBe('inconsistent')
  951. const ambiguous = defineFeature({
  952. id: 'ambiguous', summary: 'Ambiguous', mode: 'exclusive',
  953. options: [
  954. { id: 'one', label: 'One', default: true, resources: [], markers: [{ id: 'one', name: 'pkg' }] },
  955. { id: 'two', label: 'Two', resources: [], markers: [{ id: 'two', name: 'pkg' }] },
  956. ],
  957. })
  958. expect(ambiguous.inspect(view([{ id: 'one', name: 'pkg' }, { id: 'two', name: 'pkg' }])).state)
  959. .toBe('inconsistent')
  960. const noValidator = defineFeature({
  961. id: 'no-validator', summary: 'No validator', mode: 'single',
  962. options: [{
  963. id: 'one', label: 'One', default: true,
  964. resources: [{ kind: 'file-cordis-config-entry', id: 'plain', path: 'plain-package' }],
  965. }],
  966. })
  967. expect(noValidator.inspect(view([{ id: 'plain', name: 'plain-package' }])).state).toBe('enabled')
  968. const app = createBuiltinRegistry(profile).get(featureId('app'))
  969. expect(app.inspect(view([{ id: 'acp', name: '@deepseek-ai/dsh-acp' }])).state)
  970. .toBe('inconsistent')
  971. })
  972. })