@skitterbyte/skitterspec 11.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.json.example +3 -0
- package/assets/core/env.config.md +48 -2
- package/assets/rules/spec-planning.md +46 -13
- package/assets/skills/spec-cancel/SKILL.md +5 -0
- package/assets/skills/spec-complete/SKILL.md +61 -11
- package/assets/skills/spec-connect/SKILL.md +6 -0
- package/assets/skills/spec-go/SKILL.md +3 -1
- package/assets/skills/spec-hotfix/SKILL.md +161 -0
- package/assets/skills/spec-init/SKILL.md +8 -0
- package/assets/skills/spec-live/SKILL.md +73 -0
- package/assets/skills/spec-to-main/SKILL.md +99 -0
- package/package.json +1 -1
- package/src/cli.js +647 -32
- package/src/env/config.js +30 -1
- package/src/env/hotfix.js +133 -0
- package/src/env/live.js +357 -0
- package/src/env/provision.js +5 -1
- package/src/env/prune.js +119 -0
- package/src/env/resolve.js +53 -4
- package/src/env/teardown.js +20 -13
- package/src/init.js +241 -6
- package/src/prompts.js +37 -1
package/src/cli.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const fs = require('fs')
|
|
4
4
|
const path = require('path')
|
|
5
5
|
const { execFileSync } = require('child_process')
|
|
6
|
-
const { init } = require('./init.js')
|
|
6
|
+
const { init, resync, reset, isExistingSetup } = require('./init.js')
|
|
7
7
|
const {
|
|
8
8
|
detectReleaseTooling,
|
|
9
9
|
removeReleaseTooling,
|
|
@@ -21,14 +21,27 @@ const {
|
|
|
21
21
|
resolveSpec,
|
|
22
22
|
resolveBaseBranch,
|
|
23
23
|
resolvePrimaryCheckout,
|
|
24
|
+
assertPrimaryOnMain,
|
|
24
25
|
repoInfo,
|
|
25
26
|
expandTokens,
|
|
26
27
|
splitPrefix,
|
|
27
28
|
} = require('./env/resolve.js')
|
|
29
|
+
const {
|
|
30
|
+
readReceipt,
|
|
31
|
+
writeReceipt,
|
|
32
|
+
clearReceipt,
|
|
33
|
+
summarizeReceipt,
|
|
34
|
+
migrationsHit,
|
|
35
|
+
planTake,
|
|
36
|
+
planRelease,
|
|
37
|
+
planAbort,
|
|
38
|
+
} = require('./env/live.js')
|
|
28
39
|
const { ensureWorktreeDirTrusted } = require('./env/trust.js')
|
|
29
40
|
const { planUp } = require('./env/provision.js')
|
|
30
41
|
const { planDown } = require('./env/teardown.js')
|
|
42
|
+
const { planPrune, liveSlugsForSpecs, reconcileRegistry } = require('./env/prune.js')
|
|
31
43
|
const { planIntegrate } = require('./env/integrate.js')
|
|
44
|
+
const { planHotfixLand } = require('./env/hotfix.js')
|
|
32
45
|
const { planDev } = require('./env/dev.js')
|
|
33
46
|
const { startProcess, stopProcess, waitHealthy } = require('./env/supervise.js')
|
|
34
47
|
const { renderRoutes, portsInUse, waitListening } = require('./env/proxy.js')
|
|
@@ -39,23 +52,31 @@ const HELP = `skitterspec — spec-driven-development for Claude Code
|
|
|
39
52
|
|
|
40
53
|
Usage:
|
|
41
54
|
skitterspec init [dir] Install the spec lifecycle skills, rule, and specs/
|
|
42
|
-
folders
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
folders. On an already-set-up repo it detects that
|
|
56
|
+
and offers resync / start-again / leave (interactive)
|
|
57
|
+
— non-interactively it just adds anything missing.
|
|
58
|
+
skitterspec update [dir] Resync managed files to the latest, keeping your
|
|
59
|
+
edits (--force to overwrite). Leaves specs/ + live
|
|
60
|
+
.core config alone.
|
|
45
61
|
skitterspec spec-env <cmd> Per-spec isolation engine (opt-in; needs
|
|
46
62
|
specs/.core/env.config.json). Subcommands:
|
|
47
63
|
up <spec> plan a worktree + Docker stack + opener
|
|
48
64
|
down <spec> tear down (guards; --keep-volumes, --force)
|
|
65
|
+
prune reap orphaned test-DB volumes (--older-than <days>)
|
|
49
66
|
dev up <spec> start host dev servers on the spec's ports
|
|
50
67
|
dev down <spec> stop the spec's host dev servers
|
|
51
68
|
connect <spec> expose a spec on the canonical ports (main = off)
|
|
52
69
|
integrate <spec> plan rebase + fast-forward onto the base branch
|
|
70
|
+
hotfix land <spec> tag + cherry-pick a hotfix (--also <tag>)
|
|
53
71
|
status list provisioned specs + port blocks
|
|
54
72
|
resolve <spec> print resolved slug/type/branch/paths
|
|
55
73
|
skitterspec --help Show this help
|
|
56
74
|
skitterspec --version Print version
|
|
57
75
|
|
|
58
76
|
Options (init / update):
|
|
77
|
+
--resync (init) Update managed files to latest, keep your edits
|
|
78
|
+
--reset (init) Start again: reset managed scaffolding fresh
|
|
79
|
+
(needs --yes; never touches your specs or config)
|
|
59
80
|
--force Overwrite skill/rule/script files that already exist
|
|
60
81
|
--dir <path> Target project dir (default: positional arg or cwd)
|
|
61
82
|
--no-claude-md Skip creating/patching CLAUDE.md
|
|
@@ -80,6 +101,8 @@ function parse(argv) {
|
|
|
80
101
|
yes: false,
|
|
81
102
|
isolation: undefined,
|
|
82
103
|
removeReleaseTooling: false,
|
|
104
|
+
resync: false,
|
|
105
|
+
reset: false,
|
|
83
106
|
}
|
|
84
107
|
const positional = []
|
|
85
108
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -90,6 +113,8 @@ function parse(argv) {
|
|
|
90
113
|
else if (a === '--isolation') opts.isolation = true
|
|
91
114
|
else if (a === '--no-isolation') opts.isolation = false
|
|
92
115
|
else if (a === '--remove-release-tooling') opts.removeReleaseTooling = true
|
|
116
|
+
else if (a === '--resync') opts.resync = true
|
|
117
|
+
else if (a === '--reset') opts.reset = true
|
|
93
118
|
else if (a === '--dir') opts.dir = argv[++i]
|
|
94
119
|
else if (a.startsWith('--')) throw new Error(`unknown option: ${a}`)
|
|
95
120
|
else positional.push(a)
|
|
@@ -250,7 +275,9 @@ function gitReader(cwd) {
|
|
|
250
275
|
// the resolved integration branch; `merged` is true when HEAD is already an
|
|
251
276
|
// ancestor of it (fully landed), which lets teardown skip the unpushed guard.
|
|
252
277
|
function worktreeGitState(worktreePath, base) {
|
|
253
|
-
if (!fs.existsSync(worktreePath))
|
|
278
|
+
if (!fs.existsSync(worktreePath)) {
|
|
279
|
+
return { dirty: false, unpushed: false, merged: true, reachableFromTag: false }
|
|
280
|
+
}
|
|
254
281
|
const git = gitReader(worktreePath)
|
|
255
282
|
|
|
256
283
|
const status = git(['status', '--porcelain'])
|
|
@@ -272,7 +299,13 @@ function worktreeGitState(worktreePath, base) {
|
|
|
272
299
|
// exits 0 when true; gitReader maps a non-zero exit to null.
|
|
273
300
|
const merged = base != null && git(['merge-base', '--is-ancestor', 'HEAD', base]) !== null
|
|
274
301
|
|
|
275
|
-
|
|
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 }
|
|
276
309
|
}
|
|
277
310
|
|
|
278
311
|
// A deterministic-enough compact timestamp for backup filenames (CLI-only; the
|
|
@@ -337,6 +370,166 @@ function specEnvDown(dir, config, specArg, flags) {
|
|
|
337
370
|
process.stdout.write(out.join('\n') + '\n')
|
|
338
371
|
}
|
|
339
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
|
+
|
|
340
533
|
// Integrate: land a spec's worktree branch onto the base branch (rebase + ff).
|
|
341
534
|
// Queries git for the facts, prints the plan / block / no-op. The /spec-complete
|
|
342
535
|
// skill executes the printed commands (and aborts a conflicting rebase).
|
|
@@ -348,21 +541,43 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
348
541
|
|
|
349
542
|
// `dir` is already anchored on the primary checkout by the dispatch, so it is
|
|
350
543
|
// both where the spec resolves and the target of the fast-forward — /spec-complete
|
|
351
|
-
// can run this from inside the worktree and still land on main.
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
544
|
+
// can run this from inside the worktree and still land on main. A spec authored
|
|
545
|
+
// entirely on its branch may not exist in the primary checkout's specs/** —
|
|
546
|
+
// resolveSpecWithWorktree offers its worktree as a fallback search location.
|
|
547
|
+
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
548
|
+
const base = resolveBaseBranch(config, gitReader(dir))
|
|
549
|
+
|
|
550
|
+
// Live-aware: if this spec is live on the primary checkout (branch-switched by
|
|
551
|
+
// `live take`), end the live session first — release back to base, re-isolate the
|
|
552
|
+
// branch, clear the receipt — so the normal rebase→ff plan below applies
|
|
553
|
+
// unchanged. Refuse if a *different* spec holds the primary checkout.
|
|
554
|
+
const primary = assertPrimaryOnMain(config, gitReader(dir))
|
|
555
|
+
if (!primary.onBase) {
|
|
556
|
+
if (primary.branch !== spec.branch) {
|
|
557
|
+
process.stdout.write(
|
|
558
|
+
`spec-env integrate: blocked — another spec (${primary.branch}) holds the ` +
|
|
559
|
+
'primary checkout; release it with `/spec-live main` first.\n',
|
|
560
|
+
)
|
|
561
|
+
return
|
|
562
|
+
}
|
|
563
|
+
const pstatus = gitReader(dir)(['status', '--porcelain'])
|
|
564
|
+
if (pstatus === null || pstatus.length > 0) {
|
|
565
|
+
process.stdout.write(
|
|
566
|
+
`spec-env integrate: blocked — commit your live fixes to ${spec.branch} first.\n`,
|
|
567
|
+
)
|
|
568
|
+
return
|
|
569
|
+
}
|
|
570
|
+
const co = runGit(dir, ['checkout', base])
|
|
571
|
+
if (!co.ok) {
|
|
572
|
+
process.stdout.write(`spec-env integrate: could not check out ${base} — ${co.err}\n`)
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
if (fs.existsSync(spec.worktreePath)) runGit(spec.worktreePath, ['switch', spec.branch])
|
|
576
|
+
clearReceipt(dir, config)
|
|
577
|
+
process.stdout.write(
|
|
578
|
+
`spec-env integrate: ended live session — ${spec.folder} released to its worktree.\n`,
|
|
579
|
+
)
|
|
580
|
+
}
|
|
366
581
|
|
|
367
582
|
if (!fs.existsSync(spec.worktreePath)) {
|
|
368
583
|
process.stdout.write(
|
|
@@ -371,7 +586,6 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
371
586
|
return
|
|
372
587
|
}
|
|
373
588
|
|
|
374
|
-
const base = resolveBaseBranch(config, gitReader(dir))
|
|
375
589
|
const wtGit = gitReader(spec.worktreePath)
|
|
376
590
|
const status = wtGit(['status', '--porcelain'])
|
|
377
591
|
const dirty = status !== null && status.length > 0
|
|
@@ -408,6 +622,93 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
408
622
|
process.stdout.write(out.join('\n') + '\n')
|
|
409
623
|
}
|
|
410
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
|
+
|
|
411
712
|
// Print the resolved identity/coordinates for a single spec.
|
|
412
713
|
function specEnvResolve(dir, config, specArg) {
|
|
413
714
|
if (!specArg) {
|
|
@@ -588,15 +889,287 @@ async function specEnvConnect(dir, config, specArg) {
|
|
|
588
889
|
|
|
589
890
|
// Dispatch `skitterspec spec-env <sub> [args] [--dir path]`. No-ops with a clear
|
|
590
891
|
// message when the feature isn't enabled (no specs/.core/env.config.json).
|
|
892
|
+
// Run a mutating git command; return { ok, err } (stderr captured, not swallowed).
|
|
893
|
+
function runGit(cwd, args) {
|
|
894
|
+
try {
|
|
895
|
+
execFileSync('git', ['-C', cwd, ...args], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
896
|
+
return { ok: true, err: '' }
|
|
897
|
+
} catch (error) {
|
|
898
|
+
const err = (error.stderr && error.stderr.toString().trim()) || error.message
|
|
899
|
+
return { ok: false, err }
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Lockfiles/manifests whose change means a dev server needs a restart, not HMR.
|
|
904
|
+
const DEPS_RE = /(^|\/)(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.lock)$/
|
|
905
|
+
|
|
906
|
+
// Live overlay: test a spec on the already-running instance by checking its
|
|
907
|
+
// branch out in the primary checkout, so the running dev server hot-reloads the
|
|
908
|
+
// feature. The branch that's checked out IS the lock (assertPrimaryOnMain); the
|
|
909
|
+
// receipt is advisory metadata. `status` is read-only; `take` performs the switch
|
|
910
|
+
// (release/abort land in a later phase).
|
|
911
|
+
async function specEnvLive(dir, config, positional) {
|
|
912
|
+
const action = positional[0] || 'status'
|
|
913
|
+
switch (action) {
|
|
914
|
+
case 'status':
|
|
915
|
+
specEnvLiveStatus(dir, config)
|
|
916
|
+
break
|
|
917
|
+
case 'take':
|
|
918
|
+
await specEnvLiveTake(dir, config, positional[1])
|
|
919
|
+
break
|
|
920
|
+
case 'release':
|
|
921
|
+
await specEnvLiveRelease(dir, config, positional[1])
|
|
922
|
+
break
|
|
923
|
+
case 'abort':
|
|
924
|
+
await specEnvLiveAbort(dir, config)
|
|
925
|
+
break
|
|
926
|
+
default:
|
|
927
|
+
process.stdout.write('Usage: skitterspec spec-env live <take|release|abort|status> [spec]\n')
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Resolve a spec, offering its worktree as a fallback search dir — a spec authored
|
|
932
|
+
// on its own branch may not exist in the primary checkout's specs/**.
|
|
933
|
+
function resolveSpecWithWorktree(dir, config, specArg) {
|
|
934
|
+
const { slug } = splitPrefix(path.basename(specArg))
|
|
935
|
+
const { repo, repoSlug } = repoInfo(dir)
|
|
936
|
+
const wtTokens = { repo, repoSlug, slug }
|
|
937
|
+
const worktreeGuess = path.resolve(
|
|
938
|
+
dir,
|
|
939
|
+
expandTokens(config.worktree.root, wtTokens),
|
|
940
|
+
expandTokens(config.worktree.folderPattern, wtTokens),
|
|
941
|
+
)
|
|
942
|
+
return resolveSpec(specArg, dir, config, { searchDirs: [worktreeGuess] })
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Take the running instance: rebase the spec's branch onto base, free it from its
|
|
946
|
+
// worktree, and check it out in the primary checkout so the dev server reloads it.
|
|
947
|
+
async function specEnvLiveTake(dir, config, specArg) {
|
|
948
|
+
if (!specArg) {
|
|
949
|
+
process.stdout.write('Usage: skitterspec spec-env live take <spec>\n')
|
|
950
|
+
return
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
954
|
+
|
|
955
|
+
// Probe the primary checkout's git state (IO stays here; the planner is pure).
|
|
956
|
+
const primaryGit = gitReader(dir)
|
|
957
|
+
const primary = assertPrimaryOnMain(config, primaryGit)
|
|
958
|
+
const base = resolveBaseBranch(config, primaryGit)
|
|
959
|
+
const status = primaryGit(['status', '--porcelain'])
|
|
960
|
+
const clean = status !== null && status.length === 0
|
|
961
|
+
const worktreeExists = fs.existsSync(spec.worktreePath)
|
|
962
|
+
const baseMainCommit = primaryGit(['rev-parse', 'HEAD'])
|
|
963
|
+
|
|
964
|
+
// Diff base...branch to spot migration / dependency changes (best-effort).
|
|
965
|
+
const changed = primaryGit(['diff', '--name-only', `${base}...${spec.branch}`])
|
|
966
|
+
const files = changed ? changed.split('\n').filter(Boolean) : []
|
|
967
|
+
const depsChanged = files.some((f) => DEPS_RE.test(f))
|
|
968
|
+
|
|
969
|
+
// Verify-only: probe the declared canonical (frontPort) ports. None declared →
|
|
970
|
+
// no health gate (serverUp = null); the switch proceeds with a warning.
|
|
971
|
+
const canonicalPorts = config.dev.map((d) => d.frontPort).filter((p) => typeof p === 'number')
|
|
972
|
+
let serverUp = null
|
|
973
|
+
if (canonicalPorts.length) {
|
|
974
|
+
const up = await portsInUse(canonicalPorts, config.proxy.host)
|
|
975
|
+
serverUp = up.length === canonicalPorts.length
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const plan = planTake(spec, config, {
|
|
979
|
+
primary,
|
|
980
|
+
primaryPath: dir,
|
|
981
|
+
clean,
|
|
982
|
+
worktreeExists,
|
|
983
|
+
base,
|
|
984
|
+
baseMainCommit,
|
|
985
|
+
serverUp,
|
|
986
|
+
canonicalPorts,
|
|
987
|
+
migrationsHit: migrationsHit(files, config.live.migrations),
|
|
988
|
+
depsChanged,
|
|
989
|
+
holder: primaryGit(['config', 'user.name']) || 'unknown',
|
|
990
|
+
heldSince: new Date().toISOString(),
|
|
991
|
+
})
|
|
992
|
+
|
|
993
|
+
if (plan.blocked) {
|
|
994
|
+
process.stdout.write(`spec-env live take: blocked — ${plan.reason}.\n`)
|
|
995
|
+
return
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Execute the switch. Rebase first; on conflict, abort and bail (state untouched).
|
|
999
|
+
const reb = runGit(spec.worktreePath, ['rebase', base])
|
|
1000
|
+
if (!reb.ok) {
|
|
1001
|
+
runGit(spec.worktreePath, ['rebase', '--abort'])
|
|
1002
|
+
process.stdout.write(
|
|
1003
|
+
`spec-env live take: rebase of ${spec.branch} onto ${base} hit conflicts — ` +
|
|
1004
|
+
`resolve them in ${spec.worktreePath}, then retry.\n`,
|
|
1005
|
+
)
|
|
1006
|
+
return
|
|
1007
|
+
}
|
|
1008
|
+
const det = runGit(spec.worktreePath, ['switch', '--detach'])
|
|
1009
|
+
if (!det.ok) {
|
|
1010
|
+
process.stdout.write(`spec-env live take: could not detach the worktree — ${det.err}\n`)
|
|
1011
|
+
return
|
|
1012
|
+
}
|
|
1013
|
+
const co = runGit(dir, ['checkout', spec.branch])
|
|
1014
|
+
if (!co.ok) {
|
|
1015
|
+
// Roll the detach back so the worktree keeps its branch.
|
|
1016
|
+
runGit(spec.worktreePath, ['switch', spec.branch])
|
|
1017
|
+
process.stdout.write(
|
|
1018
|
+
`spec-env live take: could not check out ${spec.branch} in the primary ` +
|
|
1019
|
+
`checkout — ${co.err}\n`,
|
|
1020
|
+
)
|
|
1021
|
+
return
|
|
1022
|
+
}
|
|
1023
|
+
const receipt = writeReceipt(dir, config, plan.receipt)
|
|
1024
|
+
|
|
1025
|
+
const out = [`spec-env live take: ${spec.folder} is live on the primary checkout`]
|
|
1026
|
+
out.push('')
|
|
1027
|
+
out.push(` primary: now on ${receipt.branch} (was ${base} @ ${receipt.baseMainCommit.slice(0, 7)})`)
|
|
1028
|
+
out.push(` worktree: ${spec.worktreePath} (detached — branch handed to the primary checkout)`)
|
|
1029
|
+
for (const w of plan.warnings) out.push(` ! ${w}`)
|
|
1030
|
+
out.push('')
|
|
1031
|
+
out.push(' Test at your canonical URL; release with: /spec-live main')
|
|
1032
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Release the running instance: hand the primary checkout back to base and
|
|
1036
|
+
// re-isolate the spec's branch into its worktree. With no spec arg, the live spec
|
|
1037
|
+
// is read from the receipt (this is what `/spec-live main` runs).
|
|
1038
|
+
async function specEnvLiveRelease(dir, config, specArg) {
|
|
1039
|
+
const receipt = readReceipt(dir, config)
|
|
1040
|
+
const target = specArg || (receipt && receipt.spec)
|
|
1041
|
+
if (!target) {
|
|
1042
|
+
const primaryGit = gitReader(dir)
|
|
1043
|
+
const primary = assertPrimaryOnMain(config, primaryGit)
|
|
1044
|
+
process.stdout.write(
|
|
1045
|
+
primary.onBase
|
|
1046
|
+
? `spec-env live release: nothing is live — the primary checkout is on ${primary.baseBranch}.\n`
|
|
1047
|
+
: `spec-env live release: no receipt, but the primary checkout is on ${primary.branch} — ` +
|
|
1048
|
+
'use `spec-env live abort` to recover.\n',
|
|
1049
|
+
)
|
|
1050
|
+
return
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const spec = resolveSpecWithWorktree(dir, config, target)
|
|
1054
|
+
const primaryGit = gitReader(dir)
|
|
1055
|
+
const primary = assertPrimaryOnMain(config, primaryGit)
|
|
1056
|
+
const base = resolveBaseBranch(config, primaryGit)
|
|
1057
|
+
const status = primaryGit(['status', '--porcelain'])
|
|
1058
|
+
const clean = status !== null && status.length === 0
|
|
1059
|
+
const worktreeExists = fs.existsSync(spec.worktreePath)
|
|
1060
|
+
|
|
1061
|
+
const plan = planRelease(spec, config, {
|
|
1062
|
+
primary,
|
|
1063
|
+
primaryPath: dir,
|
|
1064
|
+
base,
|
|
1065
|
+
clean,
|
|
1066
|
+
worktreeExists,
|
|
1067
|
+
})
|
|
1068
|
+
|
|
1069
|
+
if (plan.noop) {
|
|
1070
|
+
process.stdout.write(`spec-env live release: ${plan.reason}.\n`)
|
|
1071
|
+
return
|
|
1072
|
+
}
|
|
1073
|
+
if (plan.blocked) {
|
|
1074
|
+
process.stdout.write(`spec-env live release: blocked — ${plan.reason}.\n`)
|
|
1075
|
+
return
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const co = runGit(dir, ['checkout', base])
|
|
1079
|
+
if (!co.ok) {
|
|
1080
|
+
process.stdout.write(`spec-env live release: could not check out ${base} — ${co.err}\n`)
|
|
1081
|
+
return
|
|
1082
|
+
}
|
|
1083
|
+
if (worktreeExists) runGit(spec.worktreePath, ['switch', spec.branch])
|
|
1084
|
+
clearReceipt(dir, config)
|
|
1085
|
+
|
|
1086
|
+
process.stdout.write(
|
|
1087
|
+
`spec-env live release: ${spec.folder} released — primary back on ${base}, ` +
|
|
1088
|
+
`${spec.branch} re-isolated to its worktree.\n`,
|
|
1089
|
+
)
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Crash recovery: force the primary checkout back to base from the receipt and
|
|
1093
|
+
// re-isolate, without discarding uncommitted work (it refuses on a dirty tree).
|
|
1094
|
+
async function specEnvLiveAbort(dir, config) {
|
|
1095
|
+
const receipt = readReceipt(dir, config)
|
|
1096
|
+
const primaryGit = gitReader(dir)
|
|
1097
|
+
const primary = assertPrimaryOnMain(config, primaryGit)
|
|
1098
|
+
const base = resolveBaseBranch(config, primaryGit)
|
|
1099
|
+
const status = primaryGit(['status', '--porcelain'])
|
|
1100
|
+
const clean = status !== null && status.length === 0
|
|
1101
|
+
|
|
1102
|
+
// Resolve the worktree from the receipt (best-effort — may be gone/unresolvable).
|
|
1103
|
+
let worktreePath = null
|
|
1104
|
+
if (receipt) {
|
|
1105
|
+
try {
|
|
1106
|
+
worktreePath = resolveSpecWithWorktree(dir, config, receipt.spec).worktreePath
|
|
1107
|
+
} catch {
|
|
1108
|
+
worktreePath = null
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
const worktreeExists = worktreePath !== null && fs.existsSync(worktreePath)
|
|
1112
|
+
|
|
1113
|
+
const plan = planAbort(config, {
|
|
1114
|
+
receipt,
|
|
1115
|
+
primary,
|
|
1116
|
+
primaryPath: dir,
|
|
1117
|
+
base,
|
|
1118
|
+
clean,
|
|
1119
|
+
worktreeExists,
|
|
1120
|
+
worktreePath,
|
|
1121
|
+
})
|
|
1122
|
+
|
|
1123
|
+
if (plan.noop) {
|
|
1124
|
+
process.stdout.write(`spec-env live abort: ${plan.reason}.\n`)
|
|
1125
|
+
return
|
|
1126
|
+
}
|
|
1127
|
+
if (plan.blocked) {
|
|
1128
|
+
process.stdout.write(`spec-env live abort: blocked — ${plan.reason}.\n`)
|
|
1129
|
+
return
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
if (!primary.onBase) {
|
|
1133
|
+
const co = runGit(dir, ['checkout', base])
|
|
1134
|
+
if (!co.ok) {
|
|
1135
|
+
process.stdout.write(`spec-env live abort: could not check out ${base} — ${co.err}\n`)
|
|
1136
|
+
return
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
if (worktreeExists) runGit(worktreePath, ['switch', plan.branch])
|
|
1140
|
+
clearReceipt(dir, config)
|
|
1141
|
+
|
|
1142
|
+
process.stdout.write(
|
|
1143
|
+
`spec-env live abort: recovered — primary back on ${base}` +
|
|
1144
|
+
(worktreeExists ? `, ${plan.branch} re-isolated to its worktree` : '') +
|
|
1145
|
+
'.\n',
|
|
1146
|
+
)
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function specEnvLiveStatus(dir, config) {
|
|
1150
|
+
const { onBase, branch, baseBranch } = assertPrimaryOnMain(config, gitReader(dir))
|
|
1151
|
+
const receipt = readReceipt(dir, config)
|
|
1152
|
+
const state = onBase
|
|
1153
|
+
? 'on base — free'
|
|
1154
|
+
: `feature in control — not on ${baseBranch}`
|
|
1155
|
+
process.stdout.write(
|
|
1156
|
+
'spec-env live:\n' +
|
|
1157
|
+
` primary: ${branch || '(detached)'} (${state})\n` +
|
|
1158
|
+
` receipt: ${summarizeReceipt(receipt)}\n`,
|
|
1159
|
+
)
|
|
1160
|
+
}
|
|
1161
|
+
|
|
591
1162
|
async function specEnv(rest) {
|
|
592
1163
|
const [sub, ...args] = rest
|
|
593
1164
|
let dir = process.cwd()
|
|
594
1165
|
const positional = []
|
|
595
|
-
const flags = { keepVolumes: false, force: false }
|
|
1166
|
+
const flags = { keepVolumes: false, force: false, also: [], olderThanDays: null }
|
|
596
1167
|
for (let i = 0; i < args.length; i++) {
|
|
597
1168
|
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
598
1169
|
else if (args[i] === '--keep-volumes') flags.keepVolumes = true
|
|
599
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])
|
|
600
1173
|
else positional.push(args[i])
|
|
601
1174
|
}
|
|
602
1175
|
dir = path.resolve(dir)
|
|
@@ -620,6 +1193,9 @@ async function specEnv(rest) {
|
|
|
620
1193
|
case 'down':
|
|
621
1194
|
specEnvDown(dir, config, positional[0], flags)
|
|
622
1195
|
break
|
|
1196
|
+
case 'prune':
|
|
1197
|
+
specEnvPrune(dir, config, flags)
|
|
1198
|
+
break
|
|
623
1199
|
case 'dev':
|
|
624
1200
|
await specEnvDev(dir, config, positional)
|
|
625
1201
|
break
|
|
@@ -629,15 +1205,21 @@ async function specEnv(rest) {
|
|
|
629
1205
|
case 'integrate':
|
|
630
1206
|
specEnvIntegrate(dir, config, positional[0])
|
|
631
1207
|
break
|
|
1208
|
+
case 'hotfix':
|
|
1209
|
+
specEnvHotfix(dir, config, positional, flags)
|
|
1210
|
+
break
|
|
632
1211
|
case 'status':
|
|
633
1212
|
specEnvStatus(dir, config)
|
|
634
1213
|
break
|
|
635
1214
|
case 'resolve':
|
|
636
1215
|
specEnvResolve(dir, config, positional[0])
|
|
637
1216
|
break
|
|
1217
|
+
case 'live':
|
|
1218
|
+
await specEnvLive(dir, config, positional)
|
|
1219
|
+
break
|
|
638
1220
|
default:
|
|
639
1221
|
process.stdout.write(
|
|
640
|
-
'Usage: skitterspec spec-env <up|down|dev|connect|integrate|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',
|
|
641
1223
|
)
|
|
642
1224
|
}
|
|
643
1225
|
}
|
|
@@ -664,21 +1246,54 @@ async function run(argv) {
|
|
|
664
1246
|
|
|
665
1247
|
switch (cmd) {
|
|
666
1248
|
case 'init': {
|
|
667
|
-
// Isolation defaults OFF; a flag or an interactive "yes" opts in.
|
|
668
|
-
let isolation = opts.isolation === true
|
|
669
|
-
|
|
670
1249
|
const interactive = Boolean(process.stdin.isTTY) && !opts.yes
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
1250
|
+
|
|
1251
|
+
// Already set up? Route to resync / reset / leave instead of a silent
|
|
1252
|
+
// create-missing (safer-init). A fresh repo falls straight through.
|
|
1253
|
+
if (isExistingSetup(dir)) {
|
|
1254
|
+
let action
|
|
1255
|
+
if (opts.reset) {
|
|
1256
|
+
if (!opts.yes) {
|
|
1257
|
+
process.stdout.write('init: --reset overwrites managed files — re-run with --yes. Left unchanged.\n')
|
|
1258
|
+
break
|
|
1259
|
+
}
|
|
1260
|
+
action = 'reset'
|
|
1261
|
+
} else if (opts.resync) action = 'resync'
|
|
1262
|
+
else if (opts.force) action = 'resync' // --force resyncs, clobbering customized
|
|
1263
|
+
else if (interactive) {
|
|
1264
|
+
const { promptExistingSetup } = require('./prompts.js')
|
|
1265
|
+
action = await promptExistingSetup()
|
|
1266
|
+
} else action = 'create-missing' // non-interactive default: add missing, never clobber
|
|
1267
|
+
|
|
1268
|
+
if (action === 'leave') {
|
|
1269
|
+
process.stdout.write('init: existing setup left unchanged.\n')
|
|
1270
|
+
break
|
|
1271
|
+
}
|
|
1272
|
+
if (action === 'reset') {
|
|
1273
|
+
reset(dir, { claudeMd: opts.claudeMd })
|
|
1274
|
+
break
|
|
1275
|
+
}
|
|
1276
|
+
if (action === 'resync') {
|
|
1277
|
+
resync(dir, { claudeMd: opts.claudeMd, force: opts.force })
|
|
1278
|
+
break
|
|
1279
|
+
}
|
|
1280
|
+
// action === 'create-missing' → fall through to a normal (skip-existing) init.
|
|
675
1281
|
}
|
|
676
1282
|
|
|
1283
|
+
// Fresh repo (or create-missing): isolation defaults OFF; a flag or an
|
|
1284
|
+
// interactive "yes" opts in. Only prompt for isolation on a fresh repo.
|
|
1285
|
+
let isolation = opts.isolation === true
|
|
1286
|
+
if (interactive && !isExistingSetup(dir)) {
|
|
1287
|
+
const { promptSetup } = require('./prompts.js')
|
|
1288
|
+
isolation = (await promptSetup({ isolationSeed: isolation })).isolation
|
|
1289
|
+
}
|
|
677
1290
|
await init({ dir, force: opts.force, claudeMd: opts.claudeMd, mode: 'init', isolation })
|
|
678
1291
|
break
|
|
679
1292
|
}
|
|
680
1293
|
case 'update':
|
|
681
|
-
|
|
1294
|
+
// `update` is a resync — refresh managed files, keep customized ones
|
|
1295
|
+
// (--force to overwrite). Leaves specs/ and live .core config alone.
|
|
1296
|
+
resync(dir, { claudeMd: opts.claudeMd, force: opts.force })
|
|
682
1297
|
await cleanupReleaseTooling(dir, opts)
|
|
683
1298
|
break
|
|
684
1299
|
default:
|