@shazhou/proman-core 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +18 -0
  3. package/dist/commands/bump.d.ts +13 -0
  4. package/dist/commands/bump.d.ts.map +1 -0
  5. package/dist/commands/bump.js +115 -0
  6. package/dist/commands/deploy.d.ts +9 -0
  7. package/dist/commands/deploy.d.ts.map +1 -0
  8. package/dist/commands/deploy.js +42 -0
  9. package/dist/commands/dev.d.ts +15 -0
  10. package/dist/commands/dev.d.ts.map +1 -0
  11. package/dist/commands/dev.js +175 -0
  12. package/dist/commands/index.d.ts +7 -0
  13. package/dist/commands/index.d.ts.map +1 -0
  14. package/dist/commands/index.js +7 -0
  15. package/dist/commands/init.d.ts +5 -0
  16. package/dist/commands/init.d.ts.map +1 -0
  17. package/dist/commands/init.js +262 -0
  18. package/dist/commands/link.d.ts +19 -0
  19. package/dist/commands/link.d.ts.map +1 -0
  20. package/dist/commands/link.js +155 -0
  21. package/dist/commands/publish.d.ts +18 -0
  22. package/dist/commands/publish.d.ts.map +1 -0
  23. package/dist/commands/publish.js +125 -0
  24. package/dist/config/index.d.ts +4 -0
  25. package/dist/config/index.d.ts.map +1 -0
  26. package/dist/config/index.js +2 -0
  27. package/dist/config/load-config.d.ts +6 -0
  28. package/dist/config/load-config.d.ts.map +1 -0
  29. package/dist/config/load-config.js +29 -0
  30. package/dist/config/types.d.ts +17 -0
  31. package/dist/config/types.d.ts.map +1 -0
  32. package/dist/config/types.js +1 -0
  33. package/dist/config/validate-config.d.ts +7 -0
  34. package/dist/config/validate-config.d.ts.map +1 -0
  35. package/dist/config/validate-config.js +72 -0
  36. package/dist/index.d.ts +6 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +6 -0
  39. package/dist/utils/changeset.d.ts +16 -0
  40. package/dist/utils/changeset.d.ts.map +1 -0
  41. package/dist/utils/changeset.js +80 -0
  42. package/dist/utils/fingerprint.d.ts +38 -0
  43. package/dist/utils/fingerprint.d.ts.map +1 -0
  44. package/dist/utils/fingerprint.js +182 -0
  45. package/dist/utils/git.d.ts +23 -0
  46. package/dist/utils/git.d.ts.map +1 -0
  47. package/dist/utils/git.js +105 -0
  48. package/dist/utils/index.d.ts +8 -0
  49. package/dist/utils/index.d.ts.map +1 -0
  50. package/dist/utils/index.js +8 -0
  51. package/dist/utils/npm.d.ts +30 -0
  52. package/dist/utils/npm.d.ts.map +1 -0
  53. package/dist/utils/npm.js +85 -0
  54. package/dist/utils/smoke-test.d.ts +7 -0
  55. package/dist/utils/smoke-test.d.ts.map +1 -0
  56. package/dist/utils/smoke-test.js +59 -0
  57. package/dist/utils/version.d.ts +5 -0
  58. package/dist/utils/version.d.ts.map +1 -0
  59. package/dist/utils/version.js +36 -0
  60. package/dist/utils/workspace.d.ts +21 -0
  61. package/dist/utils/workspace.d.ts.map +1 -0
  62. package/dist/utils/workspace.js +73 -0
  63. package/package.json +45 -0
  64. package/src/commands/bump.ts +131 -0
  65. package/src/commands/deploy.ts +52 -0
  66. package/src/commands/dev.ts +214 -0
  67. package/src/commands/index.ts +7 -0
  68. package/src/commands/init.integration.test.ts +59 -0
  69. package/src/commands/init.test.ts +179 -0
  70. package/src/commands/init.ts +290 -0
  71. package/src/commands/link.ts +195 -0
  72. package/src/commands/publish.ts +168 -0
  73. package/src/config/index.ts +8 -0
  74. package/src/config/load-config.ts +33 -0
  75. package/src/config/types.ts +19 -0
  76. package/src/config/validate-config.ts +81 -0
  77. package/src/index.ts +29 -0
  78. package/src/utils/changeset.ts +98 -0
  79. package/src/utils/fingerprint.ts +199 -0
  80. package/src/utils/git.ts +119 -0
  81. package/src/utils/index.ts +8 -0
  82. package/src/utils/npm.ts +110 -0
  83. package/src/utils/smoke-test.ts +79 -0
  84. package/src/utils/version.ts +41 -0
  85. package/src/utils/workspace.ts +94 -0
  86. package/tests/build-fingerprint-integration.test.ts +403 -0
  87. package/tests/bump.test.ts +261 -0
  88. package/tests/changeset.test.ts +147 -0
  89. package/tests/deploy.test.ts +98 -0
  90. package/tests/dev.test.ts +756 -0
  91. package/tests/fingerprint.test.ts +316 -0
  92. package/tests/fixtures/api-only/packages/api/.gitkeep +0 -0
  93. package/tests/fixtures/api-only/proman.yaml +4 -0
  94. package/tests/fixtures/bad-packages/proman.yaml +1 -0
  95. package/tests/fixtures/bun-project/packages/a/.gitkeep +0 -0
  96. package/tests/fixtures/bun-project/proman.yaml +4 -0
  97. package/tests/fixtures/defaults/proman.yaml +3 -0
  98. package/tests/fixtures/no-deployable/packages/core/.gitkeep +0 -0
  99. package/tests/fixtures/no-deployable/packages/mycli/.gitkeep +0 -0
  100. package/tests/fixtures/no-deployable/proman.yaml +7 -0
  101. package/tests/fixtures/node-runtime/packages/a/package.json +5 -0
  102. package/tests/fixtures/node-runtime/proman.yaml +3 -0
  103. package/tests/fixtures/pnpm-project/packages/a/package.json +1 -0
  104. package/tests/fixtures/pnpm-project/pnpm-lock.yaml +0 -0
  105. package/tests/fixtures/pnpm-project/proman.yaml +3 -0
  106. package/tests/fixtures/typed/packages/api/.gitkeep +0 -0
  107. package/tests/fixtures/typed/packages/core/.gitkeep +0 -0
  108. package/tests/fixtures/typed/packages/dashboard/.gitkeep +0 -0
  109. package/tests/fixtures/typed/packages/mycli/.gitkeep +0 -0
  110. package/tests/fixtures/typed/proman.yaml +13 -0
  111. package/tests/fixtures/valid/packages/cli/package.json +5 -0
  112. package/tests/fixtures/valid/packages/core/package.json +5 -0
  113. package/tests/fixtures/valid/packages/fs/package.json +5 -0
  114. package/tests/fixtures/valid/proman.yaml +13 -0
  115. package/tests/fixtures/webui-only/packages/dashboard/.gitkeep +0 -0
  116. package/tests/fixtures/webui-only/proman.yaml +4 -0
  117. package/tests/link.test.ts +419 -0
  118. package/tests/load-config.test.ts +44 -0
  119. package/tests/npm.test.ts +199 -0
  120. package/tests/publish.test.ts +599 -0
  121. package/tests/smoke-test.test.ts +211 -0
  122. package/tests/validate-config.test.ts +67 -0
  123. package/tests/version.test.ts +86 -0
  124. package/tests/workflow-schema.test.ts +72 -0
  125. package/tests/workspace.test.ts +160 -0
  126. package/tsconfig.build.json +14 -0
  127. package/tsconfig.json +8 -0
  128. package/tsconfig.tsbuildinfo +1 -0
  129. package/vitest.config.ts +8 -0
