documents.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { Writable } from 'node:stream'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import { CordisYamlFile, JsExpression } from '../src/documents/cordis-yaml-file.ts'
  7. import { EnvFile } from '../src/documents/env-file.ts'
  8. import { PackageJsonFile } from '../src/documents/package-json-file.ts'
  9. import { PnpmWorkspaceFile } from '../src/documents/pnpm-workspace-file.ts'
  10. import { TsConfigFile } from '../src/documents/tsconfig-file.ts'
  11. import { TextProjectFile, withTrailingNewline } from '../src/documents/project-file.ts'
  12. import { featureId, resourceKey } from '../src/ids.ts'
  13. import { LinkWorkspace } from '../src/package-managers/link-workspace.ts'
  14. import { LocalPluginBlueprint } from '../src/plugins/local-plugin-blueprint.ts'
  15. import {
  16. NpmPackageManager,
  17. NodeCommandRunner,
  18. PnpmPackageManager,
  19. YarnPackageManager,
  20. createPackageManager,
  21. inferPackageManagerName,
  22. probePackageManagerVersion,
  23. scrubEnvironment,
  24. type CommandRunner,
  25. } from '../src/package-managers/package-manager.ts'
  26. import { createBaselineProjectArtifacts } from '../src/templates/project-template.ts'
  27. import { loadHelperTemplate } from '../src/templates/template-assets.ts'
  28. import { TextTemplate } from '../src/templates/text-template.ts'
  29. import { resolveNpmDependency } from '../src/project/npm-dependency-policy.ts'
  30. const temporary: string[] = []
  31. afterEach(async () => {
  32. await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
  33. })
  34. describe('structured project documents', () => {
  35. it('normalizes trailing newlines and preserves managed package fields', () => {
  36. expect(withTrailingNewline('a\n\n')).toBe('a\n')
  37. const manifest = PackageJsonFile.parse('{"name":"demo","custom":1,"dependencies":{"z":"1"}}\n')
  38. manifest.setScript('start', 'node index.js')
  39. manifest.setNpmDependency('dependencies', 'a', '2')
  40. manifest.setNpmDependency('devDependencies', 'typescript', '3')
  41. manifest.removeNpmDependency('dependencies', 'z')
  42. manifest.addWorkspace('plugins/*')
  43. manifest.addWorkspace('plugins/*')
  44. manifest.setPackageManager('pnpm@10.0.0')
  45. manifest.setResolution('a', 'portal:../a')
  46. manifest.validate()
  47. expect(manifest.npmDependency('a')).toEqual({ section: 'dependencies', spec: '2' })
  48. expect(manifest.npmDependencyNames()).toEqual(['a', 'typescript'])
  49. expect(JSON.parse(manifest.serialize())).toMatchObject({
  50. name: 'demo',
  51. custom: 1,
  52. dependencies: { a: '2' },
  53. workspaces: ['plugins/*'],
  54. })
  55. manifest.setPackageManager(undefined)
  56. expect(manifest.value().packageManager).toBeUndefined()
  57. expect(() => PackageJsonFile.parse('[]')).toThrow('root must be an object')
  58. expect(() => PackageJsonFile.parse('{')).toThrow('invalid package.json')
  59. for (const [text, diagnostic] of [
  60. ['{}', 'name must be'],
  61. ['{"name":"x","scripts":null}', 'scripts must be an object'],
  62. ['{"name":"x","dependencies":[]}', 'dependencies must be an object'],
  63. ['{"name":"x","devDependencies":{"bad":""}}', 'must be a non-empty string'],
  64. ['{"name":"x","workspaces":"bad"}', 'workspaces must be an array'],
  65. ] as const) expect(() => { PackageJsonFile.parse(text).validate() }).toThrow(diagnostic)
  66. const minimal = PackageJsonFile.parse('{"name":"x"}')
  67. minimal.validate()
  68. expect(minimal.npmDependency('missing')).toBeUndefined()
  69. expect(minimal.serialize()).toBe('{\n "name": "x"\n}\n')
  70. expect(PackageJsonFile.parse('{"name":"x","devDependencies":{"a":"1"}}').npmDependencyNames()).toEqual(['a'])
  71. })
  72. it('round-trips Cordis comments and !!js while editing owned fields', () => {
  73. const created = CordisYamlFile.create().clone()
  74. created.addEntry({ id: 'created', name: 'created-package' }, `Uncomment this example.
  75. config:
  76. value: true`)
  77. expect(created.serialize()).toMatch(/^- id: created/m)
  78. expect(created.serialize()).toContain(' # Uncomment this example.\n # config:\n # value: true')
  79. expect(created.serialize()).not.toMatch(/^\[/)
  80. const flow = CordisYamlFile.parse('[{ id: flow, name: flow-package, config: { root: ./flow } }]\n')
  81. expect(flow.serialize()).toContain('- id: flow\n name: flow-package\n config:\n root: ./flow')
  82. expect(flow.serialize()).not.toContain('{')
  83. const document = CordisYamlFile.parse(`# lead
  84. - id: provider
  85. name: '@deepseek-ai/dsh-llm-deepseek'
  86. config:
  87. apiKey: !!js process.env.DEEPSEEK_API_KEY
  88. custom: keep
  89. `)
  90. const apiKey = document.entry('provider')?.config?.apiKey
  91. expect(apiKey).toBeInstanceOf(JsExpression)
  92. document.updateOwnedConfig('provider', ['apiKey'], { apiKey: new JsExpression('process.env.NEXT_KEY') })
  93. document.setDisabled('provider', true)
  94. document.addEntry({ id: 'tool', name: 'demo-tool' })
  95. document.validate()
  96. const text = document.serialize()
  97. expect(text).toContain('# lead')
  98. expect(text).toContain('!!js process.env.NEXT_KEY')
  99. expect(text).toContain('custom: keep')
  100. expect(document.removeEntry('tool')).toBe(true)
  101. expect(document.removeEntry('tool')).toBe(false)
  102. document.setDisabled('provider', false)
  103. expect(document.entry('provider')?.disabled).toBeUndefined()
  104. expect(() => { document.addEntry({ id: 'provider', name: 'duplicate' }) }).toThrow('already exists')
  105. expect(() => CordisYamlFile.parse('{}')).toThrow('root must be a sequence')
  106. })
  107. it('rejects malformed Cordis config entries and missing mutation targets', () => {
  108. expect(() => new JsExpression(' ')).toThrow('must not be empty')
  109. expect(() => CordisYamlFile.parse('[')).toThrow('invalid cordis.yml')
  110. for (const [text, diagnostic] of [
  111. ['- nope\n', 'every entry must be a mapping'],
  112. ['- name: pkg\n', 'id must be'],
  113. ['- id: x\n', 'name must be'],
  114. ['- id: x\n name: pkg\n config: nope\n', 'config must be'],
  115. ['- id: x\n name: pkg\n disabled: nope\n', 'disabled must be'],
  116. ] as const) expect(() => CordisYamlFile.parse(text).entries()).toThrow(diagnostic)
  117. const duplicate = CordisYamlFile.parse('- id: x\n name: one\n- id: x\n name: two\n')
  118. expect(() => { duplicate.validate() }).toThrow('duplicate Cordis config entry id')
  119. const document = CordisYamlFile.create()
  120. expect(() => { document.setDisabled('missing', true) }).toThrow('does not exist')
  121. expect(() => { document.updateOwnedConfig('missing', [], {}) }).toThrow('does not exist')
  122. document.addEntry({ id: 'plain', name: 'pkg' })
  123. document.updateOwnedConfig('plain', [], { value: 1 })
  124. expect(document.entry('plain')?.config).toEqual({ value: 1 })
  125. document.updateOwnedConfig('plain', ['value'], {})
  126. expect(document.entry('plain')?.config).toBeUndefined()
  127. const scalar = CordisYamlFile.parse('- id: plain\n name: pkg\n config: value\n')
  128. expect(() => { scalar.updateOwnedConfig('plain', [], {}) }).toThrow('config is not a mapping')
  129. expect(() => { CordisYamlFile.parse('- nope\n').setDisabled('missing', true) }).toThrow('does not exist')
  130. })
  131. it('keeps .env append-only while managing .env.example strictly', () => {
  132. const document = EnvFile.parse('.env', '# keep\nA=1\nexport B=2\n')
  133. expect(document.get('A')).toBe('1')
  134. expect(document.get('B')).toBe('2')
  135. expect(document.append('A', 'next')).toBe(false)
  136. expect(document.append('C', '', 'Required')).toBe(true)
  137. expect(document.serialize()).toBe('# keep\nA=1\nexport B=2\n# Required\nC=\n')
  138. expect(() => { document.append('bad-name', 'x') }).toThrow('invalid environment variable')
  139. expect(() => { document.append('D', '', '') }).toThrow('non-empty line')
  140. expect(() => { document.append('D', '', 'bad\ncomment') }).toThrow('non-empty line')
  141. expect(() => { document.set('A', 'next') }).toThrow('.env is append-only')
  142. expect(() => { document.remove('A') }).toThrow('.env is append-only')
  143. const duplicateEnv = EnvFile.parse('.env', 'A=1\nA=2\n')
  144. expect(duplicateEnv.get('A')).toBe('2')
  145. expect(duplicateEnv.append('A', 'next')).toBe(false)
  146. expect(() => { duplicateEnv.validate() }).not.toThrow()
  147. const example = EnvFile.parse('.env.example', '# keep\nA=1\n')
  148. example.set('A', 'next')
  149. example.set('C', '')
  150. example.remove('C')
  151. expect(example.serialize()).toBe('# keep\nA=next\n')
  152. expect(() => { example.append('C', '') }).toThrow('.env.example is SDK-managed')
  153. expect(() => { example.set('bad-name', 'x') }).toThrow('invalid environment variable')
  154. const duplicate = EnvFile.parse('.env.example', 'A=1\nA=2\n')
  155. expect(() => { duplicate.validate() }).toThrow('duplicate variable A')
  156. expect(() => duplicate.get('A')).toThrow('duplicate variable A')
  157. expect(() => { duplicate.set('A', 'next') }).toThrow('duplicate variable A')
  158. expect(() => { duplicate.remove('A') }).toThrow('duplicate variable A')
  159. example.remove('missing')
  160. expect(document.get('missing')).toBeUndefined()
  161. expect(EnvFile.parse('.env', '').clone().serialize()).toBe('\n')
  162. })
  163. it('patches JSONC references without erasing comments', () => {
  164. const document = TsConfigFile.parse(`{
  165. // retained
  166. "references": [{ "path": "./plugins/a" }]
  167. }`)
  168. document.addReference('./plugins/a')
  169. document.addReference('./plugins/b')
  170. document.validate()
  171. expect(document.serialize()).toContain('// retained')
  172. expect(document.serialize()).toContain('./plugins/b')
  173. expect(() => TsConfigFile.parse('{')).toThrow('valid JSONC object')
  174. const malformed = TsConfigFile.parse('{"references": {}}')
  175. expect(() => { malformed.addReference('./plugins/x') }).toThrow('must be an array')
  176. const badItem = TsConfigFile.parse('{"references":[null]}')
  177. expect(() => { badItem.addReference('./plugins/x') }).toThrow('must contain')
  178. expect(() => { badItem.validate() }).toThrow('must contain')
  179. const created = TsConfigFile.create()
  180. created.validate()
  181. expect(created.clone().serialize()).toContain('"references": []')
  182. TsConfigFile.parse('{}').validate()
  183. const noReferences = TsConfigFile.parse('{}')
  184. noReferences.addReference('./plugin')
  185. expect(noReferences.serialize()).toContain('./plugin')
  186. expect(() => { TsConfigFile.parse('{"references":{}}').validate() }).toThrow('must be an array')
  187. })
  188. it('creates and parses pnpm workspace policy', () => {
  189. const document = PnpmWorkspaceFile.create()
  190. document.addPackage('plugins/*')
  191. document.addPackage('plugins/*')
  192. document.disableAutoInstallPeers()
  193. document.validate()
  194. expect(document.serialize()).toContain('autoInstallPeers: false')
  195. const parsed = PnpmWorkspaceFile.parse(document.serialize())
  196. expect(parsed.clone().serialize()).toBe(document.serialize())
  197. expect(() => PnpmWorkspaceFile.parse('packages: [')).toThrow('invalid pnpm-workspace.yaml')
  198. expect(() => PnpmWorkspaceFile.parse('packages: nope')).toThrow('packages must be an array')
  199. expect(() => PnpmWorkspaceFile.parse('packages: [{}]')).toThrow('packages must be an array')
  200. expect(() => PnpmWorkspaceFile.parse('packages: [1]')).toThrow('packages must be an array')
  201. expect(() => PnpmWorkspaceFile.parse('[]')).toThrow('root must be an object')
  202. expect(() => PnpmWorkspaceFile.parse('packages: []\nautoInstallPeers: nope')).toThrow('must be boolean')
  203. const invalid = PnpmWorkspaceFile.create()
  204. invalid.addPackage(' ')
  205. expect(() => { invalid.validate() }).toThrow('must not be empty')
  206. expect(PnpmWorkspaceFile.parse('packages: []\n').serialize()).not.toContain('autoInstallPeers')
  207. const preserved = PnpmWorkspaceFile.parse(`# keep workspace settings
  208. packages:
  209. - apps/*
  210. catalog:
  211. react: ^19.0.0
  212. overrides:
  213. legacy: modern
  214. `)
  215. preserved.disableAutoInstallPeers()
  216. const preservedText = preserved.clone().serialize()
  217. expect(preservedText).toContain('# keep workspace settings')
  218. expect(preservedText).toContain('catalog:\n react: ^19.0.0')
  219. expect(preservedText).toContain('overrides:\n legacy: modern')
  220. expect(preservedText).toContain('autoInstallPeers: false')
  221. })
  222. it('renders strict complete-file templates without escaping code text', () => {
  223. const template = new TextTemplate<{ value: string }>('value={{value}} missing={{missing}}')
  224. expect(() => template.render({ value: '<code>' })).toThrow()
  225. const valid = new TextTemplate<{ value: string }>('value={{value}}')
  226. expect(valid.render({ value: '<code>' })).toBe('value=<code>')
  227. expect(new TextTemplate<Record<string, never>>('\\{{model}}').render({})).toBe('{{model}}')
  228. expect(() => new TextProjectFile('/absolute', 'x')).toThrow('stay inside')
  229. expect(() => new TextProjectFile('../outside', 'x')).toThrow('stay inside')
  230. expect(new TextProjectFile('inside', 'x').clone().serialize()).toBe('x\n')
  231. expect(() => featureId('Bad Id')).toThrow('invalid feature id')
  232. expect(() => resourceKey('')).toThrow('must not be empty')
  233. expect(() => loadHelperTemplate('../bad.tpl')).toThrow('must not contain a directory')
  234. expect(createBaselineProjectArtifacts({
  235. name: 'demo', description: 'demo', releaseVersion: '0.0.1', model: 'model', modelLiteral: '"model"', packageManager: 'yarn',
  236. isAcp: false, isEmbed: true,
  237. installArgs: 'install', buildArgs: 'build',
  238. }).map(document => document.relativePath)).toContain('.yarnrc.yml')
  239. expect(() => new LocalPluginBlueprint('---', 'plugin')).toThrow('invalid local plugin name')
  240. expect(new LocalPluginBlueprint('tool', 'tool').packageName('@scope/project')).toBe('@scope/project-tool')
  241. expect(new LocalPluginBlueprint('tool', 'tool').packageName('@invalid')).toBe('@invalid-tool')
  242. })
  243. })
  244. describe('package manager strategies', () => {
  245. it('owns workspace fields, execution commands, and supported version floors', () => {
  246. const npm = new NpmPackageManager('10.1.0')
  247. const pnpm = new PnpmPackageManager('10.2.0')
  248. const yarn = new YarnPackageManager('4.0.0')
  249. for (const manager of [npm, pnpm, yarn]) manager.validateVersion()
  250. expect(npm.localPluginSpec()).toBe('*')
  251. expect(npm.linkSpec('../x')).toBe('file:../x')
  252. expect(npm.configureWorkspace(PackageJsonFile.create('{"name":"demo"}'))).toEqual([])
  253. const pnpmManifest = PackageJsonFile.create('{"name":"demo"}')
  254. expect(pnpm.configureWorkspace(pnpmManifest)[0]).toBeInstanceOf(PnpmWorkspaceFile)
  255. expect(pnpm.localPluginSpec()).toBe('workspace:*')
  256. expect(pnpm.linkSpec('../x')).toBe('link:../x')
  257. const yarnManifest = PackageJsonFile.create('{"name":"demo"}')
  258. expect(yarn.configureWorkspace(yarnManifest)).toEqual([])
  259. expect(yarn.localPluginSpec()).toBe('workspace:*')
  260. expect(yarn.linkSpec('../x')).toBe('portal:../x')
  261. expect(yarn.buildCommand()).toEqual(['build'])
  262. expect(npm.installCommand()).toEqual(['install'])
  263. expect(npm.buildCommand()).toEqual(['run', 'build'])
  264. expect(() => createPackageManager('npm', '9.0.0')).toThrow('npm >=10')
  265. expect(() => createPackageManager('pnpm', '9.0.0')).toThrow('pnpm >=10')
  266. expect(() => createPackageManager('yarn', '1.22.0')).toThrow('Yarn >=2')
  267. expect(inferPackageManagerName(undefined, 'pnpm/10.0.0 node/v24')).toBe('pnpm')
  268. expect(inferPackageManagerName(undefined, 'unknown/1')).toBeUndefined()
  269. expect(inferPackageManagerName('yarn', undefined)).toBe('yarn')
  270. expect(() => createPackageManager('npm', 'invalid')).toThrow('invalid package manager version')
  271. expect(resolveNpmDependency('cordis', 'devDependencies', '0.0.1')).toEqual({
  272. section: 'devDependencies', spec: '^4.0.0-rc.7',
  273. })
  274. expect(resolveNpmDependency('@cordisjs/plugin-hmr', 'dependencies', '0.0.1').spec).toBe('^1.0.15')
  275. expect(resolveNpmDependency('tsdown', 'devDependencies', '0.0.1').spec).toBe('0.22.2')
  276. expect(resolveNpmDependency('@deepseek-ai/dsh-tools', 'dependencies', '1.2.3').spec).toBe('^1.2.3')
  277. expect(() => resolveNpmDependency('unknown', 'dependencies', '0.0.1')).toThrow('no generated-project')
  278. })
  279. it('checks install/build process outcomes and scrubs credential-shaped names', async () => {
  280. const calls: string[][] = []
  281. const runner: CommandRunner = {
  282. run: async (command, args) => {
  283. calls.push([command, ...args])
  284. return { exitCode: 0, signal: null }
  285. },
  286. }
  287. const npm = new NpmPackageManager('10.0.0')
  288. await npm.install('/tmp', runner)
  289. await npm.build('/tmp', runner)
  290. expect(calls).toEqual([['npm', 'install'], ['npm', 'run', 'build']])
  291. await npm.add('some-pkg@1.0.0', '/tmp', runner)
  292. const pnpm = createPackageManager('pnpm', '10.0.0')
  293. await pnpm.add('github:o/r#sha', '/tmp', runner)
  294. expect(calls).toContainEqual(['npm', 'install', 'some-pkg@1.0.0'])
  295. expect(calls).toContainEqual(['pnpm', 'add', 'github:o/r#sha'])
  296. const failed: CommandRunner = { run: async () => ({ exitCode: 2, signal: null }) }
  297. await expect(npm.install('/tmp', failed)).rejects.toThrow('exited with code 2')
  298. const killed: CommandRunner = { run: async () => ({ exitCode: null, signal: 'SIGTERM' }) }
  299. await expect(npm.build('/tmp', killed)).rejects.toThrow('killed by SIGTERM')
  300. expect(scrubEnvironment({
  301. PATH: '/bin',
  302. API_KEY: 'secret',
  303. DB_PASSWORD: 'secret',
  304. TOKEN_VALUE: 'secret',
  305. })).toEqual({ PATH: '/bin' })
  306. })
  307. it('probes versions and runs real child-process boundaries', async () => {
  308. await expect(probePackageManagerVersion('npm', process.cwd())).resolves.toMatch(/^\d+/)
  309. await expect(probePackageManagerVersion('npm', '/missing/dsh-cwd')).rejects.toThrow('cannot run npm --version')
  310. const root = await mkdtemp(join(tmpdir(), 'dsh-empty-version-'))
  311. temporary.push(root)
  312. const executable = join(root, 'npm')
  313. await writeFile(executable, '#!/bin/sh\nexit 0\n')
  314. await chmod(executable, 0o755)
  315. const before = process.env.PATH
  316. process.env.PATH = root
  317. await expect(probePackageManagerVersion('npm', root)).rejects.toThrow('empty version output')
  318. process.env.PATH = before
  319. const runner = new NodeCommandRunner()
  320. await expect(runner.run(process.execPath, ['-e', ''], root)).resolves.toEqual({ exitCode: 0, signal: null })
  321. await expect(runner.run('missing-dsh-command', [], root)).rejects.toThrow()
  322. let redirected = ''
  323. const output = new Writable({
  324. write(chunk, _encoding, callback) { redirected += String(chunk); callback() },
  325. })
  326. const redirecting = new NodeCommandRunner(output)
  327. await expect(redirecting.run(
  328. process.execPath,
  329. ['-e', 'process.stdout.write("child-out"); process.stderr.write("child-err")'],
  330. root,
  331. )).resolves.toEqual({ exitCode: 0, signal: null })
  332. expect(redirected).toContain('child-out')
  333. expect(redirected).toContain('child-err')
  334. await expect(redirecting.run('missing-dsh-command', [], root)).rejects.toThrow()
  335. })
  336. it('discovers and rewrites a repository-local NPM dependency closure', async () => {
  337. const root = await mkdtemp(join(tmpdir(), 'dsh-link-workspace-'))
  338. temporary.push(root)
  339. await mkdir(join(root, 'vendor', 'cordis'), { recursive: true })
  340. await mkdir(join(root, 'packages', 'sdk', 'scripts'), { recursive: true })
  341. await mkdir(join(root, 'packages', 'sdk', 'helper'), { recursive: true })
  342. await writeFile(join(root, 'vendor', 'cordis', 'package.json'), JSON.stringify({ name: 'cordis' }))
  343. await writeFile(join(root, 'packages', 'sdk', 'helper', 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-helper' }))
  344. await writeFile(join(root, 'packages', 'sdk', 'scripts', 'package.json'), JSON.stringify({
  345. name: '@deepseek-ai/dsh-scripts', dependencies: { '@deepseek-ai/dsh-helper': '^0.0.1' }, peerDependencies: { cordis: '^4' },
  346. }))
  347. const workspace = await LinkWorkspace.open(root)
  348. expect(workspace.closure(['@deepseek-ai/dsh-scripts'])).toEqual([
  349. '@deepseek-ai/dsh-helper', '@deepseek-ai/dsh-scripts', 'cordis',
  350. ])
  351. const manifest = PackageJsonFile.create('{"name":"consumer","description":"test"}')
  352. manifest.setNpmDependency('dependencies', '@deepseek-ai/dsh-scripts', '^0.0.1')
  353. const pnpmWorkspace = PnpmWorkspaceFile.create()
  354. workspace.apply(join(root, 'consumer'), manifest, new PnpmPackageManager('10.0.0'), [pnpmWorkspace])
  355. expect(manifest.npmDependency('cordis')?.spec).toMatch(/^link:/)
  356. expect(pnpmWorkspace.serialize()).toContain('autoInstallPeers: false')
  357. expect(workspace.packageDirectory('cordis')).toBe(join(root, 'vendor', 'cordis'))
  358. expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('cordis')
  359. expect(workspace.packageDirectory('missing')).toBeUndefined()
  360. const yarnManifest = PackageJsonFile.create('{"name":"consumer"}')
  361. yarnManifest.setNpmDependency('dependencies', '@deepseek-ai/dsh-scripts', '^0.0.1')
  362. workspace.apply(join(root, 'consumer-yarn'), yarnManifest, new YarnPackageManager('4.0.0'), [])
  363. expect(yarnManifest.value().resolutions).toBeDefined()
  364. const pnpmManifest = PackageJsonFile.create('{"name":"consumer"}')
  365. pnpmManifest.setNpmDependency('dependencies', '@deepseek-ai/dsh-scripts', '^0.0.1')
  366. expect(() => { workspace.apply(join(root, 'consumer-pnpm'), pnpmManifest, new PnpmPackageManager('10.0.0'), []) })
  367. .toThrow('requires pnpm-workspace.yaml')
  368. })
  369. it('rejects malformed linked repositories', async () => {
  370. const missing = await mkdtemp(join(tmpdir(), 'dsh-link-missing-'))
  371. temporary.push(missing)
  372. await mkdir(join(missing, 'vendor'), { recursive: true })
  373. await mkdir(join(missing, 'packages'), { recursive: true })
  374. await expect(LinkWorkspace.open(missing)).rejects.toThrow('not a DeepSeek Harness repository root')
  375. const unreadable = await mkdtemp(join(tmpdir(), 'dsh-link-unreadable-'))
  376. temporary.push(unreadable)
  377. await mkdir(join(unreadable, 'vendor', 'bad'), { recursive: true })
  378. await mkdir(join(unreadable, 'packages'), { recursive: true })
  379. await expect(LinkWorkspace.open(unreadable)).rejects.toThrow('cannot read linked package')
  380. const unnamed = await mkdtemp(join(tmpdir(), 'dsh-link-unnamed-'))
  381. temporary.push(unnamed)
  382. await mkdir(join(unnamed, 'vendor', 'unnamed'), { recursive: true })
  383. await mkdir(join(unnamed, 'packages'), { recursive: true })
  384. await writeFile(join(unnamed, 'vendor', 'unnamed', 'package.json'), '{}')
  385. await expect(LinkWorkspace.open(unnamed)).rejects.toThrow('not a DeepSeek Harness repository root')
  386. const duplicate = await mkdtemp(join(tmpdir(), 'dsh-link-duplicate-'))
  387. temporary.push(duplicate)
  388. await mkdir(join(duplicate, 'vendor', 'one'), { recursive: true })
  389. await mkdir(join(duplicate, 'packages', 'group', 'two'), { recursive: true })
  390. await writeFile(join(duplicate, 'vendor', 'one', 'package.json'), '{"name":"duplicate"}')
  391. await writeFile(join(duplicate, 'packages', 'group', 'two', 'package.json'), '{"name":"duplicate"}')
  392. await expect(LinkWorkspace.open(duplicate)).rejects.toThrow('duplicate linked package name')
  393. })
  394. })