@entro314labs/release-kit 2.6.0 → 2.7.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 (5) hide show
  1. package/README.md +39 -6
  2. package/TRAIN.md +332 -0
  3. package/package.json +5 -2
  4. package/release.mjs +140 -71
  5. package/train.mjs +1241 -0
package/train.mjs ADDED
@@ -0,0 +1,1241 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * release-train — orchestrated releases for interdependent packages, prototype.
4
+ *
5
+ * Implements the read-only phases of TRAIN.md — discover → graph → detect changes →
6
+ * cascade → plan → preflight — plus `seed-tags`, which establishes baseline release tags.
7
+ * Execution (releasing via release-kit) is not implemented yet; `train` without
8
+ * --dry-run says so and exits.
9
+ *
10
+ * train graph print the derived dependency graph and topo order
11
+ * train --dry-run full plan + whole-train preflight, execute nothing
12
+ * train --dry-run --all plan every member, not just changed ones
13
+ * train --dry-run <id>... plan these packages and their dependents
14
+ * train seed-tags create baseline tags at each repo's HEAD (--dry-run to preview)
15
+ * train --offline skip network work (registry lookups, tag pushes)
16
+ * train --config <path> config elsewhere than ./train.config.json
17
+ *
18
+ * Reads train.config.json in the working directory. Config declares membership and
19
+ * policy only; versions, dependencies and order are derived from the package manifests —
20
+ * see TRAIN.md for why.
21
+ *
22
+ * A package's next version is derived, never guessed, from three sources in this order:
23
+ * 1. The registry. A manifest version that is not published — a pending bump that was
24
+ * committed but never released, or a package that has never been published — is
25
+ * released **as-is**: the pending version is the release, nothing is skipped over.
26
+ * A manifest *behind* the registry is a preflight failure, not a guess.
27
+ * 2. The commits since the package's last release tag, by Conventional Commit rules
28
+ * (feat → minor, breaking → major, softened below 1.0.0).
29
+ * 3. The cascade: a dependent of a releasing package joins with at least a patch.
30
+ */
31
+
32
+ import { execFileSync } from 'node:child_process'
33
+ import {
34
+ existsSync,
35
+ mkdtempSync,
36
+ readFileSync,
37
+ readdirSync,
38
+ statSync,
39
+ writeFileSync,
40
+ } from 'node:fs'
41
+ import { tmpdir } from 'node:os'
42
+ import { basename, dirname, join, relative, resolve } from 'node:path'
43
+ import { pathToFileURL } from 'node:url'
44
+
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+ // Small utilities
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+
49
+ function fail(message) {
50
+ console.error(`train: ${message}`)
51
+ process.exit(1)
52
+ }
53
+
54
+ function git(repoDir, args, { allowFailure = false } = {}) {
55
+ try {
56
+ return execFileSync('git', args, {
57
+ cwd: repoDir,
58
+ encoding: 'utf8',
59
+ stdio: ['ignore', 'pipe', 'pipe'],
60
+ }).trim()
61
+ } catch (err) {
62
+ if (allowFailure) return null
63
+ throw err
64
+ }
65
+ }
66
+
67
+ function readJson(path) {
68
+ return JSON.parse(readFileSync(path, 'utf8'))
69
+ }
70
+
71
+ // ─────────────────────────────────────────────────────────────────────────────
72
+ // Semver — the subset the train needs (release-kit owns full semver per package)
73
+ // ─────────────────────────────────────────────────────────────────────────────
74
+
75
+ export function parseSemver(version) {
76
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(version ?? '')
77
+ if (!match) return null
78
+ return { major: +match[1], minor: +match[2], patch: +match[3], prerelease: match[4] ?? null }
79
+ }
80
+
81
+ export function compareSemver(a, b) {
82
+ const pa = parseSemver(a)
83
+ const pb = parseSemver(b)
84
+ if (!pa || !pb) return 0
85
+ for (const key of ['major', 'minor', 'patch']) {
86
+ if (pa[key] !== pb[key]) return pa[key] - pb[key]
87
+ }
88
+ if (pa.prerelease && !pb.prerelease) return -1
89
+ if (!pa.prerelease && pb.prerelease) return 1
90
+ return 0
91
+ }
92
+
93
+ export function bumpSemver(version, bump) {
94
+ const v = parseSemver(version)
95
+ if (!v) return null
96
+ if (bump === 'as-is') return version
97
+ if (bump === 'major') return `${v.major + 1}.0.0`
98
+ if (bump === 'minor') return `${v.major}.${v.minor + 1}.0`
99
+ return `${v.major}.${v.minor}.${v.patch + 1}`
100
+ }
101
+
102
+ /**
103
+ * Conventional Commit derivation over full messages: feat → minor, a breaking change
104
+ * (`!` in the type or a BREAKING CHANGE footer anywhere in the body) → major, softened
105
+ * to minor below 1.0.0, else patch.
106
+ */
107
+ export function bumpFromCommits(messages, currentVersion) {
108
+ let bump = 'patch'
109
+ const below1 = parseSemver(currentVersion)?.major === 0
110
+ for (const message of messages) {
111
+ const subject = message.split('\n', 1)[0]
112
+ if (/^[a-z]+(\([^)]*\))?!:/.test(subject) || /BREAKING CHANGE/.test(message)) {
113
+ return below1 ? 'minor' : 'major'
114
+ }
115
+ if (/^feat(\([^)]*\))?:/.test(subject)) bump = 'minor'
116
+ }
117
+ return bump
118
+ }
119
+
120
+ // ─────────────────────────────────────────────────────────────────────────────
121
+ // Configuration and argument parsing
122
+ // ─────────────────────────────────────────────────────────────────────────────
123
+
124
+ const CONFIG_KEYS = new Set([
125
+ '$schema',
126
+ 'packages',
127
+ 'rangePolicy',
128
+ 'registryWait',
129
+ 'summaryFile',
130
+ 'assistant',
131
+ ])
132
+ const RANGE_POLICIES = new Set(['caret', 'tilde', 'exact', 'preserve'])
133
+ const ASSISTANT_NAMES = new Set(['none', 'auto', 'claude', 'codex'])
134
+
135
+ /** Normalize an assistant spec (a name, "auto", "none", or { tool, model, effort }). */
136
+ export function normalizeAssistant(spec) {
137
+ if (spec === null || spec === undefined || spec === 'none') return null
138
+ const object = typeof spec === 'string' ? { tool: spec } : spec
139
+ if (
140
+ typeof object.tool !== 'string' ||
141
+ !ASSISTANT_NAMES.has(object.tool) ||
142
+ object.tool === 'none'
143
+ ) {
144
+ return {
145
+ error: `assistant must be one of: ${[...ASSISTANT_NAMES].join(', ')}, or { tool, model, effort }`,
146
+ }
147
+ }
148
+ return { tool: object.tool, model: object.model ?? null, effort: object.effort ?? null }
149
+ }
150
+
151
+ export function loadConfig(configPath) {
152
+ if (!existsSync(configPath)) fail(`no ${basename(configPath)} found at ${configPath}`)
153
+ const config = readJson(configPath)
154
+ const unknown = Object.keys(config).filter((key) => !CONFIG_KEYS.has(key))
155
+ if (unknown.length)
156
+ fail(`unknown config key${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`)
157
+ if (!Array.isArray(config.packages) || config.packages.length === 0) {
158
+ fail('config must declare a non-empty "packages" array')
159
+ }
160
+ const rangePolicy = config.rangePolicy ?? 'caret'
161
+ if (!RANGE_POLICIES.has(rangePolicy))
162
+ fail(`rangePolicy must be one of: ${[...RANGE_POLICIES].join(', ')}`)
163
+ const registryWait = { timeout: 300, interval: 5, ...config.registryWait }
164
+ const assistant = normalizeAssistant(config.assistant ?? null)
165
+ if (assistant?.error) fail(assistant.error)
166
+ const summaryFile = config.summaryFile ?? null
167
+ if (summaryFile !== null && typeof summaryFile !== 'string')
168
+ fail('summaryFile must be a path or null')
169
+ return { packages: config.packages, rangePolicy, registryWait, summaryFile, assistant }
170
+ }
171
+
172
+ const KNOWN_FLAGS = new Set([
173
+ '--dry-run',
174
+ '--all',
175
+ '--offline',
176
+ '--config',
177
+ '--summary',
178
+ '--assistant',
179
+ '--help',
180
+ '-h',
181
+ ])
182
+ const COMMANDS = new Set(['graph', 'seed-tags'])
183
+
184
+ function parseArgs(argv) {
185
+ const args = {
186
+ command: null,
187
+ ids: [],
188
+ dryRun: false,
189
+ all: false,
190
+ offline: false,
191
+ configPath: null,
192
+ summaryPath: null,
193
+ assistant: undefined, // undefined = no override; null = --assistant none (kill switch)
194
+ help: false,
195
+ }
196
+ for (let i = 0; i < argv.length; i++) {
197
+ const arg = argv[i]
198
+ if (arg === '--config') {
199
+ args.configPath = argv[++i] ?? fail('--config needs a path')
200
+ } else if (arg === '--summary') {
201
+ args.summaryPath = argv[++i] ?? fail('--summary needs a path')
202
+ } else if (arg === '--assistant') {
203
+ const name = argv[++i] ?? fail('--assistant needs a name (none, auto, claude, codex)')
204
+ if (!ASSISTANT_NAMES.has(name))
205
+ fail(`--assistant must be one of: ${[...ASSISTANT_NAMES].join(', ')}`)
206
+ const normalized = normalizeAssistant(name)
207
+ args.assistant = normalized?.error ? fail(normalized.error) : normalized
208
+ } else if (arg === '--dry-run') args.dryRun = true
209
+ else if (arg === '--all') args.all = true
210
+ else if (arg === '--offline') args.offline = true
211
+ else if (arg === '--help' || arg === '-h') args.help = true
212
+ else if (arg.startsWith('-'))
213
+ fail(`unknown flag ${arg} (known: ${[...KNOWN_FLAGS].join(', ')})`)
214
+ else if (args.command === null && args.ids.length === 0 && COMMANDS.has(arg)) args.command = arg
215
+ else args.ids.push(arg)
216
+ }
217
+ if (args.all && args.ids.length)
218
+ fail('--all and explicit package ids conflict — pass one or the other')
219
+ if (args.command === 'graph' && (args.all || args.ids.length))
220
+ fail('graph takes no package ids or --all')
221
+ return args
222
+ }
223
+
224
+ /** Expand a membership entry to directories. Supports a single trailing `/*`. */
225
+ function expandEntry(rootDir, entry) {
226
+ const spec = typeof entry === 'string' ? { path: entry } : entry
227
+ if (!spec.path.includes('*')) return [spec]
228
+ const starIndex = spec.path.indexOf('*')
229
+ const parent = join(rootDir, spec.path.slice(0, starIndex))
230
+ if (!existsSync(parent)) return []
231
+ return readdirSync(parent)
232
+ .filter((name) => !name.startsWith('.') && statSync(join(parent, name)).isDirectory())
233
+ .map((name) =>
234
+ Object.assign({}, spec, { path: join(relative(rootDir, parent), name), id: undefined }),
235
+ )
236
+ }
237
+
238
+ // ─────────────────────────────────────────────────────────────────────────────
239
+ // Discovery — manifests and owning repos
240
+ // ─────────────────────────────────────────────────────────────────────────────
241
+
242
+ /** Read whichever manifest the directory carries: name, version, dependency maps. */
243
+ function readManifest(dir) {
244
+ const pkgJson = join(dir, 'package.json')
245
+ if (existsSync(pkgJson)) {
246
+ const m = readJson(pkgJson)
247
+ return {
248
+ ecosystem: 'npm',
249
+ manifestFile: 'package.json',
250
+ name: m.name ?? null,
251
+ version: m.version ?? null,
252
+ deps: { ...m.dependencies, ...m.peerDependencies, ...m.optionalDependencies },
253
+ devDeps: { ...m.devDependencies },
254
+ }
255
+ }
256
+ const composer = join(dir, 'composer.json')
257
+ if (existsSync(composer)) {
258
+ const m = readJson(composer)
259
+ return {
260
+ ecosystem: 'php',
261
+ manifestFile: 'composer.json',
262
+ name: m.name ?? null,
263
+ version: m.version ?? null,
264
+ deps: { ...m.require },
265
+ devDeps: { ...m['require-dev'] },
266
+ }
267
+ }
268
+ const pyproject = join(dir, 'pyproject.toml')
269
+ if (existsSync(pyproject)) {
270
+ const text = readFileSync(pyproject, 'utf8')
271
+ const name = /^name\s*=\s*"([^"]+)"/m.exec(text)?.[1] ?? null
272
+ const version = /^version\s*=\s*"([^"]+)"/m.exec(text)?.[1] ?? null
273
+ return {
274
+ ecosystem: 'python',
275
+ manifestFile: 'pyproject.toml',
276
+ name,
277
+ version,
278
+ deps: {},
279
+ devDeps: {},
280
+ }
281
+ }
282
+ const goMod = join(dir, 'go.mod')
283
+ if (existsSync(goMod)) {
284
+ const name = /^module\s+(\S+)/m.exec(readFileSync(goMod, 'utf8'))?.[1] ?? null
285
+ return { ecosystem: 'go', manifestFile: 'go.mod', name, version: null, deps: {}, devDeps: {} }
286
+ }
287
+ return null
288
+ }
289
+
290
+ /** Walk up from dir to the nearest .git, stopping at rootDir's parent. */
291
+ function findOwningRepo(rootDir, dir) {
292
+ let current = resolve(dir)
293
+ const stop = dirname(resolve(rootDir))
294
+ while (current !== stop && current !== dirname(current)) {
295
+ if (existsSync(join(current, '.git'))) return current
296
+ current = dirname(current)
297
+ }
298
+ return null
299
+ }
300
+
301
+ export function discover(rootDir, config) {
302
+ const members = []
303
+ const problems = []
304
+ for (const entry of config.packages.flatMap((e) => expandEntry(rootDir, e))) {
305
+ const dir = join(rootDir, entry.path)
306
+ if (!existsSync(dir)) {
307
+ problems.push(`membership path does not exist: ${entry.path}`)
308
+ continue
309
+ }
310
+ const manifest = readManifest(dir)
311
+ if (!manifest) {
312
+ problems.push(
313
+ `no manifest (package.json / composer.json / pyproject.toml / go.mod) in ${entry.path}`,
314
+ )
315
+ continue
316
+ }
317
+ const repoDir = findOwningRepo(rootDir, dir)
318
+ if (!repoDir) problems.push(`no owning git repository found for ${entry.path}`)
319
+ const releaseConfigPath = join(dir, 'release.config.json')
320
+ const releaseConfig = existsSync(releaseConfigPath) ? readJson(releaseConfigPath) : {}
321
+ members.push({
322
+ id: entry.id ?? manifest.name ?? basename(dir),
323
+ path: entry.path,
324
+ dir,
325
+ repoDir,
326
+ repoRelPath: repoDir ? relative(repoDir, dir) || '.' : null,
327
+ publish: entry.publish !== false,
328
+ branch: releaseConfig.branch === undefined ? 'main' : releaseConfig.branch,
329
+ ...manifest,
330
+ })
331
+ }
332
+ const seen = new Map()
333
+ for (const member of members) {
334
+ if (seen.has(member.id))
335
+ problems.push(
336
+ `duplicate package id "${member.id}" (${seen.get(member.id)} and ${member.path})`,
337
+ )
338
+ seen.set(member.id, member.path)
339
+ }
340
+ return { members, problems }
341
+ }
342
+
343
+ // ─────────────────────────────────────────────────────────────────────────────
344
+ // Graph — internal edges, cycles, topological order
345
+ // ─────────────────────────────────────────────────────────────────────────────
346
+
347
+ /**
348
+ * Publish-order edges come from dependencies/peerDependencies/optionalDependencies whose
349
+ * name matches another member. devDependencies never order publishes (they are not in the
350
+ * published artifact) but still cascade — a devDep edge is tracked separately.
351
+ */
352
+ export function buildGraph(members) {
353
+ const byName = new Map(members.filter((m) => m.name).map((m) => [m.name, m]))
354
+ const orderEdges = new Map(members.map((m) => [m.id, []]))
355
+ const devEdges = new Map(members.map((m) => [m.id, []]))
356
+ for (const member of members) {
357
+ for (const [depName, range] of Object.entries(member.deps ?? {})) {
358
+ const dep = byName.get(depName)
359
+ if (dep && dep.id !== member.id) orderEdges.get(member.id).push({ dep: dep.id, range })
360
+ }
361
+ for (const [depName, range] of Object.entries(member.devDeps ?? {})) {
362
+ const dep = byName.get(depName)
363
+ if (dep && dep.id !== member.id) devEdges.get(member.id).push({ dep: dep.id, range })
364
+ }
365
+ }
366
+ return { orderEdges, devEdges }
367
+ }
368
+
369
+ export function findCycles(orderEdges) {
370
+ const cycles = []
371
+ const done = new Set()
372
+ const walk = (id, stack) => {
373
+ if (done.has(id)) return
374
+ const at = stack.indexOf(id)
375
+ if (at !== -1) {
376
+ cycles.push([...stack.slice(at), id])
377
+ return
378
+ }
379
+ for (const edge of orderEdges.get(id) ?? []) walk(edge.dep, [...stack, id])
380
+ done.add(id)
381
+ }
382
+ for (const id of orderEdges.keys()) walk(id, [])
383
+ return cycles
384
+ }
385
+
386
+ /** Dependencies-first order. Deterministic: alphabetical among peers. */
387
+ export function topoSort(orderEdges) {
388
+ const order = []
389
+ const done = new Set()
390
+ const visit = (id) => {
391
+ if (done.has(id)) return
392
+ done.add(id)
393
+ for (const edge of [...(orderEdges.get(id) ?? [])].sort((a, b) => a.dep.localeCompare(b.dep)))
394
+ visit(edge.dep)
395
+ order.push(id)
396
+ }
397
+ for (const id of [...orderEdges.keys()].sort()) visit(id)
398
+ return order
399
+ }
400
+
401
+ // ─────────────────────────────────────────────────────────────────────────────
402
+ // Tags and change detection
403
+ // ─────────────────────────────────────────────────────────────────────────────
404
+
405
+ /**
406
+ * Tag scheme: `v<version>` when the repo owns exactly one member, `<name>@<version>` when
407
+ * it owns several — which keeps single-package repos identical to standalone release-kit.
408
+ */
409
+ export function tagPatternFor(member, repoMemberCount) {
410
+ return repoMemberCount > 1
411
+ ? { prefix: `${member.name}@`, glob: `${member.name}@*` }
412
+ : { prefix: 'v', glob: 'v*' }
413
+ }
414
+
415
+ function lastReleaseTag(member, repoMemberCount) {
416
+ if (!member.repoDir) return null
417
+ const { prefix, glob } = tagPatternFor(member, repoMemberCount)
418
+ const tags = git(member.repoDir, ['tag', '--list', glob], { allowFailure: true })
419
+ if (!tags) return null
420
+ const versions = tags
421
+ .split('\n')
422
+ .map((tag) => ({ tag, version: tag.slice(prefix.length) }))
423
+ .filter(({ version }) => parseSemver(version))
424
+ .sort((a, b) => compareSemver(a.version, b.version))
425
+ return versions.at(-1) ?? null
426
+ }
427
+
428
+ /** Full commit messages (subject + body) since the tag, newest first, for this path. */
429
+ function commitsSince(member, tag) {
430
+ const range = tag ? `${tag}..HEAD` : 'HEAD'
431
+ const pathArgs = member.repoRelPath === '.' ? [] : ['--', member.repoRelPath]
432
+ const out = git(member.repoDir, ['log', '--format=%B%x1e', range, ...pathArgs], {
433
+ allowFailure: true,
434
+ })
435
+ if (!out) return []
436
+ return out
437
+ .split('\u001E')
438
+ .map((message) => message.trim())
439
+ .filter(Boolean)
440
+ }
441
+
442
+ // ─────────────────────────────────────────────────────────────────────────────
443
+ // Registry — published versions, cached one lookup per package
444
+ // ─────────────────────────────────────────────────────────────────────────────
445
+
446
+ const registryCache = new Map()
447
+
448
+ /**
449
+ * All published versions of an npm package. `[]` means "confirmed never published"
450
+ * (E404); `null` means the lookup failed (network, auth) and nothing can be concluded.
451
+ */
452
+ function publishedVersions(name) {
453
+ if (registryCache.has(name)) return registryCache.get(name)
454
+ let result
455
+ try {
456
+ const out = execFileSync('npm', ['view', name, 'versions', '--json'], {
457
+ encoding: 'utf8',
458
+ stdio: ['ignore', 'pipe', 'pipe'],
459
+ timeout: 20_000,
460
+ }).trim()
461
+ const parsed = JSON.parse(out)
462
+ result = Array.isArray(parsed) ? parsed : [parsed]
463
+ } catch (err) {
464
+ result = /E404|404 Not Found/.test(`${err.stderr ?? ''}${err.stdout ?? ''}`) ? [] : null
465
+ }
466
+ registryCache.set(name, result)
467
+ return result
468
+ }
469
+
470
+ /**
471
+ * How a manifest version relates to the registry. The pure half of pending-version
472
+ * detection, so it is testable without a network.
473
+ *
474
+ * current — normal: the manifest version is the registry's latest
475
+ * pending — the manifest version is not published (a committed-but-unreleased bump,
476
+ * or a first release); it should be released as-is, not skipped over
477
+ * behind — the registry has a newer version than the manifest; releasing anything
478
+ * from this tree would regress, so it is a preflight failure
479
+ * unknown — the lookup failed or was skipped (--offline)
480
+ */
481
+ export function registryStatus(manifestVersion, versions) {
482
+ if (versions === null || !manifestVersion) return { state: 'unknown', latest: null }
483
+ const latest =
484
+ versions
485
+ .filter((v) => parseSemver(v) && !parseSemver(v).prerelease)
486
+ .sort(compareSemver)
487
+ .at(-1) ?? null
488
+ if (!versions.includes(manifestVersion)) {
489
+ if (latest && compareSemver(latest, manifestVersion) > 0) return { state: 'behind', latest }
490
+ return { state: 'pending', latest }
491
+ }
492
+ if (latest && compareSemver(latest, manifestVersion) > 0) return { state: 'behind', latest }
493
+ return { state: 'current', latest }
494
+ }
495
+
496
+ /** Gather registry status for every npm publishable member. Offline: everything unknown. */
497
+ function gatherRegistry(members, offline) {
498
+ const registry = new Map()
499
+ for (const member of members) {
500
+ if (member.ecosystem !== 'npm' || !member.publish || !member.name) {
501
+ registry.set(member.id, { state: 'unknown', latest: null, versions: null })
502
+ continue
503
+ }
504
+ const versions = offline ? null : publishedVersions(member.name)
505
+ registry.set(member.id, { ...registryStatus(member.version, versions), versions })
506
+ }
507
+ return registry
508
+ }
509
+
510
+ // ─────────────────────────────────────────────────────────────────────────────
511
+ // Plan — release set, bumps, range rewrites
512
+ // ─────────────────────────────────────────────────────────────────────────────
513
+
514
+ export function rewriteRange(policy, currentRange, newVersion) {
515
+ if (currentRange.startsWith('workspace:')) return null // the package manager rewrites these at publish
516
+ if (policy === 'preserve') {
517
+ const operator = /^[\^~]/.exec(currentRange)?.[0] ?? ''
518
+ return `${operator}${newVersion}`
519
+ }
520
+ if (policy === 'caret') return `^${newVersion}`
521
+ if (policy === 'tilde') return `~${newVersion}`
522
+ return newVersion
523
+ }
524
+
525
+ export function rangeSatisfies(range, version) {
526
+ if (range.startsWith('workspace:')) return true
527
+ const operator = /^[\^~]/.exec(range)?.[0] ?? ''
528
+ const base = parseSemver(range.replace(/^[\^~]/, ''))
529
+ const v = parseSemver(version)
530
+ if (!base || !v) return false
531
+ if (operator === '^') return v.major === base.major && compareSemver(version, range.slice(1)) >= 0
532
+ if (operator === '~')
533
+ return v.major === base.major && v.minor === base.minor && v.patch >= base.patch
534
+ return compareSemver(version, range) === 0
535
+ }
536
+
537
+ /**
538
+ * Decide the release set and each member's bump.
539
+ *
540
+ * Seeding, in priority order per member:
541
+ * 1. Registry `pending` — an unpublished manifest version is released **as-is**;
542
+ * the member joins the set even with zero new commits.
543
+ * 2. Explicit request / --all / commits since the last release tag — bump derived
544
+ * from the commit messages.
545
+ * Cascade then raises "not releasing" to patch, transitively, through both order and
546
+ * dev edges. A pending member reached by cascade stays as-is: its unpublished version
547
+ * is already the release.
548
+ */
549
+ export function planReleases({
550
+ members,
551
+ orderEdges,
552
+ devEdges,
553
+ changes,
554
+ registry,
555
+ requested,
556
+ all,
557
+ rangePolicy,
558
+ }) {
559
+ const byId = new Map(members.map((m) => [m.id, m]))
560
+ const bumps = new Map()
561
+ const reasons = new Map()
562
+
563
+ for (const member of members) {
564
+ if (registry?.get(member.id)?.state === 'pending') {
565
+ bumps.set(member.id, 'as-is')
566
+ reasons.set(member.id, `${member.version} in manifest, not on the registry`)
567
+ }
568
+ }
569
+
570
+ const seedIds = requested.length
571
+ ? requested
572
+ : members.filter((m) => (all ? true : changes.get(m.id)?.commits.length)).map((m) => m.id)
573
+ for (const id of seedIds) {
574
+ if (bumps.has(id)) continue // pending wins: the unpublished version is the release
575
+ const change = changes.get(id)
576
+ const messages = change?.commits ?? []
577
+ bumps.set(id, messages.length ? bumpFromCommits(messages, byId.get(id).version) : 'patch')
578
+ reasons.set(
579
+ id,
580
+ requested.length
581
+ ? 'requested'
582
+ : all
583
+ ? '--all'
584
+ : `${messages.length} commit${messages.length === 1 ? '' : 's'}`,
585
+ )
586
+ }
587
+
588
+ const dependents = new Map(members.map((m) => [m.id, new Set()]))
589
+ for (const [id, edges] of orderEdges) for (const e of edges) dependents.get(e.dep)?.add(id)
590
+ for (const [id, edges] of devEdges) for (const e of edges) dependents.get(e.dep)?.add(id)
591
+
592
+ const queue = [...bumps.keys()]
593
+ while (queue.length) {
594
+ const id = queue.shift()
595
+ for (const dependent of dependents.get(id) ?? []) {
596
+ if (!bumps.has(dependent)) {
597
+ bumps.set(dependent, 'patch')
598
+ reasons.set(dependent, `depends on ${id}`)
599
+ queue.push(dependent)
600
+ }
601
+ }
602
+ }
603
+
604
+ const order = topoSort(orderEdges).filter((id) => bumps.has(id))
605
+ return order.map((id) => {
606
+ const member = byId.get(id)
607
+ const bump = bumps.get(id)
608
+ const next = member.version ? bumpSemver(member.version, bump) : null
609
+ const rewrites = (orderEdges.get(id) ?? [])
610
+ .filter((edge) => bumps.has(edge.dep))
611
+ .map((edge) => {
612
+ const dep = byId.get(edge.dep)
613
+ const depNext = dep.version ? bumpSemver(dep.version, bumps.get(edge.dep)) : null
614
+ return {
615
+ dep: edge.dep,
616
+ from: edge.range,
617
+ to: depNext ? rewriteRange(rangePolicy, edge.range, depNext) : null,
618
+ }
619
+ })
620
+ .filter((r) => r.to !== null || r.from.startsWith('workspace:'))
621
+ return { id, member, bump, current: member.version, next, reason: reasons.get(id), rewrites }
622
+ })
623
+ }
624
+
625
+ // ─────────────────────────────────────────────────────────────────────────────
626
+ // Preflight — accumulate every failure, report once
627
+ // ─────────────────────────────────────────────────────────────────────────────
628
+
629
+ function preflight({
630
+ members,
631
+ plan,
632
+ cycles,
633
+ discoveryProblems,
634
+ repoMemberCounts,
635
+ changes,
636
+ registry,
637
+ offline,
638
+ rootDir,
639
+ }) {
640
+ const failures = [...discoveryProblems]
641
+ const warnings = []
642
+ if (offline)
643
+ warnings.push(
644
+ '--offline: registry checks skipped — pending versions, collisions and behind-registry states are not detected',
645
+ )
646
+
647
+ for (const cycle of cycles)
648
+ failures.push(`dependency cycle: ${cycle.join(' → ')} — no release order exists`)
649
+
650
+ const repos = new Map()
651
+ for (const member of members) {
652
+ if (member.repoDir && !repos.has(member.repoDir)) repos.set(member.repoDir, member)
653
+ }
654
+ const plannedRepoDirs = new Set(plan.map((item) => item.member.repoDir).filter(Boolean))
655
+
656
+ for (const [repoDir, sample] of repos) {
657
+ if (!plannedRepoDirs.has(repoDir)) continue
658
+ const label = relative(rootDir, repoDir) || '.'
659
+ const dirty = git(repoDir, ['status', '--porcelain'], { allowFailure: true })
660
+ if (dirty) {
661
+ const count = dirty.split('\n').length
662
+ failures.push(
663
+ `${label}: working tree not clean (${count} ${count === 1 ? 'entry' : 'entries'})`,
664
+ )
665
+ }
666
+ const branch = git(repoDir, ['branch', '--show-current'], { allowFailure: true })
667
+ if (sample.branch !== null) {
668
+ if (!branch) failures.push(`${label}: detached HEAD`)
669
+ else if (branch !== sample.branch)
670
+ failures.push(`${label}: on branch "${branch}", releases run from "${sample.branch}"`)
671
+ }
672
+ const upstream = git(repoDir, ['rev-parse', '--abbrev-ref', '@{upstream}'], {
673
+ allowFailure: true,
674
+ })
675
+ if (!upstream) {
676
+ warnings.push(
677
+ `${label}: no upstream tracking branch — cannot tell whether it is behind its remote`,
678
+ )
679
+ } else {
680
+ const behind = git(repoDir, ['rev-list', '--count', `HEAD..${upstream}`], {
681
+ allowFailure: true,
682
+ })
683
+ if (behind && +behind > 0)
684
+ failures.push(`${label}: ${behind} commit(s) behind ${upstream} (as of the last fetch)`)
685
+ }
686
+ }
687
+
688
+ for (const member of members) {
689
+ const status = registry.get(member.id)
690
+ if (status?.state === 'behind') {
691
+ failures.push(
692
+ `${member.id}: manifest says ${member.version} but the registry's latest is ${status.latest} — the tree is behind what was published; sync it before releasing`,
693
+ )
694
+ }
695
+ if (
696
+ !offline &&
697
+ member.ecosystem === 'npm' &&
698
+ member.publish &&
699
+ status?.state === 'unknown' &&
700
+ member.name
701
+ ) {
702
+ warnings.push(
703
+ `${member.id}: registry lookup failed — pending/collision checks not performed for it`,
704
+ )
705
+ }
706
+ }
707
+
708
+ for (const item of plan) {
709
+ const { member, next } = item
710
+ if (!member.version) {
711
+ warnings.push(
712
+ `${item.id}: no manifest version (${member.ecosystem}); current version must come from its last release tag`,
713
+ )
714
+ continue
715
+ }
716
+ const change = changes.get(item.id)
717
+ if (!change?.tag)
718
+ warnings.push(
719
+ `${item.id}: no release tag yet (cold start) — bump derived from the full history; consider seed-tags first`,
720
+ )
721
+ if (next) {
722
+ const { prefix } = tagPatternFor(member, repoMemberCounts.get(member.repoDir) ?? 1)
723
+ const nextTag = `${prefix}${next}`
724
+ const existing = member.repoDir
725
+ ? git(member.repoDir, ['tag', '--list', nextTag], { allowFailure: true })
726
+ : ''
727
+ if (existing && item.bump === 'as-is') {
728
+ warnings.push(
729
+ `${item.id}: tag ${nextTag} already exists — a failed publish; release-kit reuses it when it is at HEAD`,
730
+ )
731
+ } else if (existing) {
732
+ failures.push(`${item.id}: tag ${nextTag} already exists`)
733
+ }
734
+ const status = registry.get(item.id)
735
+ if (status?.versions?.includes(next) && item.bump !== 'as-is') {
736
+ failures.push(`${item.id}: ${member.name}@${next} is already on the registry`)
737
+ }
738
+ }
739
+ for (const rewrite of item.rewrites) {
740
+ if (
741
+ rewrite.to &&
742
+ !rangeSatisfies(rewrite.to, plan.find((p) => p.id === rewrite.dep)?.next ?? '')
743
+ ) {
744
+ failures.push(
745
+ `${item.id}: rewritten range ${rewrite.to} for ${rewrite.dep} does not match its planned version`,
746
+ )
747
+ }
748
+ }
749
+ }
750
+
751
+ for (const member of members) {
752
+ for (const [depName, range] of Object.entries(member.deps ?? {})) {
753
+ if (range.startsWith('workspace:') && !members.some((m) => m.name === depName)) {
754
+ failures.push(
755
+ `${member.id}: workspace range for ${depName}, which is not a member of the train`,
756
+ )
757
+ }
758
+ }
759
+ }
760
+
761
+ return { failures, warnings }
762
+ }
763
+
764
+ // ─────────────────────────────────────────────────────────────────────────────
765
+ // seed-tags — establish baseline release tags so change detection has a floor
766
+ // ─────────────────────────────────────────────────────────────────────────────
767
+
768
+ /**
769
+ * For each member: tag the manifest version at the owning repo's HEAD, then push the tag.
770
+ * Refuses per member, without touching it, when
771
+ * - the manifest version is not on the registry — the baseline would claim a release
772
+ * that never happened (pending versions are released by the train, not seeded), or
773
+ * - the manifest file itself has uncommitted changes — the version on disk may not be
774
+ * the version at HEAD, so the tag would point at the wrong commit, or
775
+ * - there is no manifest version at all (go, tag-only projects) — seed by hand.
776
+ * Under --offline the registry cannot vouch for npm members, so npm members are skipped;
777
+ * tags that are created are not pushed. --dry-run prints every action and does nothing.
778
+ */
779
+ function seedTags({ members, repoMemberCounts, registry, dryRun, offline }) {
780
+ const results = []
781
+ for (const member of members) {
782
+ const { id, repoDir, version } = member
783
+ if (!repoDir) {
784
+ results.push({ id, action: 'skip', detail: 'no owning git repository' })
785
+ continue
786
+ }
787
+ if (!version) {
788
+ results.push({
789
+ id,
790
+ action: 'skip',
791
+ detail: `no manifest version (${member.ecosystem}) — seed by hand if the registry has releases`,
792
+ })
793
+ continue
794
+ }
795
+ const { prefix } = tagPatternFor(member, repoMemberCounts.get(repoDir) ?? 1)
796
+ const tag = `${prefix}${version}`
797
+ if (git(repoDir, ['tag', '--list', tag], { allowFailure: true })) {
798
+ results.push({ id, action: 'ok', detail: `${tag} already exists` })
799
+ continue
800
+ }
801
+ if (member.ecosystem === 'npm' && member.publish) {
802
+ const status = registry.get(id)
803
+ if (status?.state === 'pending') {
804
+ results.push({
805
+ id,
806
+ action: 'refuse',
807
+ detail: `${version} is not on the registry — a pending version is released by the train, not seeded`,
808
+ })
809
+ continue
810
+ }
811
+ if (status?.state === 'behind') {
812
+ results.push({
813
+ id,
814
+ action: 'refuse',
815
+ detail: `manifest ${version} is behind the registry (${status.latest}) — sync the tree first`,
816
+ })
817
+ continue
818
+ }
819
+ if (status?.state === 'unknown') {
820
+ results.push({
821
+ id,
822
+ action: 'skip',
823
+ detail: offline
824
+ ? 'registry unverifiable under --offline'
825
+ : 'registry lookup failed — cannot verify the version was released',
826
+ })
827
+ continue
828
+ }
829
+ }
830
+ const manifestDirty = git(
831
+ repoDir,
832
+ [
833
+ 'status',
834
+ '--porcelain',
835
+ '--',
836
+ join(member.repoRelPath === '.' ? '' : member.repoRelPath, member.manifestFile),
837
+ ],
838
+ {
839
+ allowFailure: true,
840
+ },
841
+ )
842
+ if (manifestDirty) {
843
+ results.push({
844
+ id,
845
+ action: 'refuse',
846
+ detail: `${member.manifestFile} has uncommitted changes — the version on disk may not match HEAD`,
847
+ })
848
+ continue
849
+ }
850
+ if (dryRun) {
851
+ results.push({
852
+ id,
853
+ action: 'would-tag',
854
+ detail: `${tag} at HEAD${offline ? '' : ', then push'}`,
855
+ })
856
+ continue
857
+ }
858
+ const tagged = git(
859
+ repoDir,
860
+ [
861
+ 'tag',
862
+ '-a',
863
+ tag,
864
+ '-m',
865
+ `Baseline for ${member.name ?? id} ${version} (seeded by release-train)`,
866
+ ],
867
+ { allowFailure: true },
868
+ )
869
+ if (tagged === null) {
870
+ results.push({ id, action: 'error', detail: `git tag ${tag} failed` })
871
+ continue
872
+ }
873
+ if (offline) {
874
+ results.push({ id, action: 'tagged', detail: `${tag} created, not pushed (--offline)` })
875
+ continue
876
+ }
877
+ const pushed = git(repoDir, ['push', 'origin', tag], { allowFailure: true })
878
+ results.push(
879
+ pushed === null
880
+ ? { id, action: 'tagged', detail: `${tag} created; push failed — push it manually` }
881
+ : { id, action: 'tagged', detail: `${tag} created and pushed` },
882
+ )
883
+ }
884
+ return results
885
+ }
886
+
887
+ // ─────────────────────────────────────────────────────────────────────────────
888
+ // Train summary — deterministic report, optionally with a drafted announcement
889
+ // ─────────────────────────────────────────────────────────────────────────────
890
+
891
+ /**
892
+ * Markdown report of the whole train: every package with its version movement and why,
893
+ * plus the dependency ripple — which changes pulled which dependents in. Deterministic
894
+ * and buildable from the plan alone; per-package release notes stay per-package
895
+ * (release-kit owns those). `mode` is 'planned' until execution exists.
896
+ */
897
+ export function buildSummary(plan, { workspace, date, mode = 'planned' }) {
898
+ const lines = [
899
+ `# Release train — ${workspace}`,
900
+ '',
901
+ `_${date} · ${plan.length} package${plan.length === 1 ? '' : 's'} ${mode}, in dependency order_`,
902
+ '',
903
+ ]
904
+ if (plan.length === 0) {
905
+ lines.push(
906
+ 'Nothing to release: no member had commits since its last release tag or a pending unpublished version.',
907
+ )
908
+ return `${lines.join('\n')}\n`
909
+ }
910
+ lines.push('| # | Package | Version | Bump | Why |', '|---|---------|---------|------|-----|')
911
+ for (const [index, item] of plan.entries()) {
912
+ const version =
913
+ item.bump === 'as-is'
914
+ ? `${item.current} (as-is)`
915
+ : item.current
916
+ ? `${item.current} → ${item.next}`
917
+ : '(from tag)'
918
+ lines.push(`| ${index + 1} | ${item.id} | ${version} | ${item.bump} | ${item.reason} |`)
919
+ }
920
+
921
+ // Ripple: cascade reasons carry a parent pointer ("depends on <id>") — render each
922
+ // seed with the transitive dependents it pulled in.
923
+ const children = new Map()
924
+ for (const item of plan) {
925
+ const parent = /^depends on (.+)$/.exec(item.reason)?.[1]
926
+ if (parent) {
927
+ if (!children.has(parent)) children.set(parent, [])
928
+ children.get(parent).push(item.id)
929
+ }
930
+ }
931
+ const seeds = plan.filter((item) => !item.reason.startsWith('depends on '))
932
+ const rippleLines = []
933
+ const renderRipple = (id, depth) => {
934
+ for (const child of children.get(id) ?? []) {
935
+ rippleLines.push(`${' '.repeat(depth)}- ${child}`)
936
+ renderRipple(child, depth + 1)
937
+ }
938
+ }
939
+ for (const seed of seeds) {
940
+ if (!children.has(seed.id)) continue
941
+ rippleLines.push(`- **${seed.id}** (${seed.reason}) pulled in:`)
942
+ renderRipple(seed.id, 1)
943
+ }
944
+ if (rippleLines.length) lines.push('', '## Dependency ripple', '', ...rippleLines)
945
+
946
+ const rewriteCount = plan.reduce((sum, item) => sum + item.rewrites.filter((r) => r.to).length, 0)
947
+ if (rewriteCount)
948
+ lines.push(
949
+ '',
950
+ `${rewriteCount} internal dependency range${rewriteCount === 1 ? '' : 's'} updated across the train.`,
951
+ )
952
+ return `${lines.join('\n')}\n`
953
+ }
954
+
955
+ // Assistant invocations mirror release-kit's ASSISTANTS table, so a tool configured for
956
+ // one behaves identically for the other.
957
+ const ASSISTANTS = {
958
+ claude: {
959
+ command: 'claude',
960
+ args: ['-p'],
961
+ probe: ['--version'],
962
+ model: (m) => ['--model', m],
963
+ effort: (e) => ['--effort', e],
964
+ },
965
+ codex: {
966
+ command: 'codex',
967
+ args: ['exec', '--skip-git-repo-check', '--sandbox', 'read-only'],
968
+ probe: ['--version'],
969
+ model: (m) => ['-m', m],
970
+ effort: (e) => [`-c`, `model_reasoning_effort="${e}"`],
971
+ outputFile: (path) => ['--output-last-message', path],
972
+ },
973
+ }
974
+
975
+ const DRAFT_TIMEOUT_MS = 180_000
976
+
977
+ /** Strip tool attribution and markdown fences a model may wrap its answer in. */
978
+ function cleanDraft(text) {
979
+ return text
980
+ .replace(/^\s*```[a-z]*\s*\n?/i, '')
981
+ .replace(/\n?```\s*$/, '')
982
+ .split('\n')
983
+ .filter(
984
+ (line) => !/^\s*co-authored-by:/i.test(line) && !/^\s*(🤖\s*)?generated with/i.test(line),
985
+ )
986
+ .join('\n')
987
+ .trim()
988
+ }
989
+
990
+ function assistantAvailable(tool) {
991
+ try {
992
+ execFileSync(ASSISTANTS[tool].command, ASSISTANTS[tool].probe, {
993
+ stdio: 'ignore',
994
+ timeout: 10_000,
995
+ })
996
+ return true
997
+ } catch {
998
+ return false
999
+ }
1000
+ }
1001
+
1002
+ /** "auto" → the first tool on PATH; a named tool that is missing is a hard error. */
1003
+ function resolveAssistant(spec) {
1004
+ if (!spec) return null
1005
+ if (spec.tool === 'auto') {
1006
+ const found = Object.keys(ASSISTANTS).find((tool) => assistantAvailable(tool))
1007
+ return found ? { ...spec, tool: found } : null // auto degrades quietly by design
1008
+ }
1009
+ if (!assistantAvailable(spec.tool))
1010
+ fail(`assistant "${spec.tool}" is not installed or not working`)
1011
+ return spec
1012
+ }
1013
+
1014
+ /**
1015
+ * Draft a short ecosystem announcement from the deterministic summary. Every failure —
1016
+ * timeout, unusable answer, tool error — returns null and the train carries on with the
1017
+ * deterministic summary alone. A train is never blocked by a text generator.
1018
+ */
1019
+ function draftAnnouncement(assistant, summaryMarkdown) {
1020
+ const definition = ASSISTANTS[assistant.tool]
1021
+ const prompt = [
1022
+ 'Draft a short release announcement (3-6 sentences, plain prose, no headings) for this',
1023
+ 'coordinated multi-package release. Lead with what changed and why consumers care;',
1024
+ 'mention the packages by name only where it helps. Output only the announcement text.',
1025
+ '',
1026
+ summaryMarkdown,
1027
+ ].join('\n')
1028
+ const args = [
1029
+ ...definition.args,
1030
+ ...(assistant.model ? definition.model(assistant.model) : []),
1031
+ ...(assistant.effort ? definition.effort(assistant.effort) : []),
1032
+ ]
1033
+ let outputPath = null
1034
+ if (definition.outputFile) {
1035
+ outputPath = join(mkdtempSync(join(tmpdir(), 'train-')), 'answer.md')
1036
+ args.push(...definition.outputFile(outputPath))
1037
+ }
1038
+ try {
1039
+ const stdout = execFileSync(definition.command, args, {
1040
+ input: prompt,
1041
+ encoding: 'utf8',
1042
+ stdio: ['pipe', 'pipe', 'ignore'],
1043
+ timeout: DRAFT_TIMEOUT_MS,
1044
+ })
1045
+ const answer = cleanDraft(outputPath ? readFileSync(outputPath, 'utf8') : stdout)
1046
+ return answer.length >= 20 ? answer : null
1047
+ } catch {
1048
+ return null
1049
+ }
1050
+ }
1051
+
1052
+ // ─────────────────────────────────────────────────────────────────────────────
1053
+ // Output
1054
+ // ─────────────────────────────────────────────────────────────────────────────
1055
+
1056
+ function printGraph(members, orderEdges, order) {
1057
+ console.log(`train graph — ${members.length} members\n`)
1058
+ for (const id of order) {
1059
+ const edges = orderEdges.get(id) ?? []
1060
+ const deps = edges.map((e) => `${e.dep} (${e.range})`).join(', ')
1061
+ console.log(` ${id} ← ${deps || '(no internal deps)'}`)
1062
+ }
1063
+ console.log('\nTopological order (dependencies first):')
1064
+ console.log(` ${order.join(' → ')}`)
1065
+ }
1066
+
1067
+ function printPlan(plan, { failures, warnings }) {
1068
+ if (plan.length === 0) {
1069
+ console.log(
1070
+ 'Nothing to release: no member has commits since its last release tag or a pending unpublished version.',
1071
+ )
1072
+ } else {
1073
+ console.log(`Release plan — ${plan.length} package${plan.length === 1 ? '' : 's'}, in order:\n`)
1074
+ for (const [index, item] of plan.entries()) {
1075
+ const versionText =
1076
+ item.bump === 'as-is'
1077
+ ? `${item.current} (as-is)`
1078
+ : item.current
1079
+ ? `${item.current} → ${item.next}`
1080
+ : `(${item.member.ecosystem}: version from tag)`
1081
+ console.log(
1082
+ ` ${String(index + 1).padStart(2)}. ${item.id.padEnd(24)} ${item.bump.padEnd(6)} ${versionText.padEnd(20)} ${item.reason}`,
1083
+ )
1084
+ for (const rewrite of item.rewrites) {
1085
+ const target = rewrite.from.startsWith('workspace:')
1086
+ ? `${rewrite.from} (rewritten at publish)`
1087
+ : `${rewrite.from} → ${rewrite.to}`
1088
+ console.log(` deps: ${rewrite.dep} ${target}`)
1089
+ }
1090
+ }
1091
+ }
1092
+ if (warnings.length) {
1093
+ console.log(`\nWarnings (${warnings.length}):`)
1094
+ for (const warning of warnings) console.log(` warn ${warning}`)
1095
+ }
1096
+ if (failures.length) {
1097
+ console.log(
1098
+ `\nPreflight failures (${failures.length}) — nothing would mutate until every one is fixed:`,
1099
+ )
1100
+ for (const failure of failures) console.log(` FAIL ${failure}`)
1101
+ } else {
1102
+ console.log('\nPreflight: ok')
1103
+ }
1104
+ }
1105
+
1106
+ function printSeedResults(results, dryRun) {
1107
+ console.log(`seed-tags${dryRun ? ' (dry run)' : ''} — ${results.length} members\n`)
1108
+ for (const r of results) console.log(` ${r.action.padEnd(10)} ${r.id.padEnd(24)} ${r.detail}`)
1109
+ const refusedOrErrored = results.filter(
1110
+ (r) => r.action === 'refuse' || r.action === 'error',
1111
+ ).length
1112
+ if (refusedOrErrored)
1113
+ console.log(`\n${refusedOrErrored} member(s) refused or failed — see above.`)
1114
+ }
1115
+
1116
+ const HELP = `release-train (prototype — read-only phases, seed-tags, and the train summary)
1117
+
1118
+ train graph print the derived dependency graph and topo order
1119
+ train --dry-run plan + whole-train preflight, execute nothing
1120
+ train --dry-run --all plan every member
1121
+ train --dry-run <id>... plan these packages and their dependents
1122
+ train seed-tags create baseline tags (add --dry-run to preview)
1123
+ train --summary <path> write the train summary (markdown) here; overrides summaryFile
1124
+ train --assistant <name> none (whole-train kill switch), auto, claude, codex
1125
+ train --offline skip network work (registry lookups, tag pushes)
1126
+ train --config <path> config file (default ./train.config.json)
1127
+ `
1128
+
1129
+ // ─────────────────────────────────────────────────────────────────────────────
1130
+ // Main
1131
+ // ─────────────────────────────────────────────────────────────────────────────
1132
+
1133
+ function main() {
1134
+ const args = parseArgs(process.argv.slice(2))
1135
+ if (args.help) {
1136
+ console.log(HELP)
1137
+ return
1138
+ }
1139
+ const configPath = args.configPath ?? join(process.cwd(), 'train.config.json')
1140
+ const config = loadConfig(configPath)
1141
+ const rootDir = dirname(resolve(configPath))
1142
+ const { members, problems } = discover(rootDir, config)
1143
+ const { orderEdges, devEdges } = buildGraph(members)
1144
+ const cycles = findCycles(orderEdges)
1145
+ const order = topoSort(orderEdges)
1146
+
1147
+ if (args.command === 'graph') {
1148
+ printGraph(members, orderEdges, order)
1149
+ if (cycles.length) for (const cycle of cycles) console.log(`\nCYCLE: ${cycle.join(' → ')}`)
1150
+ if (problems.length) for (const problem of problems) console.log(`warn ${problem}`)
1151
+ process.exitCode = cycles.length || problems.length ? 1 : 0
1152
+ return
1153
+ }
1154
+
1155
+ const repoMemberCounts = new Map()
1156
+ for (const member of members) {
1157
+ if (member.repoDir)
1158
+ repoMemberCounts.set(member.repoDir, (repoMemberCounts.get(member.repoDir) ?? 0) + 1)
1159
+ }
1160
+ const registry = gatherRegistry(members, args.offline)
1161
+
1162
+ if (args.command === 'seed-tags') {
1163
+ const results = seedTags({
1164
+ members,
1165
+ repoMemberCounts,
1166
+ registry,
1167
+ dryRun: args.dryRun,
1168
+ offline: args.offline,
1169
+ })
1170
+ printSeedResults(results, args.dryRun)
1171
+ process.exitCode = results.some((r) => r.action === 'error') ? 1 : 0
1172
+ return
1173
+ }
1174
+
1175
+ if (!args.dryRun) fail('execution is not implemented yet — run with --dry-run')
1176
+
1177
+ const changes = new Map()
1178
+ for (const member of members) {
1179
+ if (!member.repoDir) continue
1180
+ const last = lastReleaseTag(member, repoMemberCounts.get(member.repoDir))
1181
+ changes.set(member.id, { tag: last?.tag ?? null, commits: commitsSince(member, last?.tag) })
1182
+ }
1183
+
1184
+ for (const id of args.ids)
1185
+ if (!members.some((m) => m.id === id)) fail(`unknown package id "${id}" — see train graph`)
1186
+
1187
+ const plan = planReleases({
1188
+ members,
1189
+ orderEdges,
1190
+ devEdges,
1191
+ changes,
1192
+ registry,
1193
+ requested: args.ids,
1194
+ all: args.all,
1195
+ rangePolicy: config.rangePolicy,
1196
+ })
1197
+ const result = preflight({
1198
+ members,
1199
+ plan,
1200
+ cycles,
1201
+ discoveryProblems: problems,
1202
+ repoMemberCounts,
1203
+ changes,
1204
+ registry,
1205
+ offline: args.offline,
1206
+ rootDir,
1207
+ })
1208
+ printPlan(plan, result)
1209
+
1210
+ // Train summary: deterministic always; announcement drafted only when an assistant is
1211
+ // configured (or forced via --assistant) and never blocking. --assistant none is the
1212
+ // whole-train kill switch — when execution lands it is also forwarded to every
1213
+ // release-kit run, and it is the ONLY assistant value that is forwarded: forcing a
1214
+ // drafting tool onto packages that did not opt in stays impossible by design.
1215
+ const summaryPath = args.summaryPath ?? config.summaryFile
1216
+ if (summaryPath) {
1217
+ const date = new Date().toISOString().slice(0, 10)
1218
+ let summary = buildSummary(plan, {
1219
+ workspace: basename(rootDir),
1220
+ date,
1221
+ mode: 'planned (dry run)',
1222
+ })
1223
+ const assistant = resolveAssistant(
1224
+ args.assistant === undefined ? config.assistant : args.assistant,
1225
+ )
1226
+ if (assistant && plan.length) {
1227
+ const announcement = draftAnnouncement(assistant, summary)
1228
+ if (announcement) summary = summary.replace('\n', `\n\n## Announcement\n\n${announcement}\n`)
1229
+ else
1230
+ console.log(
1231
+ `\nwarn ${assistant.tool} produced no usable announcement — deterministic summary only`,
1232
+ )
1233
+ }
1234
+ writeFileSync(resolve(rootDir, summaryPath), summary)
1235
+ console.log(`\nTrain summary written to ${summaryPath}`)
1236
+ }
1237
+
1238
+ process.exitCode = result.failures.length ? 1 : 0
1239
+ }
1240
+
1241
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main()