@skitterbyte/skitterspec-linear 4.0.0 → 6.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 +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/env/config.js
CHANGED
|
@@ -28,7 +28,12 @@
|
|
|
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)
|
|
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)
|
|
32
37
|
* }
|
|
33
38
|
*/
|
|
34
39
|
|
|
@@ -74,6 +79,16 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
74
79
|
// Integration base branch. Empty = auto-detect (origin/HEAD → main → master).
|
|
75
80
|
baseBranch: '',
|
|
76
81
|
guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
|
|
82
|
+
// Live overlay (`spec-env live`). `migrations` is a list of globs marking
|
|
83
|
+
// migration files; a branch that changes any of them is treated as stateful and
|
|
84
|
+
// `live take` refuses it (code-only v1). Default: none (nothing is stateful).
|
|
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([]) }),
|
|
77
92
|
})
|
|
78
93
|
|
|
79
94
|
function isObject(value) {
|
|
@@ -94,6 +109,8 @@ function defaults() {
|
|
|
94
109
|
branch: { ...DEFAULT_CONFIG.branch },
|
|
95
110
|
baseBranch: DEFAULT_CONFIG.baseBranch,
|
|
96
111
|
guards: { ...DEFAULT_CONFIG.guards },
|
|
112
|
+
live: { migrations: [] },
|
|
113
|
+
hotfix: { ...DEFAULT_CONFIG.hotfix, targets: [] },
|
|
97
114
|
}
|
|
98
115
|
}
|
|
99
116
|
|
|
@@ -237,6 +254,18 @@ function mergeConfig(base, parsed) {
|
|
|
237
254
|
assign(base.guards, parsed.guards, 'refuseTeardownIfUnpushed', 'boolean')
|
|
238
255
|
}
|
|
239
256
|
|
|
257
|
+
if (isObject(parsed.live) && Array.isArray(parsed.live.migrations)) {
|
|
258
|
+
base.live.migrations = normalizeFileList(parsed.live.migrations)
|
|
259
|
+
}
|
|
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
|
+
|
|
240
269
|
return base
|
|
241
270
|
}
|
|
242
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
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Live-overlay receipt — the advisory record of which spec currently holds the
|
|
5
|
+
* primary checkout for live testing.
|
|
6
|
+
*
|
|
7
|
+
* The *authority* on "who's live" is the branch checked out in the primary
|
|
8
|
+
* checkout (see `assertPrimaryOnMain` in resolve.js): on the base branch → free;
|
|
9
|
+
* on a feature branch → that spec is in control. This receipt is only metadata —
|
|
10
|
+
* it powers `live status` and crash recovery (`live abort` reads `baseMainCommit`
|
|
11
|
+
* from here to restore the primary checkout). It lives beside the slot registry
|
|
12
|
+
* at the primary checkout root (`.spec-env/live.json`, gitignored).
|
|
13
|
+
*
|
|
14
|
+
* `receiptPath`/`renderReceipt`/`summarizeReceipt` are pure; `read`/`write`/`clear`
|
|
15
|
+
* are the thin IO seam the CLI drives, mirroring env/registry.js. No `Date.now()`
|
|
16
|
+
* — the caller passes `heldSince`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs')
|
|
20
|
+
const path = require('node:path')
|
|
21
|
+
|
|
22
|
+
const REQUIRED = ['spec', 'branch', 'holder', 'heldSince', 'baseMainCommit']
|
|
23
|
+
|
|
24
|
+
// Absolute path to the receipt — a sibling of the configured registry file, so it
|
|
25
|
+
// follows wherever `.spec-env` is configured (default `.spec-env/live.json`).
|
|
26
|
+
function receiptPath(rootDir, config) {
|
|
27
|
+
return path.resolve(rootDir, path.dirname(config.registry), 'live.json')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Normalize receipt fields into the persisted shape. Pure; throws on a missing
|
|
31
|
+
// field so a half-formed receipt is never written.
|
|
32
|
+
function renderReceipt(fields) {
|
|
33
|
+
for (const key of REQUIRED) {
|
|
34
|
+
if (!fields || !fields[key]) throw new Error(`live receipt: missing ${key}`)
|
|
35
|
+
}
|
|
36
|
+
const receipt = {}
|
|
37
|
+
for (const key of REQUIRED) receipt[key] = String(fields[key])
|
|
38
|
+
return receipt
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Read the receipt. Missing file → null (no one is live). Malformed JSON → Error.
|
|
42
|
+
function readReceipt(rootDir, config) {
|
|
43
|
+
const file = receiptPath(rootDir, config)
|
|
44
|
+
let raw
|
|
45
|
+
try {
|
|
46
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error.code === 'ENOENT') return null
|
|
49
|
+
throw error
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(raw)
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Invalid live receipt ${path.dirname(config.registry)}/live.json: ${error.message}`,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Persist the receipt, creating its parent dir as needed. Returns the written shape.
|
|
61
|
+
function writeReceipt(rootDir, config, fields) {
|
|
62
|
+
const receipt = renderReceipt(fields)
|
|
63
|
+
const file = receiptPath(rootDir, config)
|
|
64
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
65
|
+
fs.writeFileSync(file, JSON.stringify(receipt, null, 2) + '\n')
|
|
66
|
+
return receipt
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Remove the receipt. Idempotent: clearing an absent receipt is a clean no-op.
|
|
70
|
+
function clearReceipt(rootDir, config) {
|
|
71
|
+
const file = receiptPath(rootDir, config)
|
|
72
|
+
try {
|
|
73
|
+
fs.unlinkSync(file)
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error.code !== 'ENOENT') throw error
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A one-line human summary of a receipt (or the "free" state when null). Pure.
|
|
80
|
+
function summarizeReceipt(receipt) {
|
|
81
|
+
if (!receipt) return 'free — no spec is live'
|
|
82
|
+
return (
|
|
83
|
+
`${receipt.spec} (branch ${receipt.branch}) — ` +
|
|
84
|
+
`held by ${receipt.holder} since ${receipt.heldSince}`
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- stateful detection (glob matching) -----------------------------------
|
|
89
|
+
|
|
90
|
+
// Translate a restricted glob into an anchored RegExp: `**` matches across path
|
|
91
|
+
// separators (with an optional trailing `/`), `*` matches within a segment, `?`
|
|
92
|
+
// a single non-separator char. Enough for migration-path globs, no dependency.
|
|
93
|
+
function globToRegExp(glob) {
|
|
94
|
+
let re = ''
|
|
95
|
+
for (let i = 0; i < glob.length; i++) {
|
|
96
|
+
const ch = glob[i]
|
|
97
|
+
if (ch === '*') {
|
|
98
|
+
if (glob[i + 1] === '*') {
|
|
99
|
+
re += '.*'
|
|
100
|
+
i++
|
|
101
|
+
if (glob[i + 1] === '/') i++ // consume the `/` in `**/`
|
|
102
|
+
} else {
|
|
103
|
+
re += '[^/]*'
|
|
104
|
+
}
|
|
105
|
+
} else if (ch === '?') {
|
|
106
|
+
re += '[^/]'
|
|
107
|
+
} else if ('.+^${}()|[]\\/'.includes(ch)) {
|
|
108
|
+
re += '\\' + ch
|
|
109
|
+
} else {
|
|
110
|
+
re += ch
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return new RegExp('^' + re + '$')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// True when any file path matches any of the migration globs. Empty patterns or
|
|
117
|
+
// no files → false (nothing configured / nothing changed).
|
|
118
|
+
function migrationsHit(files, patterns) {
|
|
119
|
+
if (!Array.isArray(files) || !files.length) return false
|
|
120
|
+
if (!Array.isArray(patterns) || !patterns.length) return false
|
|
121
|
+
const res = patterns.map(globToRegExp)
|
|
122
|
+
return files.some((f) => res.some((r) => r.test(f)))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- take planner ---------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Pure planner for `spec-env live take`. Validates the preconditions for putting
|
|
129
|
+
* a spec live on the running instance by branch-switch, and returns either a
|
|
130
|
+
* structured refusal or the ordered git steps + the receipt to write. All git /
|
|
131
|
+
* port state is probed by the CLI and passed in `ctx`, keeping this deterministic
|
|
132
|
+
* and unit-testable with no live git.
|
|
133
|
+
*
|
|
134
|
+
* ctx:
|
|
135
|
+
* primary { onBase, branch, baseBranch } — the guard result for the primary checkout
|
|
136
|
+
* primaryPath absolute path of the primary checkout (the checkout target)
|
|
137
|
+
* clean boolean — primary checkout working tree is clean
|
|
138
|
+
* worktreeExists boolean — the spec's worktree is on disk
|
|
139
|
+
* base resolved base branch name (rebase target)
|
|
140
|
+
* baseMainCommit primary HEAD before the switch (receipt / crash recovery)
|
|
141
|
+
* serverUp true | false | null — null = no canonical ports declared → no gate
|
|
142
|
+
* canonicalPorts number[] — declared canonical (frontPort) ports, for messages
|
|
143
|
+
* migrationsHit boolean — the branch diff touches configured migration globs
|
|
144
|
+
* depsChanged boolean — the branch diff touches a lockfile/manifest (→ warn)
|
|
145
|
+
* holder,heldSince receipt provenance (injected by the CLI; no Date.now here)
|
|
146
|
+
*
|
|
147
|
+
* @returns {object} { blocked, reason, commands, warnings, receipt, base, branch, worktreePath }
|
|
148
|
+
*/
|
|
149
|
+
function planTake(spec, config, ctx) {
|
|
150
|
+
const c = ctx || {}
|
|
151
|
+
const branch = spec.branch
|
|
152
|
+
const base = c.base
|
|
153
|
+
const result = {
|
|
154
|
+
blocked: false,
|
|
155
|
+
reason: null,
|
|
156
|
+
commands: [],
|
|
157
|
+
warnings: [],
|
|
158
|
+
receipt: null,
|
|
159
|
+
base,
|
|
160
|
+
branch,
|
|
161
|
+
worktreePath: spec.worktreePath,
|
|
162
|
+
}
|
|
163
|
+
const block = (reason) => ({ ...result, blocked: true, reason })
|
|
164
|
+
|
|
165
|
+
// 1. The lock: the primary checkout must be on base (free). Off-base → a spec
|
|
166
|
+
// (or you) already holds the live instance.
|
|
167
|
+
if (!c.primary || !c.primary.onBase) {
|
|
168
|
+
const on = c.primary && c.primary.branch ? c.primary.branch : '(detached)'
|
|
169
|
+
return block(
|
|
170
|
+
`primary checkout is on ${on}, not ${base} — a spec already holds the live ` +
|
|
171
|
+
'instance; release it with `/spec-live main` first',
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
// 2. Never switch a dirty tree — the checkout is reset back to base on release.
|
|
175
|
+
if (!c.clean) {
|
|
176
|
+
return block('primary checkout has uncommitted changes — commit or stash them first')
|
|
177
|
+
}
|
|
178
|
+
// 3. Need a worktree holding the branch to detach and hand over.
|
|
179
|
+
if (!c.worktreeExists) {
|
|
180
|
+
return block(`${spec.folder} has no worktree — run \`/spec-go ${spec.folder}\` first`)
|
|
181
|
+
}
|
|
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)…
|
|
192
|
+
if (spec.stack === 'docker') {
|
|
193
|
+
return block(
|
|
194
|
+
`${spec.folder} is stateful (Stack: worktree + docker) — live overlay is ` +
|
|
195
|
+
'code-only; use `/spec-connect` for a Docker-backed spec',
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
// 6. …and refuse a branch that changes migrations (would mutate the shared DB).
|
|
199
|
+
if (c.migrationsHit) {
|
|
200
|
+
return block(
|
|
201
|
+
`${spec.folder}'s branch changes migrations — live overlay is code-only; ` +
|
|
202
|
+
'use `/spec-connect`',
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
// 7. Verify-only: a dev server must be listening to hot-reload the switch.
|
|
206
|
+
if (c.serverUp === false) {
|
|
207
|
+
return block(
|
|
208
|
+
`no dev server listening on canonical port(s) ${(c.canonicalPorts || []).join(', ')} — ` +
|
|
209
|
+
'start your dev server (or `skitterspec spec-env dev up`) first',
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const warnings = []
|
|
214
|
+
if (c.depsChanged) {
|
|
215
|
+
warnings.push('dependencies changed on this branch — restart your dev server after the switch')
|
|
216
|
+
}
|
|
217
|
+
if (c.serverUp === null) {
|
|
218
|
+
warnings.push('no canonical dev ports configured — nothing to hot-reload; switching anyway')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
...result,
|
|
223
|
+
commands: [
|
|
224
|
+
`git -C ${spec.worktreePath} rebase ${base}`,
|
|
225
|
+
`git -C ${spec.worktreePath} switch --detach`,
|
|
226
|
+
`git -C ${c.primaryPath} checkout ${branch}`,
|
|
227
|
+
],
|
|
228
|
+
warnings,
|
|
229
|
+
receipt: {
|
|
230
|
+
spec: spec.folder,
|
|
231
|
+
branch,
|
|
232
|
+
holder: c.holder,
|
|
233
|
+
heldSince: c.heldSince,
|
|
234
|
+
baseMainCommit: c.baseMainCommit,
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- release planner ------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Pure planner for `spec-env live release` — hand the running instance back to
|
|
243
|
+
* base and re-isolate the spec's branch into its worktree (the graceful exit of
|
|
244
|
+
* an unfinished session). All git state is probed by the CLI and passed in.
|
|
245
|
+
*
|
|
246
|
+
* In the branch-switch model `take` never moves the base ref, so release is just
|
|
247
|
+
* `checkout base` (frees the branch) then re-attach it to the worktree — no reset.
|
|
248
|
+
*
|
|
249
|
+
* ctx: { primary:{onBase,branch}, primaryPath, base, clean, worktreeExists }
|
|
250
|
+
* @returns {object} { blocked, noop, reason, commands, clears, base, branch, worktreePath }
|
|
251
|
+
*/
|
|
252
|
+
function planRelease(spec, config, ctx) {
|
|
253
|
+
const c = ctx || {}
|
|
254
|
+
const branch = spec.branch
|
|
255
|
+
const base = c.base
|
|
256
|
+
const result = {
|
|
257
|
+
blocked: false,
|
|
258
|
+
noop: false,
|
|
259
|
+
reason: null,
|
|
260
|
+
commands: [],
|
|
261
|
+
clears: false,
|
|
262
|
+
base,
|
|
263
|
+
branch,
|
|
264
|
+
worktreePath: spec.worktreePath,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Nothing live — the primary checkout is already on base.
|
|
268
|
+
if (c.primary && c.primary.onBase) {
|
|
269
|
+
return { ...result, noop: true, reason: `nothing is live — the primary checkout is on ${base}` }
|
|
270
|
+
}
|
|
271
|
+
// A different spec holds the instance.
|
|
272
|
+
if (c.primary && c.primary.branch !== branch) {
|
|
273
|
+
return {
|
|
274
|
+
...result,
|
|
275
|
+
blocked: true,
|
|
276
|
+
reason: `the live spec is ${c.primary.branch}, not ${branch} — release that one`,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Fixes made while live must be committed to the branch first (never discarded).
|
|
280
|
+
if (!c.clean) {
|
|
281
|
+
return {
|
|
282
|
+
...result,
|
|
283
|
+
blocked: true,
|
|
284
|
+
reason: `primary checkout has uncommitted changes — commit your fixes to ${branch} first`,
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const commands = [`git -C ${c.primaryPath} checkout ${base}`]
|
|
289
|
+
if (c.worktreeExists) commands.push(`git -C ${spec.worktreePath} switch ${branch}`)
|
|
290
|
+
return { ...result, commands, clears: true }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// --- abort planner --------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Pure planner for `spec-env live abort` — crash recovery. Works from the receipt
|
|
297
|
+
* (not a resolved spec), so it recovers even when the spec folder can't be found.
|
|
298
|
+
* Conservative: it refuses to discard uncommitted work, and won't force anything
|
|
299
|
+
* without a receipt to tell it what "live" was.
|
|
300
|
+
*
|
|
301
|
+
* Like release, recovery is `checkout base` + re-isolate — no `reset --hard`:
|
|
302
|
+
* branch-switch never moved base, and resetting to the receipt's recorded commit
|
|
303
|
+
* would discard any legitimate advance of base. `baseMainCommit` stays a record.
|
|
304
|
+
*
|
|
305
|
+
* ctx: { receipt, primary:{onBase,branch}, primaryPath, base, clean, worktreeExists, worktreePath }
|
|
306
|
+
* @returns {object} { blocked, noop, reason, commands, clears, base, branch }
|
|
307
|
+
*/
|
|
308
|
+
function planAbort(config, ctx) {
|
|
309
|
+
const c = ctx || {}
|
|
310
|
+
const base = c.base
|
|
311
|
+
const result = { blocked: false, noop: false, reason: null, commands: [], clears: false, base, branch: null }
|
|
312
|
+
|
|
313
|
+
if (!c.receipt) {
|
|
314
|
+
if (c.primary && c.primary.onBase) {
|
|
315
|
+
return { ...result, noop: true, reason: `nothing is live — the primary checkout is on ${base}` }
|
|
316
|
+
}
|
|
317
|
+
const on = c.primary && c.primary.branch ? c.primary.branch : '(detached)'
|
|
318
|
+
return {
|
|
319
|
+
...result,
|
|
320
|
+
blocked: true,
|
|
321
|
+
reason:
|
|
322
|
+
`no live receipt, but the primary checkout is on ${on} — no recorded base ` +
|
|
323
|
+
`to restore; check out ${base} manually once you're sure it's safe`,
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Receipt present. Never discard uncommitted work — surface it instead.
|
|
328
|
+
if (!c.clean) {
|
|
329
|
+
return {
|
|
330
|
+
...result,
|
|
331
|
+
blocked: true,
|
|
332
|
+
reason:
|
|
333
|
+
'the primary checkout has uncommitted changes that abort would discard — ' +
|
|
334
|
+
'commit or stash them (or resolve manually) first',
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const branch = c.receipt.branch
|
|
339
|
+
const commands = []
|
|
340
|
+
if (!(c.primary && c.primary.onBase)) commands.push(`git -C ${c.primaryPath} checkout ${base}`)
|
|
341
|
+
if (c.worktreeExists) commands.push(`git -C ${c.worktreePath} switch ${branch}`)
|
|
342
|
+
return { ...result, commands, clears: true, branch }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
module.exports = {
|
|
346
|
+
receiptPath,
|
|
347
|
+
renderReceipt,
|
|
348
|
+
readReceipt,
|
|
349
|
+
writeReceipt,
|
|
350
|
+
clearReceipt,
|
|
351
|
+
summarizeReceipt,
|
|
352
|
+
globToRegExp,
|
|
353
|
+
migrationsHit,
|
|
354
|
+
planTake,
|
|
355
|
+
planRelease,
|
|
356
|
+
planAbort,
|
|
357
|
+
}
|
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`)
|
package/src/env/prune.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure planner for `spec-env prune` — the orphaned-test-DB reaper.
|
|
5
|
+
*
|
|
6
|
+
* Per-spec isolation gives each Docker-escalated spec its own compose stack
|
|
7
|
+
* namespaced by `{repoSlug}_{slug}`; the DB storage is a Docker named volume
|
|
8
|
+
* (`{repoSlug}_{slug}_<volname>`). Those volumes only ever get dropped by an
|
|
9
|
+
* explicit single-spec `spec-env down`, so they leak whenever that path is
|
|
10
|
+
* skipped (declined/guard-aborted teardown, manual `git worktree remove`,
|
|
11
|
+
* `--keep-volumes`). This planner reconciles the live namespace volumes against
|
|
12
|
+
* the specs that are still live and returns the `docker volume rm` commands for
|
|
13
|
+
* the orphans.
|
|
14
|
+
*
|
|
15
|
+
* **Liveness = an existing worktree, NOT the slot registry.** The registry is
|
|
16
|
+
* exactly what goes stale (a declined teardown leaves both the slot and the
|
|
17
|
+
* volume behind), so the caller derives `liveSlugs` from real worktrees and the
|
|
18
|
+
* registry is only reconciled afterwards. See the spec Decisions.
|
|
19
|
+
*
|
|
20
|
+
* We never parse a slug out of an orphan. Instead we build a protected-prefix
|
|
21
|
+
* set from the live slugs (`{repoSlug}_{slug}_`) and keep any volume matching
|
|
22
|
+
* one — everything else in the `{repoSlug}_` namespace is an orphan. The
|
|
23
|
+
* trailing `_` makes the slug match exact: slugs are kebab-case (`[a-z0-9-]`)
|
|
24
|
+
* and `_` is the compose project/volume separator, so `add` never protects
|
|
25
|
+
* `add-widget`.
|
|
26
|
+
*
|
|
27
|
+
* Side-effect free and deterministic: no `Date.now()` — the caller supplies
|
|
28
|
+
* `now` (and per-volume `createdAt`) when age-gating.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const { splitPrefix } = require('./resolve.js')
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {Array<string|{name:string,createdAt?:number|null}>} volumes
|
|
35
|
+
* live Docker volume names (or objects carrying `createdAt` epoch-ms for age
|
|
36
|
+
* gating). Plain strings are treated as unknown-age.
|
|
37
|
+
* @param {Iterable<string>} liveSlugs slugs of specs that still have a worktree.
|
|
38
|
+
* @param {object} opts { repoSlug, olderThanDays?, now? }
|
|
39
|
+
* `repoSlug` (required) is the namespace prefix. `olderThanDays` (optional)
|
|
40
|
+
* keeps only orphans strictly older than the cutoff; when set, `now`
|
|
41
|
+
* (epoch-ms) is required and volumes of unknown age are conservatively kept.
|
|
42
|
+
* @returns {{ orphans: Array<{name:string,createdAt:number|null}>, commands: string[] }}
|
|
43
|
+
*/
|
|
44
|
+
function planPrune(volumes, liveSlugs, opts = {}) {
|
|
45
|
+
const { repoSlug, olderThanDays = null, now = null } = opts
|
|
46
|
+
if (!repoSlug) throw new Error('planPrune: opts.repoSlug is required')
|
|
47
|
+
|
|
48
|
+
const namespace = `${repoSlug}_`
|
|
49
|
+
const live = liveSlugs instanceof Set ? liveSlugs : new Set(liveSlugs || [])
|
|
50
|
+
const protectedPrefixes = [...live].map((slug) => `${repoSlug}_${slug}_`)
|
|
51
|
+
|
|
52
|
+
let orphans = (volumes || [])
|
|
53
|
+
.map((v) => (typeof v === 'string' ? { name: v, createdAt: null } : v))
|
|
54
|
+
.filter((v) => {
|
|
55
|
+
const name = v && v.name
|
|
56
|
+
if (!name || !name.startsWith(namespace)) return false
|
|
57
|
+
return !protectedPrefixes.some((p) => name.startsWith(p))
|
|
58
|
+
})
|
|
59
|
+
.map((v) => ({ name: v.name, createdAt: v.createdAt == null ? null : v.createdAt }))
|
|
60
|
+
|
|
61
|
+
if (olderThanDays != null) {
|
|
62
|
+
if (now == null) throw new Error('planPrune: olderThanDays requires opts.now')
|
|
63
|
+
const cutoff = now - olderThanDays * 24 * 60 * 60 * 1000
|
|
64
|
+
// Unknown age (createdAt == null) is kept — never drop what we can't date.
|
|
65
|
+
orphans = orphans.filter((v) => v.createdAt != null && v.createdAt <= cutoff)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const commands = orphans.map((v) => `docker volume rm ${v.name}`)
|
|
69
|
+
return { orphans, commands }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Derive the set of live spec slugs from the specs that still have a git
|
|
74
|
+
* worktree. The **worktree is the liveness signal** (see module header): the
|
|
75
|
+
* registry is not consulted here. Pure — the caller supplies the spec list and
|
|
76
|
+
* the set of live worktree paths (both gathered via IO).
|
|
77
|
+
*
|
|
78
|
+
* @param {Array<{slug:string, worktreePath:string}>} specs
|
|
79
|
+
* @param {Set<string>|Iterable<string>} liveWorktreePaths absolute paths.
|
|
80
|
+
* @returns {Set<string>} slugs whose worktree currently exists.
|
|
81
|
+
*/
|
|
82
|
+
function liveSlugsForSpecs(specs, liveWorktreePaths) {
|
|
83
|
+
const live = liveWorktreePaths instanceof Set ? liveWorktreePaths : new Set(liveWorktreePaths || [])
|
|
84
|
+
const slugs = new Set()
|
|
85
|
+
for (const spec of specs || []) {
|
|
86
|
+
if (spec && spec.worktreePath && live.has(spec.worktreePath)) slugs.add(spec.slug)
|
|
87
|
+
}
|
|
88
|
+
return slugs
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Reconcile the slot registry against the volumes we're about to reap. A stale
|
|
93
|
+
* slot is one whose spec's DB volume is an orphan — freeing it converges the
|
|
94
|
+
* registry back to what actually exists (the registry is keyed by spec *folder*,
|
|
95
|
+
* so we split each folder's prefix to get its slug and match it to a reaped
|
|
96
|
+
* volume by the same `{repoSlug}_{slug}_` prefix used for orphan detection).
|
|
97
|
+
* Pure: returns a new registry object plus the folders freed; never mutates.
|
|
98
|
+
*
|
|
99
|
+
* @param {{slots: Object<string,number>}} registry
|
|
100
|
+
* @param {Array<{name:string}>} orphans the volumes planPrune decided to reap.
|
|
101
|
+
* @param {string} repoSlug
|
|
102
|
+
* @returns {{ registry: {slots: Object<string,number>}, freed: string[] }}
|
|
103
|
+
*/
|
|
104
|
+
function reconcileRegistry(registry, orphans, repoSlug) {
|
|
105
|
+
const slots = { ...((registry && registry.slots) || {}) }
|
|
106
|
+
const reaped = (orphans || []).map((o) => o.name)
|
|
107
|
+
const freed = []
|
|
108
|
+
for (const folder of Object.keys(slots)) {
|
|
109
|
+
const { slug } = splitPrefix(folder)
|
|
110
|
+
const prefix = `${repoSlug}_${slug}_`
|
|
111
|
+
if (reaped.some((name) => name.startsWith(prefix))) {
|
|
112
|
+
delete slots[folder]
|
|
113
|
+
freed.push(folder)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { registry: { slots }, freed }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { planPrune, liveSlugsForSpecs, reconcileRegistry }
|