@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.
- package/CHANGELOG.md +26 -0
- package/LICENSE +18 -0
- package/dist/commands/bump.d.ts +13 -0
- package/dist/commands/bump.d.ts.map +1 -0
- package/dist/commands/bump.js +115 -0
- package/dist/commands/deploy.d.ts +9 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +42 -0
- package/dist/commands/dev.d.ts +15 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +175 -0
- package/dist/commands/index.d.ts +7 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +7 -0
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +262 -0
- package/dist/commands/link.d.ts +19 -0
- package/dist/commands/link.d.ts.map +1 -0
- package/dist/commands/link.js +155 -0
- package/dist/commands/publish.d.ts +18 -0
- package/dist/commands/publish.d.ts.map +1 -0
- package/dist/commands/publish.js +125 -0
- package/dist/config/index.d.ts +4 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +2 -0
- package/dist/config/load-config.d.ts +6 -0
- package/dist/config/load-config.d.ts.map +1 -0
- package/dist/config/load-config.js +29 -0
- package/dist/config/types.d.ts +17 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +1 -0
- package/dist/config/validate-config.d.ts +7 -0
- package/dist/config/validate-config.d.ts.map +1 -0
- package/dist/config/validate-config.js +72 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/utils/changeset.d.ts +16 -0
- package/dist/utils/changeset.d.ts.map +1 -0
- package/dist/utils/changeset.js +80 -0
- package/dist/utils/fingerprint.d.ts +38 -0
- package/dist/utils/fingerprint.d.ts.map +1 -0
- package/dist/utils/fingerprint.js +182 -0
- package/dist/utils/git.d.ts +23 -0
- package/dist/utils/git.d.ts.map +1 -0
- package/dist/utils/git.js +105 -0
- package/dist/utils/index.d.ts +8 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +8 -0
- package/dist/utils/npm.d.ts +30 -0
- package/dist/utils/npm.d.ts.map +1 -0
- package/dist/utils/npm.js +85 -0
- package/dist/utils/smoke-test.d.ts +7 -0
- package/dist/utils/smoke-test.d.ts.map +1 -0
- package/dist/utils/smoke-test.js +59 -0
- package/dist/utils/version.d.ts +5 -0
- package/dist/utils/version.d.ts.map +1 -0
- package/dist/utils/version.js +36 -0
- package/dist/utils/workspace.d.ts +21 -0
- package/dist/utils/workspace.d.ts.map +1 -0
- package/dist/utils/workspace.js +73 -0
- package/package.json +45 -0
- package/src/commands/bump.ts +131 -0
- package/src/commands/deploy.ts +52 -0
- package/src/commands/dev.ts +214 -0
- package/src/commands/index.ts +7 -0
- package/src/commands/init.integration.test.ts +59 -0
- package/src/commands/init.test.ts +179 -0
- package/src/commands/init.ts +290 -0
- package/src/commands/link.ts +195 -0
- package/src/commands/publish.ts +168 -0
- package/src/config/index.ts +8 -0
- package/src/config/load-config.ts +33 -0
- package/src/config/types.ts +19 -0
- package/src/config/validate-config.ts +81 -0
- package/src/index.ts +29 -0
- package/src/utils/changeset.ts +98 -0
- package/src/utils/fingerprint.ts +199 -0
- package/src/utils/git.ts +119 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/npm.ts +110 -0
- package/src/utils/smoke-test.ts +79 -0
- package/src/utils/version.ts +41 -0
- package/src/utils/workspace.ts +94 -0
- package/tests/build-fingerprint-integration.test.ts +403 -0
- package/tests/bump.test.ts +261 -0
- package/tests/changeset.test.ts +147 -0
- package/tests/deploy.test.ts +98 -0
- package/tests/dev.test.ts +756 -0
- package/tests/fingerprint.test.ts +316 -0
- package/tests/fixtures/api-only/packages/api/.gitkeep +0 -0
- package/tests/fixtures/api-only/proman.yaml +4 -0
- package/tests/fixtures/bad-packages/proman.yaml +1 -0
- package/tests/fixtures/bun-project/packages/a/.gitkeep +0 -0
- package/tests/fixtures/bun-project/proman.yaml +4 -0
- package/tests/fixtures/defaults/proman.yaml +3 -0
- package/tests/fixtures/no-deployable/packages/core/.gitkeep +0 -0
- package/tests/fixtures/no-deployable/packages/mycli/.gitkeep +0 -0
- package/tests/fixtures/no-deployable/proman.yaml +7 -0
- package/tests/fixtures/node-runtime/packages/a/package.json +5 -0
- package/tests/fixtures/node-runtime/proman.yaml +3 -0
- package/tests/fixtures/pnpm-project/packages/a/package.json +1 -0
- package/tests/fixtures/pnpm-project/pnpm-lock.yaml +0 -0
- package/tests/fixtures/pnpm-project/proman.yaml +3 -0
- package/tests/fixtures/typed/packages/api/.gitkeep +0 -0
- package/tests/fixtures/typed/packages/core/.gitkeep +0 -0
- package/tests/fixtures/typed/packages/dashboard/.gitkeep +0 -0
- package/tests/fixtures/typed/packages/mycli/.gitkeep +0 -0
- package/tests/fixtures/typed/proman.yaml +13 -0
- package/tests/fixtures/valid/packages/cli/package.json +5 -0
- package/tests/fixtures/valid/packages/core/package.json +5 -0
- package/tests/fixtures/valid/packages/fs/package.json +5 -0
- package/tests/fixtures/valid/proman.yaml +13 -0
- package/tests/fixtures/webui-only/packages/dashboard/.gitkeep +0 -0
- package/tests/fixtures/webui-only/proman.yaml +4 -0
- package/tests/link.test.ts +419 -0
- package/tests/load-config.test.ts +44 -0
- package/tests/npm.test.ts +199 -0
- package/tests/publish.test.ts +599 -0
- package/tests/smoke-test.test.ts +211 -0
- package/tests/validate-config.test.ts +67 -0
- package/tests/version.test.ts +86 -0
- package/tests/workflow-schema.test.ts +72 -0
- package/tests/workspace.test.ts +160 -0
- package/tsconfig.build.json +14 -0
- package/tsconfig.json +8 -0
- package/tsconfig.tsbuildinfo +1 -0
- 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
|
+
}
|
package/src/utils/git.ts
ADDED
|
@@ -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
|
+
}
|
package/src/utils/npm.ts
ADDED
|
@@ -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
|
+
}
|