@skitterbyte/skitterspec 0.1.0 → 1.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 +97 -2
- package/assets/claude-md-section.md +11 -0
- package/assets/core/env.config.json.example +24 -0
- package/assets/core/env.config.md +83 -0
- package/assets/core/linear.config.json.example +39 -0
- package/assets/core/linear.config.md +121 -0
- package/assets/rules/spec-planning.md +29 -11
- package/assets/skills/spec/SKILL.md +61 -13
- package/assets/skills/spec-bug/SKILL.md +0 -4
- package/assets/skills/spec-cancel/SKILL.md +8 -3
- package/assets/skills/spec-complete/SKILL.md +10 -10
- package/assets/skills/spec-env/SKILL.md +57 -0
- package/assets/skills/spec-env-down/SKILL.md +56 -0
- package/assets/skills/spec-go/SKILL.md +39 -3
- package/assets/skills/spec-init/SKILL.md +0 -8
- package/assets/skills/spec-pull/SKILL.md +46 -0
- package/assets/skills/spec-push/SKILL.md +53 -0
- package/assets/skills/spec-ready/SKILL.md +0 -2
- package/assets/skills/spec-review/SKILL.md +2 -2
- package/assets/skills/spec-status/SKILL.md +46 -0
- package/bin/skitterspec.js +0 -0
- package/package.json +6 -6
- package/src/cli.js +497 -2
- package/src/env/config.js +152 -0
- package/src/env/provision.js +76 -0
- package/src/env/registry.js +95 -0
- package/src/env/render.js +26 -0
- package/src/env/resolve.js +184 -0
- package/src/env/teardown.js +94 -0
- package/src/init.js +82 -27
- package/src/prompts.js +23 -12
- package/src/sync/apply.js +66 -0
- package/src/sync/base.js +83 -0
- package/src/sync/compare.js +99 -0
- package/src/sync/config.js +198 -0
- package/src/sync/mcp.js +112 -0
- package/src/sync/normalize.js +249 -0
- package/src/sync/pull.js +84 -0
- package/src/sync/push.js +106 -0
- 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 }
|