@@ -0,0 +1,419 @@
1
+ import { mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join, resolve } from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest'
5
+ import { link, linkStatus, unlink } from '../src/commands/link.ts'
6
+ import type { SpawnFn } from '../src/utils/npm.ts'
7
+
8
+ type Call = { argv: string[]; cwd: string }
9
+
10
+ function makeSpawn(code = 0, stdout = '', stderr = '') {
11
+ const calls: Call[] = []
12
+ const fn: SpawnFn = async (argv, cwd) => {
13
+ calls.push({ argv, cwd })
14
+ return { code, stdout, stderr }
15
+ }
16
+ return { spawn: fn, calls }
17
+ }
18
+
19
+ describe('link command - provider mode (without args)', () => {
20
+ let tmpDir: string
21
+
22
+ beforeEach(() => {
23
+ tmpDir = resolve(
24
+ tmpdir(),
25
+ `proman-link-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
26
+ )
27
+ mkdirSync(tmpDir, { recursive: true })
28
+ })
29
+
30
+ afterEach(() => {
31
+ rmSync(tmpDir, { recursive: true, force: true })
32
+ })
33
+
34
+ test('Test 1: links current package globally with build artifacts', async () => {
35
+ // Given: package directory with dist/ folder
36
+ const pkgDir = tmpDir
37
+ mkdirSync(join(pkgDir, 'dist'), { recursive: true })
38
+ writeFileSync(join(pkgDir, 'dist/index.js'), 'export const x = 1')
39
+ writeFileSync(
40
+ join(pkgDir, 'package.json'),
41
+ JSON.stringify({ name: '@scope/test-pkg', version: '1.0.0' }),
42
+ )
43
+
44
+ const { spawn, calls } = makeSpawn()
45
+
46
+ // When: user runs proman link
47
+ await link({ cwd: pkgDir, spawn })
48
+
49
+ // Then: pnpm link --global is called
50
+ expect(calls).toHaveLength(1)
51
+ expect(calls[0]?.argv).toEqual(['pnpm', 'link', '--global'])
52
+ expect(calls[0]?.cwd).toBe(pkgDir)
53
+ })
54
+
55
+ test('Test 2: throws error when no build artifacts exist', async () => {
56
+ // Given: package directory without dist/ folder
57
+ const pkgDir = tmpDir
58
+ writeFileSync(
59
+ join(pkgDir, 'package.json'),
60
+ JSON.stringify({ name: '@scope/test-pkg', version: '1.0.0' }),
61
+ )
62
+
63
+ const { spawn } = makeSpawn()
64
+
65
+ // When/Then: should throw error about missing build
66
+ await expect(link({ cwd: pkgDir, spawn })).rejects.toThrow('No dist/ folder in')
67
+ })
68
+
69
+ test('Test 3: throws error when not in package directory', async () => {
70
+ // Given: directory without package.json
71
+ const pkgDir = tmpDir
72
+
73
+ const { spawn } = makeSpawn()
74
+
75
+ // When/Then: should throw error
76
+ await expect(link({ cwd: pkgDir, spawn })).rejects.toThrow('Missing package.json in')
77
+ })
78
+
79
+ test('Test 4: throws error when package.json has no name field', async () => {
80
+ // Given: package.json without name
81
+ const pkgDir = tmpDir
82
+ mkdirSync(join(pkgDir, 'dist'), { recursive: true })
83
+ writeFileSync(join(pkgDir, 'package.json'), JSON.stringify({ version: '1.0.0' }))
84
+
85
+ const { spawn } = makeSpawn()
86
+
87
+ // When/Then: should throw error
88
+ await expect(link({ cwd: pkgDir, spawn })).rejects.toThrow('missing a "name" field')
89
+ })
90
+ })
91
+
92
+ describe('link command - consumer mode (with package arg)', () => {
93
+ let tmpDir: string
94
+
95
+ beforeEach(() => {
96
+ tmpDir = resolve(
97
+ tmpdir(),
98
+ `proman-link-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
99
+ )
100
+ mkdirSync(tmpDir, { recursive: true })
101
+ })
102
+
103
+ afterEach(() => {
104
+ rmSync(tmpDir, { recursive: true, force: true })
105
+ })
106
+
107
+ test('Test 4: links specific package from global registry', async () => {
108
+ // Given: consumer project with package in dependencies
109
+ const consumerDir = tmpDir
110
+ writeFileSync(
111
+ join(consumerDir, 'package.json'),
112
+ JSON.stringify({
113
+ name: '@consumer/app',
114
+ version: '1.0.0',
115
+ dependencies: { '@scope/some-package': '^1.0.0' },
116
+ }),
117
+ )
118
+
119
+ const { spawn, calls } = makeSpawn()
120
+
121
+ // When: user runs proman link @scope/some-package
122
+ await link({ cwd: consumerDir, packageName: '@scope/some-package', spawn })
123
+
124
+ // Then: pnpm link --global @scope/some-package is called
125
+ expect(calls).toHaveLength(1)
126
+ expect(calls[0]?.argv).toEqual(['pnpm', 'link', '--global', '@scope/some-package'])
127
+ expect(calls[0]?.cwd).toBe(consumerDir)
128
+ })
129
+
130
+ test('Test 5: throws error when package not in dependencies', async () => {
131
+ // Given: consumer project without the package in deps
132
+ const consumerDir = tmpDir
133
+ writeFileSync(
134
+ join(consumerDir, 'package.json'),
135
+ JSON.stringify({
136
+ name: '@consumer/app',
137
+ version: '1.0.0',
138
+ dependencies: {},
139
+ }),
140
+ )
141
+
142
+ const { spawn } = makeSpawn()
143
+
144
+ // When/Then: should throw error
145
+ await expect(
146
+ link({ cwd: consumerDir, packageName: '@scope/unknown-package', spawn }),
147
+ ).rejects.toThrow('not found in dependencies or devDependencies')
148
+ })
149
+
150
+ test('Test 5b: allows linking package in devDependencies', async () => {
151
+ // Given: consumer project with package in devDependencies
152
+ const consumerDir = tmpDir
153
+ writeFileSync(
154
+ join(consumerDir, 'package.json'),
155
+ JSON.stringify({
156
+ name: '@consumer/app',
157
+ version: '1.0.0',
158
+ devDependencies: { '@scope/dev-package': '^1.0.0' },
159
+ }),
160
+ )
161
+
162
+ const { spawn, calls } = makeSpawn()
163
+
164
+ // When: user runs proman link @scope/dev-package
165
+ await link({ cwd: consumerDir, packageName: '@scope/dev-package', spawn })
166
+
167
+ // Then: command succeeds
168
+ expect(calls).toHaveLength(1)
169
+ expect(calls[0]?.argv).toEqual(['pnpm', 'link', '--global', '@scope/dev-package'])
170
+ })
171
+ })
172
+
173
+ describe('linkStatus command', () => {
174
+ let tmpDir: string
175
+
176
+ beforeEach(() => {
177
+ tmpDir = resolve(
178
+ tmpdir(),
179
+ `proman-link-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
180
+ )
181
+ mkdirSync(tmpDir, { recursive: true })
182
+ })
183
+
184
+ afterEach(() => {
185
+ rmSync(tmpDir, { recursive: true, force: true })
186
+ })
187
+
188
+ test('Test 6: shows linked packages with source paths', async () => {
189
+ // Given: project with linked packages
190
+ const consumerDir = tmpDir
191
+ writeFileSync(
192
+ join(consumerDir, 'package.json'),
193
+ JSON.stringify({ name: '@consumer/app', version: '1.0.0' }),
194
+ )
195
+
196
+ // Create node_modules with symlinks
197
+ const nodeModulesDir = join(consumerDir, 'node_modules')
198
+ mkdirSync(join(nodeModulesDir, '@scope'), { recursive: true })
199
+
200
+ const targetA = join(tmpdir(), 'monorepo/packages/a')
201
+ const targetB = join(tmpdir(), 'monorepo/packages/b')
202
+ mkdirSync(targetA, { recursive: true })
203
+ mkdirSync(targetB, { recursive: true })
204
+ writeFileSync(join(targetA, 'package.json'), JSON.stringify({ name: '@scope/package-a' }))
205
+ writeFileSync(join(targetB, 'package.json'), JSON.stringify({ name: '@scope/package-b' }))
206
+
207
+ symlinkSync(targetA, join(nodeModulesDir, '@scope', 'package-a'), 'dir')
208
+ symlinkSync(targetB, join(nodeModulesDir, '@scope', 'package-b'), 'dir')
209
+
210
+ const { spawn } = makeSpawn()
211
+
212
+ // When: user runs proman link --status
213
+ const result = await linkStatus({ cwd: consumerDir, spawn })
214
+
215
+ // Then: output shows both packages
216
+ expect(result).toContain('@scope/package-a')
217
+ expect(result).toContain('@scope/package-b')
218
+ expect(result).toContain(targetA)
219
+ expect(result).toContain(targetB)
220
+ })
221
+
222
+ test('Test 7: shows no linked packages message', async () => {
223
+ // Given: project with no linked packages
224
+ const consumerDir = tmpDir
225
+ writeFileSync(
226
+ join(consumerDir, 'package.json'),
227
+ JSON.stringify({ name: '@consumer/app', version: '1.0.0' }),
228
+ )
229
+
230
+ const { spawn } = makeSpawn()
231
+
232
+ // When: user runs proman link --status
233
+ const result = await linkStatus({ cwd: consumerDir, spawn })
234
+
235
+ // Then: message shows no linked packages
236
+ expect(result).toBe('No linked packages found')
237
+ })
238
+ })
239
+
240
+ describe('unlink command', () => {
241
+ let tmpDir: string
242
+
243
+ beforeEach(() => {
244
+ tmpDir = resolve(
245
+ tmpdir(),
246
+ `proman-link-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
247
+ )
248
+ mkdirSync(tmpDir, { recursive: true })
249
+ })
250
+
251
+ afterEach(() => {
252
+ rmSync(tmpDir, { recursive: true, force: true })
253
+ })
254
+
255
+ test('Test 8: unlinks all packages', async () => {
256
+ // Given: project with 2 linked packages
257
+ const consumerDir = tmpDir
258
+ writeFileSync(
259
+ join(consumerDir, 'package.json'),
260
+ JSON.stringify({ name: '@consumer/app', version: '1.0.0' }),
261
+ )
262
+
263
+ // Create symlinks
264
+ const nodeModulesDir = join(consumerDir, 'node_modules')
265
+ mkdirSync(join(nodeModulesDir, '@scope'), { recursive: true })
266
+
267
+ const targetA = join(tmpdir(), `temp-a-${Date.now()}`)
268
+ const targetB = join(tmpdir(), `temp-b-${Date.now()}`)
269
+ mkdirSync(targetA, { recursive: true })
270
+ mkdirSync(targetB, { recursive: true })
271
+ writeFileSync(join(targetA, 'package.json'), JSON.stringify({ name: '@scope/package-a' }))
272
+ writeFileSync(join(targetB, 'package.json'), JSON.stringify({ name: '@scope/package-b' }))
273
+
274
+ symlinkSync(targetA, join(nodeModulesDir, '@scope', 'package-a'), 'dir')
275
+ symlinkSync(targetB, join(nodeModulesDir, '@scope', 'package-b'), 'dir')
276
+
277
+ const { spawn, calls } = makeSpawn()
278
+
279
+ // When: user runs proman unlink
280
+ await unlink({ cwd: consumerDir, spawn })
281
+
282
+ // Then: pnpm unlink called for each + pnpm install
283
+ expect(calls.length).toBe(3)
284
+ expect(calls[0]?.argv).toEqual(['pnpm', 'unlink', '@scope/package-a'])
285
+ expect(calls[1]?.argv).toEqual(['pnpm', 'unlink', '@scope/package-b'])
286
+ expect(calls[2]?.argv).toEqual(['pnpm', 'install'])
287
+ })
288
+
289
+ test('Test 9: unlinks specific package', async () => {
290
+ // Given: project with linked package
291
+ const consumerDir = tmpDir
292
+ writeFileSync(
293
+ join(consumerDir, 'package.json'),
294
+ JSON.stringify({ name: '@consumer/app', version: '1.0.0' }),
295
+ )
296
+
297
+ const { spawn, calls } = makeSpawn()
298
+
299
+ // When: user runs proman unlink @scope/package-a
300
+ await unlink({ cwd: consumerDir, packageName: '@scope/package-a', spawn })
301
+
302
+ // Then: pnpm unlink + pnpm install for that package
303
+ expect(calls).toHaveLength(2)
304
+ expect(calls[0]?.argv).toEqual(['pnpm', 'unlink', '@scope/package-a'])
305
+ expect(calls[1]?.argv).toEqual(['pnpm', 'install', '@scope/package-a'])
306
+ })
307
+
308
+ test('Test 10: message when no packages linked', async () => {
309
+ // Given: project with no linked packages
310
+ const consumerDir = tmpDir
311
+ writeFileSync(
312
+ join(consumerDir, 'package.json'),
313
+ JSON.stringify({ name: '@consumer/app', version: '1.0.0' }),
314
+ )
315
+
316
+ const { spawn, calls } = makeSpawn()
317
+
318
+ // When: user runs proman unlink
319
+ await unlink({ cwd: consumerDir, spawn })
320
+
321
+ // Then: no commands executed, only message
322
+ expect(calls).toHaveLength(0)
323
+ })
324
+ })
325
+
326
+ describe('readPackageJson validation', () => {
327
+ let tmpDir: string
328
+
329
+ beforeEach(() => {
330
+ tmpDir = resolve(
331
+ tmpdir(),
332
+ `proman-link-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
333
+ )
334
+ mkdirSync(tmpDir, { recursive: true })
335
+ })
336
+
337
+ afterEach(() => {
338
+ rmSync(tmpDir, { recursive: true, force: true })
339
+ })
340
+
341
+ test('Test 1: throws error for primitive string value', async () => {
342
+ // Given: package.json with primitive string
343
+ const pkgDir = tmpDir
344
+ mkdirSync(join(pkgDir, 'dist'), { recursive: true })
345
+ writeFileSync(join(pkgDir, 'package.json'), '"just a string"')
346
+ writeFileSync(join(pkgDir, 'dist/index.js'), '')
347
+
348
+ const { spawn } = makeSpawn()
349
+
350
+ // When/Then: should throw validation error
351
+ await expect(link({ cwd: pkgDir, spawn })).rejects.toThrow(
352
+ `Invalid package.json at ${join(pkgDir, 'package.json')}`,
353
+ )
354
+ })
355
+
356
+ test('Test 2: throws error for array value', async () => {
357
+ // Given: package.json with array
358
+ const pkgDir = tmpDir
359
+ mkdirSync(join(pkgDir, 'dist'), { recursive: true })
360
+ writeFileSync(join(pkgDir, 'package.json'), '[1, 2, 3]')
361
+ writeFileSync(join(pkgDir, 'dist/index.js'), '')
362
+
363
+ const { spawn } = makeSpawn()
364
+
365
+ // When/Then: should throw validation error
366
+ await expect(link({ cwd: pkgDir, spawn })).rejects.toThrow(
367
+ `Invalid package.json at ${join(pkgDir, 'package.json')}`,
368
+ )
369
+ })
370
+
371
+ test('Test 3: throws error for null value', async () => {
372
+ // Given: package.json with null
373
+ const pkgDir = tmpDir
374
+ mkdirSync(join(pkgDir, 'dist'), { recursive: true })
375
+ writeFileSync(join(pkgDir, 'package.json'), 'null')
376
+ writeFileSync(join(pkgDir, 'dist/index.js'), '')
377
+
378
+ const { spawn } = makeSpawn()
379
+
380
+ // When/Then: should throw validation error
381
+ await expect(link({ cwd: pkgDir, spawn })).rejects.toThrow(
382
+ `Invalid package.json at ${join(pkgDir, 'package.json')}`,
383
+ )
384
+ })
385
+
386
+ test('Test 4: succeeds for valid object with name', async () => {
387
+ // Given: package.json with valid object
388
+ const pkgDir = tmpDir
389
+ writeFileSync(
390
+ join(pkgDir, 'package.json'),
391
+ JSON.stringify({ name: 'test-package', version: '1.0.0' }),
392
+ )
393
+ mkdirSync(join(pkgDir, 'dist'), { recursive: true })
394
+ writeFileSync(join(pkgDir, 'dist/index.js'), '')
395
+
396
+ const { spawn, calls } = makeSpawn()
397
+
398
+ // When: readPackageJson is called via link command
399
+ await link({ cwd: pkgDir, spawn })
400
+
401
+ // Then: should succeed without throwing
402
+ expect(calls).toHaveLength(1)
403
+ expect(calls[0]?.argv).toEqual(['pnpm', 'link', '--global'])
404
+ })
405
+
406
+ test('Test 5: succeeds for empty object', async () => {
407
+ // Given: package.json with empty object
408
+ const pkgDir = tmpDir
409
+ writeFileSync(join(pkgDir, 'package.json'), '{}')
410
+ mkdirSync(join(pkgDir, 'dist'), { recursive: true })
411
+ writeFileSync(join(pkgDir, 'dist/index.js'), '')
412
+
413
+ const { spawn } = makeSpawn()
414
+
415
+ // When/Then: readPackageJson succeeds, but link fails due to missing name
416
+ // This validates that readPackageJson accepts empty objects
417
+ await expect(link({ cwd: pkgDir, spawn })).rejects.toThrow('missing a "name" field')
418
+ })
419
+ })
@@ -0,0 +1,44 @@
1
+ import { dirname, resolve } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+
4
+ const __dirname = dirname(fileURLToPath(import.meta.url))
5
+
6
+ import { describe, expect, test } from 'vitest'
7
+ import { loadConfig } from '../src/config/index.ts'
8
+
9
+ const FIX = (name: string) => resolve(__dirname, 'fixtures', name)
10
+
11
+ describe('loadConfig', () => {
12
+ test('happy path — loads issue example fixture', () => {
13
+ const cfg = loadConfig(FIX('valid'))
14
+ expect(cfg.packages).toHaveLength(3)
15
+ expect(cfg.packages.map((p) => p.name)).toEqual(['@ocas/core', '@ocas/fs', '@ocas/cli'])
16
+ expect(cfg.release?.registry).toBe('https://registry.npmjs.org')
17
+ expect(cfg.release?.access).toBe('public')
18
+ expect(cfg.release?.gitTagPrefix).toBe('v')
19
+ })
20
+
21
+ test('applies registry + gitTagPrefix defaults when release omitted', () => {
22
+ const cfg = loadConfig(FIX('defaults'))
23
+ expect(cfg.release?.registry).toBe('https://registry.npmjs.org')
24
+ expect(cfg.release?.gitTagPrefix).toBe('v')
25
+ expect(cfg.release?.access).toBeUndefined()
26
+ })
27
+
28
+ test('rejects when proman.yaml is missing', () => {
29
+ expect(() => loadConfig(resolve(__dirname, 'fixtures', 'no-such-dir'))).toThrow(
30
+ /proman\.yaml not found/,
31
+ )
32
+ })
33
+
34
+ test('rejects empty packages', () => {
35
+ expect(() => loadConfig(FIX('bad-packages'))).toThrow(/packages/i)
36
+ })
37
+
38
+ test('T5: defaults each package type to lib when omitted', () => {
39
+ const cfg = loadConfig(FIX('valid'))
40
+ for (const p of cfg.packages) {
41
+ expect(p.type).toBe('lib')
42
+ }
43
+ })
44
+ })
@@ -0,0 +1,199 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import {
3
+ createNpmRunner,
4
+ defaultSpawn,
5
+ formatRcVersion,
6
+ nextRcNumber,
7
+ parseReleaseBranch,
8
+ runOrThrow,
9
+ type SpawnFn,
10
+ } from '../src/utils/npm.ts'
11
+
12
+ describe('parseReleaseBranch', () => {
13
+ test('parses release/0.3.0', () => {
14
+ expect(parseReleaseBranch('release/0.3.0')).toBe('0.3.0')
15
+ })
16
+ test('parses prerelease branches', () => {
17
+ expect(parseReleaseBranch('release/1.2.3-beta.1')).toBe('1.2.3-beta.1')
18
+ })
19
+ test('throws on main', () => {
20
+ expect(() => parseReleaseBranch('main')).toThrow()
21
+ })
22
+ test('throws on feature/x', () => {
23
+ expect(() => parseReleaseBranch('feature/x')).toThrow()
24
+ })
25
+ test('throws on release/ (empty)', () => {
26
+ expect(() => parseReleaseBranch('release/')).toThrow()
27
+ })
28
+ test('throws on empty', () => {
29
+ expect(() => parseReleaseBranch('')).toThrow()
30
+ })
31
+ })
32
+
33
+ describe('nextRcNumber', () => {
34
+ test('empty registry => 1', () => {
35
+ expect(nextRcNumber({ baseVersion: '0.3.0', existing: [] })).toBe(1)
36
+ })
37
+ test('mixed versions, single rc', () => {
38
+ expect(nextRcNumber({ baseVersion: '0.3.0', existing: ['0.3.0', '0.2.0', '0.3.0-rc.1'] })).toBe(
39
+ 2,
40
+ )
41
+ })
42
+ test('finds max across multiple rcs', () => {
43
+ expect(
44
+ nextRcNumber({
45
+ baseVersion: '0.3.0',
46
+ existing: ['0.3.0-rc.1', '0.3.0-rc.2', '0.3.0-rc.5'],
47
+ }),
48
+ ).toBe(6)
49
+ })
50
+ test('ignores rc for other base', () => {
51
+ expect(
52
+ nextRcNumber({
53
+ baseVersion: '0.3.0',
54
+ existing: ['0.2.0-rc.9', '0.3.0-rc.1'],
55
+ }),
56
+ ).toBe(2)
57
+ })
58
+ test('ignores non-rc prereleases', () => {
59
+ expect(
60
+ nextRcNumber({
61
+ baseVersion: '0.3.0',
62
+ existing: ['0.3.0-beta.1', '0.3.0-alpha.4'],
63
+ }),
64
+ ).toBe(1)
65
+ })
66
+ test('handles non-numeric rc tag', () => {
67
+ expect(
68
+ nextRcNumber({
69
+ baseVersion: '0.3.0',
70
+ existing: ['0.3.0-rc.x', '0.3.0-rc.2'],
71
+ }),
72
+ ).toBe(3)
73
+ })
74
+ })
75
+
76
+ describe('formatRcVersion', () => {
77
+ test('basic', () => {
78
+ expect(formatRcVersion('0.3.0', 1)).toBe('0.3.0-rc.1')
79
+ })
80
+ test('higher number', () => {
81
+ expect(formatRcVersion('1.0.0', 7)).toBe('1.0.0-rc.7')
82
+ })
83
+ })
84
+
85
+ describe('runOrThrow', () => {
86
+ test('should export runOrThrow from npm utils', () => {
87
+ expect(runOrThrow).toBeDefined()
88
+ expect(typeof runOrThrow).toBe('function')
89
+ })
90
+
91
+ test('should succeed when spawn returns code 0', async () => {
92
+ const spawn: SpawnFn = async () => ({ code: 0, stdout: '', stderr: '' })
93
+ await expect(runOrThrow(spawn, ['echo', 'hello'], '/tmp')).resolves.toBeUndefined()
94
+ })
95
+
96
+ test('should throw when spawn returns non-zero', async () => {
97
+ const spawn: SpawnFn = async () => ({ code: 1, stdout: '', stderr: 'error' })
98
+ await expect(runOrThrow(spawn, ['fail'], '/tmp')).rejects.toThrow(/fail failed: error/)
99
+ })
100
+
101
+ test('should include stdout in error when stderr is empty', async () => {
102
+ const spawn: SpawnFn = async () => ({ code: 1, stdout: 'stdout detail', stderr: '' })
103
+ await expect(runOrThrow(spawn, ['fail'], '/tmp')).rejects.toThrow(/fail failed: stdout detail/)
104
+ })
105
+
106
+ test('should format error message with command name', async () => {
107
+ const spawn: SpawnFn = async () => ({ code: 1, stdout: '', stderr: '' })
108
+ await expect(runOrThrow(spawn, ['pnpm', 'build'], '/tmp')).rejects.toThrow(/pnpm build failed/)
109
+ })
110
+ })
111
+
112
+ describe('createNpmRunner format argv', () => {
113
+ function makeSpawn(code = 0) {
114
+ const calls: string[][] = []
115
+ const fn: SpawnFn = async (argv, _cwd) => {
116
+ calls.push(argv)
117
+ return { code, stdout: '', stderr: 'boom' }
118
+ }
119
+ return { spawn: fn, calls }
120
+ }
121
+
122
+ test('B3: format is a function', () => {
123
+ const { spawn } = makeSpawn()
124
+ const runner = createNpmRunner('/root', spawn)
125
+ expect(typeof runner.format).toBe('function')
126
+ })
127
+
128
+ test('runs format via pnpm', async () => {
129
+ const { spawn, calls } = makeSpawn()
130
+ const runner = createNpmRunner('/root', spawn)
131
+ await runner.format()
132
+ expect(calls[0]).toEqual(['pnpm', 'run', 'format'])
133
+ })
134
+
135
+ test('B4: non-zero exit throws with argv', async () => {
136
+ const { spawn } = makeSpawn(1)
137
+ const runner = createNpmRunner('/root', spawn)
138
+ await expect(runner.format()).rejects.toThrow(/pnpm run format/)
139
+ })
140
+ })
141
+
142
+ describe('createNpmRunner publish argv', () => {
143
+ function makeSpawn() {
144
+ const calls: string[][] = []
145
+ const fn: SpawnFn = async (argv, _cwd) => {
146
+ calls.push(argv)
147
+ return { code: 0, stdout: '', stderr: '' }
148
+ }
149
+ return { spawn: fn, calls }
150
+ }
151
+
152
+ test('publishes via pnpm with --no-git-checks', async () => {
153
+ const { spawn, calls } = makeSpawn()
154
+ const runner = createNpmRunner('/root', spawn)
155
+ await runner.publish('/root/packages/a', { tag: 'rc' })
156
+ const last = calls[calls.length - 1] as string[]
157
+ expect(last[0]).toBe('pnpm')
158
+ expect(last[1]).toBe('publish')
159
+ expect(last).toContain('--no-git-checks')
160
+ expect(last).toContain('--tag')
161
+ expect(last).toContain('rc')
162
+ })
163
+
164
+ test('passes --access public', async () => {
165
+ const { spawn, calls } = makeSpawn()
166
+ const runner = createNpmRunner('/root', spawn)
167
+ await runner.publish('/root/packages/a', { tag: 'rc', access: 'public' })
168
+ const last = calls[calls.length - 1] as string[]
169
+ expect(last).toContain('--access')
170
+ expect(last).toContain('public')
171
+ })
172
+ })
173
+
174
+ describe('defaultSpawn captures output via pipe', () => {
175
+ test('captures stdout from a real process', async () => {
176
+ const result = await defaultSpawn(['echo', 'hello world'], process.cwd())
177
+ expect(result.code).toBe(0)
178
+ expect(result.stdout.trim()).toBe('hello world')
179
+ })
180
+
181
+ test('captures stderr from a real process', async () => {
182
+ const result = await defaultSpawn(['node', '-e', 'process.stderr.write("oops")'], process.cwd())
183
+ expect(result.code).toBe(0)
184
+ expect(result.stderr).toBe('oops')
185
+ })
186
+
187
+ test('returns non-zero exit code with stderr captured', async () => {
188
+ const result = await defaultSpawn(
189
+ [
190
+ 'node',
191
+ '-e',
192
+ 'process.stderr.write("You cannot publish over the previously published versions: 0.1.1"); process.exit(1)',
193
+ ],
194
+ process.cwd(),
195
+ )
196
+ expect(result.code).toBe(1)
197
+ expect(result.stderr).toContain('cannot publish over the previously published versions')
198
+ })
199
+ })