@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,756 @@
1
+ import { chmodSync, existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { dirname, join, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url))
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest'
9
+ import { build, check, format, runTests } from '../src/commands/dev.ts'
10
+
11
+ import type { SpawnFn } from '../src/utils/npm.ts'
12
+
13
+ const FIX = (name: string) => resolve(__dirname, 'fixtures', name)
14
+
15
+ type Call = { argv: string[]; cwd: string }
16
+
17
+ function makeSpawn(code = 0, stdout = '', stderr = '') {
18
+ const calls: Call[] = []
19
+ const fn: SpawnFn = async (argv, cwd) => {
20
+ calls.push({ argv, cwd })
21
+ return { code, stdout, stderr }
22
+ }
23
+ return { spawn: fn, calls }
24
+ }
25
+
26
+ /** Check that argv contains `pm exec bin` pattern */
27
+ function expectExec(argv: string[], bin: string, args: string[]) {
28
+ const binIdx = argv.indexOf(bin)
29
+ expect(binIdx).toBeGreaterThanOrEqual(0)
30
+ for (let i = 0; i < args.length; i++) {
31
+ expect(argv[binIdx + 1 + i]).toBe(args[i])
32
+ }
33
+ }
34
+
35
+ describe('build command', () => {
36
+ test('C1: dispatches tsc --build per package in order', async () => {
37
+ const { spawn, calls } = makeSpawn()
38
+ await build({ cwd: FIX('valid'), spawn })
39
+ expect(calls).toHaveLength(3) // core, fs, cli
40
+ for (const c of calls) {
41
+ expect(c.argv).toEqual(['pnpm', 'exec', 'tsc', '--build'])
42
+ expectExec(c.argv, 'tsc', ['--build'])
43
+ }
44
+ expect(calls[0]?.cwd).toBe(resolve(FIX('valid'), 'packages/core'))
45
+ expect(calls[1]?.cwd).toBe(resolve(FIX('valid'), 'packages/fs'))
46
+ expect(calls[2]?.cwd).toBe(resolve(FIX('valid'), 'packages/cli'))
47
+ })
48
+
49
+ test('C1b: node-runtime uses pnpm exec tsc --build', async () => {
50
+ const { spawn, calls } = makeSpawn()
51
+ await build({ cwd: FIX('node-runtime'), spawn })
52
+ expect(calls).toHaveLength(1)
53
+ expect(calls[0]?.argv).toEqual(['pnpm', 'exec', 'tsc', '--build'])
54
+ })
55
+
56
+ test('C1c: pnpm project uses pnpm exec tsc --build', async () => {
57
+ const { spawn, calls } = makeSpawn()
58
+ await build({ cwd: FIX('pnpm-project'), spawn })
59
+ expect(calls).toHaveLength(1)
60
+ expect(calls[0]?.argv).toEqual(['pnpm', 'exec', 'tsc', '--build'])
61
+ })
62
+
63
+ test('C1d: typed fixture dispatches by type in declared order', async () => {
64
+ const { spawn, calls } = makeSpawn()
65
+ await build({ cwd: FIX('typed'), spawn })
66
+ expect(calls).toHaveLength(4)
67
+ // lib: tsc --build
68
+ expectExec(calls[0]?.argv, 'tsc', ['--build'])
69
+ expect(calls[0]?.cwd).toBe(resolve(FIX('typed'), 'packages/core'))
70
+ // cli: pnpm run build
71
+ expect(calls[1]?.argv).toEqual(['pnpm', 'run', 'build'])
72
+ expect(calls[1]?.cwd).toBe(resolve(FIX('typed'), 'packages/mycli'))
73
+ // webui: vite build
74
+ expectExec(calls[2]?.argv, 'vite', ['build'])
75
+ expect(calls[2]?.cwd).toBe(resolve(FIX('typed'), 'packages/dashboard'))
76
+ // api: tsc --build
77
+ expectExec(calls[3]?.argv, 'tsc', ['--build'])
78
+ expect(calls[3]?.cwd).toBe(resolve(FIX('typed'), 'packages/api'))
79
+ })
80
+
81
+ test('C-bin: webui uses pnpm exec vite', async () => {
82
+ const { spawn, calls } = makeSpawn()
83
+ await build({ cwd: FIX('webui-only'), spawn })
84
+ expect(calls).toHaveLength(1)
85
+ expectExec(calls[0]?.argv, 'vite', ['build'])
86
+ })
87
+
88
+ test('C-pnpm: uses pnpm exec', async () => {
89
+ const { spawn, calls } = makeSpawn()
90
+ await build({ cwd: FIX('valid'), spawn })
91
+ expect(calls[0]?.argv[0]).toBe('pnpm')
92
+ expect(calls[0]?.argv[1]).toBe('exec')
93
+ })
94
+
95
+ test('C6: build throws on non-zero exit', async () => {
96
+ const { spawn } = makeSpawn(1, '', 'build error')
97
+ await expect(build({ cwd: FIX('valid'), spawn })).rejects.toThrow()
98
+ })
99
+ })
100
+
101
+ describe('test command', () => {
102
+ test('C2: invokes pnpm exec vitest run', async () => {
103
+ const { spawn, calls } = makeSpawn()
104
+ await runTests({ cwd: FIX('valid'), spawn })
105
+ expect(calls).toHaveLength(1)
106
+ const { argv, cwd } = calls[0] as Call
107
+ expect(argv).toEqual(['pnpm', 'exec', 'vitest', 'run'])
108
+ expect(cwd).toBe(FIX('valid'))
109
+ })
110
+
111
+ test('C3: node-runtime invokes pnpm exec vitest run', async () => {
112
+ const { spawn, calls } = makeSpawn()
113
+ await runTests({ cwd: FIX('node-runtime'), spawn })
114
+ expect(calls).toHaveLength(1)
115
+ const { argv } = calls[0] as Call
116
+ expect(argv).toEqual(['pnpm', 'exec', 'vitest', 'run'])
117
+ })
118
+
119
+ test('C3b: pnpm project invokes pnpm exec vitest run', async () => {
120
+ const { spawn, calls } = makeSpawn()
121
+ await runTests({ cwd: FIX('pnpm-project'), spawn })
122
+ expect(calls).toHaveLength(1)
123
+ const { argv } = calls[0] as Call
124
+ expect(argv).toEqual(['pnpm', 'exec', 'vitest', 'run'])
125
+ })
126
+
127
+ test('C6: test throws on non-zero exit', async () => {
128
+ const { spawn } = makeSpawn(1, '', 'fail')
129
+ await expect(runTests({ cwd: FIX('valid'), spawn })).rejects.toThrow()
130
+ })
131
+ })
132
+
133
+ describe('check command', () => {
134
+ test('C4: invokes pnpm exec biome check .', async () => {
135
+ const { spawn, calls } = makeSpawn()
136
+ await check({ cwd: FIX('node-runtime'), spawn })
137
+ expect(calls).toHaveLength(1)
138
+ expect(calls[0]?.argv).toEqual(['pnpm', 'exec', 'biome', 'check', '.'])
139
+ })
140
+
141
+ test('C4b: valid fixture invokes pnpm exec biome check .', async () => {
142
+ const { spawn, calls } = makeSpawn()
143
+ await check({ cwd: FIX('valid'), spawn })
144
+ expect(calls).toHaveLength(1)
145
+ expect(calls[0]?.argv).toEqual(['pnpm', 'exec', 'biome', 'check', '.'])
146
+ })
147
+
148
+ test('C4c: pnpm project invokes pnpm exec biome check .', async () => {
149
+ const { spawn, calls } = makeSpawn()
150
+ await check({ cwd: FIX('pnpm-project'), spawn })
151
+ expect(calls).toHaveLength(1)
152
+ expect(calls[0]?.argv).toEqual(['pnpm', 'exec', 'biome', 'check', '.'])
153
+ })
154
+
155
+ test('C6: check throws on non-zero exit', async () => {
156
+ const { spawn } = makeSpawn(1, '', 'fail')
157
+ await expect(check({ cwd: FIX('valid'), spawn })).rejects.toThrow()
158
+ })
159
+ })
160
+
161
+ describe('check — workflow validation', () => {
162
+ let tmpDir: string
163
+
164
+ function writeCheckFixture(opts: {
165
+ workflows?: Record<string, string>
166
+ uwfInstalled?: boolean
167
+ uwfExitCode?: number
168
+ uwfStderr?: string
169
+ }): { cwd: string; spawn: SpawnFn; calls: Call[] } {
170
+ tmpDir = resolve(
171
+ tmpdir(),
172
+ `proman-wf-check-${Date.now()}-${Math.random().toString(36).slice(2)}`,
173
+ )
174
+ mkdirSync(tmpDir, { recursive: true })
175
+
176
+ // minimal proman.yaml
177
+ writeFileSync(
178
+ join(tmpDir, 'proman.yaml'),
179
+ 'packages:\n - name: "@test/core"\n path: packages/core\n type: lib\n',
180
+ )
181
+ mkdirSync(join(tmpDir, 'packages/core/src'), { recursive: true })
182
+ writeFileSync(join(tmpDir, 'packages/core/src/index.ts'), 'export const x = 1')
183
+ writeFileSync(join(tmpDir, 'packages/core/package.json'), '{}')
184
+
185
+ // .workflows/ files
186
+ if (opts.workflows) {
187
+ mkdirSync(join(tmpDir, '.workflows'), { recursive: true })
188
+ for (const [name, content] of Object.entries(opts.workflows)) {
189
+ writeFileSync(join(tmpDir, '.workflows', name), content)
190
+ }
191
+ }
192
+
193
+ const calls: Call[] = []
194
+ const uwfInstalled = opts.uwfInstalled ?? true
195
+ const uwfExitCode = opts.uwfExitCode ?? 0
196
+ const uwfStderr = opts.uwfStderr ?? ''
197
+
198
+ const spawn: SpawnFn = async (argv, cwd) => {
199
+ calls.push({ argv, cwd })
200
+ // biome check always passes
201
+ if (argv.includes('biome')) return { code: 0, stdout: '', stderr: '' }
202
+ // which uwf
203
+ if (argv[0] === 'which' && argv[1] === 'uwf') {
204
+ return { code: uwfInstalled ? 0 : 1, stdout: '', stderr: '' }
205
+ }
206
+ // uwf workflow validate
207
+ if (argv[0] === 'uwf' && argv[1] === 'workflow' && argv[2] === 'validate') {
208
+ return { code: uwfExitCode, stdout: uwfExitCode === 0 ? '✓ valid' : '', stderr: uwfStderr }
209
+ }
210
+ return { code: 0, stdout: '', stderr: '' }
211
+ }
212
+
213
+ return { cwd: tmpDir, spawn, calls }
214
+ }
215
+
216
+ afterEach(() => {
217
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true })
218
+ })
219
+
220
+ test('WF1: validates workflow files with uwf workflow validate', async () => {
221
+ const { cwd, spawn, calls } = writeCheckFixture({
222
+ workflows: { 'solve-issue.yaml': 'version: 1\nname: solve-issue\n' },
223
+ })
224
+ await check({ cwd, spawn })
225
+
226
+ const uwfCalls = calls.filter((c) => c.argv[0] === 'uwf')
227
+ expect(uwfCalls).toHaveLength(1)
228
+ expect(uwfCalls[0]?.argv[2]).toBe('validate')
229
+ expect(uwfCalls[0]?.argv[3]).toContain('solve-issue.yaml')
230
+ })
231
+
232
+ test('WF2: validates multiple workflow files', async () => {
233
+ const { cwd, spawn, calls } = writeCheckFixture({
234
+ workflows: {
235
+ 'solve-issue.yaml': 'version: 1\nname: solve-issue\n',
236
+ 'review-pr.yaml': 'version: 1\nname: review-pr\n',
237
+ },
238
+ })
239
+ await check({ cwd, spawn })
240
+
241
+ const uwfCalls = calls.filter((c) => c.argv[0] === 'uwf')
242
+ expect(uwfCalls).toHaveLength(2)
243
+ })
244
+
245
+ test('WF3: skips with warning when uwf is not installed', async () => {
246
+ const { cwd, spawn, calls } = writeCheckFixture({
247
+ workflows: { 'solve-issue.yaml': 'version: 1\nname: solve-issue\n' },
248
+ uwfInstalled: false,
249
+ })
250
+ await check({ cwd, spawn })
251
+
252
+ const uwfValidateCalls = calls.filter((c) => c.argv[0] === 'uwf' && c.argv[1] === 'workflow')
253
+ expect(uwfValidateCalls).toHaveLength(0)
254
+ })
255
+
256
+ test('WF4: throws when validation fails', async () => {
257
+ const { cwd, spawn } = writeCheckFixture({
258
+ workflows: { 'bad.yaml': 'invalid' },
259
+ uwfExitCode: 1,
260
+ uwfStderr: 'missing required field: name',
261
+ })
262
+ await expect(check({ cwd, spawn })).rejects.toThrow('Workflow validation failed')
263
+ })
264
+
265
+ test('WF5: no .workflows directory — skips silently', async () => {
266
+ const { cwd, spawn, calls } = writeCheckFixture({})
267
+ await check({ cwd, spawn })
268
+
269
+ const uwfCalls = calls.filter((c) => c.argv[0] === 'uwf' || c.argv[0] === 'which')
270
+ expect(uwfCalls).toHaveLength(0)
271
+ })
272
+
273
+ test('WF6: ignores non-yaml files in .workflows/', async () => {
274
+ const { cwd, spawn, calls } = writeCheckFixture({
275
+ workflows: { 'README.md': '# workflows' },
276
+ })
277
+ await check({ cwd, spawn })
278
+
279
+ const uwfCalls = calls.filter((c) => c.argv[0] === 'uwf' || c.argv[0] === 'which')
280
+ expect(uwfCalls).toHaveLength(0)
281
+ })
282
+ })
283
+
284
+ describe('format command', () => {
285
+ test('C5: invokes pnpm exec biome format --write .', async () => {
286
+ const { spawn, calls } = makeSpawn()
287
+ await format({ cwd: FIX('node-runtime'), spawn })
288
+ expect(calls).toHaveLength(1)
289
+ expect(calls[0]?.argv).toEqual(['pnpm', 'exec', 'biome', 'format', '--write', '.'])
290
+ })
291
+
292
+ test('C6: format throws on non-zero exit', async () => {
293
+ const { spawn } = makeSpawn(1, '', 'fail')
294
+ await expect(format({ cwd: FIX('valid'), spawn })).rejects.toThrow()
295
+ })
296
+ })
297
+
298
+ // ── chmod +x bin entries after build ────────────────────────────────────────
299
+
300
+ describe('build — chmod +x bin entries', () => {
301
+ let tmpDir: string
302
+
303
+ beforeEach(() => {
304
+ tmpDir = resolve(tmpdir(), `proman-chmod-test-${Date.now()}`)
305
+ mkdirSync(tmpDir, { recursive: true })
306
+ })
307
+
308
+ afterEach(() => {
309
+ rmSync(tmpDir, { recursive: true, force: true })
310
+ })
311
+
312
+ function writeTmpProject(bins: Record<string, string> | string | undefined): {
313
+ cwd: string
314
+ spawn: SpawnFn
315
+ } {
316
+ const pkgDir = join(tmpDir, 'packages', 'mycli')
317
+ const distDir = join(pkgDir, 'dist')
318
+ mkdirSync(distDir, { recursive: true })
319
+
320
+ // proman.yaml
321
+ writeFileSync(
322
+ join(tmpDir, 'proman.yaml'),
323
+ 'packages:\n - name: "@test/cli"\n path: packages/mycli\n type: cli\n',
324
+ )
325
+
326
+ // package.json with bin
327
+ const pkgJson: Record<string, unknown> = { name: '@test/cli', version: '1.0.0' }
328
+ if (bins !== undefined) pkgJson.bin = bins
329
+ writeFileSync(join(pkgDir, 'package.json'), JSON.stringify(pkgJson))
330
+
331
+ // Mock spawn that recreates dist/cli.js with 644 (simulates tsc output)
332
+ const spawn: SpawnFn = async (_argv, cwd) => {
333
+ const out = join(cwd, 'dist')
334
+ mkdirSync(out, { recursive: true })
335
+ writeFileSync(join(out, 'cli.js'), '#!/usr/bin/env node\n')
336
+ chmodSync(join(out, 'cli.js'), 0o644)
337
+ return { code: 0, stdout: '', stderr: '' }
338
+ }
339
+
340
+ return { cwd: tmpDir, spawn }
341
+ }
342
+
343
+ test('B1: bin object — chmod +x applied after build', async () => {
344
+ const { cwd, spawn } = writeTmpProject({ mycli: './dist/cli.js' })
345
+ await build({ cwd, spawn })
346
+
347
+ const mode = statSync(join(cwd, 'packages/mycli/dist/cli.js')).mode & 0o777
348
+ expect(mode).toBe(0o755)
349
+ })
350
+
351
+ test('B2: bin string — chmod +x applied after build', async () => {
352
+ const { cwd, spawn } = writeTmpProject('./dist/cli.js')
353
+ await build({ cwd, spawn })
354
+
355
+ const mode = statSync(join(cwd, 'packages/mycli/dist/cli.js')).mode & 0o777
356
+ expect(mode).toBe(0o755)
357
+ })
358
+
359
+ test('B3: no bin field — no crash', async () => {
360
+ const { cwd, spawn } = writeTmpProject(undefined)
361
+ await build({ cwd, spawn })
362
+ // Should not throw
363
+ })
364
+
365
+ test('B4: bin points to missing file — no crash', async () => {
366
+ const { cwd, spawn } = writeTmpProject({ mycli: './dist/nonexistent.js' })
367
+ await build({ cwd, spawn })
368
+ // Should not throw
369
+ })
370
+ })
371
+
372
+ // ── fingerprint skip ────────────────────────────────────────────────────────
373
+
374
+ describe('build — fingerprint skip', () => {
375
+ let tmpDir: string
376
+
377
+ function writeMonorepoFixture(): string {
378
+ tmpDir = resolve(
379
+ tmpdir(),
380
+ `proman-fp-build-${Date.now()}-${Math.random().toString(36).slice(2)}`,
381
+ )
382
+ mkdirSync(tmpDir, { recursive: true })
383
+
384
+ // proman.yaml
385
+ writeFileSync(
386
+ join(tmpDir, 'proman.yaml'),
387
+ [
388
+ 'packages:',
389
+ ' - name: "@test/core"',
390
+ ' path: packages/core',
391
+ ' type: lib',
392
+ ' - name: "@test/fs"',
393
+ ' path: packages/fs',
394
+ ' type: lib',
395
+ ' - name: "@test/cli"',
396
+ ' path: packages/cli',
397
+ ' type: cli',
398
+ ].join('\n'),
399
+ )
400
+
401
+ // core (no deps)
402
+ mkdirSync(join(tmpDir, 'packages/core/src'), { recursive: true })
403
+ writeFileSync(join(tmpDir, 'packages/core/src/index.ts'), 'export const x = 1')
404
+ writeFileSync(
405
+ join(tmpDir, 'packages/core/package.json'),
406
+ JSON.stringify({ name: '@test/core', version: '1.0.0' }),
407
+ )
408
+ writeFileSync(join(tmpDir, 'packages/core/tsconfig.json'), '{}')
409
+
410
+ // fs (depends on core)
411
+ mkdirSync(join(tmpDir, 'packages/fs/src'), { recursive: true })
412
+ writeFileSync(join(tmpDir, 'packages/fs/src/index.ts'), 'export const y = 2')
413
+ writeFileSync(
414
+ join(tmpDir, 'packages/fs/package.json'),
415
+ JSON.stringify({
416
+ name: '@test/fs',
417
+ version: '1.0.0',
418
+ dependencies: { '@test/core': 'workspace:*' },
419
+ }),
420
+ )
421
+ writeFileSync(join(tmpDir, 'packages/fs/tsconfig.json'), '{}')
422
+
423
+ // cli (depends on fs)
424
+ mkdirSync(join(tmpDir, 'packages/cli/src'), { recursive: true })
425
+ writeFileSync(join(tmpDir, 'packages/cli/src/index.ts'), 'export const z = 3')
426
+ writeFileSync(
427
+ join(tmpDir, 'packages/cli/package.json'),
428
+ JSON.stringify({
429
+ name: '@test/cli',
430
+ version: '1.0.0',
431
+ dependencies: { '@test/fs': 'workspace:*' },
432
+ }),
433
+ )
434
+ writeFileSync(join(tmpDir, 'packages/cli/tsconfig.json'), '{}')
435
+
436
+ return tmpDir
437
+ }
438
+
439
+ afterEach(() => {
440
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true })
441
+ })
442
+
443
+ test('FP-B1: first run — no stored fingerprint → runs build, writes fingerprint', async () => {
444
+ const cwd = writeMonorepoFixture()
445
+ const { spawn, calls } = makeSpawn()
446
+
447
+ await build({ cwd, spawn, force: false })
448
+
449
+ // build ran for all 3 packages
450
+ expect(calls.length).toBe(3)
451
+ // fingerprint files written inside each package's dist folder
452
+ expect(existsSync(join(cwd, 'packages/core/dist/.build-fingerprint'))).toBe(true)
453
+ expect(existsSync(join(cwd, 'packages/fs/dist/.build-fingerprint'))).toBe(true)
454
+ expect(existsSync(join(cwd, 'packages/cli/dist/.build-fingerprint'))).toBe(true)
455
+ })
456
+
457
+ test('FP-B2: second run — fingerprint matches → skips build', async () => {
458
+ const cwd = writeMonorepoFixture()
459
+ const { spawn, calls } = makeSpawn()
460
+
461
+ await build({ cwd, spawn, force: false })
462
+ expect(calls.length).toBe(3)
463
+
464
+ // Second run — should skip all
465
+ const { spawn: spawn2, calls: calls2 } = makeSpawn()
466
+ await build({ cwd, spawn: spawn2, force: false })
467
+ expect(calls2.length).toBe(0)
468
+ })
469
+
470
+ test('FP-B3: file changed — fingerprint mismatches → runs build', async () => {
471
+ const cwd = writeMonorepoFixture()
472
+ const { spawn } = makeSpawn()
473
+ await build({ cwd, spawn, force: false })
474
+
475
+ // Modify core's source
476
+ writeFileSync(join(cwd, 'packages/core/src/index.ts'), 'export const x = 999')
477
+
478
+ const { spawn: spawn2, calls: calls2 } = makeSpawn()
479
+ await build({ cwd, spawn: spawn2, force: false })
480
+ // All 3 should re-run (core changed, fs/cli depend on core)
481
+ expect(calls2.length).toBe(3)
482
+ })
483
+
484
+ test('FP-B4: force=true — runs even when fingerprint matches', async () => {
485
+ const cwd = writeMonorepoFixture()
486
+ const { spawn } = makeSpawn()
487
+ await build({ cwd, spawn, force: false })
488
+
489
+ const { spawn: spawn2, calls: calls2 } = makeSpawn()
490
+ await build({ cwd, spawn: spawn2, force: true })
491
+ expect(calls2.length).toBe(3)
492
+ })
493
+
494
+ test('FP-B5: build failure — does NOT write fingerprint', async () => {
495
+ const cwd = writeMonorepoFixture()
496
+ const { spawn } = makeSpawn(1, '', 'build error')
497
+
498
+ await expect(build({ cwd, spawn, force: false })).rejects.toThrow()
499
+
500
+ // No fingerprint files should exist
501
+ expect(existsSync(join(cwd, '.proman/build/@test-core.fingerprint'))).toBe(false)
502
+ })
503
+
504
+ test('FP-B6: dependency propagation — core change re-runs fs and cli', async () => {
505
+ const cwd = writeMonorepoFixture()
506
+ const { spawn } = makeSpawn()
507
+ await build({ cwd, spawn, force: false })
508
+
509
+ // Modify only core
510
+ writeFileSync(join(cwd, 'packages/core/src/index.ts'), 'export const x = 777')
511
+
512
+ const { spawn: spawn2, calls: calls2 } = makeSpawn()
513
+ await build({ cwd, spawn: spawn2, force: false })
514
+
515
+ // All 3 re-run because core → fs → cli dependency chain
516
+ expect(calls2.length).toBe(3)
517
+ expect(calls2[0]?.cwd).toBe(resolve(cwd, 'packages/core'))
518
+ expect(calls2[1]?.cwd).toBe(resolve(cwd, 'packages/fs'))
519
+ expect(calls2[2]?.cwd).toBe(resolve(cwd, 'packages/cli'))
520
+ })
521
+
522
+ test('FP-B7: CI env — runs build even when fingerprint matches', async () => {
523
+ const cwd = writeMonorepoFixture()
524
+ const { spawn } = makeSpawn()
525
+ await build({ cwd, spawn, force: false })
526
+
527
+ // Simulate CI behavior: cli.ts passes force=true when CI=true
528
+ const { spawn: spawn2, calls: calls2 } = makeSpawn()
529
+ await build({ cwd, spawn: spawn2, force: true })
530
+ expect(calls2.length).toBe(3)
531
+ })
532
+
533
+ test('FP-B8: partial skip — only stale packages rebuild', async () => {
534
+ // Monorepo with 2 independent packages (no cross-deps)
535
+ const dir = resolve(
536
+ tmpdir(),
537
+ `proman-fp-partial-${Date.now()}-${Math.random().toString(36).slice(2)}`,
538
+ )
539
+ mkdirSync(dir, { recursive: true })
540
+ tmpDir = dir
541
+
542
+ writeFileSync(
543
+ join(dir, 'proman.yaml'),
544
+ [
545
+ 'packages:',
546
+ ' - name: "@test/alpha"',
547
+ ' path: packages/alpha',
548
+ ' type: lib',
549
+ ' - name: "@test/beta"',
550
+ ' path: packages/beta',
551
+ ' type: lib',
552
+ ].join('\n'),
553
+ )
554
+
555
+ // alpha (no deps)
556
+ mkdirSync(join(dir, 'packages/alpha/src'), { recursive: true })
557
+ writeFileSync(join(dir, 'packages/alpha/src/index.ts'), 'export const a = 1')
558
+ writeFileSync(
559
+ join(dir, 'packages/alpha/package.json'),
560
+ JSON.stringify({ name: '@test/alpha', version: '1.0.0' }),
561
+ )
562
+ writeFileSync(join(dir, 'packages/alpha/tsconfig.json'), '{}')
563
+
564
+ // beta (no deps)
565
+ mkdirSync(join(dir, 'packages/beta/src'), { recursive: true })
566
+ writeFileSync(join(dir, 'packages/beta/src/index.ts'), 'export const b = 1')
567
+ writeFileSync(
568
+ join(dir, 'packages/beta/package.json'),
569
+ JSON.stringify({ name: '@test/beta', version: '1.0.0' }),
570
+ )
571
+ writeFileSync(join(dir, 'packages/beta/tsconfig.json'), '{}')
572
+
573
+ // First run — both build
574
+ const { spawn: s1, calls: c1 } = makeSpawn()
575
+ await build({ cwd: dir, spawn: s1, force: false })
576
+ expect(c1.length).toBe(2)
577
+
578
+ // Modify only beta
579
+ writeFileSync(join(dir, 'packages/beta/src/index.ts'), 'export const b = 999')
580
+
581
+ // Second run — only beta should rebuild
582
+ const { spawn: s2, calls: c2 } = makeSpawn()
583
+ await build({ cwd: dir, spawn: s2, force: false })
584
+ expect(c2.length).toBe(1)
585
+ expect(c2[0]?.cwd).toBe(resolve(dir, 'packages/beta'))
586
+ })
587
+ })
588
+
589
+ describe('test — fingerprint skip', () => {
590
+ let tmpDir: string
591
+
592
+ function writeRootFixture(): string {
593
+ tmpDir = resolve(
594
+ tmpdir(),
595
+ `proman-fp-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
596
+ )
597
+ mkdirSync(tmpDir, { recursive: true })
598
+
599
+ writeFileSync(
600
+ join(tmpDir, 'proman.yaml'),
601
+ 'packages:\n - name: "@test/core"\n path: packages/core\n type: lib\n',
602
+ )
603
+ mkdirSync(join(tmpDir, 'packages/core/src'), { recursive: true })
604
+ writeFileSync(join(tmpDir, 'packages/core/src/index.ts'), 'export const x = 1')
605
+ writeFileSync(join(tmpDir, 'packages/core/package.json'), '{}')
606
+ mkdirSync(join(tmpDir, 'src'), { recursive: true })
607
+ mkdirSync(join(tmpDir, 'tests'), { recursive: true })
608
+ writeFileSync(join(tmpDir, 'src/a.ts'), 'export const a = 1')
609
+ writeFileSync(join(tmpDir, 'tests/a.test.ts'), 'test("a", () => {})')
610
+ writeFileSync(join(tmpDir, 'package.json'), '{}')
611
+ return tmpDir
612
+ }
613
+
614
+ afterEach(() => {
615
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true })
616
+ })
617
+
618
+ test('FP-T1: first run → runs test, writes fingerprint', async () => {
619
+ const cwd = writeRootFixture()
620
+ const { spawn, calls } = makeSpawn()
621
+
622
+ await runTests({ cwd, spawn, force: false })
623
+
624
+ expect(calls.length).toBe(1)
625
+ expect(existsSync(join(cwd, '.proman/test/root.fingerprint'))).toBe(true)
626
+ })
627
+
628
+ test('FP-T2: no changes → skips test', async () => {
629
+ const cwd = writeRootFixture()
630
+ const { spawn } = makeSpawn()
631
+ await runTests({ cwd, spawn, force: false })
632
+
633
+ const { spawn: spawn2, calls: calls2 } = makeSpawn()
634
+ await runTests({ cwd, spawn: spawn2, force: false })
635
+ expect(calls2.length).toBe(0)
636
+ })
637
+
638
+ test('FP-T3: force=true → runs test even if cached', async () => {
639
+ const cwd = writeRootFixture()
640
+ const { spawn } = makeSpawn()
641
+ await runTests({ cwd, spawn, force: false })
642
+
643
+ const { spawn: spawn2, calls: calls2 } = makeSpawn()
644
+ await runTests({ cwd, spawn: spawn2, force: true })
645
+ expect(calls2.length).toBe(1)
646
+ })
647
+
648
+ test('FP-T4: test failure → fingerprint NOT written', async () => {
649
+ const cwd = writeRootFixture()
650
+ const { spawn } = makeSpawn(1, '', 'test failure')
651
+
652
+ await expect(runTests({ cwd, spawn, force: false })).rejects.toThrow()
653
+ expect(existsSync(join(cwd, '.proman/test/root.fingerprint'))).toBe(false)
654
+ })
655
+ })
656
+
657
+ describe('check — fingerprint skip', () => {
658
+ let tmpDir: string
659
+
660
+ function writeCheckFixture(): string {
661
+ tmpDir = resolve(
662
+ tmpdir(),
663
+ `proman-fp-check-${Date.now()}-${Math.random().toString(36).slice(2)}`,
664
+ )
665
+ mkdirSync(tmpDir, { recursive: true })
666
+
667
+ writeFileSync(
668
+ join(tmpDir, 'proman.yaml'),
669
+ 'packages:\n - name: "@test/core"\n path: packages/core\n type: lib\n',
670
+ )
671
+ mkdirSync(join(tmpDir, 'packages/core/src'), { recursive: true })
672
+ writeFileSync(join(tmpDir, 'packages/core/src/index.ts'), 'export const x = 1')
673
+ writeFileSync(join(tmpDir, 'packages/core/package.json'), '{}')
674
+ mkdirSync(join(tmpDir, 'src'), { recursive: true })
675
+ writeFileSync(join(tmpDir, 'src/a.ts'), 'export const a = 1')
676
+ writeFileSync(join(tmpDir, 'package.json'), '{}')
677
+ writeFileSync(join(tmpDir, 'biome.json'), '{}')
678
+ return tmpDir
679
+ }
680
+
681
+ afterEach(() => {
682
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true })
683
+ })
684
+
685
+ test('FP-C1: first run → runs check, writes fingerprint', async () => {
686
+ const cwd = writeCheckFixture()
687
+ const { spawn, calls } = makeSpawn()
688
+
689
+ await check({ cwd, spawn, force: false })
690
+
691
+ expect(calls.length).toBe(1)
692
+ expect(existsSync(join(cwd, '.proman/check/root.fingerprint'))).toBe(true)
693
+ })
694
+
695
+ test('FP-C2: no changes → skips check', async () => {
696
+ const cwd = writeCheckFixture()
697
+ const { spawn } = makeSpawn()
698
+ await check({ cwd, spawn, force: false })
699
+
700
+ const { spawn: spawn2, calls: calls2 } = makeSpawn()
701
+ await check({ cwd, spawn: spawn2, force: false })
702
+ expect(calls2.length).toBe(0)
703
+ })
704
+
705
+ test('FP-C3: force=true → runs check even if cached', async () => {
706
+ const cwd = writeCheckFixture()
707
+ const { spawn } = makeSpawn()
708
+ await check({ cwd, spawn, force: false })
709
+
710
+ const { spawn: spawn2, calls: calls2 } = makeSpawn()
711
+ await check({ cwd, spawn: spawn2, force: true })
712
+ expect(calls2.length).toBe(1)
713
+ })
714
+
715
+ test('FP-C4: check failure → fingerprint NOT written', async () => {
716
+ const cwd = writeCheckFixture()
717
+ const { spawn } = makeSpawn(1, '', 'check failure')
718
+
719
+ await expect(check({ cwd, spawn, force: false })).rejects.toThrow()
720
+ expect(existsSync(join(cwd, '.proman/check/root.fingerprint'))).toBe(false)
721
+ })
722
+ })
723
+
724
+ describe('format — no fingerprint', () => {
725
+ let tmpDir: string
726
+
727
+ afterEach(() => {
728
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true })
729
+ })
730
+
731
+ test('FP-F1: format always runs, no fingerprint directory created', async () => {
732
+ tmpDir = resolve(tmpdir(), `proman-fp-fmt-${Date.now()}-${Math.random().toString(36).slice(2)}`)
733
+ mkdirSync(tmpDir, { recursive: true })
734
+
735
+ writeFileSync(
736
+ join(tmpDir, 'proman.yaml'),
737
+ 'packages:\n - name: "@test/core"\n path: packages/core\n type: lib\n',
738
+ )
739
+ mkdirSync(join(tmpDir, 'packages/core/src'), { recursive: true })
740
+ writeFileSync(join(tmpDir, 'packages/core/src/index.ts'), 'export const x = 1')
741
+ writeFileSync(join(tmpDir, 'packages/core/package.json'), '{}')
742
+ writeFileSync(join(tmpDir, 'package.json'), '{}')
743
+
744
+ const { spawn, calls } = makeSpawn()
745
+ await format({ cwd: tmpDir, spawn })
746
+ expect(calls.length).toBe(1)
747
+
748
+ // format has no fingerprint logic — no .proman/format/ directory
749
+ expect(existsSync(join(tmpDir, '.proman/format'))).toBe(false)
750
+
751
+ // Second run also always runs
752
+ const { spawn: spawn2, calls: calls2 } = makeSpawn()
753
+ await format({ cwd: tmpDir, spawn: spawn2 })
754
+ expect(calls2.length).toBe(1)
755
+ })
756
+ })