@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,19 @@
1
+ export type PackageType = 'lib' | 'cli' | 'webui' | 'api'
2
+
3
+ export type PackageEntry = {
4
+ name: string
5
+ path: string
6
+ type: PackageType
7
+ private?: boolean
8
+ }
9
+
10
+ export type ReleaseConfig = {
11
+ registry?: string
12
+ access?: 'public' | 'restricted'
13
+ gitTagPrefix?: string
14
+ }
15
+
16
+ export type PromanConfig = {
17
+ packages: PackageEntry[]
18
+ release?: ReleaseConfig
19
+ }
@@ -0,0 +1,81 @@
1
+ import type { PackageEntry, PackageType, PromanConfig } from './types.js'
2
+
3
+ const ERR_PREFIX = 'Invalid proman config:'
4
+ const VALID_TYPES: readonly PackageType[] = ['lib', 'cli', 'webui', 'api']
5
+
6
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
7
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
8
+ }
9
+
10
+ function validatePackageEntry(entry: unknown, index: number): PackageEntry {
11
+ if (!isPlainObject(entry)) {
12
+ throw new Error(`${ERR_PREFIX} packages[${index}] must be an object`)
13
+ }
14
+ const { name, path, type, private: isPrivate } = entry
15
+ if (typeof name !== 'string' || name.length === 0) {
16
+ throw new Error(`${ERR_PREFIX} packages[${index}].name must be a non-empty string`)
17
+ }
18
+ if (typeof path !== 'string' || path.length === 0) {
19
+ throw new Error(`${ERR_PREFIX} packages[${index}].path must be a non-empty string`)
20
+ }
21
+ let resolvedType: PackageType = 'lib'
22
+ if (type !== undefined) {
23
+ if (typeof type !== 'string' || !VALID_TYPES.includes(type as PackageType)) {
24
+ throw new Error(
25
+ `${ERR_PREFIX} packages[${index}].type must be one of 'lib' | 'cli' | 'webui' | 'api'`,
26
+ )
27
+ }
28
+ resolvedType = type as PackageType
29
+ }
30
+ if (isPrivate !== undefined && typeof isPrivate !== 'boolean') {
31
+ throw new Error(`${ERR_PREFIX} packages[${index}].private must be a boolean`)
32
+ }
33
+ const result: PackageEntry = { name, path, type: resolvedType }
34
+ if (isPrivate === true) result.private = true
35
+ return result
36
+ }
37
+
38
+ /**
39
+ * Pure validator. Throws Error with descriptive message on failure.
40
+ * Returns a typed PromanConfig (does not mutate input, does not apply defaults).
41
+ */
42
+ export function validateConfig(value: unknown): PromanConfig {
43
+ if (!isPlainObject(value)) {
44
+ throw new Error(`${ERR_PREFIX} config must be an object`)
45
+ }
46
+
47
+ const { packages, release } = value
48
+
49
+ if (!Array.isArray(packages) || packages.length === 0) {
50
+ throw new Error(`${ERR_PREFIX} packages must be a non-empty array`)
51
+ }
52
+ const validatedPackages = packages.map((p, i) => validatePackageEntry(p, i))
53
+
54
+ let validatedRelease: PromanConfig['release']
55
+ if (release !== undefined) {
56
+ if (!isPlainObject(release)) {
57
+ throw new Error(`${ERR_PREFIX} release must be an object`)
58
+ }
59
+ const { registry, access, gitTagPrefix } = release
60
+ if (registry !== undefined && (typeof registry !== 'string' || registry.length === 0)) {
61
+ throw new Error(`${ERR_PREFIX} release.registry must be a non-empty string`)
62
+ }
63
+ if (access !== undefined && access !== 'public' && access !== 'restricted') {
64
+ throw new Error(`${ERR_PREFIX} release.access must be 'public' or 'restricted'`)
65
+ }
66
+ if (gitTagPrefix !== undefined && typeof gitTagPrefix !== 'string') {
67
+ throw new Error(`${ERR_PREFIX} release.gitTagPrefix must be a string`)
68
+ }
69
+ validatedRelease = {
70
+ registry: registry as string | undefined,
71
+ access: access as 'public' | 'restricted' | undefined,
72
+ gitTagPrefix: gitTagPrefix as string | undefined,
73
+ }
74
+ }
75
+
76
+ const result: PromanConfig = {
77
+ packages: validatedPackages,
78
+ }
79
+ if (validatedRelease !== undefined) result.release = validatedRelease
80
+ return result
81
+ }
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ // Re-export config utilities
2
+
3
+ export type {
4
+ BumpOptions,
5
+ DeployCommandOptions,
6
+ DevCommandOptions,
7
+ InitOptions,
8
+ LinkCommandOptions,
9
+ PublishOptions,
10
+ } from './commands/index.js'
11
+ // Re-export all command functions
12
+ export {
13
+ build,
14
+ bump,
15
+ check,
16
+ deploy,
17
+ format,
18
+ init,
19
+ link,
20
+ linkStatus,
21
+ publish,
22
+ runTests,
23
+ unlink,
24
+ } from './commands/index.js'
25
+ export type { PackageEntry, PackageType, PromanConfig, ReleaseConfig } from './config/index.js'
26
+ export { loadConfig, validateConfig } from './config/index.js'
27
+
28
+ // Re-export utility functions
29
+ export * from './utils/index.js'
@@ -0,0 +1,98 @@
1
+ import { readdir, readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ export type Bump = 'major' | 'minor' | 'patch'
5
+
6
+ export type Changeset = {
7
+ file: string
8
+ packages: Record<string, Bump>
9
+ body: string
10
+ }
11
+
12
+ const VALID_BUMPS: ReadonlySet<string> = new Set(['major', 'minor', 'patch'])
13
+
14
+ function stripQuotes(s: string): string {
15
+ const t = s.trim()
16
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
17
+ return t.slice(1, -1)
18
+ }
19
+ return t
20
+ }
21
+
22
+ export function parseChangeset(raw: string, file: string): Changeset {
23
+ const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
24
+ if (!fmMatch) {
25
+ throw new Error(`missing frontmatter in changeset: ${file}`)
26
+ }
27
+ const fmBlock = fmMatch[1] as string
28
+ const body = (fmMatch[2] as string).replace(/^\s+|\s+$/g, '')
29
+ const packages: Record<string, Bump> = {}
30
+ const lines = fmBlock.split(/\r?\n/)
31
+ for (const line of lines) {
32
+ const trimmed = line.trim()
33
+ if (!trimmed) continue
34
+ const colonIdx = trimmed.indexOf(':')
35
+ if (colonIdx < 0) {
36
+ throw new Error(`invalid frontmatter line in ${file}: ${line}`)
37
+ }
38
+ const key = stripQuotes(trimmed.slice(0, colonIdx))
39
+ const value = stripQuotes(trimmed.slice(colonIdx + 1))
40
+ if (!VALID_BUMPS.has(value)) {
41
+ throw new Error(`invalid bump '${value}' for package '${key}' in ${file}`)
42
+ }
43
+ packages[key] = value as Bump
44
+ }
45
+ return { file, packages, body }
46
+ }
47
+
48
+ export async function readChangesets(rootDir: string): Promise<Changeset[]> {
49
+ const dir = join(rootDir, '.changeset')
50
+ let entries: string[]
51
+ try {
52
+ entries = await readdir(dir)
53
+ } catch {
54
+ return []
55
+ }
56
+ const mdFiles = entries.filter((n) => n.endsWith('.md') && n.toLowerCase() !== 'readme.md').sort()
57
+ const out: Changeset[] = []
58
+ for (const name of mdFiles) {
59
+ const file = join(dir, name)
60
+ const raw = await readFile(file, 'utf8')
61
+ out.push(parseChangeset(raw, file))
62
+ }
63
+ return out
64
+ }
65
+
66
+ export type ChangelogEntryInput = {
67
+ version: string
68
+ date: string
69
+ bodies: string[]
70
+ }
71
+
72
+ export function buildChangelogEntry(input: ChangelogEntryInput): string {
73
+ const { version, date, bodies } = input
74
+ const lines: string[] = [`## ${version} — ${date}`, '']
75
+ for (const body of bodies) {
76
+ const parts = body.split(/\r?\n/)
77
+ const first = parts[0] ?? ''
78
+ lines.push(`- ${first}`)
79
+ for (let i = 1; i < parts.length; i++) {
80
+ lines.push(` ${parts[i]}`)
81
+ }
82
+ }
83
+ lines.push('')
84
+ return `${lines.join('\n')}\n`
85
+ }
86
+
87
+ export function prependChangelog(existing: string | null, entry: string): string {
88
+ if (!existing || existing.trim() === '') {
89
+ return `# Changelog\n\n${entry}`
90
+ }
91
+ const headingMatch = existing.match(/^(#\s+[^\n]*\n)(\n*)([\s\S]*)$/)
92
+ if (headingMatch) {
93
+ const heading = headingMatch[1] as string
94
+ const rest = headingMatch[3] as string
95
+ return `${heading}\n${entry}${rest}`
96
+ }
97
+ return `${entry}\n${existing}`
98
+ }
@@ -0,0 +1,199 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
3
+ import { dirname, join, relative, resolve } from 'node:path'
4
+ import type { PackageEntry } from '../config/types.js'
5
+
6
+ /**
7
+ * Recursively collect files matching glob-like patterns.
8
+ * Supports `**\/*.ext`, `dir/**\/*.ext`, and literal filenames.
9
+ */
10
+ function collectFiles(baseDir: string, patterns: string[]): string[] {
11
+ const results: string[] = []
12
+
13
+ function walk(dir: string): void {
14
+ let entries: string[]
15
+ try {
16
+ entries = readdirSync(dir)
17
+ } catch {
18
+ return
19
+ }
20
+ for (const name of entries) {
21
+ if (name === 'node_modules' || name === '.git' || name === 'dist') continue
22
+ if (name === '.proman') continue
23
+ const full = join(dir, name)
24
+ let st: ReturnType<typeof statSync>
25
+ try {
26
+ st = statSync(full)
27
+ } catch {
28
+ continue
29
+ }
30
+ if (st.isDirectory()) {
31
+ walk(full)
32
+ } else if (st.isFile()) {
33
+ const rel = relative(baseDir, full)
34
+ if (matchesAny(rel, patterns)) {
35
+ results.push(rel)
36
+ }
37
+ }
38
+ }
39
+ }
40
+
41
+ walk(baseDir)
42
+ return results.sort()
43
+ }
44
+
45
+ function matchesAny(relPath: string, patterns: string[]): boolean {
46
+ for (const p of patterns) {
47
+ // Handle patterns with **/ anywhere (e.g. "**/*.ts", "src/**/*.ts")
48
+ const dstarIdx = p.indexOf('**/')
49
+ if (dstarIdx >= 0) {
50
+ const prefix = p.slice(0, dstarIdx) // e.g. "" or "src/"
51
+ const suffix = p.slice(dstarIdx + 3) // e.g. "*.ts"
52
+
53
+ // Check prefix: relPath must start with the prefix (if any)
54
+ if (prefix && !relPath.startsWith(prefix)) continue
55
+
56
+ // Check suffix: get the part after the prefix match
57
+ const rest = prefix ? relPath.slice(prefix.length) : relPath
58
+ if (suffix.startsWith('*')) {
59
+ // *.ext pattern
60
+ const ext = suffix.slice(1) // e.g. ".ts"
61
+ if (rest.endsWith(ext)) return true
62
+ } else if (rest.endsWith(suffix)) {
63
+ return true
64
+ }
65
+ } else {
66
+ // Exact filename match (e.g. "package.json", "biome.json")
67
+ const base = relPath.split('/').pop() ?? relPath
68
+ if (base === p || relPath === p) return true
69
+ }
70
+ }
71
+ return false
72
+ }
73
+
74
+ /**
75
+ * Hash file contents for a set of glob patterns under a directory.
76
+ * Returns a hex sha256 hash string.
77
+ */
78
+ export function hashFiles(dir: string, patterns: string[]): string {
79
+ const files = collectFiles(dir, patterns)
80
+ const hash = createHash('sha256')
81
+ for (const rel of files) {
82
+ hash.update(rel)
83
+ hash.update('\0')
84
+ const content = readFileSync(join(dir, rel))
85
+ hash.update(content)
86
+ hash.update('\0')
87
+ }
88
+ return hash.digest('hex')
89
+ }
90
+
91
+ /** Read a stored fingerprint. Returns null if file does not exist. */
92
+ export function readFingerprint(path: string): string | null {
93
+ try {
94
+ return readFileSync(path, 'utf-8').trim()
95
+ } catch {
96
+ return null
97
+ }
98
+ }
99
+
100
+ /** Write a fingerprint, creating parent dirs as needed. */
101
+ export function writeFingerprint(path: string, hash: string): void {
102
+ mkdirSync(dirname(path), { recursive: true })
103
+ writeFileSync(path, hash)
104
+ }
105
+
106
+ /**
107
+ * Compute per-package build fingerprints with dependency propagation.
108
+ * Processes packages in order (assumed topo-sorted in proman.yaml).
109
+ * Each package's fingerprint = hash(own src/** + package.json + tsconfig.json + dep fingerprints).
110
+ */
111
+ export function computeBuildFingerprints(
112
+ cwd: string,
113
+ packages: readonly Pick<PackageEntry, 'name' | 'path'>[],
114
+ ): Map<string, string> {
115
+ // Build dependency map from package.json files
116
+ const depMap = new Map<string, string[]>()
117
+ const pkgNames = new Set(packages.map((p) => p.name))
118
+
119
+ for (const pkg of packages) {
120
+ const pkgJsonPath = join(resolve(cwd, pkg.path), 'package.json')
121
+ let deps: string[] = []
122
+ try {
123
+ const json = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as {
124
+ dependencies?: Record<string, string>
125
+ }
126
+ if (json.dependencies) {
127
+ deps = Object.keys(json.dependencies).filter((d) => pkgNames.has(d))
128
+ }
129
+ } catch {
130
+ // no package.json or parse error — no deps
131
+ }
132
+ depMap.set(pkg.name, deps)
133
+ }
134
+
135
+ const fingerprints = new Map<string, string>()
136
+
137
+ for (const pkg of packages) {
138
+ const pkgDir = resolve(cwd, pkg.path)
139
+ // Own file hash
140
+ const ownHash = hashFiles(pkgDir, ['src/**/*.ts', 'package.json', 'tsconfig.json'])
141
+
142
+ // Combine with dependency fingerprints
143
+ const hash = createHash('sha256')
144
+ hash.update(ownHash)
145
+
146
+ const deps = depMap.get(pkg.name) ?? []
147
+ for (const dep of deps.sort()) {
148
+ const depFp = fingerprints.get(dep) ?? ''
149
+ hash.update(dep)
150
+ hash.update('\0')
151
+ hash.update(depFp)
152
+ hash.update('\0')
153
+ }
154
+
155
+ fingerprints.set(pkg.name, hash.digest('hex'))
156
+ }
157
+
158
+ return fingerprints
159
+ }
160
+
161
+ /**
162
+ * Compute a root-level fingerprint for test or check commands.
163
+ * - test: all .ts files + package.json (covers src, tests, vitest.config.ts)
164
+ * - check: all .ts files + package.json + biome.json
165
+ */
166
+ export function computeRootFingerprint(cwd: string, command: 'test' | 'check'): string {
167
+ const patterns: string[] =
168
+ command === 'test' ? ['**/*.ts', 'package.json'] : ['**/*.ts', 'package.json', 'biome.json']
169
+
170
+ return hashFiles(cwd, patterns)
171
+ }
172
+
173
+ /** Convert a package name to a safe filename: @scope/name → @scope-name */
174
+ export function pkgNameToFilename(name: string): string {
175
+ return name.replace(/\//g, '-')
176
+ }
177
+
178
+ /**
179
+ * Get the path where a fingerprint file should be stored.
180
+ *
181
+ * Storage strategy differs by command type:
182
+ * - **build**: stores fingerprint at `<pkg>/dist/.build-fingerprint` so that
183
+ * deleting `dist/` (clean build) automatically invalidates the cache.
184
+ * - **test / check**: stores in `.proman/<command>/` directory, which is
185
+ * independent of build artifacts and survives `dist/` cleanup.
186
+ *
187
+ * This separation ensures `rm -rf dist` forces a rebuild without
188
+ * accidentally invalidating test/check caches (and vice-versa).
189
+ */
190
+ export function fingerprintPath(cwd: string, command: string, pkgName?: string): string {
191
+ if (command === 'build' && pkgName) {
192
+ // For build command, store fingerprint inside package's dist folder
193
+ return join(cwd, 'dist/.build-fingerprint')
194
+ }
195
+
196
+ // For test/check commands or when no package name, use .proman directory
197
+ const filename = pkgName ? `${pkgNameToFilename(pkgName)}.fingerprint` : 'root.fingerprint'
198
+ return join(cwd, '.proman', command, filename)
199
+ }
@@ -0,0 +1,119 @@
1
+ export type GitOps = {
2
+ getCurrentBranch: () => Promise<string>
3
+ isCleanTree: () => Promise<boolean>
4
+ branchExists: (name: string) => Promise<boolean>
5
+ checkoutNewBranch: (name: string) => Promise<void>
6
+ checkoutNewBranchFrom: (name: string, ref: string) => Promise<void>
7
+ tagExists: (tag: string) => Promise<boolean>
8
+ addAll: () => Promise<void>
9
+ commit: (msg: string, author?: string) => Promise<void>
10
+ push: (branch: string) => Promise<void>
11
+ log: (range?: string) => Promise<string>
12
+ tag: (name: string, message?: string) => Promise<void>
13
+ pushTags: () => Promise<void>
14
+ checkout: (branch: string) => Promise<void>
15
+ merge: (branch: string, opts?: { noFf?: boolean; message?: string }) => Promise<void>
16
+ deleteBranchLocal: (name: string) => Promise<void>
17
+ deleteBranchRemote: (name: string) => Promise<void>
18
+ }
19
+
20
+ async function run(args: string[], cwd: string = process.cwd()): Promise<string> {
21
+ const { execFileSync } = await import('node:child_process')
22
+ try {
23
+ return execFileSync('git', args, {
24
+ cwd,
25
+ encoding: 'utf-8',
26
+ stdio: ['pipe', 'pipe', 'pipe'],
27
+ maxBuffer: 10 * 1024 * 1024,
28
+ })
29
+ } catch (err: unknown) {
30
+ const e = err as { stderr?: string; stdout?: string }
31
+ throw new Error(`git ${args.join(' ')} failed: ${(e.stderr ?? e.stdout ?? '').trim()}`)
32
+ }
33
+ }
34
+
35
+ function parseAuthor(author: string): { name: string; email: string } {
36
+ const m = author.match(/^(.+?)\s*<(.+)>\s*$/)
37
+ if (!m) throw new Error(`invalid author string: ${author}`)
38
+ return { name: (m[1] as string).trim(), email: (m[2] as string).trim() }
39
+ }
40
+
41
+ export function createGitOps(cwd: string = process.cwd()): GitOps {
42
+ return {
43
+ getCurrentBranch: async () => (await run(['branch', '--show-current'], cwd)).trim(),
44
+ isCleanTree: async () => (await run(['status', '--porcelain'], cwd)).trim() === '',
45
+ branchExists: async (name) => {
46
+ const { execFileSync } = await import('node:child_process')
47
+ try {
48
+ execFileSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${name}`], {
49
+ cwd,
50
+ stdio: ['pipe', 'pipe', 'pipe'],
51
+ })
52
+ return true
53
+ } catch {
54
+ return false
55
+ }
56
+ },
57
+ checkoutNewBranch: async (name) => {
58
+ await run(['checkout', '-b', name], cwd)
59
+ },
60
+ checkoutNewBranchFrom: async (name, ref) => {
61
+ await run(['checkout', '-b', name, ref], cwd)
62
+ },
63
+ tagExists: async (tag) => {
64
+ const { execFileSync } = await import('node:child_process')
65
+ try {
66
+ execFileSync('git', ['rev-parse', '--verify', '-q', `refs/tags/${tag}`], {
67
+ cwd,
68
+ stdio: ['pipe', 'pipe', 'pipe'],
69
+ })
70
+ return true
71
+ } catch {
72
+ return false
73
+ }
74
+ },
75
+ addAll: async () => {
76
+ await run(['add', '-A'], cwd)
77
+ },
78
+ commit: async (msg, author) => {
79
+ const args = ['commit', '-m', msg]
80
+ if (author) {
81
+ const { name, email } = parseAuthor(author)
82
+ args.unshift('-c', `user.name=${name}`, '-c', `user.email=${email}`)
83
+ args.push(`--author=${author}`)
84
+ }
85
+ await run(args, cwd)
86
+ },
87
+ push: async (branch) => {
88
+ await run(['push', '-u', 'origin', branch], cwd)
89
+ },
90
+ log: async (range) => {
91
+ const args = ['log', '--pretty=%s']
92
+ if (range) args.push(range)
93
+ return await run(args, cwd)
94
+ },
95
+ tag: async (name, message) => {
96
+ const args = message ? ['tag', '-a', name, '-m', message] : ['tag', name]
97
+ await run(args, cwd)
98
+ },
99
+ pushTags: async () => {
100
+ await run(['push', '--tags'], cwd)
101
+ },
102
+ checkout: async (branch) => {
103
+ await run(['checkout', branch], cwd)
104
+ },
105
+ merge: async (branch, opts) => {
106
+ const args = ['merge']
107
+ if (opts?.noFf) args.push('--no-ff')
108
+ if (opts?.message) args.push('-m', opts.message)
109
+ args.push(branch)
110
+ await run(args, cwd)
111
+ },
112
+ deleteBranchLocal: async (name) => {
113
+ await run(['branch', '-d', name], cwd)
114
+ },
115
+ deleteBranchRemote: async (name) => {
116
+ await run(['push', 'origin', '--delete', name], cwd)
117
+ },
118
+ }
119
+ }
@@ -0,0 +1,8 @@
1
+ // Export all utility functions
2
+ export * from './changeset.js'
3
+ export * from './fingerprint.js'
4
+ export * from './git.js'
5
+ export * from './npm.js'
6
+ export * from './smoke-test.js'
7
+ export * from './version.js'
8
+ export * from './workspace.js'
@@ -0,0 +1,110 @@
1
+ export type NpmRegistryFetch = (pkg: string) => Promise<string[]>
2
+
3
+ export type PublishOptions = {
4
+ tag: string
5
+ access?: 'public' | 'restricted'
6
+ }
7
+
8
+ export type NpmRunner = {
9
+ install: () => Promise<void>
10
+ build: () => Promise<void>
11
+ test: () => Promise<void>
12
+ check: () => Promise<void>
13
+ format: () => Promise<void>
14
+ publish: (pkgDir: string, opts: PublishOptions) => Promise<void>
15
+ }
16
+
17
+ export type NextRcOptions = {
18
+ baseVersion: string
19
+ existing: string[]
20
+ }
21
+
22
+ const RELEASE_BRANCH_RE = /^release\/(.+)$/
23
+
24
+ export function parseReleaseBranch(branch: string): string {
25
+ const m = branch.match(RELEASE_BRANCH_RE)
26
+ if (!m) throw new Error(`not a release branch: '${branch}'`)
27
+ const v = (m[1] as string).trim()
28
+ if (!v) throw new Error(`malformed release branch: '${branch}'`)
29
+ return v
30
+ }
31
+
32
+ export function nextRcNumber(opts: NextRcOptions): number {
33
+ const { baseVersion, existing } = opts
34
+ const prefix = `${baseVersion}-rc.`
35
+ let max = 0
36
+ for (const v of existing) {
37
+ if (!v.startsWith(prefix)) continue
38
+ const tail = v.slice(prefix.length)
39
+ const n = Number.parseInt(tail, 10)
40
+ if (Number.isFinite(n) && n > max) max = n
41
+ }
42
+ return max + 1
43
+ }
44
+
45
+ export function formatRcVersion(baseVersion: string, n: number): string {
46
+ return `${baseVersion}-rc.${n}`
47
+ }
48
+
49
+ export const defaultRegistryFetch: NpmRegistryFetch = async (pkg) => {
50
+ const res = await fetch(`https://registry.npmjs.org/${pkg}`)
51
+ if (!res.ok) {
52
+ if (res.status === 404) return []
53
+ throw new Error(`registry fetch failed for ${pkg}: ${res.status} ${res.statusText}`)
54
+ }
55
+ const json = (await res.json()) as { versions?: Record<string, unknown> }
56
+ return Object.keys(json.versions ?? {})
57
+ }
58
+
59
+ export type SpawnFn = (
60
+ argv: string[],
61
+ cwd: string,
62
+ ) => Promise<{ code: number; stdout: string; stderr: string }>
63
+
64
+ export const defaultSpawn: SpawnFn = async (argv, cwd) => {
65
+ const { spawnSync } = await import('node:child_process')
66
+ const result = spawnSync(argv[0] as string, argv.slice(1), {
67
+ cwd,
68
+ stdio: 'pipe',
69
+ })
70
+ const stdout = result.stdout?.toString() ?? ''
71
+ const stderr = result.stderr?.toString() ?? ''
72
+ // Forward captured output to terminal so users still see progress
73
+ if (stdout) process.stdout.write(stdout)
74
+ if (stderr) process.stderr.write(stderr)
75
+ return {
76
+ code: result.status ?? 1,
77
+ stdout,
78
+ stderr,
79
+ }
80
+ }
81
+
82
+ export async function runOrThrow(spawn: SpawnFn, argv: string[], cwd: string): Promise<void> {
83
+ const { code, stdout, stderr } = await spawn(argv, cwd)
84
+ if (code !== 0) {
85
+ const detail = stderr.trim() || stdout.trim()
86
+ throw new Error(detail ? `${argv.join(' ')} failed: ${detail}` : `${argv.join(' ')} failed`)
87
+ }
88
+ }
89
+
90
+ export function createNpmRunner(cwd: string, spawn: SpawnFn = defaultSpawn): NpmRunner {
91
+ const runScript = (script: string) => async () => {
92
+ await runOrThrow(spawn, ['pnpm', 'run', script], cwd)
93
+ }
94
+ return {
95
+ install: async () => {
96
+ await runOrThrow(spawn, ['pnpm', 'install'], cwd)
97
+ },
98
+ build: runScript('build'),
99
+ test: async () => {
100
+ await runOrThrow(spawn, ['pnpm', 'exec', 'vitest', 'run'], cwd)
101
+ },
102
+ check: runScript('check'),
103
+ format: runScript('format'),
104
+ publish: async (pkgDir, opts) => {
105
+ const args = ['pnpm', 'publish', '--tag', opts.tag, '--no-git-checks']
106
+ if (opts.access) args.push('--access', opts.access)
107
+ await runOrThrow(spawn, args, pkgDir)
108
+ },
109
+ }
110
+ }