@skitterbyte/skitterspec-linear 4.0.0 → 5.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 +39 -0
- package/assets/core/env.config.json.example +3 -0
- package/assets/core/env.config.md +9 -0
- package/assets/rules/spec-planning.md +14 -0
- package/assets/skills/spec-complete/SKILL.md +6 -0
- package/assets/skills/spec-connect/SKILL.md +6 -0
- package/assets/skills/spec-go/SKILL.md +3 -1
- package/assets/skills/spec-init/SKILL.md +8 -0
- package/assets/skills/spec-live/SKILL.md +70 -0
- package/package.json +1 -1
- package/src/cli.js +377 -29
- package/src/env/config.js +12 -1
- package/src/env/live.js +348 -0
- package/src/env/resolve.js +25 -0
- 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,10 +21,21 @@ 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')
|
|
@@ -39,9 +50,12 @@ const HELP = `skitterspec — spec-driven-development for Claude Code
|
|
|
39
50
|
|
|
40
51
|
Usage:
|
|
41
52
|
skitterspec init [dir] Install the spec lifecycle skills, rule, and specs/
|
|
42
|
-
folders
|
|
43
|
-
|
|
44
|
-
|
|
53
|
+
folders. On an already-set-up repo it detects that
|
|
54
|
+
and offers resync / start-again / leave (interactive)
|
|
55
|
+
— non-interactively it just adds anything missing.
|
|
56
|
+
skitterspec update [dir] Resync managed files to the latest, keeping your
|
|
57
|
+
edits (--force to overwrite). Leaves specs/ + live
|
|
58
|
+
.core config alone.
|
|
45
59
|
skitterspec spec-env <cmd> Per-spec isolation engine (opt-in; needs
|
|
46
60
|
specs/.core/env.config.json). Subcommands:
|
|
47
61
|
up <spec> plan a worktree + Docker stack + opener
|
|
@@ -56,6 +70,9 @@ Usage:
|
|
|
56
70
|
skitterspec --version Print version
|
|
57
71
|
|
|
58
72
|
Options (init / update):
|
|
73
|
+
--resync (init) Update managed files to latest, keep your edits
|
|
74
|
+
--reset (init) Start again: reset managed scaffolding fresh
|
|
75
|
+
(needs --yes; never touches your specs or config)
|
|
59
76
|
--force Overwrite skill/rule/script files that already exist
|
|
60
77
|
--dir <path> Target project dir (default: positional arg or cwd)
|
|
61
78
|
--no-claude-md Skip creating/patching CLAUDE.md
|
|
@@ -80,6 +97,8 @@ function parse(argv) {
|
|
|
80
97
|
yes: false,
|
|
81
98
|
isolation: undefined,
|
|
82
99
|
removeReleaseTooling: false,
|
|
100
|
+
resync: false,
|
|
101
|
+
reset: false,
|
|
83
102
|
}
|
|
84
103
|
const positional = []
|
|
85
104
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -90,6 +109,8 @@ function parse(argv) {
|
|
|
90
109
|
else if (a === '--isolation') opts.isolation = true
|
|
91
110
|
else if (a === '--no-isolation') opts.isolation = false
|
|
92
111
|
else if (a === '--remove-release-tooling') opts.removeReleaseTooling = true
|
|
112
|
+
else if (a === '--resync') opts.resync = true
|
|
113
|
+
else if (a === '--reset') opts.reset = true
|
|
93
114
|
else if (a === '--dir') opts.dir = argv[++i]
|
|
94
115
|
else if (a.startsWith('--')) throw new Error(`unknown option: ${a}`)
|
|
95
116
|
else positional.push(a)
|
|
@@ -348,21 +369,43 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
348
369
|
|
|
349
370
|
// `dir` is already anchored on the primary checkout by the dispatch, so it is
|
|
350
371
|
// 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
|
-
|
|
372
|
+
// can run this from inside the worktree and still land on main. A spec authored
|
|
373
|
+
// entirely on its branch may not exist in the primary checkout's specs/** —
|
|
374
|
+
// resolveSpecWithWorktree offers its worktree as a fallback search location.
|
|
375
|
+
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
376
|
+
const base = resolveBaseBranch(config, gitReader(dir))
|
|
377
|
+
|
|
378
|
+
// Live-aware: if this spec is live on the primary checkout (branch-switched by
|
|
379
|
+
// `live take`), end the live session first — release back to base, re-isolate the
|
|
380
|
+
// branch, clear the receipt — so the normal rebase→ff plan below applies
|
|
381
|
+
// unchanged. Refuse if a *different* spec holds the primary checkout.
|
|
382
|
+
const primary = assertPrimaryOnMain(config, gitReader(dir))
|
|
383
|
+
if (!primary.onBase) {
|
|
384
|
+
if (primary.branch !== spec.branch) {
|
|
385
|
+
process.stdout.write(
|
|
386
|
+
`spec-env integrate: blocked — another spec (${primary.branch}) holds the ` +
|
|
387
|
+
'primary checkout; release it with `/spec-live main` first.\n',
|
|
388
|
+
)
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
const pstatus = gitReader(dir)(['status', '--porcelain'])
|
|
392
|
+
if (pstatus === null || pstatus.length > 0) {
|
|
393
|
+
process.stdout.write(
|
|
394
|
+
`spec-env integrate: blocked — commit your live fixes to ${spec.branch} first.\n`,
|
|
395
|
+
)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
const co = runGit(dir, ['checkout', base])
|
|
399
|
+
if (!co.ok) {
|
|
400
|
+
process.stdout.write(`spec-env integrate: could not check out ${base} — ${co.err}\n`)
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
if (fs.existsSync(spec.worktreePath)) runGit(spec.worktreePath, ['switch', spec.branch])
|
|
404
|
+
clearReceipt(dir, config)
|
|
405
|
+
process.stdout.write(
|
|
406
|
+
`spec-env integrate: ended live session — ${spec.folder} released to its worktree.\n`,
|
|
407
|
+
)
|
|
408
|
+
}
|
|
366
409
|
|
|
367
410
|
if (!fs.existsSync(spec.worktreePath)) {
|
|
368
411
|
process.stdout.write(
|
|
@@ -371,7 +414,6 @@ function specEnvIntegrate(dir, config, specArg) {
|
|
|
371
414
|
return
|
|
372
415
|
}
|
|
373
416
|
|
|
374
|
-
const base = resolveBaseBranch(config, gitReader(dir))
|
|
375
417
|
const wtGit = gitReader(spec.worktreePath)
|
|
376
418
|
const status = wtGit(['status', '--porcelain'])
|
|
377
419
|
const dirty = status !== null && status.length > 0
|
|
@@ -588,6 +630,276 @@ async function specEnvConnect(dir, config, specArg) {
|
|
|
588
630
|
|
|
589
631
|
// Dispatch `skitterspec spec-env <sub> [args] [--dir path]`. No-ops with a clear
|
|
590
632
|
// message when the feature isn't enabled (no specs/.core/env.config.json).
|
|
633
|
+
// Run a mutating git command; return { ok, err } (stderr captured, not swallowed).
|
|
634
|
+
function runGit(cwd, args) {
|
|
635
|
+
try {
|
|
636
|
+
execFileSync('git', ['-C', cwd, ...args], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
637
|
+
return { ok: true, err: '' }
|
|
638
|
+
} catch (error) {
|
|
639
|
+
const err = (error.stderr && error.stderr.toString().trim()) || error.message
|
|
640
|
+
return { ok: false, err }
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Lockfiles/manifests whose change means a dev server needs a restart, not HMR.
|
|
645
|
+
const DEPS_RE = /(^|\/)(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.lock)$/
|
|
646
|
+
|
|
647
|
+
// Live overlay: test a spec on the already-running instance by checking its
|
|
648
|
+
// branch out in the primary checkout, so the running dev server hot-reloads the
|
|
649
|
+
// feature. The branch that's checked out IS the lock (assertPrimaryOnMain); the
|
|
650
|
+
// receipt is advisory metadata. `status` is read-only; `take` performs the switch
|
|
651
|
+
// (release/abort land in a later phase).
|
|
652
|
+
async function specEnvLive(dir, config, positional) {
|
|
653
|
+
const action = positional[0] || 'status'
|
|
654
|
+
switch (action) {
|
|
655
|
+
case 'status':
|
|
656
|
+
specEnvLiveStatus(dir, config)
|
|
657
|
+
break
|
|
658
|
+
case 'take':
|
|
659
|
+
await specEnvLiveTake(dir, config, positional[1])
|
|
660
|
+
break
|
|
661
|
+
case 'release':
|
|
662
|
+
await specEnvLiveRelease(dir, config, positional[1])
|
|
663
|
+
break
|
|
664
|
+
case 'abort':
|
|
665
|
+
await specEnvLiveAbort(dir, config)
|
|
666
|
+
break
|
|
667
|
+
default:
|
|
668
|
+
process.stdout.write('Usage: skitterspec spec-env live <take|release|abort|status> [spec]\n')
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Resolve a spec, offering its worktree as a fallback search dir — a spec authored
|
|
673
|
+
// on its own branch may not exist in the primary checkout's specs/**.
|
|
674
|
+
function resolveSpecWithWorktree(dir, config, specArg) {
|
|
675
|
+
const { slug } = splitPrefix(path.basename(specArg))
|
|
676
|
+
const { repo, repoSlug } = repoInfo(dir)
|
|
677
|
+
const wtTokens = { repo, repoSlug, slug }
|
|
678
|
+
const worktreeGuess = path.resolve(
|
|
679
|
+
dir,
|
|
680
|
+
expandTokens(config.worktree.root, wtTokens),
|
|
681
|
+
expandTokens(config.worktree.folderPattern, wtTokens),
|
|
682
|
+
)
|
|
683
|
+
return resolveSpec(specArg, dir, config, { searchDirs: [worktreeGuess] })
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Take the running instance: rebase the spec's branch onto base, free it from its
|
|
687
|
+
// worktree, and check it out in the primary checkout so the dev server reloads it.
|
|
688
|
+
async function specEnvLiveTake(dir, config, specArg) {
|
|
689
|
+
if (!specArg) {
|
|
690
|
+
process.stdout.write('Usage: skitterspec spec-env live take <spec>\n')
|
|
691
|
+
return
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
695
|
+
|
|
696
|
+
// Probe the primary checkout's git state (IO stays here; the planner is pure).
|
|
697
|
+
const primaryGit = gitReader(dir)
|
|
698
|
+
const primary = assertPrimaryOnMain(config, primaryGit)
|
|
699
|
+
const base = resolveBaseBranch(config, primaryGit)
|
|
700
|
+
const status = primaryGit(['status', '--porcelain'])
|
|
701
|
+
const clean = status !== null && status.length === 0
|
|
702
|
+
const worktreeExists = fs.existsSync(spec.worktreePath)
|
|
703
|
+
const baseMainCommit = primaryGit(['rev-parse', 'HEAD'])
|
|
704
|
+
|
|
705
|
+
// Diff base...branch to spot migration / dependency changes (best-effort).
|
|
706
|
+
const changed = primaryGit(['diff', '--name-only', `${base}...${spec.branch}`])
|
|
707
|
+
const files = changed ? changed.split('\n').filter(Boolean) : []
|
|
708
|
+
const depsChanged = files.some((f) => DEPS_RE.test(f))
|
|
709
|
+
|
|
710
|
+
// Verify-only: probe the declared canonical (frontPort) ports. None declared →
|
|
711
|
+
// no health gate (serverUp = null); the switch proceeds with a warning.
|
|
712
|
+
const canonicalPorts = config.dev.map((d) => d.frontPort).filter((p) => typeof p === 'number')
|
|
713
|
+
let serverUp = null
|
|
714
|
+
if (canonicalPorts.length) {
|
|
715
|
+
const up = await portsInUse(canonicalPorts, config.proxy.host)
|
|
716
|
+
serverUp = up.length === canonicalPorts.length
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const plan = planTake(spec, config, {
|
|
720
|
+
primary,
|
|
721
|
+
primaryPath: dir,
|
|
722
|
+
clean,
|
|
723
|
+
worktreeExists,
|
|
724
|
+
base,
|
|
725
|
+
baseMainCommit,
|
|
726
|
+
serverUp,
|
|
727
|
+
canonicalPorts,
|
|
728
|
+
migrationsHit: migrationsHit(files, config.live.migrations),
|
|
729
|
+
depsChanged,
|
|
730
|
+
holder: primaryGit(['config', 'user.name']) || 'unknown',
|
|
731
|
+
heldSince: new Date().toISOString(),
|
|
732
|
+
})
|
|
733
|
+
|
|
734
|
+
if (plan.blocked) {
|
|
735
|
+
process.stdout.write(`spec-env live take: blocked — ${plan.reason}.\n`)
|
|
736
|
+
return
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Execute the switch. Rebase first; on conflict, abort and bail (state untouched).
|
|
740
|
+
const reb = runGit(spec.worktreePath, ['rebase', base])
|
|
741
|
+
if (!reb.ok) {
|
|
742
|
+
runGit(spec.worktreePath, ['rebase', '--abort'])
|
|
743
|
+
process.stdout.write(
|
|
744
|
+
`spec-env live take: rebase of ${spec.branch} onto ${base} hit conflicts — ` +
|
|
745
|
+
`resolve them in ${spec.worktreePath}, then retry.\n`,
|
|
746
|
+
)
|
|
747
|
+
return
|
|
748
|
+
}
|
|
749
|
+
const det = runGit(spec.worktreePath, ['switch', '--detach'])
|
|
750
|
+
if (!det.ok) {
|
|
751
|
+
process.stdout.write(`spec-env live take: could not detach the worktree — ${det.err}\n`)
|
|
752
|
+
return
|
|
753
|
+
}
|
|
754
|
+
const co = runGit(dir, ['checkout', spec.branch])
|
|
755
|
+
if (!co.ok) {
|
|
756
|
+
// Roll the detach back so the worktree keeps its branch.
|
|
757
|
+
runGit(spec.worktreePath, ['switch', spec.branch])
|
|
758
|
+
process.stdout.write(
|
|
759
|
+
`spec-env live take: could not check out ${spec.branch} in the primary ` +
|
|
760
|
+
`checkout — ${co.err}\n`,
|
|
761
|
+
)
|
|
762
|
+
return
|
|
763
|
+
}
|
|
764
|
+
const receipt = writeReceipt(dir, config, plan.receipt)
|
|
765
|
+
|
|
766
|
+
const out = [`spec-env live take: ${spec.folder} is live on the primary checkout`]
|
|
767
|
+
out.push('')
|
|
768
|
+
out.push(` primary: now on ${receipt.branch} (was ${base} @ ${receipt.baseMainCommit.slice(0, 7)})`)
|
|
769
|
+
out.push(` worktree: ${spec.worktreePath} (detached — branch handed to the primary checkout)`)
|
|
770
|
+
for (const w of plan.warnings) out.push(` ! ${w}`)
|
|
771
|
+
out.push('')
|
|
772
|
+
out.push(' Test at your canonical URL; release with: /spec-live main')
|
|
773
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Release the running instance: hand the primary checkout back to base and
|
|
777
|
+
// re-isolate the spec's branch into its worktree. With no spec arg, the live spec
|
|
778
|
+
// is read from the receipt (this is what `/spec-live main` runs).
|
|
779
|
+
async function specEnvLiveRelease(dir, config, specArg) {
|
|
780
|
+
const receipt = readReceipt(dir, config)
|
|
781
|
+
const target = specArg || (receipt && receipt.spec)
|
|
782
|
+
if (!target) {
|
|
783
|
+
const primaryGit = gitReader(dir)
|
|
784
|
+
const primary = assertPrimaryOnMain(config, primaryGit)
|
|
785
|
+
process.stdout.write(
|
|
786
|
+
primary.onBase
|
|
787
|
+
? `spec-env live release: nothing is live — the primary checkout is on ${primary.baseBranch}.\n`
|
|
788
|
+
: `spec-env live release: no receipt, but the primary checkout is on ${primary.branch} — ` +
|
|
789
|
+
'use `spec-env live abort` to recover.\n',
|
|
790
|
+
)
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const spec = resolveSpecWithWorktree(dir, config, target)
|
|
795
|
+
const primaryGit = gitReader(dir)
|
|
796
|
+
const primary = assertPrimaryOnMain(config, primaryGit)
|
|
797
|
+
const base = resolveBaseBranch(config, primaryGit)
|
|
798
|
+
const status = primaryGit(['status', '--porcelain'])
|
|
799
|
+
const clean = status !== null && status.length === 0
|
|
800
|
+
const worktreeExists = fs.existsSync(spec.worktreePath)
|
|
801
|
+
|
|
802
|
+
const plan = planRelease(spec, config, {
|
|
803
|
+
primary,
|
|
804
|
+
primaryPath: dir,
|
|
805
|
+
base,
|
|
806
|
+
clean,
|
|
807
|
+
worktreeExists,
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
if (plan.noop) {
|
|
811
|
+
process.stdout.write(`spec-env live release: ${plan.reason}.\n`)
|
|
812
|
+
return
|
|
813
|
+
}
|
|
814
|
+
if (plan.blocked) {
|
|
815
|
+
process.stdout.write(`spec-env live release: blocked — ${plan.reason}.\n`)
|
|
816
|
+
return
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const co = runGit(dir, ['checkout', base])
|
|
820
|
+
if (!co.ok) {
|
|
821
|
+
process.stdout.write(`spec-env live release: could not check out ${base} — ${co.err}\n`)
|
|
822
|
+
return
|
|
823
|
+
}
|
|
824
|
+
if (worktreeExists) runGit(spec.worktreePath, ['switch', spec.branch])
|
|
825
|
+
clearReceipt(dir, config)
|
|
826
|
+
|
|
827
|
+
process.stdout.write(
|
|
828
|
+
`spec-env live release: ${spec.folder} released — primary back on ${base}, ` +
|
|
829
|
+
`${spec.branch} re-isolated to its worktree.\n`,
|
|
830
|
+
)
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Crash recovery: force the primary checkout back to base from the receipt and
|
|
834
|
+
// re-isolate, without discarding uncommitted work (it refuses on a dirty tree).
|
|
835
|
+
async function specEnvLiveAbort(dir, config) {
|
|
836
|
+
const receipt = readReceipt(dir, config)
|
|
837
|
+
const primaryGit = gitReader(dir)
|
|
838
|
+
const primary = assertPrimaryOnMain(config, primaryGit)
|
|
839
|
+
const base = resolveBaseBranch(config, primaryGit)
|
|
840
|
+
const status = primaryGit(['status', '--porcelain'])
|
|
841
|
+
const clean = status !== null && status.length === 0
|
|
842
|
+
|
|
843
|
+
// Resolve the worktree from the receipt (best-effort — may be gone/unresolvable).
|
|
844
|
+
let worktreePath = null
|
|
845
|
+
if (receipt) {
|
|
846
|
+
try {
|
|
847
|
+
worktreePath = resolveSpecWithWorktree(dir, config, receipt.spec).worktreePath
|
|
848
|
+
} catch {
|
|
849
|
+
worktreePath = null
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
const worktreeExists = worktreePath !== null && fs.existsSync(worktreePath)
|
|
853
|
+
|
|
854
|
+
const plan = planAbort(config, {
|
|
855
|
+
receipt,
|
|
856
|
+
primary,
|
|
857
|
+
primaryPath: dir,
|
|
858
|
+
base,
|
|
859
|
+
clean,
|
|
860
|
+
worktreeExists,
|
|
861
|
+
worktreePath,
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
if (plan.noop) {
|
|
865
|
+
process.stdout.write(`spec-env live abort: ${plan.reason}.\n`)
|
|
866
|
+
return
|
|
867
|
+
}
|
|
868
|
+
if (plan.blocked) {
|
|
869
|
+
process.stdout.write(`spec-env live abort: blocked — ${plan.reason}.\n`)
|
|
870
|
+
return
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (!primary.onBase) {
|
|
874
|
+
const co = runGit(dir, ['checkout', base])
|
|
875
|
+
if (!co.ok) {
|
|
876
|
+
process.stdout.write(`spec-env live abort: could not check out ${base} — ${co.err}\n`)
|
|
877
|
+
return
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
if (worktreeExists) runGit(worktreePath, ['switch', plan.branch])
|
|
881
|
+
clearReceipt(dir, config)
|
|
882
|
+
|
|
883
|
+
process.stdout.write(
|
|
884
|
+
`spec-env live abort: recovered — primary back on ${base}` +
|
|
885
|
+
(worktreeExists ? `, ${plan.branch} re-isolated to its worktree` : '') +
|
|
886
|
+
'.\n',
|
|
887
|
+
)
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function specEnvLiveStatus(dir, config) {
|
|
891
|
+
const { onBase, branch, baseBranch } = assertPrimaryOnMain(config, gitReader(dir))
|
|
892
|
+
const receipt = readReceipt(dir, config)
|
|
893
|
+
const state = onBase
|
|
894
|
+
? 'on base — free'
|
|
895
|
+
: `feature in control — not on ${baseBranch}`
|
|
896
|
+
process.stdout.write(
|
|
897
|
+
'spec-env live:\n' +
|
|
898
|
+
` primary: ${branch || '(detached)'} (${state})\n` +
|
|
899
|
+
` receipt: ${summarizeReceipt(receipt)}\n`,
|
|
900
|
+
)
|
|
901
|
+
}
|
|
902
|
+
|
|
591
903
|
async function specEnv(rest) {
|
|
592
904
|
const [sub, ...args] = rest
|
|
593
905
|
let dir = process.cwd()
|
|
@@ -635,9 +947,12 @@ async function specEnv(rest) {
|
|
|
635
947
|
case 'resolve':
|
|
636
948
|
specEnvResolve(dir, config, positional[0])
|
|
637
949
|
break
|
|
950
|
+
case 'live':
|
|
951
|
+
await specEnvLive(dir, config, positional)
|
|
952
|
+
break
|
|
638
953
|
default:
|
|
639
954
|
process.stdout.write(
|
|
640
|
-
'Usage: skitterspec spec-env <up|down|dev|connect|integrate|status|resolve> [spec] [--keep-volumes] [--force]\n',
|
|
955
|
+
'Usage: skitterspec spec-env <up|down|dev|connect|integrate|live|status|resolve> [spec] [--keep-volumes] [--force]\n',
|
|
641
956
|
)
|
|
642
957
|
}
|
|
643
958
|
}
|
|
@@ -664,21 +979,54 @@ async function run(argv) {
|
|
|
664
979
|
|
|
665
980
|
switch (cmd) {
|
|
666
981
|
case 'init': {
|
|
667
|
-
// Isolation defaults OFF; a flag or an interactive "yes" opts in.
|
|
668
|
-
let isolation = opts.isolation === true
|
|
669
|
-
|
|
670
982
|
const interactive = Boolean(process.stdin.isTTY) && !opts.yes
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
983
|
+
|
|
984
|
+
// Already set up? Route to resync / reset / leave instead of a silent
|
|
985
|
+
// create-missing (safer-init). A fresh repo falls straight through.
|
|
986
|
+
if (isExistingSetup(dir)) {
|
|
987
|
+
let action
|
|
988
|
+
if (opts.reset) {
|
|
989
|
+
if (!opts.yes) {
|
|
990
|
+
process.stdout.write('init: --reset overwrites managed files — re-run with --yes. Left unchanged.\n')
|
|
991
|
+
break
|
|
992
|
+
}
|
|
993
|
+
action = 'reset'
|
|
994
|
+
} else if (opts.resync) action = 'resync'
|
|
995
|
+
else if (opts.force) action = 'resync' // --force resyncs, clobbering customized
|
|
996
|
+
else if (interactive) {
|
|
997
|
+
const { promptExistingSetup } = require('./prompts.js')
|
|
998
|
+
action = await promptExistingSetup()
|
|
999
|
+
} else action = 'create-missing' // non-interactive default: add missing, never clobber
|
|
1000
|
+
|
|
1001
|
+
if (action === 'leave') {
|
|
1002
|
+
process.stdout.write('init: existing setup left unchanged.\n')
|
|
1003
|
+
break
|
|
1004
|
+
}
|
|
1005
|
+
if (action === 'reset') {
|
|
1006
|
+
reset(dir, { claudeMd: opts.claudeMd })
|
|
1007
|
+
break
|
|
1008
|
+
}
|
|
1009
|
+
if (action === 'resync') {
|
|
1010
|
+
resync(dir, { claudeMd: opts.claudeMd, force: opts.force })
|
|
1011
|
+
break
|
|
1012
|
+
}
|
|
1013
|
+
// action === 'create-missing' → fall through to a normal (skip-existing) init.
|
|
675
1014
|
}
|
|
676
1015
|
|
|
1016
|
+
// Fresh repo (or create-missing): isolation defaults OFF; a flag or an
|
|
1017
|
+
// interactive "yes" opts in. Only prompt for isolation on a fresh repo.
|
|
1018
|
+
let isolation = opts.isolation === true
|
|
1019
|
+
if (interactive && !isExistingSetup(dir)) {
|
|
1020
|
+
const { promptSetup } = require('./prompts.js')
|
|
1021
|
+
isolation = (await promptSetup({ isolationSeed: isolation })).isolation
|
|
1022
|
+
}
|
|
677
1023
|
await init({ dir, force: opts.force, claudeMd: opts.claudeMd, mode: 'init', isolation })
|
|
678
1024
|
break
|
|
679
1025
|
}
|
|
680
1026
|
case 'update':
|
|
681
|
-
|
|
1027
|
+
// `update` is a resync — refresh managed files, keep customized ones
|
|
1028
|
+
// (--force to overwrite). Leaves specs/ and live .core config alone.
|
|
1029
|
+
resync(dir, { claudeMd: opts.claudeMd, force: opts.force })
|
|
682
1030
|
await cleanupReleaseTooling(dir, opts)
|
|
683
1031
|
break
|
|
684
1032
|
default:
|
package/src/env/config.js
CHANGED
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
* registry: ".spec-env/registry.json",
|
|
29
29
|
* branch: { pattern, identifierField }, // git branch naming (provider-neutral)
|
|
30
30
|
* baseBranch: "", // "" = auto-detect (origin/HEAD → main → master)
|
|
31
|
-
* guards: { refuseTeardownIfDirty, refuseTeardownIfUnpushed }
|
|
31
|
+
* guards: { refuseTeardownIfDirty, refuseTeardownIfUnpushed },
|
|
32
|
+
* live: { migrations: [ "glob", ... ] } // migration globs → `live take`
|
|
33
|
+
* // refuses a branch that changes them (code-only v1)
|
|
32
34
|
* }
|
|
33
35
|
*/
|
|
34
36
|
|
|
@@ -74,6 +76,10 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
74
76
|
// Integration base branch. Empty = auto-detect (origin/HEAD → main → master).
|
|
75
77
|
baseBranch: '',
|
|
76
78
|
guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
|
|
79
|
+
// Live overlay (`spec-env live`). `migrations` is a list of globs marking
|
|
80
|
+
// migration files; a branch that changes any of them is treated as stateful and
|
|
81
|
+
// `live take` refuses it (code-only v1). Default: none (nothing is stateful).
|
|
82
|
+
live: Object.freeze({ migrations: Object.freeze([]) }),
|
|
77
83
|
})
|
|
78
84
|
|
|
79
85
|
function isObject(value) {
|
|
@@ -94,6 +100,7 @@ function defaults() {
|
|
|
94
100
|
branch: { ...DEFAULT_CONFIG.branch },
|
|
95
101
|
baseBranch: DEFAULT_CONFIG.baseBranch,
|
|
96
102
|
guards: { ...DEFAULT_CONFIG.guards },
|
|
103
|
+
live: { migrations: [] },
|
|
97
104
|
}
|
|
98
105
|
}
|
|
99
106
|
|
|
@@ -237,6 +244,10 @@ function mergeConfig(base, parsed) {
|
|
|
237
244
|
assign(base.guards, parsed.guards, 'refuseTeardownIfUnpushed', 'boolean')
|
|
238
245
|
}
|
|
239
246
|
|
|
247
|
+
if (isObject(parsed.live) && Array.isArray(parsed.live.migrations)) {
|
|
248
|
+
base.live.migrations = normalizeFileList(parsed.live.migrations)
|
|
249
|
+
}
|
|
250
|
+
|
|
240
251
|
return base
|
|
241
252
|
}
|
|
242
253
|
|