@skitterbyte/skitterspec 0.1.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +97 -2
  2. package/assets/claude-md-section.md +11 -0
  3. package/assets/core/env.config.json.example +24 -0
  4. package/assets/core/env.config.md +83 -0
  5. package/assets/core/linear.config.json.example +39 -0
  6. package/assets/core/linear.config.md +121 -0
  7. package/assets/rules/spec-planning.md +29 -11
  8. package/assets/skills/spec/SKILL.md +61 -13
  9. package/assets/skills/spec-bug/SKILL.md +0 -4
  10. package/assets/skills/spec-cancel/SKILL.md +8 -3
  11. package/assets/skills/spec-complete/SKILL.md +10 -10
  12. package/assets/skills/spec-env/SKILL.md +57 -0
  13. package/assets/skills/spec-env-down/SKILL.md +56 -0
  14. package/assets/skills/spec-go/SKILL.md +39 -3
  15. package/assets/skills/spec-init/SKILL.md +0 -8
  16. package/assets/skills/spec-pull/SKILL.md +46 -0
  17. package/assets/skills/spec-push/SKILL.md +53 -0
  18. package/assets/skills/spec-ready/SKILL.md +0 -2
  19. package/assets/skills/spec-review/SKILL.md +2 -2
  20. package/assets/skills/spec-status/SKILL.md +46 -0
  21. package/bin/skitterspec.js +0 -0
  22. package/package.json +6 -6
  23. package/src/cli.js +497 -2
  24. package/src/env/config.js +152 -0
  25. package/src/env/provision.js +76 -0
  26. package/src/env/registry.js +95 -0
  27. package/src/env/render.js +26 -0
  28. package/src/env/resolve.js +184 -0
  29. package/src/env/teardown.js +94 -0
  30. package/src/init.js +82 -27
  31. package/src/prompts.js +23 -12
  32. package/src/sync/apply.js +66 -0
  33. package/src/sync/base.js +83 -0
  34. package/src/sync/compare.js +99 -0
  35. package/src/sync/config.js +198 -0
  36. package/src/sync/mcp.js +112 -0
  37. package/src/sync/normalize.js +249 -0
  38. package/src/sync/pull.js +84 -0
  39. package/src/sync/push.js +106 -0
  40. package/src/sync/write.js +86 -0
