@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,290 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'
3
+ import { basename, join, resolve } from 'node:path'
4
+
5
+ export type InitOptions = {
6
+ targetDir: string
7
+ }
8
+
9
+ function jsonStringify(obj: unknown): string {
10
+ return JSON.stringify(obj, null, 2)
11
+ }
12
+
13
+ /**
14
+ * Sanitize a directory name into a valid npm package name segment.
15
+ *
16
+ * Rules applied (per https://github.com/npm/validate-npm-package-name):
17
+ * - lowercase only
18
+ * - strip characters not in `[a-z0-9._-]` (replaces with hyphens)
19
+ * - strip leading `.`, `_`, or `-`
20
+ * - collapse consecutive hyphens
21
+ * - enforce 214-character npm limit
22
+ * - fall back to `'my-project'` if nothing remains
23
+ */
24
+ function toPackageName(dirName: string): string {
25
+ return (
26
+ dirName
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9._-]/g, '-') // replace invalid chars
29
+ .replace(/^[._-]+/, '') // strip leading dots/hyphens/underscores
30
+ .replace(/-+/g, '-') // collapse consecutive hyphens
31
+ .slice(0, 214) || // npm name length limit
32
+ 'my-project'
33
+ ) // fallback if everything was stripped
34
+ }
35
+
36
+ export async function init(opts: InitOptions): Promise<void> {
37
+ const targetDir = resolve(opts.targetDir)
38
+ const projectName = toPackageName(basename(targetDir))
39
+
40
+ // Check if directory is empty
41
+ if (existsSync(targetDir)) {
42
+ const entries = readdirSync(targetDir)
43
+ if (entries.length > 0) {
44
+ throw new Error(`Directory is not empty: ${targetDir}`)
45
+ }
46
+ } else {
47
+ mkdirSync(targetDir, { recursive: true })
48
+ }
49
+
50
+ // Create root files
51
+ createRootPackageJson(targetDir, projectName)
52
+ createPromanYaml(targetDir, projectName)
53
+ createPnpmWorkspace(targetDir)
54
+ createBiomeJson(targetDir)
55
+ createTsConfig(targetDir)
56
+ createGitignore(targetDir)
57
+
58
+ // Create packages
59
+ createCorePackage(targetDir, projectName)
60
+ createCliPackage(targetDir, projectName)
61
+
62
+ // Format all JSON files with biome
63
+ try {
64
+ execSync('pnpm exec biome format --write .', { cwd: targetDir, stdio: 'ignore' })
65
+ } catch {
66
+ // Ignore biome formatting errors during init - user can run format later
67
+ }
68
+
69
+ // Print post-init message
70
+ console.log(`✓ Created monorepo in ${targetDir}`)
71
+ console.log('')
72
+ console.log('Next steps:')
73
+ if (targetDir !== process.cwd()) {
74
+ console.log(` cd ${projectName}`)
75
+ }
76
+ console.log(' pnpm install')
77
+ console.log(' proman build')
78
+ }
79
+
80
+ function createRootPackageJson(targetDir: string, projectName: string): void {
81
+ const content = {
82
+ name: projectName,
83
+ private: true,
84
+ type: 'module',
85
+ scripts: {
86
+ build: 'proman build',
87
+ test: 'proman test',
88
+ check: 'proman check',
89
+ format: 'proman format',
90
+ },
91
+ devDependencies: {
92
+ '@biomejs/biome': '^2.4.16',
93
+ '@shazhou/proman': '^0.7.0',
94
+ '@types/node': '^22.0.0',
95
+ typescript: '^5.9.3',
96
+ vitest: '^4.1.8',
97
+ },
98
+ }
99
+ writeFileSync(join(targetDir, 'package.json'), `${jsonStringify(content)}\n`)
100
+ }
101
+
102
+ function createPromanYaml(targetDir: string, projectName: string): void {
103
+ const content = `packages:
104
+ - name: '@${projectName}/core'
105
+ path: packages/core
106
+ type: lib
107
+ - name: '@${projectName}/cli'
108
+ path: packages/cli
109
+ type: cli
110
+ `
111
+ writeFileSync(join(targetDir, 'proman.yaml'), content)
112
+ }
113
+
114
+ function createPnpmWorkspace(targetDir: string): void {
115
+ const content = `packages:
116
+ - 'packages/*'
117
+
118
+ allowBuilds:
119
+ esbuild: true
120
+ `
121
+ writeFileSync(join(targetDir, 'pnpm-workspace.yaml'), content)
122
+ }
123
+
124
+ function createBiomeJson(targetDir: string): void {
125
+ const content = {
126
+ $schema: 'https://biomejs.dev/schemas/2.4.16/schema.json',
127
+ assist: { actions: { source: { organizeImports: 'on' } } },
128
+ linter: {
129
+ enabled: true,
130
+ rules: { recommended: true },
131
+ },
132
+ formatter: {
133
+ enabled: true,
134
+ indentStyle: 'space',
135
+ indentWidth: 2,
136
+ lineWidth: 100,
137
+ },
138
+ javascript: {
139
+ formatter: {
140
+ quoteStyle: 'single',
141
+ semicolons: 'asNeeded',
142
+ trailingCommas: 'all',
143
+ },
144
+ },
145
+ files: {
146
+ includes: ['**', '!**/dist', '!**/node_modules', '!**/tests/fixtures', '!.worktrees'],
147
+ },
148
+ }
149
+ writeFileSync(join(targetDir, 'biome.json'), `${jsonStringify(content)}\n`)
150
+ }
151
+
152
+ function createTsConfig(targetDir: string): void {
153
+ const content = {
154
+ compilerOptions: {
155
+ target: 'ESNext',
156
+ module: 'ESNext',
157
+ moduleResolution: 'bundler',
158
+ strict: true,
159
+ skipLibCheck: true,
160
+ verbatimModuleSyntax: true,
161
+ esModuleInterop: true,
162
+ resolveJsonModule: true,
163
+ types: ['node'],
164
+ lib: ['ESNext'],
165
+ },
166
+ references: [{ path: './packages/core' }, { path: './packages/cli' }],
167
+ }
168
+ writeFileSync(join(targetDir, 'tsconfig.json'), `${jsonStringify(content)}\n`)
169
+ }
170
+
171
+ function createGitignore(targetDir: string): void {
172
+ const content = `node_modules
173
+ dist
174
+ .proman
175
+ *.tsbuildinfo
176
+ `
177
+ writeFileSync(join(targetDir, '.gitignore'), content)
178
+ }
179
+
180
+ function createCorePackage(targetDir: string, projectName: string): void {
181
+ const pkgDir = join(targetDir, 'packages', 'core')
182
+ mkdirSync(join(pkgDir, 'src'), { recursive: true })
183
+
184
+ // package.json
185
+ const packageJson = {
186
+ name: `@${projectName}/core`,
187
+ version: '0.0.1',
188
+ type: 'module',
189
+ exports: {
190
+ '.': {
191
+ types: './dist/index.d.ts',
192
+ default: './dist/index.js',
193
+ },
194
+ },
195
+ files: ['dist'],
196
+ scripts: {
197
+ build: 'tsc --build',
198
+ },
199
+ }
200
+ writeFileSync(join(pkgDir, 'package.json'), `${jsonStringify(packageJson)}\n`)
201
+
202
+ // tsconfig.json
203
+ const tsConfig = {
204
+ extends: '../../tsconfig.json',
205
+ compilerOptions: {
206
+ composite: true,
207
+ outDir: 'dist',
208
+ rootDir: 'src',
209
+ noEmit: false,
210
+ declaration: true,
211
+ },
212
+ include: ['src/**/*'],
213
+ }
214
+ writeFileSync(join(pkgDir, 'tsconfig.json'), `${jsonStringify(tsConfig)}\n`)
215
+
216
+ // src/index.ts
217
+ const indexTs = `export function hello(): string {
218
+ return 'hello'
219
+ }
220
+ `
221
+ writeFileSync(join(pkgDir, 'src', 'index.ts'), indexTs)
222
+
223
+ // src/index.test.ts
224
+ const testTs = `import { describe, expect, test } from 'vitest'
225
+ import { hello } from './index.js'
226
+
227
+ describe('hello', () => {
228
+ test('returns hello', () => {
229
+ expect(hello()).toBe('hello')
230
+ })
231
+ })
232
+ `
233
+ writeFileSync(join(pkgDir, 'src', 'index.test.ts'), testTs)
234
+ }
235
+
236
+ function createCliPackage(targetDir: string, projectName: string): void {
237
+ const pkgDir = join(targetDir, 'packages', 'cli')
238
+ mkdirSync(join(pkgDir, 'src'), { recursive: true })
239
+
240
+ // package.json
241
+ const packageJson = {
242
+ name: `@${projectName}/cli`,
243
+ version: '0.0.1',
244
+ type: 'module',
245
+ bin: {
246
+ [projectName]: 'dist/cli.js',
247
+ },
248
+ files: ['dist'],
249
+ scripts: {
250
+ build: 'tsc --build',
251
+ },
252
+ dependencies: {
253
+ [`@${projectName}/core`]: 'workspace:*',
254
+ },
255
+ }
256
+ writeFileSync(join(pkgDir, 'package.json'), `${jsonStringify(packageJson)}\n`)
257
+
258
+ // tsconfig.json
259
+ const tsConfig = {
260
+ extends: '../../tsconfig.json',
261
+ compilerOptions: {
262
+ composite: true,
263
+ outDir: 'dist',
264
+ rootDir: 'src',
265
+ noEmit: false,
266
+ },
267
+ include: ['src/**/*'],
268
+ references: [{ path: '../core' }],
269
+ }
270
+ writeFileSync(join(pkgDir, 'tsconfig.json'), `${jsonStringify(tsConfig)}\n`)
271
+
272
+ // src/cli.ts
273
+ const cliTs = `#!/usr/bin/env node
274
+ import { hello } from '@${projectName}/core'
275
+
276
+ console.log(hello())
277
+ `
278
+ writeFileSync(join(pkgDir, 'src', 'cli.ts'), cliTs)
279
+
280
+ // src/cli.test.ts
281
+ const testTs = `import { describe, expect, test } from 'vitest'
282
+
283
+ describe('cli', () => {
284
+ test('placeholder test', () => {
285
+ expect(true).toBe(true)
286
+ })
287
+ })
288
+ `
289
+ writeFileSync(join(pkgDir, 'src', 'cli.test.ts'), testTs)
290
+ }
@@ -0,0 +1,195 @@
1
+ import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { defaultSpawn, runOrThrow, type SpawnFn } from '../utils/npm.js'
4
+
5
+ export type LinkCommandOptions = {
6
+ cwd: string
7
+ packageName?: string
8
+ spawn?: SpawnFn
9
+ }
10
+
11
+ function readPackageJson(cwd: string): {
12
+ name?: string
13
+ dependencies?: Record<string, string>
14
+ devDependencies?: Record<string, string>
15
+ } {
16
+ const pkgPath = join(cwd, 'package.json')
17
+ if (!existsSync(pkgPath)) {
18
+ throw new Error(`Missing package.json in ${cwd}`)
19
+ }
20
+ const json = JSON.parse(readFileSync(pkgPath, 'utf-8'))
21
+
22
+ // Runtime validation: ensure parsed value is an object (not array, primitive, or null)
23
+ if (typeof json !== 'object' || json === null || Array.isArray(json)) {
24
+ throw new Error(`Invalid package.json at ${pkgPath}: expected a JSON object`)
25
+ }
26
+
27
+ return json
28
+ }
29
+
30
+ function hasDistFolder(cwd: string): boolean {
31
+ const distPath = join(cwd, 'dist')
32
+ return existsSync(distPath)
33
+ }
34
+
35
+ /**
36
+ * Link a package globally (provider mode) or link from global registry (consumer mode)
37
+ */
38
+ export async function link(opts: LinkCommandOptions): Promise<void> {
39
+ const spawn = opts.spawn ?? defaultSpawn
40
+ const cwd = resolve(opts.cwd)
41
+
42
+ // Consumer mode: link specific package from global registry
43
+ if (opts.packageName) {
44
+ const pkg = readPackageJson(cwd)
45
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
46
+ if (!allDeps[opts.packageName]) {
47
+ throw new Error(
48
+ `Package "${opts.packageName}" not found in dependencies or devDependencies of ${cwd}`,
49
+ )
50
+ }
51
+
52
+ await runOrThrow(spawn, ['pnpm', 'link', '--global', opts.packageName], cwd)
53
+ console.log(`✓ Linked ${opts.packageName} from global registry`)
54
+ return
55
+ }
56
+
57
+ // Provider mode: link current package globally
58
+ const pkg = readPackageJson(cwd)
59
+ if (!pkg.name) {
60
+ throw new Error(`package.json in ${cwd} is missing a "name" field`)
61
+ }
62
+
63
+ if (!hasDistFolder(cwd)) {
64
+ throw new Error(`No dist/ folder in ${cwd}. Run \`proman build\` first.`)
65
+ }
66
+
67
+ await runOrThrow(spawn, ['pnpm', 'link', '--global'], cwd)
68
+ console.log(`✓ Linked ${pkg.name} globally`)
69
+ }
70
+
71
+ /**
72
+ * Show currently linked packages
73
+ */
74
+ export async function linkStatus(opts: Omit<LinkCommandOptions, 'packageName'>): Promise<string> {
75
+ const cwd = resolve(opts.cwd)
76
+ const nodeModulesDir = join(cwd, 'node_modules')
77
+
78
+ if (!existsSync(nodeModulesDir)) {
79
+ return 'No linked packages found'
80
+ }
81
+
82
+ const linkedPackages: { name: string; target: string }[] = []
83
+
84
+ // Scan node_modules for symlinks
85
+ function scanDir(dir: string, prefix = ''): void {
86
+ if (!existsSync(dir)) return
87
+
88
+ for (const entry of readdirSync(dir)) {
89
+ const fullPath = join(dir, entry)
90
+ const stat = lstatSync(fullPath)
91
+
92
+ // Check if it's a symlink
93
+ if (stat.isSymbolicLink()) {
94
+ const target = readlinkSync(fullPath)
95
+ const resolvedTarget = resolve(dir, target)
96
+ const pkgJsonPath = join(fullPath, 'package.json')
97
+
98
+ if (existsSync(pkgJsonPath)) {
99
+ try {
100
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'))
101
+ const name = pkgJson.name || `${prefix}${entry}`
102
+ linkedPackages.push({ name, target: resolvedTarget })
103
+ } catch {
104
+ // Invalid package.json, skip
105
+ }
106
+ }
107
+ } else if (stat.isDirectory() && entry.startsWith('@')) {
108
+ // Scan scoped packages
109
+ scanDir(fullPath, `${entry}/`)
110
+ }
111
+ }
112
+ }
113
+
114
+ scanDir(nodeModulesDir)
115
+
116
+ if (linkedPackages.length === 0) {
117
+ return 'No linked packages found'
118
+ }
119
+
120
+ const lines = ['Linked packages:']
121
+ for (const { name, target } of linkedPackages) {
122
+ lines.push(`• ${name} → ${target}`)
123
+ }
124
+
125
+ return lines.join('\n')
126
+ }
127
+
128
+ /**
129
+ * Unlink packages (all or specific)
130
+ */
131
+ export async function unlink(opts: LinkCommandOptions): Promise<void> {
132
+ const spawn = opts.spawn ?? defaultSpawn
133
+ const cwd = resolve(opts.cwd)
134
+
135
+ // Unlink specific package
136
+ if (opts.packageName) {
137
+ await runOrThrow(spawn, ['pnpm', 'unlink', opts.packageName], cwd)
138
+ await runOrThrow(spawn, ['pnpm', 'install', opts.packageName], cwd)
139
+ console.log(`✓ Unlinked ${opts.packageName} and restored from registry`)
140
+ return
141
+ }
142
+
143
+ // Unlink all packages
144
+ const nodeModulesDir = join(cwd, 'node_modules')
145
+
146
+ if (!existsSync(nodeModulesDir)) {
147
+ console.log('No linked packages to unlink')
148
+ return
149
+ }
150
+
151
+ const linkedPackages: string[] = []
152
+
153
+ // Find all symlinked packages
154
+ function scanDir(dir: string): void {
155
+ if (!existsSync(dir)) return
156
+
157
+ for (const entry of readdirSync(dir)) {
158
+ const fullPath = join(dir, entry)
159
+ const stat = lstatSync(fullPath)
160
+
161
+ if (stat.isSymbolicLink()) {
162
+ const pkgJsonPath = join(fullPath, 'package.json')
163
+ if (existsSync(pkgJsonPath)) {
164
+ try {
165
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'))
166
+ if (pkgJson.name) {
167
+ linkedPackages.push(pkgJson.name)
168
+ }
169
+ } catch {
170
+ // Invalid package.json, skip
171
+ }
172
+ }
173
+ } else if (stat.isDirectory() && entry.startsWith('@')) {
174
+ scanDir(fullPath)
175
+ }
176
+ }
177
+ }
178
+
179
+ scanDir(nodeModulesDir)
180
+
181
+ if (linkedPackages.length === 0) {
182
+ console.log('No linked packages to unlink')
183
+ return
184
+ }
185
+
186
+ // Unlink each package
187
+ for (const pkgName of linkedPackages) {
188
+ await runOrThrow(spawn, ['pnpm', 'unlink', pkgName], cwd)
189
+ }
190
+
191
+ // Restore all packages
192
+ await runOrThrow(spawn, ['pnpm', 'install'], cwd)
193
+
194
+ console.log(`✓ Unlinked ${linkedPackages.length} package(s) and restored from registry`)
195
+ }
@@ -0,0 +1,168 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+ import { loadConfig } from '../config/load-config.js'
4
+ import { createGitOps, type GitOps } from '../utils/git.js'
5
+ import {
6
+ createNpmRunner,
7
+ defaultRegistryFetch,
8
+ defaultSpawn,
9
+ type NpmRegistryFetch,
10
+ type NpmRunner,
11
+ type SpawnFn,
12
+ } from '../utils/npm.js'
13
+ import { smokeTestTarball } from '../utils/smoke-test.js'
14
+
15
+ export type { GitOps } from '../utils/git.js'
16
+ export type { NpmRunner } from '../utils/npm.js'
17
+
18
+ export type PublishOptions = {
19
+ skipTests?: boolean
20
+ cwd?: string
21
+ git?: GitOps
22
+ npm?: NpmRunner
23
+ registryFetch?: NpmRegistryFetch
24
+ spawn?: SpawnFn
25
+ }
26
+
27
+ const AUTHOR = '小橘 <xiaoju@shazhou.work>'
28
+
29
+ async function readJson(path: string): Promise<Record<string, unknown>> {
30
+ const text = await readFile(path, 'utf8')
31
+ return JSON.parse(text) as Record<string, unknown>
32
+ }
33
+
34
+ function isRcVersion(version: string): boolean {
35
+ return /-rc\.\d+$/.test(version)
36
+ }
37
+
38
+ const ALREADY_PUBLISHED_RE =
39
+ /cannot publish over the previously published versions|you cannot publish over the previously published version/i
40
+
41
+ function isAlreadyPublished(message: string): boolean {
42
+ return ALREADY_PUBLISHED_RE.test(message)
43
+ }
44
+
45
+ /**
46
+ * Publish all packages. Reads each package's version from its own package.json.
47
+ * build → test → check → smoke test tarball → publish → commit → tag → push
48
+ */
49
+ export async function publish(opts: PublishOptions = {}): Promise<void> {
50
+ const { skipTests = false } = opts
51
+ const cwd = opts.cwd ?? process.cwd()
52
+ const git = opts.git ?? createGitOps(cwd)
53
+ const fetchVersions = opts.registryFetch ?? defaultRegistryFetch
54
+ const spawn = opts.spawn ?? defaultSpawn
55
+
56
+ const cfg = loadConfig(cwd)
57
+ const npm = opts.npm ?? createNpmRunner(cwd)
58
+
59
+ // Separate publishable (non-private) from private packages
60
+ type PkgJsonInfo = { version: string; private?: boolean }
61
+ const pkgJsonMap: Record<string, PkgJsonInfo> = {}
62
+ for (const pkg of cfg.packages) {
63
+ const pkgPath = resolve(cwd, pkg.path, 'package.json')
64
+ const json = await readJson(pkgPath)
65
+ const version = json.version as string
66
+ if (!version) throw new Error(`missing version in ${pkgPath}`)
67
+ pkgJsonMap[pkg.name] = { version, private: json.private === true }
68
+ }
69
+
70
+ const publishablePackages = cfg.packages.filter(
71
+ (pkg) => pkg.private !== true && pkgJsonMap[pkg.name]?.private !== true,
72
+ )
73
+
74
+ // Read each publishable package's version
75
+ const versions: Record<string, string> = {}
76
+ for (const pkg of publishablePackages) {
77
+ versions[pkg.name] = pkgJsonMap[pkg.name]?.version as string
78
+ }
79
+
80
+ // Build + test + check
81
+ await npm.install()
82
+ await npm.build()
83
+ console.log('✓ build')
84
+ if (!skipTests) {
85
+ await npm.test()
86
+ console.log('✓ test')
87
+ }
88
+ await npm.check()
89
+ console.log('✓ check')
90
+
91
+ // Log skipped private packages
92
+ for (const pkg of cfg.packages) {
93
+ if (pkg.private === true || pkgJsonMap[pkg.name]?.private === true) {
94
+ console.log(`⏭ skipped ${pkg.name} (private)`)
95
+ }
96
+ }
97
+
98
+ // Publish each publishable package
99
+ const access = cfg.release?.access
100
+ for (let i = 0; i < publishablePackages.length; i++) {
101
+ const entry = publishablePackages[i]
102
+ const version = versions[entry.name] as string
103
+ const isRc = isRcVersion(version)
104
+ const publishTag = isRc ? 'rc' : 'latest'
105
+ const pkgDir = resolve(cwd, entry.path)
106
+
107
+ // Pre-check: skip if already published on registry
108
+ const existingVersions = await fetchVersions(entry.name)
109
+ if (existingVersions.includes(version)) {
110
+ console.log(`⏭ skipped ${entry.name}@${version} (already published)`)
111
+ continue
112
+ }
113
+
114
+ // Smoke test: validate tarball before publishing
115
+ try {
116
+ await smokeTestTarball(pkgDir, spawn)
117
+ } catch (err) {
118
+ const message = (err as Error).message
119
+ const published = publishablePackages.slice(0, i).map((p) => p.name)
120
+ const remaining = publishablePackages.slice(i + 1).map((p) => p.name)
121
+ const msg =
122
+ `smoke test failed for ${entry.name}: ${message}\n` +
123
+ ` published: ${published.join(', ') || '(none)'}\n` +
124
+ ` unpublished: ${[entry.name, ...remaining].join(', ')}`
125
+ throw new Error(msg)
126
+ }
127
+
128
+ try {
129
+ await npm.publish(pkgDir, { tag: publishTag, ...(access ? { access } : {}) })
130
+ console.log(`✓ published ${entry.name}@${version}`)
131
+ } catch (err) {
132
+ const message = (err as Error).message
133
+ // Fallback: catch the error in case of race condition
134
+ if (isAlreadyPublished(message)) {
135
+ console.log(`⏭ skipped ${entry.name}@${version} (already published)`)
136
+ continue
137
+ }
138
+ const published = publishablePackages.slice(0, i).map((p) => p.name)
139
+ const remaining = publishablePackages.slice(i + 1).map((p) => p.name)
140
+ const msg =
141
+ `publish failed for ${entry.name}: ${message}\n` +
142
+ ` published: ${published.join(', ') || '(none)'}\n` +
143
+ ` unpublished: ${[entry.name, ...remaining].join(', ')}`
144
+ throw new Error(msg)
145
+ }
146
+ }
147
+
148
+ // Commit + tag + push all publishable packages
149
+ // Changelog generation and changeset cleanup are now bump's responsibility (issue #74)
150
+ await git.addAll()
151
+
152
+ const tagPrefix = cfg.release?.gitTagPrefix ?? 'v'
153
+ const bumpedVersions = Object.entries(versions)
154
+
155
+ const commitVersion = bumpedVersions[0]?.[1] ?? 'unknown'
156
+ await git.commit(`release: v${commitVersion}`, AUTHOR)
157
+
158
+ for (const [pkgName, version] of bumpedVersions) {
159
+ const tagName = `${pkgName}@${tagPrefix}${version}`
160
+ await git.tag(tagName, `Release ${pkgName}@${version}`)
161
+ }
162
+ await git.pushTags()
163
+ await git.push('main')
164
+ for (const [pkgName, version] of bumpedVersions) {
165
+ console.log(`✓ tagged ${pkgName}@${tagPrefix}${version}`)
166
+ }
167
+ console.log(`✓ pushed`)
168
+ }
@@ -0,0 +1,8 @@
1
+ export { loadConfig } from './load-config.js'
2
+ export type {
3
+ PackageEntry,
4
+ PackageType,
5
+ PromanConfig,
6
+ ReleaseConfig,
7
+ } from './types.js'
8
+ export { validateConfig } from './validate-config.js'
@@ -0,0 +1,33 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { parse } from 'yaml'
4
+ import type { PromanConfig, ReleaseConfig } from './types.js'
5
+ import { validateConfig } from './validate-config.js'
6
+
7
+ const DEFAULT_REGISTRY = 'https://registry.npmjs.org'
8
+ const DEFAULT_GIT_TAG_PREFIX = 'v'
9
+
10
+ function applyDefaults(config: PromanConfig): PromanConfig {
11
+ const release: ReleaseConfig = {
12
+ registry: config.release?.registry ?? DEFAULT_REGISTRY,
13
+ gitTagPrefix: config.release?.gitTagPrefix ?? DEFAULT_GIT_TAG_PREFIX,
14
+ }
15
+ if (config.release?.access !== undefined) {
16
+ release.access = config.release.access
17
+ }
18
+ return { ...config, release }
19
+ }
20
+
21
+ /**
22
+ * Loads `proman.yaml` from the given cwd (or `process.cwd()`).
23
+ */
24
+ export function loadConfig(cwd: string = process.cwd()): PromanConfig {
25
+ const absPath = resolve(cwd, 'proman.yaml')
26
+ if (!existsSync(absPath)) {
27
+ throw new Error(`proman.yaml not found at ${absPath}`)
28
+ }
29
+ const text = readFileSync(absPath, 'utf8')
30
+ const raw = parse(text)
31
+ const validated = validateConfig(raw)
32
+ return applyDefaults(validated)
33
+ }