@skitterbyte/skitterspec 12.0.0 → 13.0.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/README.md +15 -4
- package/assets/core/env.config.md +39 -2
- package/assets/rules/spec-planning.md +32 -13
- package/assets/skills/spec-cancel/SKILL.md +5 -0
- package/assets/skills/spec-complete/SKILL.md +55 -11
- package/assets/skills/spec-hotfix/SKILL.md +161 -0
- package/assets/skills/spec-live/SKILL.md +4 -1
- package/assets/skills/spec-to-main/SKILL.md +99 -0
- package/package.json +1 -1
- package/src/cli.js +271 -4
- package/src/env/config.js +18 -0
- package/src/env/hotfix.js +133 -0
- package/src/env/live.js +12 -3
- package/src/env/provision.js +5 -1
- package/src/env/prune.js +119 -0
- package/src/env/resolve.js +28 -4
- package/src/env/teardown.js +20 -13
package/src/cli.js
CHANGED
|
@@ -39,7 +39,9 @@ const {
|
|
|
39
39
|
const { ensureWorktreeDirTrusted } = require('./env/trust.js')
|
|
40
40
|
const { planUp } = require('./env/provision.js')
|
|
41
41
|
const { planDown } = require('./env/teardown.js')
|
|
42
|
+
const { planPrune, liveSlugsForSpecs, reconcileRegistry } = require('./env/prune.js')
|
|
42
43
|
const { planIntegrate } = require('./env/integrate.js')
|
|
44
|
+
const { planHotfixLand } = require('./env/hotfix.js')
|
|
43
45
|
const { planDev } = require('./env/dev.js')
|
|
44
46
|
const { startProcess, stopProcess, waitHealthy } = require('./env/supervise.js')
|
|
45
47
|
const { renderRoutes, portsInUse, waitListening } = require('./env/proxy.js')
|
|
@@ -60,10 +62,12 @@ Usage:
|
|
|
60
62
|
specs/.core/env.config.json). Subcommands:
|
|
61
63
|
up <spec> plan a worktree + Docker stack + opener
|
|
62
64
|
down <spec> tear down (guards; --keep-volumes, --force)
|
|
65
|
+
prune reap orphaned test-DB volumes (--older-than <days>)
|
|
63
66
|
dev up <spec> start host dev servers on the spec's ports
|
|
64
67
|
dev down <spec> stop the spec's host dev servers
|
|
65
68
|
connect <spec> expose a spec on the canonical ports (main = off)
|
|
66
69
|
integrate <spec> plan rebase + fast-forward onto the base branch
|
|
70
|
+
hotfix land <spec> tag + cherry-pick a hotfix (--also <tag>)
|
|
67
71
|
status list provisioned specs + port blocks
|
|
68
72
|
resolve <spec> print resolved slug/type/branch/paths
|
|
69
73
|
skitterspec --help Show this help
|
|
@@ -271,7 +275,9 @@ function gitReader(cwd) {
|
|
|
271
275
|
// the resolved integration branch; `merged` is true when HEAD is already an
|
|
272
276
|
// ancestor of it (fully landed), which lets teardown skip the unpushed guard.
|
|
273
277
|
function worktreeGitState(worktreePath, base) {
|
|
274
|
-
if (!fs.existsSync(worktreePath))
|
|
278
|
+
if (!fs.existsSync(worktreePath)) {
|
|
279
|
+
return { dirty: false, unpushed: false, merged: true, reachableFromTag: false }
|
|
280
|
+
}
|
|
275
281
|
const git = gitReader(worktreePath)
|
|
276
282
|
|
|
277
283
|
const status = git(['status', '--porcelain'])
|
|
@@ -293,7 +299,13 @@ function worktreeGitState(worktreePath, base) {
|
|
|
293
299
|
// exits 0 when true; gitReader maps a non-zero exit to null.
|
|
294
300
|
const merged = base != null && git(['merge-base', '--is-ancestor', 'HEAD', base]) !== null
|
|
295
301
|
|
|
296
|
-
|
|
302
|
+
// reachableFromTag = HEAD is captured by a tag (the deploy tag from `hotfix
|
|
303
|
+
// land`). A hotfix branch is never merged into base, so this is what tells
|
|
304
|
+
// teardown its commits are safely recoverable.
|
|
305
|
+
const pointing = git(['tag', '--points-at', 'HEAD'])
|
|
306
|
+
const reachableFromTag = pointing !== null && pointing.length > 0
|
|
307
|
+
|
|
308
|
+
return { dirty, unpushed, merged, reachableFromTag }
|
|
297
309
|
}
|
|
298
310
|
|
|
299
311
|
// A deterministic-enough compact timestamp for backup filenames (CLI-only; the
|
|
@@ -358,6 +370,166 @@ function specEnvDown(dir, config, specArg, flags) {
|
|
|
358
370
|
process.stdout.write(out.join('\n') + '\n')
|
|
359
371
|
}
|
|
360
372
|
|
|
373
|
+
// --- prune: reap orphaned per-spec test-DB volumes -------------------------
|
|
374
|
+
|
|
375
|
+
// Live Docker volumes in the repo namespace (`{repoSlug}_…`). Returns
|
|
376
|
+
// { ok, names }: ok:false means docker is unavailable / errored (non-fatal — the
|
|
377
|
+
// caller reports and skips). The `name=` filter is a substring match, so we
|
|
378
|
+
// re-check the prefix in the pure planner.
|
|
379
|
+
function listRepoVolumes(repoSlug) {
|
|
380
|
+
try {
|
|
381
|
+
const out = execFileSync(
|
|
382
|
+
'docker',
|
|
383
|
+
['volume', 'ls', '--format', '{{.Name}}', '--filter', `name=${repoSlug}_`],
|
|
384
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
385
|
+
)
|
|
386
|
+
.toString()
|
|
387
|
+
.trim()
|
|
388
|
+
const names = out ? out.split('\n').map((s) => s.trim()).filter(Boolean) : []
|
|
389
|
+
return { ok: true, names }
|
|
390
|
+
} catch (error) {
|
|
391
|
+
const err = (error.stderr && error.stderr.toString().trim()) || error.message
|
|
392
|
+
return { ok: false, names: [], err }
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Map orphan-candidate volume names → creation epoch-ms via `docker volume
|
|
397
|
+
// inspect`. Unknown/unparseable timestamps stay null (the planner keeps them).
|
|
398
|
+
function volumeCreatedAt(names) {
|
|
399
|
+
const byName = new Map()
|
|
400
|
+
if (!names.length) return byName
|
|
401
|
+
try {
|
|
402
|
+
const out = execFileSync(
|
|
403
|
+
'docker',
|
|
404
|
+
['volume', 'inspect', '--format', '{{.Name}}\t{{.CreatedAt}}', ...names],
|
|
405
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
406
|
+
)
|
|
407
|
+
.toString()
|
|
408
|
+
.trim()
|
|
409
|
+
for (const line of out.split('\n')) {
|
|
410
|
+
const [name, created] = line.split('\t')
|
|
411
|
+
const ms = created ? Date.parse(created.trim()) : NaN
|
|
412
|
+
if (name) byName.set(name.trim(), Number.isNaN(ms) ? null : ms)
|
|
413
|
+
}
|
|
414
|
+
} catch {
|
|
415
|
+
// Inspect failed wholesale → treat every candidate as unknown-age (kept).
|
|
416
|
+
}
|
|
417
|
+
return byName
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Absolute paths of every checkout git knows about (primary + all worktrees).
|
|
421
|
+
function liveWorktreePaths(dir) {
|
|
422
|
+
const out = gitReader(dir)(['worktree', 'list', '--porcelain'])
|
|
423
|
+
const paths = new Set()
|
|
424
|
+
if (out == null) return paths
|
|
425
|
+
for (const line of out.split('\n')) {
|
|
426
|
+
if (line.startsWith('worktree ')) {
|
|
427
|
+
paths.add(path.resolve(line.slice('worktree '.length).trim()))
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return paths
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Every spec folder name found under specs/* across the given checkout roots.
|
|
434
|
+
// An in-progress spec lives on its *worktree branch*, not the primary checkout,
|
|
435
|
+
// so we must scan the worktrees too — otherwise a live spec's DB looks orphaned.
|
|
436
|
+
function collectSpecFolders(roots) {
|
|
437
|
+
const folders = new Set()
|
|
438
|
+
for (const root of roots) {
|
|
439
|
+
for (const bucket of ['backlog', 'in-progress', 'complete', 'cancelled']) {
|
|
440
|
+
let entries
|
|
441
|
+
try {
|
|
442
|
+
entries = fs.readdirSync(path.join(root, 'specs', bucket), { withFileTypes: true })
|
|
443
|
+
} catch {
|
|
444
|
+
continue
|
|
445
|
+
}
|
|
446
|
+
for (const entry of entries) if (entry.isDirectory()) folders.add(entry.name)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return folders
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Resolve every spec folder (found in the primary checkout OR any worktree) to
|
|
453
|
+
// { folder, slug, worktreePath }. `searchDirs` lets resolveSpec locate a spec
|
|
454
|
+
// that was authored on its branch and never committed to the primary checkout.
|
|
455
|
+
function allSpecs(dir, config, worktreePaths) {
|
|
456
|
+
const searchDirs = [...worktreePaths]
|
|
457
|
+
const specs = []
|
|
458
|
+
for (const folder of collectSpecFolders([dir, ...searchDirs])) {
|
|
459
|
+
try {
|
|
460
|
+
const spec = resolveSpec(folder, dir, config, { searchDirs })
|
|
461
|
+
specs.push({ folder: spec.folder, slug: spec.slug, worktreePath: spec.worktreePath })
|
|
462
|
+
} catch {
|
|
463
|
+
// Unresolvable folder (not a real spec) — skip.
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return specs
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Prune: reconcile namespace volumes against specs that still have a worktree and
|
|
470
|
+
// print the `docker volume rm` commands for the orphans. Liveness keys off the
|
|
471
|
+
// worktree, NOT the registry (a declined teardown leaves a stale slot behind), so
|
|
472
|
+
// this correctly reaps those and frees their stale slots. Destructive removal is
|
|
473
|
+
// executed by the caller (skill) after confirmation — the CLI only plans + writes
|
|
474
|
+
// the registry, mirroring `spec-env down`.
|
|
475
|
+
function specEnvPrune(dir, config, flags) {
|
|
476
|
+
const { repoSlug } = repoInfo(dir)
|
|
477
|
+
|
|
478
|
+
const vols = listRepoVolumes(repoSlug)
|
|
479
|
+
if (!vols.ok) {
|
|
480
|
+
process.stdout.write(
|
|
481
|
+
`spec-env prune: could not list docker volumes — ${vols.err || 'docker unavailable'}.\n` +
|
|
482
|
+
'Is Docker running? Nothing pruned.\n',
|
|
483
|
+
)
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const worktrees = liveWorktreePaths(dir)
|
|
488
|
+
const specs = allSpecs(dir, config, worktrees)
|
|
489
|
+
const liveSlugs = liveSlugsForSpecs(specs, worktrees)
|
|
490
|
+
|
|
491
|
+
const olderThanDays =
|
|
492
|
+
flags && Number.isFinite(flags.olderThanDays) ? flags.olderThanDays : null
|
|
493
|
+
let volumes = vols.names
|
|
494
|
+
let now = null
|
|
495
|
+
if (olderThanDays != null) {
|
|
496
|
+
const createdAt = volumeCreatedAt(vols.names)
|
|
497
|
+
volumes = vols.names.map((name) => ({ name, createdAt: createdAt.get(name) ?? null }))
|
|
498
|
+
now = Date.now()
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const plan = planPrune(volumes, liveSlugs, { repoSlug, olderThanDays, now })
|
|
502
|
+
|
|
503
|
+
if (!plan.orphans.length) {
|
|
504
|
+
process.stdout.write(
|
|
505
|
+
`spec-env prune: no orphaned volumes in ${repoSlug}_* ` +
|
|
506
|
+
`(${vols.names.length} namespace volume(s), ${liveSlugs.size} live spec(s) protected).\n`,
|
|
507
|
+
)
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Reconcile the registry: free the slot of any spec whose volume we're reaping.
|
|
512
|
+
const registry = readRegistry(dir, config)
|
|
513
|
+
const { registry: nextRegistry, freed } = reconcileRegistry(registry, plan.orphans, repoSlug)
|
|
514
|
+
if (freed.length) writeRegistry(dir, config, nextRegistry)
|
|
515
|
+
|
|
516
|
+
const ageNote = olderThanDays != null ? ` older than ${olderThanDays}d` : ''
|
|
517
|
+
const out = []
|
|
518
|
+
out.push(
|
|
519
|
+
`spec-env prune: ${plan.orphans.length} orphaned volume(s)${ageNote} ` +
|
|
520
|
+
`(${liveSlugs.size} live spec(s) protected)`,
|
|
521
|
+
)
|
|
522
|
+
out.push('')
|
|
523
|
+
out.push(' orphans:')
|
|
524
|
+
for (const o of plan.orphans) out.push(` ${o.name}`)
|
|
525
|
+
if (freed.length) out.push(` slots freed: ${freed.join(', ')}`)
|
|
526
|
+
out.push(' backup: none (prune does not back up — orphans have no running DB)')
|
|
527
|
+
out.push('')
|
|
528
|
+
out.push(' run these:')
|
|
529
|
+
for (const cmd of plan.commands) out.push(` ${cmd}`)
|
|
530
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
531
|
+
}
|
|
532
|
+
|
|
361
533
|
// Integrate: land a spec's worktree branch onto the base branch (rebase + ff).
|
|
362
534
|
// Queries git for the facts, prints the plan / block / no-op. The /spec-complete
|
|
363
535
|
// skill executes the printed commands (and aborts a conflicting rebase).
|
|
@@ -450,6 +622,93 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
450
622
|
process.stdout.write(out.join('\n') + '\n')
|
|
451
623
|
}
|
|
452
624
|
|
|
625
|
+
// Land a hotfix: tag the branch with the patch-bumped base tag (the prod deploy
|
|
626
|
+
// tag), cherry-pick the fix onto any extra base tags (test/demo lines) and onto
|
|
627
|
+
// the base branch for the next release. Queries git for the facts, prints the plan
|
|
628
|
+
// / block / no-op. The /spec-complete skill runs the printed commands (aborting a
|
|
629
|
+
// cherry-pick on conflict). NEVER pushes — pushing the deploy tag is the operator's.
|
|
630
|
+
function specEnvHotfix(dir, config, positional, flags) {
|
|
631
|
+
const action = positional[0]
|
|
632
|
+
const specArg = positional[1]
|
|
633
|
+
if (action !== 'land' || !specArg) {
|
|
634
|
+
process.stdout.write('Usage: skitterspec spec-env hotfix land <spec> [--also <tag>]...\n')
|
|
635
|
+
return
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// A hotfix may be authored entirely on its branch, so fall back to its worktree.
|
|
639
|
+
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
640
|
+
if (spec.type !== 'hotfix') {
|
|
641
|
+
process.stdout.write(
|
|
642
|
+
`spec-env hotfix land: ${spec.folder} is not a hotfix — needs Type: Hotfix / a hotfix- prefix.\n`,
|
|
643
|
+
)
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
if (!fs.existsSync(spec.worktreePath)) {
|
|
647
|
+
process.stdout.write(`spec-env hotfix land: ${spec.folder} has no worktree — nothing to land.\n`)
|
|
648
|
+
return
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const base = resolveBaseBranch(config, gitReader(dir))
|
|
652
|
+
const wtGit = gitReader(spec.worktreePath)
|
|
653
|
+
const status = wtGit(['status', '--porcelain'])
|
|
654
|
+
const dirty = status !== null && status.length > 0
|
|
655
|
+
const ahead = wtGit(['rev-list', '--count', `${spec.baseRef}..HEAD`])
|
|
656
|
+
const aheadOfBase = ahead !== null && Number(ahead) > 0
|
|
657
|
+
const tagList = wtGit(['tag', '--list'])
|
|
658
|
+
const existingTags = tagList ? tagList.split('\n').map((s) => s.trim()).filter(Boolean) : []
|
|
659
|
+
|
|
660
|
+
// Extra targets: --also flags first, then any config defaults; drop blanks, the
|
|
661
|
+
// base tag itself, and duplicates.
|
|
662
|
+
const seen = new Set()
|
|
663
|
+
const extraTargets = [...(flags.also || []), ...(config.hotfix.targets || [])].filter((t) => {
|
|
664
|
+
if (!t || t === spec.baseRef || seen.has(t)) return false
|
|
665
|
+
seen.add(t)
|
|
666
|
+
return true
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
let plan
|
|
670
|
+
try {
|
|
671
|
+
plan = planHotfixLand(spec, config, {
|
|
672
|
+
worktreeState: { dirty },
|
|
673
|
+
aheadOfBase,
|
|
674
|
+
fixRange: `${spec.baseRef}..${spec.branch}`,
|
|
675
|
+
mainRepoPath: dir,
|
|
676
|
+
base,
|
|
677
|
+
extraTargets,
|
|
678
|
+
existingTags,
|
|
679
|
+
})
|
|
680
|
+
} catch (error) {
|
|
681
|
+
process.stdout.write(`spec-env hotfix land: ${error.message}.\n`)
|
|
682
|
+
return
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (plan.blocked) {
|
|
686
|
+
process.stdout.write(`spec-env hotfix land: blocked — ${plan.reason}.\n`)
|
|
687
|
+
return
|
|
688
|
+
}
|
|
689
|
+
if (plan.noop) {
|
|
690
|
+
process.stdout.write(`spec-env hotfix land: ${plan.reason}.\n`)
|
|
691
|
+
return
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const out = []
|
|
695
|
+
out.push(`spec-env hotfix land: ${spec.folder}`)
|
|
696
|
+
out.push('')
|
|
697
|
+
out.push(` base tag: ${spec.baseRef}`)
|
|
698
|
+
out.push(` branch: ${spec.branch}`)
|
|
699
|
+
out.push(` prod tag: ${plan.prodTag} (created locally — push to deploy)`)
|
|
700
|
+
for (const t of plan.targets) {
|
|
701
|
+
if (t.kind === 'extra') out.push(` target: ${t.base} -> ${t.tag}`)
|
|
702
|
+
if (t.kind === 'main') out.push(` next rel: cherry-pick onto ${t.base}`)
|
|
703
|
+
}
|
|
704
|
+
out.push('')
|
|
705
|
+
out.push(' run these (abort a cherry-pick on conflict, resolve, then re-run):')
|
|
706
|
+
for (const cmd of plan.commands) out.push(` ${cmd}`)
|
|
707
|
+
out.push('')
|
|
708
|
+
out.push(` then push the deploy tag yourself: git push origin ${plan.prodTag}`)
|
|
709
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
710
|
+
}
|
|
711
|
+
|
|
453
712
|
// Print the resolved identity/coordinates for a single spec.
|
|
454
713
|
function specEnvResolve(dir, config, specArg) {
|
|
455
714
|
if (!specArg) {
|
|
@@ -904,11 +1163,13 @@ async function specEnv(rest) {
|
|
|
904
1163
|
const [sub, ...args] = rest
|
|
905
1164
|
let dir = process.cwd()
|
|
906
1165
|
const positional = []
|
|
907
|
-
const flags = { keepVolumes: false, force: false }
|
|
1166
|
+
const flags = { keepVolumes: false, force: false, also: [], olderThanDays: null }
|
|
908
1167
|
for (let i = 0; i < args.length; i++) {
|
|
909
1168
|
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
910
1169
|
else if (args[i] === '--keep-volumes') flags.keepVolumes = true
|
|
911
1170
|
else if (args[i] === '--force') flags.force = true
|
|
1171
|
+
else if (args[i] === '--also') flags.also.push(args[++i])
|
|
1172
|
+
else if (args[i] === '--older-than') flags.olderThanDays = Number(args[++i])
|
|
912
1173
|
else positional.push(args[i])
|
|
913
1174
|
}
|
|
914
1175
|
dir = path.resolve(dir)
|
|
@@ -932,6 +1193,9 @@ async function specEnv(rest) {
|
|
|
932
1193
|
case 'down':
|
|
933
1194
|
specEnvDown(dir, config, positional[0], flags)
|
|
934
1195
|
break
|
|
1196
|
+
case 'prune':
|
|
1197
|
+
specEnvPrune(dir, config, flags)
|
|
1198
|
+
break
|
|
935
1199
|
case 'dev':
|
|
936
1200
|
await specEnvDev(dir, config, positional)
|
|
937
1201
|
break
|
|
@@ -941,6 +1205,9 @@ async function specEnv(rest) {
|
|
|
941
1205
|
case 'integrate':
|
|
942
1206
|
specEnvIntegrate(dir, config, positional[0])
|
|
943
1207
|
break
|
|
1208
|
+
case 'hotfix':
|
|
1209
|
+
specEnvHotfix(dir, config, positional, flags)
|
|
1210
|
+
break
|
|
944
1211
|
case 'status':
|
|
945
1212
|
specEnvStatus(dir, config)
|
|
946
1213
|
break
|
|
@@ -952,7 +1219,7 @@ async function specEnv(rest) {
|
|
|
952
1219
|
break
|
|
953
1220
|
default:
|
|
954
1221
|
process.stdout.write(
|
|
955
|
-
'Usage: skitterspec spec-env <up|down|dev|connect|integrate|live|status|resolve> [spec] [--keep-volumes] [--force]\n',
|
|
1222
|
+
'Usage: skitterspec spec-env <up|down|prune|dev|connect|integrate|hotfix|live|status|resolve> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>]\n',
|
|
956
1223
|
)
|
|
957
1224
|
}
|
|
958
1225
|
}
|
package/src/env/config.js
CHANGED
|
@@ -31,6 +31,9 @@
|
|
|
31
31
|
* guards: { refuseTeardownIfDirty, refuseTeardownIfUnpushed },
|
|
32
32
|
* live: { migrations: [ "glob", ... ] } // migration globs → `live take`
|
|
33
33
|
* // refuses a branch that changes them (code-only v1)
|
|
34
|
+
* hotfix: { bump, cherryPickMain, targets } // `hotfix land`: patch-bump the
|
|
35
|
+
* // deploy tag, also cherry-pick onto the base branch (main), and
|
|
36
|
+
* // onto any extra base tags in `targets` (test/demo lines)
|
|
34
37
|
* }
|
|
35
38
|
*/
|
|
36
39
|
|
|
@@ -80,6 +83,12 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
80
83
|
// migration files; a branch that changes any of them is treated as stateful and
|
|
81
84
|
// `live take` refuses it (code-only v1). Default: none (nothing is stateful).
|
|
82
85
|
live: Object.freeze({ migrations: Object.freeze([]) }),
|
|
86
|
+
// Hotfix landing (`spec-env hotfix land`). `bump` is the version-bump strategy
|
|
87
|
+
// for the new deploy tag (only "patch" today). `cherryPickMain` also cherry-picks
|
|
88
|
+
// the fix onto the base branch for the next release (default true). `targets` is
|
|
89
|
+
// an optional default list of extra base tags to also patch (test/demo lines);
|
|
90
|
+
// `--also <tag>` on the command adds more at run time. Default: patch, main, none.
|
|
91
|
+
hotfix: Object.freeze({ bump: 'patch', cherryPickMain: true, targets: Object.freeze([]) }),
|
|
83
92
|
})
|
|
84
93
|
|
|
85
94
|
function isObject(value) {
|
|
@@ -101,6 +110,7 @@ function defaults() {
|
|
|
101
110
|
baseBranch: DEFAULT_CONFIG.baseBranch,
|
|
102
111
|
guards: { ...DEFAULT_CONFIG.guards },
|
|
103
112
|
live: { migrations: [] },
|
|
113
|
+
hotfix: { ...DEFAULT_CONFIG.hotfix, targets: [] },
|
|
104
114
|
}
|
|
105
115
|
}
|
|
106
116
|
|
|
@@ -248,6 +258,14 @@ function mergeConfig(base, parsed) {
|
|
|
248
258
|
base.live.migrations = normalizeFileList(parsed.live.migrations)
|
|
249
259
|
}
|
|
250
260
|
|
|
261
|
+
if (isObject(parsed.hotfix)) {
|
|
262
|
+
assign(base.hotfix, parsed.hotfix, 'bump', 'string')
|
|
263
|
+
assign(base.hotfix, parsed.hotfix, 'cherryPickMain', 'boolean')
|
|
264
|
+
if (Array.isArray(parsed.hotfix.targets)) {
|
|
265
|
+
base.hotfix.targets = normalizeFileList(parsed.hotfix.targets)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
251
269
|
return base
|
|
252
270
|
}
|
|
253
271
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure landing planner for `spec-env hotfix land`.
|
|
5
|
+
*
|
|
6
|
+
* A hotfix is built on an old release tag (its `Base version`), so it can't be
|
|
7
|
+
* fast-forwarded onto `main` like an ordinary spec (that's `integrate.js`).
|
|
8
|
+
* Instead `planHotfixLand` emits the exact side-effect-free, **never-pushing**
|
|
9
|
+
* commands to:
|
|
10
|
+
* 1. tag the hotfix branch head with the patch-bumped base tag (the prod deploy
|
|
11
|
+
* tag — the branch head already is baseRef + the fix);
|
|
12
|
+
* 2. for each extra target tag, cherry-pick the fix onto a throwaway worktree at
|
|
13
|
+
* that tag and re-tag it with its own patch bump (test/demo release lines);
|
|
14
|
+
* 3. cherry-pick the fix onto the base branch (main) for the next release.
|
|
15
|
+
*
|
|
16
|
+
* It performs no side effects — the caller (the CLI) probes git for `dirty` /
|
|
17
|
+
* `aheadOfBase` / `existingTags` / `mainRepoPath` and supplies them, keeping this
|
|
18
|
+
* deterministic and unit-testable with no live git. Conflict handling lives in the
|
|
19
|
+
* skill (run a cherry-pick, abort on non-zero exit, hand back), mirroring
|
|
20
|
+
* `integrate.js` — the planner never reasons about conflicts.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const path = require('node:path')
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse a semver-ish tag and return it with its PATCH bumped by one. Preserves any
|
|
27
|
+
* non-digit prefix (e.g. `v` → `v33.16.5`) and drops any pre-release/build suffix
|
|
28
|
+
* (`-rc1`, `+build`). Throws on a tag with no `MAJOR.MINOR.PATCH` core.
|
|
29
|
+
*/
|
|
30
|
+
function bumpPatch(tag) {
|
|
31
|
+
const m = /^(\D*)(\d+)\.(\d+)\.(\d+)(?:.*)$/.exec(String(tag || '').trim())
|
|
32
|
+
if (!m) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`hotfix: cannot bump a patch version from tag "${tag}" — need <prefix>MAJOR.MINOR.PATCH`,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
const [, prefix, major, minor, patch] = m
|
|
38
|
+
return `${prefix}${major}.${minor}.${Number(patch) + 1}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A filesystem/branch-safe slug derived from a tag (`v30.2.1` → `v30-2-1`).
|
|
42
|
+
function tagSlug(tag) {
|
|
43
|
+
return String(tag)
|
|
44
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
45
|
+
.replace(/^-+|-+$/g, '')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {object} spec resolved hotfix spec: { slug, branch, worktreePath, baseRef, ... }
|
|
50
|
+
* @param {object} config normalised env config (reads `config.hotfix`).
|
|
51
|
+
* @param {object} ctx { worktreeState:{dirty}, aheadOfBase, fixRange, mainRepoPath,
|
|
52
|
+
* base, extraTargets: string[], existingTags: string[] }
|
|
53
|
+
* @returns {object} { blocked, noop, reason, commands, prodTag, targets, branch, base }
|
|
54
|
+
*/
|
|
55
|
+
function planHotfixLand(spec, config, ctx) {
|
|
56
|
+
const c = ctx || {}
|
|
57
|
+
const branch = spec.branch
|
|
58
|
+
const baseRef = spec.baseRef
|
|
59
|
+
const hotfixCfg = (config && config.hotfix) || { cherryPickMain: true, targets: [] }
|
|
60
|
+
const result = {
|
|
61
|
+
blocked: false,
|
|
62
|
+
noop: false,
|
|
63
|
+
reason: null,
|
|
64
|
+
commands: [],
|
|
65
|
+
prodTag: null,
|
|
66
|
+
targets: [],
|
|
67
|
+
branch,
|
|
68
|
+
base: c.base,
|
|
69
|
+
}
|
|
70
|
+
const block = (reason) => ({ ...result, blocked: true, reason })
|
|
71
|
+
|
|
72
|
+
// A hotfix must carry the tag it forked from — everything below bumps from it.
|
|
73
|
+
if (!baseRef) {
|
|
74
|
+
return block('spec has no Base version — not a hotfix, or the header is missing')
|
|
75
|
+
}
|
|
76
|
+
// The completion edits (status flip, git mv) must be committed before landing.
|
|
77
|
+
if (c.worktreeState && c.worktreeState.dirty) {
|
|
78
|
+
return block('worktree has uncommitted changes — commit the completion first')
|
|
79
|
+
}
|
|
80
|
+
// Nothing on the branch beyond the base tag → nothing to land.
|
|
81
|
+
if (!c.aheadOfBase) {
|
|
82
|
+
return { ...result, noop: true, reason: `no commits on ${branch} beyond ${baseRef} — nothing to land` }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Track tags we'd create so we never plan a collision (with an existing tag, or
|
|
86
|
+
// between the prod tag and a target tag).
|
|
87
|
+
const taken = new Set(c.existingTags || [])
|
|
88
|
+
const fixRange = c.fixRange
|
|
89
|
+
const commands = []
|
|
90
|
+
const targets = []
|
|
91
|
+
|
|
92
|
+
// 1. Prod line: tag the hotfix branch head (already baseRef + fix). No push.
|
|
93
|
+
const prodTag = bumpPatch(baseRef)
|
|
94
|
+
if (taken.has(prodTag)) {
|
|
95
|
+
return block(`tag ${prodTag} already exists — bump the base version or delete the stale tag`)
|
|
96
|
+
}
|
|
97
|
+
taken.add(prodTag)
|
|
98
|
+
commands.push(`git -C ${spec.worktreePath} tag ${prodTag}`)
|
|
99
|
+
targets.push({ kind: 'prod', base: baseRef, tag: prodTag })
|
|
100
|
+
|
|
101
|
+
// 2. Extra targets: cherry-pick the fix onto a throwaway worktree at each tag,
|
|
102
|
+
// re-tag with its own patch bump, then remove the worktree + temp branch (the
|
|
103
|
+
// commits survive under the new tag, so `-D` is safe for the throwaway branch).
|
|
104
|
+
const wtRoot = path.dirname(spec.worktreePath)
|
|
105
|
+
for (const t of c.extraTargets || []) {
|
|
106
|
+
const tag = bumpPatch(t)
|
|
107
|
+
if (taken.has(tag)) {
|
|
108
|
+
return block(`tag ${tag} (for target ${t}) already exists — resolve it before landing`)
|
|
109
|
+
}
|
|
110
|
+
taken.add(tag)
|
|
111
|
+
const slug = `${spec.slug}-onto-${tagSlug(t)}`
|
|
112
|
+
const tmpBranch = `hotfix/${slug}`
|
|
113
|
+
const tmpPath = path.join(wtRoot, slug)
|
|
114
|
+
commands.push(
|
|
115
|
+
`git worktree add ${tmpPath} -b ${tmpBranch} ${t}`,
|
|
116
|
+
`git -C ${tmpPath} cherry-pick ${fixRange}`,
|
|
117
|
+
`git -C ${tmpPath} tag ${tag}`,
|
|
118
|
+
`git worktree remove ${tmpPath}`,
|
|
119
|
+
`git branch -D ${tmpBranch}`,
|
|
120
|
+
)
|
|
121
|
+
targets.push({ kind: 'extra', base: t, tag, worktreePath: tmpPath })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 3. Cherry-pick the fix onto the base branch (main) for the next release.
|
|
125
|
+
if (hotfixCfg.cherryPickMain !== false) {
|
|
126
|
+
commands.push(`git -C ${c.mainRepoPath} cherry-pick ${fixRange}`)
|
|
127
|
+
targets.push({ kind: 'main', base: c.base })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { ...result, commands, prodTag, targets, fixRange }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { bumpPatch, tagSlug, planHotfixLand }
|
package/src/env/live.js
CHANGED
|
@@ -179,21 +179,30 @@ function planTake(spec, config, ctx) {
|
|
|
179
179
|
if (!c.worktreeExists) {
|
|
180
180
|
return block(`${spec.folder} has no worktree — run \`/spec-go ${spec.folder}\` first`)
|
|
181
181
|
}
|
|
182
|
-
// 4.
|
|
182
|
+
// 4. A hotfix is built on an old release tag; checking its branch out under the
|
|
183
|
+
// running dev server risks schema/DB drift breaking the shared instance.
|
|
184
|
+
// Always refuse, regardless of Stack — test it in isolation via /spec-connect.
|
|
185
|
+
if (spec.type === 'hotfix') {
|
|
186
|
+
return block(
|
|
187
|
+
`${spec.folder} is a hotfix (built on an old release tag) — live overlay ` +
|
|
188
|
+
'could break the running instance; use `/spec-connect` to test it in isolation',
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
// 5. v1 is code-only: refuse a stateful spec (Stack: worktree + docker)…
|
|
183
192
|
if (spec.stack === 'docker') {
|
|
184
193
|
return block(
|
|
185
194
|
`${spec.folder} is stateful (Stack: worktree + docker) — live overlay is ` +
|
|
186
195
|
'code-only; use `/spec-connect` for a Docker-backed spec',
|
|
187
196
|
)
|
|
188
197
|
}
|
|
189
|
-
//
|
|
198
|
+
// 6. …and refuse a branch that changes migrations (would mutate the shared DB).
|
|
190
199
|
if (c.migrationsHit) {
|
|
191
200
|
return block(
|
|
192
201
|
`${spec.folder}'s branch changes migrations — live overlay is code-only; ` +
|
|
193
202
|
'use `/spec-connect`',
|
|
194
203
|
)
|
|
195
204
|
}
|
|
196
|
-
//
|
|
205
|
+
// 7. Verify-only: a dev server must be listening to hot-reload the switch.
|
|
197
206
|
if (c.serverUp === false) {
|
|
198
207
|
return block(
|
|
199
208
|
`no dev server listening on canonical port(s) ${(c.canonicalPorts || []).join(', ')} — ` +
|
package/src/env/provision.js
CHANGED
|
@@ -94,10 +94,14 @@ function planUp(spec, alloc, config) {
|
|
|
94
94
|
|
|
95
95
|
const commands = []
|
|
96
96
|
// Fresh branch → -b; attach an existing branch/slot → plain form (never clobber).
|
|
97
|
+
// A hotfix forks its fresh branch from a release tag (`spec.baseRef`, e.g.
|
|
98
|
+
// `v33.16.4`) instead of base HEAD — so the fix is built on the exact commit
|
|
99
|
+
// line prod runs. Attaching an existing branch ignores baseRef (already forked).
|
|
100
|
+
const forkPoint = !attached && spec.baseRef ? ` ${spec.baseRef}` : ''
|
|
97
101
|
commands.push(
|
|
98
102
|
attached
|
|
99
103
|
? `git worktree add ${spec.worktreePath} ${spec.branch}`
|
|
100
|
-
: `git worktree add ${spec.worktreePath} -b ${spec.branch}`,
|
|
104
|
+
: `git worktree add ${spec.worktreePath} -b ${spec.branch}${forkPoint}`,
|
|
101
105
|
)
|
|
102
106
|
if (wantsDocker) {
|
|
103
107
|
commands.push(`docker compose --project-name ${spec.projectName} up -d`)
|