@@ -0,0 +1,152 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Config loader for the per-spec isolation feature (`/spec-env`).
5
+ *
6
+ * Reads `specs/.core/env.config.json` from the project root and normalises it
7
+ * over frozen defaults. The feature is strictly opt-in: when the file is absent
8
+ * the loader never throws — it returns the defaults with `present:false`, which
9
+ * every caller treats as "feature unused".
10
+ *
11
+ * Mirrors the shape/idiom of `assets/scripts/lib/config.js` (frozen defaults,
12
+ * merge known keys only, forward-compatible on unknown keys). Zero-dependency.
13
+ *
14
+ * Shape (see specs/.core/env.config.md for field docs):
15
+ * {
16
+ * worktree: { root, folderPattern },
17
+ * docker: { enabled, composeFile, projectNamePattern, portBase,
18
+ * portsPerSpec, envFile, backupCommand },
19
+ * open: { command }, // optional, editor/terminal-agnostic opener
20
+ * registry: ".spec-env/registry.json",
21
+ * linkLinear: true,
22
+ * guards: { refuseTeardownIfDirty, refuseTeardownIfUnpushed }
23
+ * }
24
+ */
25
+
26
+ const { readFileSync } = require('node:fs')
27
+ const { join } = require('node:path')
28
+
29
+ const CONFIG_FILE = join('specs', '.core', 'env.config.json')
30
+
31
+ const DEFAULT_CONFIG = Object.freeze({
32
+ worktree: Object.freeze({ root: '../{repo}-wt', folderPattern: '{slug}' }),
33
+ docker: Object.freeze({
34
+ enabled: true,
35
+ composeFile: 'docker-compose.yml',
36
+ projectNamePattern: '{repoSlug}_{slug}',
37
+ portBase: 3000,
38
+ portsPerSpec: 10,
39
+ envFile: '.env',
40
+ backupCommand: '',
41
+ }),
42
+ open: Object.freeze({ command: '' }),
43
+ registry: '.spec-env/registry.json',
44
+ linkLinear: true,
45
+ guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
46
+ })
47
+
48
+ function isObject(value) {
49
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
50
+ }
51
+
52
+ // A fresh, deeply-mutable copy of the defaults to merge onto.
53
+ function defaults() {
54
+ return {
55
+ worktree: { ...DEFAULT_CONFIG.worktree },
56
+ docker: { ...DEFAULT_CONFIG.docker },
57
+ open: { ...DEFAULT_CONFIG.open },
58
+ registry: DEFAULT_CONFIG.registry,
59
+ linkLinear: DEFAULT_CONFIG.linkLinear,
60
+ guards: { ...DEFAULT_CONFIG.guards },
61
+ }
62
+ }
63
+
64
+ // Copy a typed field from parsed[key] onto base[key] when it matches `type`.
65
+ // Strings are trimmed and must be non-empty to override.
66
+ function assign(base, parsed, key, type) {
67
+ const v = parsed[key]
68
+ if (type === 'string') {
69
+ if (typeof v === 'string' && v.trim()) base[key] = v.trim()
70
+ } else if (type === 'string?') {
71
+ // string that may be intentionally empty (e.g. backupCommand)
72
+ if (typeof v === 'string') base[key] = v
73
+ } else if (type === 'boolean') {
74
+ if (typeof v === 'boolean') base[key] = v
75
+ } else if (type === 'number') {
76
+ if (typeof v === 'number' && Number.isFinite(v)) base[key] = v
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Merge a parsed config over the defaults. Only known keys are copied (unknown
82
+ * keys ignored for forward-compat). Nested objects are merged field-by-field.
83
+ */
84
+ function mergeConfig(base, parsed) {
85
+ if (!isObject(parsed)) return base
86
+
87
+ if (isObject(parsed.worktree)) {
88
+ assign(base.worktree, parsed.worktree, 'root', 'string')
89
+ assign(base.worktree, parsed.worktree, 'folderPattern', 'string')
90
+ }
91
+
92
+ if (isObject(parsed.docker)) {
93
+ assign(base.docker, parsed.docker, 'enabled', 'boolean')
94
+ assign(base.docker, parsed.docker, 'composeFile', 'string')
95
+ assign(base.docker, parsed.docker, 'projectNamePattern', 'string')
96
+ assign(base.docker, parsed.docker, 'portBase', 'number')
97
+ assign(base.docker, parsed.docker, 'portsPerSpec', 'number')
98
+ assign(base.docker, parsed.docker, 'envFile', 'string')
99
+ assign(base.docker, parsed.docker, 'backupCommand', 'string?')
100
+ }
101
+
102
+ if (isObject(parsed.open)) {
103
+ // command may be intentionally empty (no auto-open)
104
+ assign(base.open, parsed.open, 'command', 'string?')
105
+ }
106
+
107
+ assign(base, parsed, 'registry', 'string')
108
+ assign(base, parsed, 'linkLinear', 'boolean')
109
+
110
+ if (isObject(parsed.guards)) {
111
+ assign(base.guards, parsed.guards, 'refuseTeardownIfDirty', 'boolean')
112
+ assign(base.guards, parsed.guards, 'refuseTeardownIfUnpushed', 'boolean')
113
+ }
114
+
115
+ return base
116
+ }
117
+
118
+ /**
119
+ * Load and normalise `specs/.core/env.config.json` from `dir` (default cwd).
120
+ * Returns `{ config, present }`:
121
+ * - missing file → `{ config: defaults, present: false }` (opt-out; never throws)
122
+ * - present → `{ config: merged, present: true }`
123
+ * Malformed JSON → throws a clear Error (callers exit non-zero).
124
+ */
125
+ function loadEnvConfig(dir = process.cwd()) {
126
+ const base = defaults()
127
+ const file = join(dir, CONFIG_FILE)
128
+
129
+ let raw
130
+ try {
131
+ raw = readFileSync(file, 'utf-8')
132
+ } catch (error) {
133
+ if (error.code === 'ENOENT') return { config: base, present: false }
134
+ throw error
135
+ }
136
+
137
+ let parsed
138
+ try {
139
+ parsed = JSON.parse(raw)
140
+ } catch (error) {
141
+ throw new Error(`Invalid ${CONFIG_FILE}: ${error.message}`)
142
+ }
143
+
144
+ return { config: mergeConfig(base, parsed), present: true }
145
+ }
146
+
147
+ module.exports = {
148
+ loadEnvConfig,
149
+ mergeConfig,
150
+ DEFAULT_CONFIG,
151
+ CONFIG_FILE,
152
+ }
@@ -0,0 +1,76 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Pure provisioning planner for `spec-env up`.
5
+ *
6
+ * Given a resolved spec and its allocated slot, `planUp` returns the exact
7
+ * side-effecting commands the `/spec-env` skill runs (`git worktree add`,
8
+ * `docker compose up`), the rendered `.env` contents, and the expanded opener —
9
+ * but performs no side effects itself. The caller (the CLI) reads/allocates the
10
+ * registry and passes the slot; this stays deterministic and unit-testable with
11
+ * no live git/docker.
12
+ */
13
+
14
+ const { portOffset } = require('./registry.js')
15
+ const { renderEnvFile, expandOpenCommand } = require('./render.js')
16
+
17
+ /**
18
+ * Plan a provisioning run.
19
+ *
20
+ * @param {object} spec resolved spec (from resolveSpec): { slug, type, branch,
21
+ * worktreePath, projectName, ... }
22
+ * @param {object} alloc { slot, attached } — attached:true when the slot already
23
+ * existed in the registry (re-run → attach, don't clobber).
24
+ * @param {object} config normalised env config.
25
+ * @returns {object} { worktreePath, branch, projectName, slot, portOffset,
26
+ * envContents, openCommand, commands, attached }
27
+ */
28
+ function planUp(spec, alloc, config) {
29
+ const { slot, attached } = alloc
30
+
31
+ // Per-spec escalation: bring Docker up only when this spec's Stack is `docker`,
32
+ // gated by the project master switch. A spec resolved without an explicit stack
33
+ // (legacy/tests) follows the master switch — preserving pre-`Stack` behaviour.
34
+ const stack = spec.stack || (config.docker.enabled ? 'docker' : 'worktree')
35
+ const wantsDocker = stack === 'docker' && config.docker.enabled
36
+
37
+ // Slot, port block and `.env` are Docker-only. A worktree-only spec takes none
38
+ // of them: no registry slot, no PORT_OFFSET, no `.env`.
39
+ const offset = wantsDocker ? portOffset(slot, config) : null
40
+ const envContents = wantsDocker
41
+ ? renderEnvFile({ projectName: spec.projectName, portOffset: offset })
42
+ : null
43
+
44
+ const openCommand = expandOpenCommand(config.open.command, {
45
+ worktreePath: spec.worktreePath,
46
+ slug: spec.slug,
47
+ branch: spec.branch,
48
+ projectName: spec.projectName,
49
+ portOffset: offset === null ? '' : String(offset),
50
+ })
51
+
52
+ const commands = []
53
+ // Fresh branch → -b; attach an existing branch/slot → plain form (never clobber).
54
+ commands.push(
55
+ attached
56
+ ? `git worktree add ${spec.worktreePath} ${spec.branch}`
57
+ : `git worktree add ${spec.worktreePath} -b ${spec.branch}`,
58
+ )
59
+ if (wantsDocker) {
60
+ commands.push(`docker compose --project-name ${spec.projectName} up -d`)
61
+ }
62
+
63
+ return {
64
+ worktreePath: spec.worktreePath,
65
+ branch: spec.branch,
66
+ projectName: spec.projectName,
67
+ slot: wantsDocker ? slot : null,
68
+ portOffset: offset,
69
+ envContents,
70
+ openCommand,
71
+ commands,
72
+ attached,
73
+ }
74
+ }
75
+
76
+ module.exports = { planUp }
@@ -0,0 +1,95 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Slot registry for per-spec isolation — the single source of truth for which
5
+ * spec owns which slot index. It lives at the **primary checkout root** (shared
6
+ * by all worktrees, machine-local, gitignored) at the config-driven `registry`
7
+ * path (default `.spec-env/registry.json`).
8
+ *
9
+ * Slot `n` → a reserved port block: `portOffset = portBase + n * portsPerSpec`.
10
+ *
11
+ * The allocation helpers are pure transforms on a registry object so they can be
12
+ * unit-tested with no filesystem; `readRegistry`/`writeRegistry` are the only IO
13
+ * and are the seam the CLI drives. No `Date.now()`/`Math.random()` — determinism
14
+ * matters (callers pass timestamps when needed).
15
+ */
16
+
17
+ const fs = require('node:fs')
18
+ const path = require('node:path')
19
+
20
+ function isObject(value) {
21
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
22
+ }
23
+
24
+ // Absolute path to the registry file, resolved against the primary checkout root.
25
+ function registryPath(rootDir, config) {
26
+ return path.resolve(rootDir, config.registry)
27
+ }
28
+
29
+ // Read the registry from disk. Missing file → an empty registry (never throws
30
+ // on absence). Malformed JSON → a clear Error.
31
+ function readRegistry(rootDir, config) {
32
+ const file = registryPath(rootDir, config)
33
+ let raw
34
+ try {
35
+ raw = fs.readFileSync(file, 'utf-8')
36
+ } catch (error) {
37
+ if (error.code === 'ENOENT') return { slots: {} }
38
+ throw error
39
+ }
40
+ let parsed
41
+ try {
42
+ parsed = JSON.parse(raw)
43
+ } catch (error) {
44
+ throw new Error(`Invalid registry ${config.registry}: ${error.message}`)
45
+ }
46
+ return { slots: isObject(parsed.slots) ? { ...parsed.slots } : {} }
47
+ }
48
+
49
+ // Persist the registry, creating its parent dir as needed.
50
+ function writeRegistry(rootDir, config, registry) {
51
+ const file = registryPath(rootDir, config)
52
+ fs.mkdirSync(path.dirname(file), { recursive: true })
53
+ fs.writeFileSync(file, JSON.stringify({ slots: registry.slots }, null, 2) + '\n')
54
+ }
55
+
56
+ /**
57
+ * Allocate the lowest free slot index to `name`. Idempotent: if `name` already
58
+ * holds a slot, that slot is returned and the registry is unchanged. Returns a
59
+ * new registry object (does not mutate the input).
60
+ */
61
+ function allocateSlot(registry, name) {
62
+ const slots = { ...registry.slots }
63
+ if (Object.prototype.hasOwnProperty.call(slots, name)) {
64
+ return { registry: { slots }, slot: slots[name] }
65
+ }
66
+ const used = new Set(Object.values(slots))
67
+ let slot = 0
68
+ while (used.has(slot)) slot++
69
+ slots[name] = slot
70
+ return { registry: { slots }, slot }
71
+ }
72
+
73
+ /**
74
+ * Free `name`'s slot. Idempotent: freeing an absent spec is a clean no-op.
75
+ * Returns a new registry object (does not mutate the input).
76
+ */
77
+ function freeSlot(registry, name) {
78
+ const slots = { ...registry.slots }
79
+ delete slots[name]
80
+ return { slots }
81
+ }
82
+
83
+ // Port block base for a slot.
84
+ function portOffset(slot, config) {
85
+ return config.docker.portBase + slot * config.docker.portsPerSpec
86
+ }
87
+
88
+ module.exports = {
89
+ registryPath,
90
+ readRegistry,
91
+ writeRegistry,
92
+ allocateSlot,
93
+ freeSlot,
94
+ portOffset,
95
+ }
@@ -0,0 +1,26 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Pure renderers for per-spec isolation artifacts.
5
+ *
6
+ * `renderEnvFile` produces the worktree's `.env` body (the only file the engine
7
+ * writes). `expandOpenCommand` expands the optional, editor/terminal-agnostic
8
+ * `open.command` template. No side effects — unit-testable in isolation.
9
+ */
10
+
11
+ const { expandTokens } = require('./resolve.js')
12
+
13
+ // The worktree `.env`: COMPOSE_PROJECT_NAME namespaces the Docker stack and its
14
+ // named volumes; PORT_OFFSET shifts the spec's reserved port block.
15
+ function renderEnvFile({ projectName, portOffset }) {
16
+ return `COMPOSE_PROJECT_NAME=${projectName}\nPORT_OFFSET=${portOffset}\n`
17
+ }
18
+
19
+ // Expand the opener template with the provided tokens. An empty/whitespace-only
20
+ // template means "no auto-open" → returns null.
21
+ function expandOpenCommand(template, tokens) {
22
+ if (typeof template !== 'string' || !template.trim()) return null
23
+ return expandTokens(template, tokens)
24
+ }
25
+
26
+ module.exports = { renderEnvFile, expandOpenCommand }
@@ -0,0 +1,184 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Pure spec/branch resolution for per-spec isolation.
5
+ *
6
+ * Given a spec argument (a folder name or path) it locates the spec folder under
7
+ * `specs/**`, splits the `feat-`/`bug-` prefix into `{ type, slug }`, derives the
8
+ * git branch (optional Linear seam, else `{type}/{slug}`), and expands the
9
+ * config's path/name tokens (`{repo}`, `{repoSlug}`, `{slug}`). Reads files to
10
+ * locate the spec and read frontmatter, but makes no git/docker side effects —
11
+ * deterministic and safe to unit-test with fixtures.
12
+ */
13
+
14
+ const fs = require('node:fs')
15
+ const path = require('node:path')
16
+
17
+ const BUCKETS = ['backlog', 'in-progress', 'complete', 'cancelled']
18
+ const LINEAR_CONFIG = path.join('specs', '.core', 'linear.config.json')
19
+
20
+ function isObject(value) {
21
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
22
+ }
23
+
24
+ // Find the spec folder under specs/<bucket>/<name>. `specArg` may be a bare
25
+ // folder name or a path — only its basename is matched against the buckets.
26
+ function findSpecFolder(specArg, dir) {
27
+ const name = path.basename(specArg)
28
+ for (const bucket of BUCKETS) {
29
+ const abs = path.join(dir, 'specs', bucket, name)
30
+ if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
31
+ return { folder: name, bucket, path: abs }
32
+ }
33
+ }
34
+ return null
35
+ }
36
+
37
+ // Split a `feat-`/`bug-` prefix. Unknown prefix → type defaults to `feat` and
38
+ // the whole folder name is the slug.
39
+ function splitPrefix(folder) {
40
+ const m = /^(feat|bug)-(.+)$/.exec(folder)
41
+ if (m) return { type: m[1], slug: m[2] }
42
+ return { type: 'feat', slug: folder }
43
+ }
44
+
45
+ // Repo identity used for token expansion.
46
+ function repoInfo(dir) {
47
+ const repo = path.basename(dir)
48
+ const repoSlug = repo
49
+ .toLowerCase()
50
+ .replace(/[^a-z0-9]+/g, '-')
51
+ .replace(/^-+|-+$/g, '')
52
+ return { repo, repoSlug }
53
+ }
54
+
55
+ // Replace {token} occurrences from `tokens`; unknown tokens are left intact.
56
+ function expandTokens(str, tokens) {
57
+ return String(str).replace(/\{(\w+)\}/g, (m, key) =>
58
+ Object.prototype.hasOwnProperty.call(tokens, key) ? tokens[key] : m,
59
+ )
60
+ }
61
+
62
+ // Read the `linear_identifier` from a spec's 00-overview.md YAML frontmatter,
63
+ // if present. Returns null when there's no frontmatter / field / file.
64
+ function readLinearIdentifier(specPath) {
65
+ const overview = path.join(specPath, '00-overview.md')
66
+ let raw
67
+ try {
68
+ raw = fs.readFileSync(overview, 'utf-8')
69
+ } catch {
70
+ return null
71
+ }
72
+ const fm = /^---\n([\s\S]*?)\n---/.exec(raw)
73
+ if (!fm) return null
74
+ const m = /^linear_identifier:\s*(.+)$/m.exec(fm[1])
75
+ if (!m) return null
76
+ return m[1].trim().replace(/^["']|["']$/g, '') || null
77
+ }
78
+
79
+ /**
80
+ * Read a spec's `> **Stack:** …` blockquote field from 00-overview.md and map it
81
+ * to the isolation stack: any value containing `docker` → `'docker'`, otherwise
82
+ * `'worktree'`. A spec with no field falls back to the project default — which
83
+ * preserves pre-`Stack` behaviour: with Docker available (`docker.enabled`) a
84
+ * legacy spec still gets Docker, else it's worktree-only. The planner ANDs this
85
+ * with the master switch, so an explicit `worktree` always suppresses Docker.
86
+ */
87
+ function readStackField(specPath, config) {
88
+ const overview = path.join(specPath, '00-overview.md')
89
+ let raw
90
+ try {
91
+ raw = fs.readFileSync(overview, 'utf-8')
92
+ } catch {
93
+ raw = null
94
+ }
95
+ const m = raw && /^>\s*\*\*Stack:\*\*\s*(.+)$/m.exec(raw)
96
+ if (m) {
97
+ return /docker/i.test(m[1]) ? 'docker' : 'worktree'
98
+ }
99
+ return config.docker && config.docker.enabled ? 'docker' : 'worktree'
100
+ }
101
+
102
+ // Load specs/.core/linear.config.json when present, else null.
103
+ function loadLinearConfig(dir) {
104
+ const file = path.join(dir, LINEAR_CONFIG)
105
+ let raw
106
+ try {
107
+ raw = fs.readFileSync(file, 'utf-8')
108
+ } catch {
109
+ return null
110
+ }
111
+ try {
112
+ const parsed = JSON.parse(raw)
113
+ return isObject(parsed) ? parsed : null
114
+ } catch (error) {
115
+ throw new Error(`Invalid ${LINEAR_CONFIG}: ${error.message}`)
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Derive the git branch for a spec. If `linkLinear` and a linear.config.json
121
+ * with a `branch.pattern` is present and the spec has a `linear_identifier`,
122
+ * expand that pattern (`{identifier}`, `{slug}`, `{type}`). Otherwise fall back
123
+ * to `{type}/{slug}`.
124
+ */
125
+ function branchFor(spec, dir, config) {
126
+ if (config.linkLinear) {
127
+ const linear = loadLinearConfig(dir)
128
+ const pattern = linear && linear.branch && linear.branch.pattern
129
+ const identifier = readLinearIdentifier(spec.path)
130
+ if (pattern && identifier) {
131
+ return expandTokens(pattern, {
132
+ identifier,
133
+ slug: spec.slug,
134
+ type: spec.type,
135
+ })
136
+ }
137
+ }
138
+ return `${spec.type}/${spec.slug}`
139
+ }
140
+
141
+ /**
142
+ * Resolve a spec argument to its identity + isolation coordinates.
143
+ * Throws a clear Error when the spec folder can't be found.
144
+ */
145
+ function resolveSpec(specArg, dir, config) {
146
+ const found = findSpecFolder(specArg, dir)
147
+ if (!found) {
148
+ throw new Error(`spec not found under specs/**: ${specArg}`)
149
+ }
150
+
151
+ const { type, slug } = splitPrefix(found.folder)
152
+ const { repo, repoSlug } = repoInfo(dir)
153
+ const tokens = { repo, repoSlug, slug }
154
+
155
+ const stack = readStackField(found.path, config)
156
+ const spec = { folder: found.folder, bucket: found.bucket, path: found.path, type, slug, stack }
157
+ const branch = branchFor(spec, dir, config)
158
+
159
+ const worktreeRoot = expandTokens(config.worktree.root, tokens)
160
+ const worktreeFolder = expandTokens(config.worktree.folderPattern, tokens)
161
+ const worktreePath = path.resolve(dir, worktreeRoot, worktreeFolder)
162
+ const projectName = expandTokens(config.docker.projectNamePattern, tokens)
163
+
164
+ return {
165
+ ...spec,
166
+ repo,
167
+ repoSlug,
168
+ branch,
169
+ worktreeRoot,
170
+ worktreeFolder,
171
+ worktreePath,
172
+ projectName,
173
+ }
174
+ }
175
+
176
+ module.exports = {
177
+ resolveSpec,
178
+ branchFor,
179
+ splitPrefix,
180
+ repoInfo,
181
+ expandTokens,
182
+ findSpecFolder,
183
+ readStackField,
184
+ }
@@ -0,0 +1,94 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Pure teardown planner for `spec-env down`.
5
+ *
6
+ * `planDown` evaluates the dirty/unpushed guards, plans an optional config-driven
7
+ * pre-drop backup, and returns the exact commands the `/spec-env-down` skill runs
8
+ * (`docker compose down` [+`--volumes`], `git worktree remove`). It performs no
9
+ * side effects: the caller (the CLI) queries git for `worktreeState` and supplies
10
+ * a `timestamp`, keeping this deterministic and unit-testable with no live
11
+ * git/docker.
12
+ *
13
+ * Volumes are the only destructive action — dropped by default (reclaims disk)
14
+ * unless `--keep-volumes`, and always backed up first when a `backupCommand` is
15
+ * configured.
16
+ */
17
+
18
+ const { expandTokens } = require('./resolve.js')
19
+
20
+ /**
21
+ * @param {object} spec resolved spec: { slug, worktreePath, projectName, ... }
22
+ * @param {object} config normalised env config.
23
+ * @param {object} flags { keepVolumes, force }
24
+ * @param {object} ctx { worktreeState: { dirty, unpushed }, timestamp }
25
+ * @returns {object} { blocked, reason, commands, backupCommand, backupPath,
26
+ * volumesDropped }
27
+ */
28
+ function planDown(spec, config, flags, ctx) {
29
+ const { worktreeState = {}, timestamp } = ctx || {}
30
+ const force = Boolean(flags && flags.force)
31
+ const keepVolumes = Boolean(flags && flags.keepVolumes)
32
+
33
+ // --- guards (overridable with --force) ---
34
+ if (!force) {
35
+ if (config.guards.refuseTeardownIfDirty && worktreeState.dirty) {
36
+ return blocked('worktree has uncommitted changes')
37
+ }
38
+ if (config.guards.refuseTeardownIfUnpushed && worktreeState.unpushed) {
39
+ return blocked('worktree has unpushed commits')
40
+ }
41
+ }
42
+
43
+ // Docker teardown only applies to a Docker-escalated spec. A worktree-only spec
44
+ // (Stack: worktree) has no stack/volumes even when the project master switch is
45
+ // on — tearing it down is just removing the worktree. A spec resolved without an
46
+ // explicit stack (legacy/tests) follows the master switch (pre-`Stack` behaviour).
47
+ const stack = spec.stack || (config.docker.enabled ? 'docker' : 'worktree')
48
+ const wantsDocker = stack === 'docker' && config.docker.enabled
49
+
50
+ const commands = []
51
+ const volumesDropped = !keepVolumes && wantsDocker
52
+
53
+ // --- optional pre-drop backup (only when volumes are actually dropped) ---
54
+ let backupCommand = null
55
+ let backupPath = null
56
+ if (volumesDropped && config.docker.backupCommand) {
57
+ backupPath = `.spec-env/backups/${spec.slug}-${timestamp}.dump`
58
+ backupCommand = expandTokens(config.docker.backupCommand, {
59
+ backupPath,
60
+ slug: spec.slug,
61
+ projectName: spec.projectName,
62
+ timestamp: String(timestamp),
63
+ })
64
+ commands.push(backupCommand)
65
+ }
66
+
67
+ // --- docker compose down (drop volumes unless kept) ---
68
+ if (wantsDocker) {
69
+ const base = `docker compose --project-name ${spec.projectName} down`
70
+ commands.push(volumesDropped ? `${base} --volumes` : base)
71
+ }
72
+
73
+ // --- remove the worktree (force needed if we bypassed a dirty guard) ---
74
+ commands.push(
75
+ force
76
+ ? `git worktree remove --force ${spec.worktreePath}`
77
+ : `git worktree remove ${spec.worktreePath}`,
78
+ )
79
+
80
+ return { blocked: false, reason: null, commands, backupCommand, backupPath, volumesDropped }
81
+ }
82
+
83
+ function blocked(reason) {
84
+ return {
85
+ blocked: true,
86
+ reason,
87
+ commands: [],
88
+ backupCommand: null,
89
+ backupPath: null,
90
+ volumesDropped: false,
91
+ }
92
+ }
93
+
94
+ module.exports = { planDown }