@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,599 @@
1
+ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest'
5
+ import { type GitOps, type NpmRunner, publish } from '../src/commands/publish.ts'
6
+
7
+ function makeGit(overrides: Partial<GitOps> = {}) {
8
+ const calls: string[] = []
9
+ let lastAuthor: string | undefined
10
+ const base: GitOps = {
11
+ getCurrentBranch: async () => 'main',
12
+ isCleanTree: async () => true,
13
+ branchExists: async () => false,
14
+ checkoutNewBranch: async () => {},
15
+ checkoutNewBranchFrom: async () => {},
16
+ tagExists: async () => false,
17
+ addAll: async () => {
18
+ calls.push('add')
19
+ },
20
+ commit: async (m, a) => {
21
+ calls.push(`commit ${m}`)
22
+ lastAuthor = a
23
+ },
24
+ push: async (b) => {
25
+ calls.push(`push ${b}`)
26
+ },
27
+ log: async () => '',
28
+ tag: async (n) => {
29
+ calls.push(`tag ${n}`)
30
+ },
31
+ pushTags: async () => {
32
+ calls.push('pushTags')
33
+ },
34
+ checkout: async () => {},
35
+ merge: async () => {},
36
+ deleteBranchLocal: async () => {},
37
+ deleteBranchRemote: async () => {},
38
+ }
39
+ return { git: { ...base, ...overrides } as GitOps, calls, getAuthor: () => lastAuthor }
40
+ }
41
+
42
+ function makeNpm(overrides: Partial<NpmRunner> = {}) {
43
+ const calls: string[] = []
44
+ const base: NpmRunner = {
45
+ install: async () => {
46
+ calls.push('install')
47
+ },
48
+ build: async () => {
49
+ calls.push('build')
50
+ },
51
+ test: async () => {
52
+ calls.push('test')
53
+ },
54
+ check: async () => {
55
+ calls.push('check')
56
+ },
57
+ format: async () => {
58
+ calls.push('format')
59
+ },
60
+ publish: async (dir, o) => {
61
+ calls.push(`publish ${dir} tag=${o.tag}${o.access ? ` access=${o.access}` : ''}`)
62
+ },
63
+ }
64
+ return { npm: { ...base, ...overrides } as NpmRunner, calls }
65
+ }
66
+
67
+ type FixtureOptions = {
68
+ version?: string
69
+ withChangeset?: boolean
70
+ changesetBody?: string
71
+ access?: string
72
+ multiPkg?: boolean
73
+ gitTagPrefix?: string
74
+ privatePkg?: boolean | 'pkgjson-only' | 'yaml-only'
75
+ }
76
+
77
+ async function setupFixture(tmp: string, opts: FixtureOptions = {}) {
78
+ const version = opts.version ?? '0.3.0'
79
+
80
+ type PkgDef = { name: string; path: string; type: string; private?: boolean }
81
+ const packages: PkgDef[] = opts.multiPkg
82
+ ? [
83
+ { name: '@test/core', path: 'packages/core', type: 'lib' },
84
+ { name: '@test/cli', path: 'packages/cli', type: 'cli' },
85
+ ]
86
+ : [{ name: '@test/core', path: 'packages/core', type: 'lib' }]
87
+
88
+ // Private package in proman.yaml (privatePkg === true or 'yaml-only')
89
+ if (opts.privatePkg) {
90
+ const entry: PkgDef = { name: '@test/private', path: 'packages/private', type: 'lib' }
91
+ if (opts.privatePkg === true || opts.privatePkg === 'yaml-only') entry.private = true
92
+ packages.push(entry)
93
+ }
94
+
95
+ const config: Record<string, unknown> = { packages }
96
+ if (opts.access || opts.gitTagPrefix) {
97
+ const release: Record<string, string> = {}
98
+ if (opts.access) release.access = opts.access
99
+ if (opts.gitTagPrefix) release.gitTagPrefix = opts.gitTagPrefix
100
+ config.release = release
101
+ }
102
+
103
+ const { stringify } = await import('yaml')
104
+ await writeFile(join(tmp, 'proman.yaml'), stringify(config))
105
+
106
+ for (const pkg of packages) {
107
+ const dir = join(tmp, pkg.path)
108
+ await mkdir(dir, { recursive: true })
109
+ // Private via package.json when privatePkg is true or 'pkgjson-only' (not 'yaml-only')
110
+ const isPkgJsonPrivate =
111
+ pkg.name === '@test/private' &&
112
+ opts.privatePkg !== undefined &&
113
+ opts.privatePkg !== 'yaml-only'
114
+ const pkgJson: Record<string, unknown> = { name: pkg.name, version }
115
+ if (isPkgJsonPrivate) pkgJson.private = true
116
+ await writeFile(join(dir, 'package.json'), `${JSON.stringify(pkgJson, null, 2)}\n`)
117
+ }
118
+
119
+ if (opts.withChangeset) {
120
+ const csDir = join(tmp, '.changeset')
121
+ await mkdir(csDir, { recursive: true })
122
+ const body = opts.changesetBody ?? '---\n"@test/core": patch\n---\nFix bug Y\n'
123
+ await writeFile(join(csDir, 'add-feature.md'), body)
124
+ }
125
+ }
126
+
127
+ let tmp: string
128
+
129
+ beforeEach(async () => {
130
+ tmp = await mkdtemp(join(tmpdir(), 'proman-publish-'))
131
+ })
132
+
133
+ afterEach(async () => {
134
+ await rm(tmp, { recursive: true })
135
+ })
136
+
137
+ // ── Build pipeline ──
138
+
139
+ describe('build pipeline', () => {
140
+ test('runs install → build → test → check', async () => {
141
+ await setupFixture(tmp)
142
+ const { git } = makeGit()
143
+ const { npm, calls } = makeNpm()
144
+ await publish({ cwd: tmp, git, npm })
145
+
146
+ const pipeline = calls.filter((c) => ['install', 'build', 'test', 'check'].includes(c))
147
+ expect(pipeline).toEqual(['install', 'build', 'test', 'check'])
148
+ })
149
+
150
+ test('--skip-tests skips test step', async () => {
151
+ await setupFixture(tmp)
152
+ const { git } = makeGit()
153
+ const { npm, calls } = makeNpm()
154
+ await publish({ cwd: tmp, git, npm, skipTests: true })
155
+
156
+ expect(calls).not.toContain('test')
157
+ expect(calls).toContain('build')
158
+ expect(calls).toContain('check')
159
+ })
160
+ })
161
+
162
+ // ── Publish ──
163
+
164
+ describe('publish packages', () => {
165
+ test('publishes with --tag latest for stable version', async () => {
166
+ await setupFixture(tmp, { version: '0.3.0' })
167
+ const { git } = makeGit()
168
+ const { npm, calls } = makeNpm()
169
+ await publish({ cwd: tmp, git, npm })
170
+
171
+ expect(calls).toContain(`publish ${join(tmp, 'packages/core')} tag=latest`)
172
+ })
173
+
174
+ test('publishes with --tag rc for rc version', async () => {
175
+ await setupFixture(tmp, { version: '0.3.0-rc.1' })
176
+ const { git } = makeGit()
177
+ const { npm, calls } = makeNpm()
178
+ await publish({ cwd: tmp, git, npm })
179
+
180
+ expect(calls).toContain(`publish ${join(tmp, 'packages/core')} tag=rc`)
181
+ })
182
+
183
+ test('passes access from config', async () => {
184
+ await setupFixture(tmp, { access: 'public' })
185
+ const { git } = makeGit()
186
+ const { npm, calls } = makeNpm()
187
+ await publish({ cwd: tmp, git, npm })
188
+
189
+ expect(calls).toContain(`publish ${join(tmp, 'packages/core')} tag=latest access=public`)
190
+ })
191
+
192
+ test('multi-package publish in order', async () => {
193
+ await setupFixture(tmp, { multiPkg: true })
194
+ const { git } = makeGit()
195
+ const { npm, calls } = makeNpm()
196
+ await publish({ cwd: tmp, git, npm })
197
+
198
+ const publishes = calls.filter((c) => c.startsWith('publish'))
199
+ expect(publishes).toHaveLength(2)
200
+ expect(publishes[0]).toContain('packages/core')
201
+ expect(publishes[1]).toContain('packages/cli')
202
+ })
203
+
204
+ test('publish failure reports partial progress', async () => {
205
+ await setupFixture(tmp, { multiPkg: true })
206
+ const { git } = makeGit()
207
+ const { npm } = makeNpm({
208
+ publish: async (dir) => {
209
+ if (dir.includes('cli')) throw new Error('auth failed')
210
+ },
211
+ })
212
+ await expect(publish({ cwd: tmp, git, npm })).rejects.toThrow('publish failed for @test/cli')
213
+ })
214
+
215
+ test('skips already-published packages and continues', async () => {
216
+ await setupFixture(tmp, { multiPkg: true })
217
+ const { git } = makeGit()
218
+ const published: string[] = []
219
+ const { npm } = makeNpm({
220
+ publish: async (dir) => {
221
+ if (dir.includes('core')) {
222
+ throw new Error('You cannot publish over the previously published versions: 0.3.0')
223
+ }
224
+ published.push(dir)
225
+ },
226
+ })
227
+ const logs: string[] = []
228
+ const origLog = console.log
229
+ console.log = (...args: unknown[]) => logs.push(args.join(' '))
230
+ try {
231
+ await publish({ cwd: tmp, git, npm })
232
+ } finally {
233
+ console.log = origLog
234
+ }
235
+
236
+ expect(logs.some((l) => l.includes('⏭ skipped @test/core@0.3.0 (already published)'))).toBe(
237
+ true,
238
+ )
239
+ expect(published.some((d) => d.includes('cli'))).toBe(true)
240
+ })
241
+
242
+ test('pre-checks registry and skips already-published without calling npm publish', async () => {
243
+ await setupFixture(tmp, { multiPkg: true })
244
+ const { git } = makeGit()
245
+ const published: string[] = []
246
+ const { npm } = makeNpm({
247
+ publish: async (dir) => {
248
+ published.push(dir)
249
+ },
250
+ })
251
+ // Mock registry: core@0.3.0 already exists
252
+ const registryFetch = async (pkg: string) => {
253
+ if (pkg === '@test/core') return ['0.1.0', '0.2.0', '0.3.0']
254
+ return []
255
+ }
256
+ const logs: string[] = []
257
+ const origLog = console.log
258
+ console.log = (...args: unknown[]) => logs.push(args.join(' '))
259
+ try {
260
+ await publish({ cwd: tmp, git, npm, registryFetch })
261
+ } finally {
262
+ console.log = origLog
263
+ }
264
+
265
+ // core should be skipped via pre-check, not via error catch
266
+ expect(logs.some((l) => l.includes('⏭ skipped @test/core@0.3.0 (already published)'))).toBe(
267
+ true,
268
+ )
269
+ // npm.publish should NOT have been called for core
270
+ expect(published.some((d) => d.includes('core'))).toBe(false)
271
+ // cli should still be published
272
+ expect(published.some((d) => d.includes('cli'))).toBe(true)
273
+ })
274
+
275
+ test('real publish errors still abort', async () => {
276
+ await setupFixture(tmp, { multiPkg: true })
277
+ const { git } = makeGit()
278
+ const { npm } = makeNpm({
279
+ publish: async (dir) => {
280
+ if (dir.includes('core')) throw new Error('npm ERR! 401 Unauthorized')
281
+ },
282
+ })
283
+ await expect(publish({ cwd: tmp, git, npm })).rejects.toThrow('publish failed for @test/core')
284
+ })
285
+
286
+ test('skips packages with private: true in proman.yaml only', async () => {
287
+ // privatePkg === 'yaml-only': proman.yaml has private: true, package.json does NOT
288
+ await setupFixture(tmp, { privatePkg: 'yaml-only' })
289
+ const { git } = makeGit()
290
+ const { npm, calls } = makeNpm()
291
+ const logs: string[] = []
292
+ const origLog = console.log
293
+ console.log = (...args: unknown[]) => logs.push(args.join(' '))
294
+ try {
295
+ await publish({ cwd: tmp, git, npm })
296
+ } finally {
297
+ console.log = origLog
298
+ }
299
+
300
+ const publishCalls = calls.filter((c) => c.startsWith('publish'))
301
+ expect(publishCalls.every((c) => !c.includes('private'))).toBe(true)
302
+ expect(logs.some((l) => l.includes('⏭ skipped @test/private (private)'))).toBe(true)
303
+ })
304
+
305
+ test('skips packages with private: true in proman.yaml', async () => {
306
+ await setupFixture(tmp, { privatePkg: true })
307
+ const { git } = makeGit()
308
+ const { npm, calls } = makeNpm()
309
+ const logs: string[] = []
310
+ const origLog = console.log
311
+ console.log = (...args: unknown[]) => logs.push(args.join(' '))
312
+ try {
313
+ await publish({ cwd: tmp, git, npm })
314
+ } finally {
315
+ console.log = origLog
316
+ }
317
+
318
+ const publishCalls = calls.filter((c) => c.startsWith('publish'))
319
+ expect(publishCalls.every((c) => !c.includes('private'))).toBe(true)
320
+ expect(logs.some((l) => l.includes('⏭ skipped @test/private (private)'))).toBe(true)
321
+ })
322
+
323
+ test('skips packages with private: true in package.json', async () => {
324
+ // privatePkg === 'pkgjson-only': no private flag in proman.yaml, but package.json has "private": true
325
+ await setupFixture(tmp, { privatePkg: 'pkgjson-only' })
326
+ const { git } = makeGit()
327
+ const { npm, calls } = makeNpm()
328
+ const logs: string[] = []
329
+ const origLog = console.log
330
+ console.log = (...args: unknown[]) => logs.push(args.join(' '))
331
+ try {
332
+ await publish({ cwd: tmp, git, npm })
333
+ } finally {
334
+ console.log = origLog
335
+ }
336
+
337
+ const publishCalls = calls.filter((c) => c.startsWith('publish'))
338
+ expect(publishCalls.every((c) => !c.includes('private'))).toBe(true)
339
+ expect(logs.some((l) => l.includes('⏭ skipped @test/private (private)'))).toBe(true)
340
+ })
341
+
342
+ test('private packages do not break publish pipeline', async () => {
343
+ // Mix of normal + private: pipeline should succeed and only publish non-private ones
344
+ await setupFixture(tmp, { multiPkg: true, privatePkg: true })
345
+ const { git } = makeGit()
346
+ const { npm, calls } = makeNpm()
347
+ await expect(publish({ cwd: tmp, git, npm })).resolves.toBeUndefined()
348
+
349
+ const publishCalls = calls.filter((c) => c.startsWith('publish'))
350
+ expect(publishCalls).toHaveLength(2)
351
+ expect(publishCalls.some((c) => c.includes('core'))).toBe(true)
352
+ expect(publishCalls.some((c) => c.includes('cli'))).toBe(true)
353
+ expect(publishCalls.every((c) => !c.includes('private'))).toBe(true)
354
+ })
355
+ })
356
+
357
+ // ── RC version ──
358
+
359
+ describe('rc versions', () => {
360
+ test('rc version does not affect changesets (publish never touches them)', async () => {
361
+ await setupFixture(tmp, { version: '0.3.0-rc.1', withChangeset: true })
362
+ const { git } = makeGit()
363
+ const { npm } = makeNpm()
364
+ await publish({ cwd: tmp, git, npm })
365
+
366
+ // Changesets untouched by publish regardless of RC
367
+ const csDir = join(tmp, '.changeset')
368
+ const files = await readdir(csDir)
369
+ expect(files).toContain('add-feature.md')
370
+ })
371
+
372
+ test('uses rc tag for git tag', async () => {
373
+ await setupFixture(tmp, { version: '1.0.0-rc.1' })
374
+ const { git, calls } = makeGit()
375
+ const { npm } = makeNpm()
376
+ await publish({ cwd: tmp, git, npm })
377
+
378
+ expect(calls).toContain('tag @test/core@v1.0.0-rc.1')
379
+ })
380
+ })
381
+
382
+ // ── Changelog (moved to bump in issue #74) ──
383
+
384
+ describe('changelog', () => {
385
+ test('does not generate changelog even with changesets present', async () => {
386
+ await setupFixture(tmp, { version: '0.2.1', withChangeset: true })
387
+ const { git } = makeGit()
388
+ const { npm } = makeNpm()
389
+ await publish({ cwd: tmp, git, npm })
390
+
391
+ // No CHANGELOG.md created — that's bump's job now
392
+ const exists = await readFile(join(tmp, 'packages/core/CHANGELOG.md'), 'utf8').catch(() => null)
393
+ expect(exists).toBeNull()
394
+ })
395
+
396
+ test('does not delete changeset files', async () => {
397
+ await setupFixture(tmp, { version: '0.2.1', withChangeset: true })
398
+ const { git } = makeGit()
399
+ const { npm } = makeNpm()
400
+ await publish({ cwd: tmp, git, npm })
401
+
402
+ const csDir = join(tmp, '.changeset')
403
+ const files = await readdir(csDir)
404
+ expect(files).toContain('add-feature.md')
405
+ })
406
+ })
407
+
408
+ // ── Git operations ──
409
+
410
+ describe('git operations', () => {
411
+ test('commits with author, tags, pushes', async () => {
412
+ await setupFixture(tmp, { version: '0.3.0' })
413
+ const { git, calls, getAuthor } = makeGit()
414
+ const { npm } = makeNpm()
415
+ await publish({ cwd: tmp, git, npm })
416
+
417
+ expect(calls).toContain('add')
418
+ expect(calls).toContain('commit release: v0.3.0')
419
+ expect(calls).toContain('tag @test/core@v0.3.0')
420
+ expect(calls).toContain('pushTags')
421
+ expect(calls).toContain('push main')
422
+ expect(getAuthor()).toBe('小橘 <xiaoju@shazhou.work>')
423
+ })
424
+
425
+ test('custom git tag prefix', async () => {
426
+ await setupFixture(tmp, { version: '0.2.0', gitTagPrefix: 'release-' })
427
+ const { git, calls } = makeGit()
428
+ const { npm } = makeNpm()
429
+ await publish({ cwd: tmp, git, npm })
430
+
431
+ expect(calls).toContain('tag @test/core@release-0.2.0')
432
+ })
433
+
434
+ test('tags all publishable packages (publish does not read changesets)', async () => {
435
+ await setupFixture(tmp, {
436
+ multiPkg: true,
437
+ withChangeset: true,
438
+ changesetBody: '---\n"@test/core": patch\n---\nFix core bug\n',
439
+ })
440
+ const { git, calls } = makeGit()
441
+ const { npm } = makeNpm()
442
+ await publish({ cwd: tmp, git, npm })
443
+
444
+ const tags = calls.filter((c) => c.startsWith('tag '))
445
+ expect(tags).toHaveLength(2)
446
+ expect(tags[0]).toContain('@test/core@v')
447
+ expect(tags[1]).toContain('@test/cli@v')
448
+ })
449
+
450
+ test('tags all packages when no changesets (manual bump)', async () => {
451
+ await setupFixture(tmp, { multiPkg: true })
452
+ const { git, calls } = makeGit()
453
+ const { npm } = makeNpm()
454
+ await publish({ cwd: tmp, git, npm })
455
+
456
+ const tags = calls.filter((c) => c.startsWith('tag '))
457
+ expect(tags).toHaveLength(2)
458
+ expect(tags[0]).toContain('@test/core@v')
459
+ expect(tags[1]).toContain('@test/cli@v')
460
+ })
461
+ })
462
+
463
+ // ── Smoke test tarball ──
464
+
465
+ describe('smoke test tarball', () => {
466
+ test('runs smoke test before npm publish for packages with bin entry', async () => {
467
+ // Create package with bin entry
468
+ await setupFixture(tmp)
469
+ const pkgDir = join(tmp, 'packages/core')
470
+ const pkgJsonPath = join(pkgDir, 'package.json')
471
+ const pkgJson = await readFile(pkgJsonPath, 'utf8')
472
+ const parsed = JSON.parse(pkgJson) as Record<string, unknown>
473
+ parsed.bin = { testcli: './dist/cli.js' }
474
+ await writeFile(pkgJsonPath, JSON.stringify(parsed, null, 2))
475
+
476
+ const { git } = makeGit()
477
+ const spawnCalls: string[] = []
478
+ const { npm } = makeNpm({
479
+ publish: async (dir) => {
480
+ spawnCalls.push(`publish ${dir}`)
481
+ },
482
+ })
483
+
484
+ const mockSpawn = async (argv: string[], _cwd: string) => {
485
+ const cmd = argv.join(' ')
486
+ spawnCalls.push(cmd)
487
+ if (cmd.includes('pack')) {
488
+ return { code: 0, stdout: 'test-0.3.0.tgz\n', stderr: '' }
489
+ }
490
+ if (cmd.includes('--version')) {
491
+ return { code: 0, stdout: '0.3.0\n', stderr: '' }
492
+ }
493
+ if (cmd.startsWith('tar ')) {
494
+ return { code: 0, stdout: '', stderr: '' }
495
+ }
496
+ return { code: 0, stdout: '', stderr: '' }
497
+ }
498
+
499
+ await publish({ cwd: tmp, git, npm, spawn: mockSpawn })
500
+
501
+ // Verify npm pack and bin test ran before publish
502
+ const packIdx = spawnCalls.findIndex((c) => c.includes('pack'))
503
+ const publishIdx = spawnCalls.findIndex((c) => c.startsWith('publish'))
504
+ expect(packIdx).toBeGreaterThan(-1)
505
+ expect(publishIdx).toBeGreaterThan(-1)
506
+ expect(packIdx).toBeLessThan(publishIdx)
507
+ })
508
+
509
+ test('aborts publish when smoke test fails', async () => {
510
+ await setupFixture(tmp)
511
+ const pkgDir = join(tmp, 'packages/core')
512
+ const pkgJsonPath = join(pkgDir, 'package.json')
513
+ const pkgJson = await readFile(pkgJsonPath, 'utf8')
514
+ const parsed = JSON.parse(pkgJson) as Record<string, unknown>
515
+ parsed.bin = { broken: './dist/cli.js' }
516
+ await writeFile(pkgJsonPath, JSON.stringify(parsed, null, 2))
517
+
518
+ const { git } = makeGit()
519
+ const published: string[] = []
520
+ const { npm } = makeNpm({
521
+ publish: async (dir) => {
522
+ published.push(dir)
523
+ },
524
+ })
525
+
526
+ const mockSpawn = async (argv: string[]) => {
527
+ if (argv.includes('pack')) {
528
+ return { code: 0, stdout: 'test-0.3.0.tgz\n', stderr: '' }
529
+ }
530
+ if (argv.includes('--version')) {
531
+ // Simulate broken bin
532
+ return { code: 1, stdout: '', stderr: 'Error: Cannot find module' }
533
+ }
534
+ if (argv[0] === 'tar') {
535
+ return { code: 0, stdout: '', stderr: '' }
536
+ }
537
+ return { code: 0, stdout: '', stderr: '' }
538
+ }
539
+
540
+ await expect(publish({ cwd: tmp, git, npm, spawn: mockSpawn })).rejects.toThrow(
541
+ 'smoke test failed',
542
+ )
543
+
544
+ // Verify npm publish was never called
545
+ expect(published.length).toBe(0)
546
+ })
547
+
548
+ test('skips smoke test for packages without bin entry', async () => {
549
+ // Package without bin entry (pure library)
550
+ await setupFixture(tmp)
551
+
552
+ const { git } = makeGit()
553
+ const { npm } = makeNpm()
554
+ const spawnCalls: string[] = []
555
+ const mockSpawn = async (argv: string[]) => {
556
+ spawnCalls.push(argv.join(' '))
557
+ return { code: 0, stdout: '', stderr: '' }
558
+ }
559
+
560
+ await publish({ cwd: tmp, git, npm, spawn: mockSpawn })
561
+
562
+ // Should not call npm pack or tar (no smoke test needed)
563
+ expect(spawnCalls.some((c) => c.includes('pack'))).toBe(false)
564
+ expect(spawnCalls.some((c) => c.includes('tar'))).toBe(false)
565
+ })
566
+
567
+ test('smoke test does not prevent git operations on success', async () => {
568
+ await setupFixture(tmp)
569
+ const pkgDir = join(tmp, 'packages/core')
570
+ const pkgJsonPath = join(pkgDir, 'package.json')
571
+ const pkgJson = await readFile(pkgJsonPath, 'utf8')
572
+ const parsed = JSON.parse(pkgJson) as Record<string, unknown>
573
+ parsed.bin = { testcli: './dist/cli.js' }
574
+ await writeFile(pkgJsonPath, JSON.stringify(parsed, null, 2))
575
+
576
+ const { git, calls } = makeGit()
577
+ const { npm } = makeNpm()
578
+
579
+ const mockSpawn = async (argv: string[]) => {
580
+ if (argv.includes('pack')) {
581
+ return { code: 0, stdout: 'test-0.3.0.tgz\n', stderr: '' }
582
+ }
583
+ if (argv.includes('--version')) {
584
+ return { code: 0, stdout: '0.3.0\n', stderr: '' }
585
+ }
586
+ if (argv[0] === 'tar') {
587
+ return { code: 0, stdout: '', stderr: '' }
588
+ }
589
+ return { code: 0, stdout: '', stderr: '' }
590
+ }
591
+
592
+ await publish({ cwd: tmp, git, npm, spawn: mockSpawn })
593
+
594
+ // Git operations should still happen
595
+ expect(calls).toContain('add')
596
+ expect(calls).toContain('commit release: v0.3.0')
597
+ expect(calls).toContain('pushTags')
598
+ })
599
+ })