@skitterbyte/skitterspec-linear 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 +56 -0
- package/assets/claude-md-section.md +39 -0
- package/assets/core/env.config.json.example +28 -0
- package/assets/core/env.config.md +99 -0
- package/assets/core/linear.config.json.example +39 -0
- package/assets/core/linear.config.md +121 -0
- package/assets/rules/spec-planning.md +152 -0
- package/assets/skills/spec/SKILL.md +232 -0
- package/assets/skills/spec-bug/SKILL.md +110 -0
- package/assets/skills/spec-cancel/SKILL.md +61 -0
- package/assets/skills/spec-complete/SKILL.md +87 -0
- package/assets/skills/spec-env/SKILL.md +63 -0
- package/assets/skills/spec-env-down/SKILL.md +64 -0
- package/assets/skills/spec-go/SKILL.md +134 -0
- package/assets/skills/spec-init/SKILL.md +84 -0
- 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 +50 -0
- package/assets/skills/spec-review/SKILL.md +69 -0
- package/assets/skills/spec-status/SKILL.md +46 -0
- package/bin/skitterspec-linear.js +26 -0
- package/package.json +38 -0
- package/src/cli.js +495 -0
- package/src/deprecate.js +138 -0
- package/src/env/config.js +165 -0
- package/src/env/integrate.js +46 -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 +202 -0
- package/src/env/teardown.js +109 -0
- package/src/env/trust.js +87 -0
- package/src/init.js +311 -0
- package/src/prompts.js +56 -0
- package/src/vendor/linear/cli-sync.js +256 -0
- package/src/vendor/linear/config.js +198 -0
- package/src/vendor/linear/mcp.js +112 -0
- package/src/vendor/sync-core/index.js +35 -0
- package/src/vendor/sync-core/src/apply.js +66 -0
- package/src/vendor/sync-core/src/base.js +83 -0
- package/src/vendor/sync-core/src/compare.js +99 -0
- package/src/vendor/sync-core/src/normalize.js +249 -0
- package/src/vendor/sync-core/src/pull.js +84 -0
- package/src/vendor/sync-core/src/push.js +106 -0
- package/src/vendor/sync-core/src/write.js +86 -0
|
@@ -0,0 +1,165 @@
|
|
|
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
|
+
* branch: { pattern, identifierField }, // git branch naming (provider-neutral)
|
|
22
|
+
* baseBranch: "", // "" = auto-detect (origin/HEAD → main → master)
|
|
23
|
+
* guards: { refuseTeardownIfDirty, refuseTeardownIfUnpushed }
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const { readFileSync } = require('node:fs')
|
|
28
|
+
const { join } = require('node:path')
|
|
29
|
+
|
|
30
|
+
const CONFIG_FILE = join('specs', '.core', 'env.config.json')
|
|
31
|
+
|
|
32
|
+
const DEFAULT_CONFIG = Object.freeze({
|
|
33
|
+
worktree: Object.freeze({ root: '../{repo}-wt', folderPattern: '{slug}' }),
|
|
34
|
+
docker: Object.freeze({
|
|
35
|
+
enabled: true,
|
|
36
|
+
composeFile: 'docker-compose.yml',
|
|
37
|
+
projectNamePattern: '{repoSlug}_{slug}',
|
|
38
|
+
portBase: 3000,
|
|
39
|
+
portsPerSpec: 10,
|
|
40
|
+
envFile: '.env',
|
|
41
|
+
backupCommand: '',
|
|
42
|
+
}),
|
|
43
|
+
open: Object.freeze({ command: '' }),
|
|
44
|
+
registry: '.spec-env/registry.json',
|
|
45
|
+
// Git branch naming, provider-neutral. `pattern` expands {type}/{slug} and,
|
|
46
|
+
// when a tracker provider is linked, {identifier}; `identifierField` names the
|
|
47
|
+
// 00-overview.md frontmatter field a provider writes the ticket id into (empty
|
|
48
|
+
// = no identifier, so patterns referencing {identifier} fall back to type/slug).
|
|
49
|
+
branch: Object.freeze({ pattern: '{type}/{slug}', identifierField: '' }),
|
|
50
|
+
// Integration base branch. Empty = auto-detect (origin/HEAD → main → master).
|
|
51
|
+
baseBranch: '',
|
|
52
|
+
guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
function isObject(value) {
|
|
56
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A fresh, deeply-mutable copy of the defaults to merge onto.
|
|
60
|
+
function defaults() {
|
|
61
|
+
return {
|
|
62
|
+
worktree: { ...DEFAULT_CONFIG.worktree },
|
|
63
|
+
docker: { ...DEFAULT_CONFIG.docker },
|
|
64
|
+
open: { ...DEFAULT_CONFIG.open },
|
|
65
|
+
registry: DEFAULT_CONFIG.registry,
|
|
66
|
+
branch: { ...DEFAULT_CONFIG.branch },
|
|
67
|
+
baseBranch: DEFAULT_CONFIG.baseBranch,
|
|
68
|
+
guards: { ...DEFAULT_CONFIG.guards },
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Copy a typed field from parsed[key] onto base[key] when it matches `type`.
|
|
73
|
+
// Strings are trimmed and must be non-empty to override.
|
|
74
|
+
function assign(base, parsed, key, type) {
|
|
75
|
+
const v = parsed[key]
|
|
76
|
+
if (type === 'string') {
|
|
77
|
+
if (typeof v === 'string' && v.trim()) base[key] = v.trim()
|
|
78
|
+
} else if (type === 'string?') {
|
|
79
|
+
// string that may be intentionally empty (e.g. backupCommand)
|
|
80
|
+
if (typeof v === 'string') base[key] = v
|
|
81
|
+
} else if (type === 'boolean') {
|
|
82
|
+
if (typeof v === 'boolean') base[key] = v
|
|
83
|
+
} else if (type === 'number') {
|
|
84
|
+
if (typeof v === 'number' && Number.isFinite(v)) base[key] = v
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Merge a parsed config over the defaults. Only known keys are copied (unknown
|
|
90
|
+
* keys ignored for forward-compat). Nested objects are merged field-by-field.
|
|
91
|
+
*/
|
|
92
|
+
function mergeConfig(base, parsed) {
|
|
93
|
+
if (!isObject(parsed)) return base
|
|
94
|
+
|
|
95
|
+
if (isObject(parsed.worktree)) {
|
|
96
|
+
assign(base.worktree, parsed.worktree, 'root', 'string')
|
|
97
|
+
assign(base.worktree, parsed.worktree, 'folderPattern', 'string')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (isObject(parsed.docker)) {
|
|
101
|
+
assign(base.docker, parsed.docker, 'enabled', 'boolean')
|
|
102
|
+
assign(base.docker, parsed.docker, 'composeFile', 'string')
|
|
103
|
+
assign(base.docker, parsed.docker, 'projectNamePattern', 'string')
|
|
104
|
+
assign(base.docker, parsed.docker, 'portBase', 'number')
|
|
105
|
+
assign(base.docker, parsed.docker, 'portsPerSpec', 'number')
|
|
106
|
+
assign(base.docker, parsed.docker, 'envFile', 'string')
|
|
107
|
+
assign(base.docker, parsed.docker, 'backupCommand', 'string?')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (isObject(parsed.open)) {
|
|
111
|
+
// command may be intentionally empty (no auto-open)
|
|
112
|
+
assign(base.open, parsed.open, 'command', 'string?')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (isObject(parsed.branch)) {
|
|
116
|
+
assign(base.branch, parsed.branch, 'pattern', 'string')
|
|
117
|
+
assign(base.branch, parsed.branch, 'identifierField', 'string')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
assign(base, parsed, 'registry', 'string')
|
|
121
|
+
assign(base, parsed, 'baseBranch', 'string')
|
|
122
|
+
|
|
123
|
+
if (isObject(parsed.guards)) {
|
|
124
|
+
assign(base.guards, parsed.guards, 'refuseTeardownIfDirty', 'boolean')
|
|
125
|
+
assign(base.guards, parsed.guards, 'refuseTeardownIfUnpushed', 'boolean')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return base
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Load and normalise `specs/.core/env.config.json` from `dir` (default cwd).
|
|
133
|
+
* Returns `{ config, present }`:
|
|
134
|
+
* - missing file → `{ config: defaults, present: false }` (opt-out; never throws)
|
|
135
|
+
* - present → `{ config: merged, present: true }`
|
|
136
|
+
* Malformed JSON → throws a clear Error (callers exit non-zero).
|
|
137
|
+
*/
|
|
138
|
+
function loadEnvConfig(dir = process.cwd()) {
|
|
139
|
+
const base = defaults()
|
|
140
|
+
const file = join(dir, CONFIG_FILE)
|
|
141
|
+
|
|
142
|
+
let raw
|
|
143
|
+
try {
|
|
144
|
+
raw = readFileSync(file, 'utf-8')
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error.code === 'ENOENT') return { config: base, present: false }
|
|
147
|
+
throw error
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let parsed
|
|
151
|
+
try {
|
|
152
|
+
parsed = JSON.parse(raw)
|
|
153
|
+
} catch (error) {
|
|
154
|
+
throw new Error(`Invalid ${CONFIG_FILE}: ${error.message}`)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { config: mergeConfig(base, parsed), present: true }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = {
|
|
161
|
+
loadEnvConfig,
|
|
162
|
+
mergeConfig,
|
|
163
|
+
DEFAULT_CONFIG,
|
|
164
|
+
CONFIG_FILE,
|
|
165
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure integrate planner for `spec-env integrate`.
|
|
5
|
+
*
|
|
6
|
+
* `planIntegrate` decides whether a spec's worktree branch can be landed onto the
|
|
7
|
+
* base branch and, if so, emits the exact commands the `/spec-complete` skill runs
|
|
8
|
+
* (rebase the branch onto base in the worktree, then fast-forward base to it in the
|
|
9
|
+
* primary checkout). It performs no side effects: the caller (the CLI) queries git
|
|
10
|
+
* for `dirty`/`aheadOfBase`/`mainRepoPath` and supplies them, keeping this
|
|
11
|
+
* deterministic and unit-testable with no live git.
|
|
12
|
+
*
|
|
13
|
+
* Strategy is rebase + fast-forward (linear history). Conflict handling lives in
|
|
14
|
+
* the skill: it runs the rebase and, on a non-zero exit, `git rebase --abort` and
|
|
15
|
+
* hands back — so the planner never needs to reason about conflicts.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} spec resolved spec: { branch, worktreePath, folder, ... }
|
|
18
|
+
* @param {object} config normalised env config (unused today; kept for symmetry).
|
|
19
|
+
* @param {object} ctx { worktreeState: { dirty }, base, aheadOfBase, mainRepoPath }
|
|
20
|
+
* @returns {object} { blocked, noop, reason, commands, base, branch }
|
|
21
|
+
*/
|
|
22
|
+
function planIntegrate(spec, config, ctx) {
|
|
23
|
+
const { worktreeState = {}, base, aheadOfBase, mainRepoPath } = ctx || {}
|
|
24
|
+
const branch = spec.branch
|
|
25
|
+
const result = { blocked: false, noop: false, reason: null, commands: [], base, branch }
|
|
26
|
+
|
|
27
|
+
// The completion edits must be committed first — never rebase a dirty tree.
|
|
28
|
+
if (worktreeState.dirty) {
|
|
29
|
+
return { ...result, blocked: true, reason: 'worktree has uncommitted changes — commit the completion first' }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Nothing on the branch that isn't already on base → already landed.
|
|
33
|
+
if (!aheadOfBase) {
|
|
34
|
+
return { ...result, noop: true }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
...result,
|
|
39
|
+
commands: [
|
|
40
|
+
`git -C ${spec.worktreePath} rebase ${base}`,
|
|
41
|
+
`git -C ${mainRepoPath} merge --ff-only ${branch}`,
|
|
42
|
+
],
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { planIntegrate }
|
|
@@ -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,202 @@
|
|
|
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 from the config's `branch.pattern` (provider-neutral; `{identifier}`
|
|
9
|
+
* is filled from a tracker id when one is configured, else it falls back to
|
|
10
|
+
* `{type}/{slug}`), and expands the config's path/name tokens (`{repo}`,
|
|
11
|
+
* `{repoSlug}`, `{slug}`). Reads files to locate the spec and read frontmatter,
|
|
12
|
+
* but makes no git/docker side effects — deterministic and safe to unit-test with
|
|
13
|
+
* fixtures.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs')
|
|
17
|
+
const path = require('node:path')
|
|
18
|
+
|
|
19
|
+
const BUCKETS = ['backlog', 'in-progress', 'complete', 'cancelled']
|
|
20
|
+
|
|
21
|
+
// Find the spec folder under specs/<bucket>/<name>. `specArg` may be a bare
|
|
22
|
+
// folder name or a path — only its basename is matched against the buckets.
|
|
23
|
+
// Searches `dir` first, then any `extraDirs` in order — so a caller (e.g.
|
|
24
|
+
// `spec-env integrate`) can fall back to a worktree checkout for a spec that
|
|
25
|
+
// was authored on its branch and never committed to the primary checkout.
|
|
26
|
+
function findSpecFolder(specArg, dir, extraDirs = []) {
|
|
27
|
+
const name = path.basename(specArg)
|
|
28
|
+
for (const root of [dir, ...extraDirs]) {
|
|
29
|
+
for (const bucket of BUCKETS) {
|
|
30
|
+
const abs = path.join(root, 'specs', bucket, name)
|
|
31
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
|
32
|
+
return { folder: name, bucket, path: abs }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Split a `feat-`/`bug-` prefix. Unknown prefix → type defaults to `feat` and
|
|
40
|
+
// the whole folder name is the slug.
|
|
41
|
+
function splitPrefix(folder) {
|
|
42
|
+
const m = /^(feat|bug)-(.+)$/.exec(folder)
|
|
43
|
+
if (m) return { type: m[1], slug: m[2] }
|
|
44
|
+
return { type: 'feat', slug: folder }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Repo identity used for token expansion.
|
|
48
|
+
function repoInfo(dir) {
|
|
49
|
+
const repo = path.basename(dir)
|
|
50
|
+
const repoSlug = repo
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
53
|
+
.replace(/^-+|-+$/g, '')
|
|
54
|
+
return { repo, repoSlug }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Replace {token} occurrences from `tokens`; unknown tokens are left intact.
|
|
58
|
+
function expandTokens(str, tokens) {
|
|
59
|
+
return String(str).replace(/\{(\w+)\}/g, (m, key) =>
|
|
60
|
+
Object.prototype.hasOwnProperty.call(tokens, key) ? tokens[key] : m,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Read a named field from a spec's 00-overview.md YAML frontmatter, if present.
|
|
65
|
+
// `field` is provider-neutral (e.g. a tracker's ticket-id field, configured via
|
|
66
|
+
// `branch.identifierField`). Returns null when there's no frontmatter / field /
|
|
67
|
+
// file, or no field name was given.
|
|
68
|
+
function readFrontmatterField(specPath, field) {
|
|
69
|
+
if (!field) return null
|
|
70
|
+
const overview = path.join(specPath, '00-overview.md')
|
|
71
|
+
let raw
|
|
72
|
+
try {
|
|
73
|
+
raw = fs.readFileSync(overview, 'utf-8')
|
|
74
|
+
} catch {
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
const fm = /^---\n([\s\S]*?)\n---/.exec(raw)
|
|
78
|
+
if (!fm) return null
|
|
79
|
+
const m = new RegExp(`^${field}:\\s*(.+)$`, 'm').exec(fm[1])
|
|
80
|
+
if (!m) return null
|
|
81
|
+
return m[1].trim().replace(/^["']|["']$/g, '') || null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read a spec's `> **Stack:** …` blockquote field from 00-overview.md and map it
|
|
86
|
+
* to the isolation stack: any value containing `docker` → `'docker'`, otherwise
|
|
87
|
+
* `'worktree'`. A spec with no field falls back to the project default — which
|
|
88
|
+
* preserves pre-`Stack` behaviour: with Docker available (`docker.enabled`) a
|
|
89
|
+
* legacy spec still gets Docker, else it's worktree-only. The planner ANDs this
|
|
90
|
+
* with the master switch, so an explicit `worktree` always suppresses Docker.
|
|
91
|
+
*/
|
|
92
|
+
function readStackField(specPath, config) {
|
|
93
|
+
const overview = path.join(specPath, '00-overview.md')
|
|
94
|
+
let raw
|
|
95
|
+
try {
|
|
96
|
+
raw = fs.readFileSync(overview, 'utf-8')
|
|
97
|
+
} catch {
|
|
98
|
+
raw = null
|
|
99
|
+
}
|
|
100
|
+
const m = raw && /^>\s*\*\*Stack:\*\*\s*(.+)$/m.exec(raw)
|
|
101
|
+
if (m) {
|
|
102
|
+
return /docker/i.test(m[1]) ? 'docker' : 'worktree'
|
|
103
|
+
}
|
|
104
|
+
return config.docker && config.docker.enabled ? 'docker' : 'worktree'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Derive the git branch for a spec from the provider-neutral `branch.pattern`
|
|
109
|
+
* (`{type}`, `{slug}`, and optionally `{identifier}`). When the pattern uses
|
|
110
|
+
* `{identifier}`, the id is read from the frontmatter field named by
|
|
111
|
+
* `branch.identifierField` (a tracker provider writes it); if that field is unset
|
|
112
|
+
* or absent on the spec, the branch falls back to `{type}/{slug}` so we never
|
|
113
|
+
* emit a half-expanded name. No knowledge of any specific tracker lives here.
|
|
114
|
+
*/
|
|
115
|
+
function branchFor(spec, config) {
|
|
116
|
+
const branch = (config.branch && config.branch.pattern) || '{type}/{slug}'
|
|
117
|
+
const tokens = { type: spec.type, slug: spec.slug }
|
|
118
|
+
if (/\{identifier\}/.test(branch)) {
|
|
119
|
+
const field = config.branch && config.branch.identifierField
|
|
120
|
+
const identifier = readFrontmatterField(spec.path, field)
|
|
121
|
+
if (!identifier) return `${spec.type}/${spec.slug}`
|
|
122
|
+
tokens.identifier = identifier
|
|
123
|
+
}
|
|
124
|
+
return expandTokens(branch, tokens)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve the integration base branch (the branch specs fork from and land back
|
|
129
|
+
* onto). Precedence:
|
|
130
|
+
* 1. `config.baseBranch` — explicit override
|
|
131
|
+
* 2. `origin/HEAD` — the remote's default branch
|
|
132
|
+
* 3. `main` if it exists locally
|
|
133
|
+
* 4. `master` if it exists locally
|
|
134
|
+
* 5. `main` — last-resort default
|
|
135
|
+
*
|
|
136
|
+
* `git(args)` runs a read-only git command and returns trimmed stdout, or `null`
|
|
137
|
+
* on a non-zero exit / failure. It's injected so this stays pure and unit-testable
|
|
138
|
+
* with no live git; the CLI supplies a real reader. (Note: `show-ref --quiet`
|
|
139
|
+
* emits no stdout on success, so a non-null `''` still means "exists".)
|
|
140
|
+
*/
|
|
141
|
+
function resolveBaseBranch(config, git) {
|
|
142
|
+
const explicit = config && typeof config.baseBranch === 'string' && config.baseBranch.trim()
|
|
143
|
+
if (explicit) return explicit
|
|
144
|
+
|
|
145
|
+
const originHead = git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
|
|
146
|
+
if (originHead) return originHead.replace(/^origin\//, '')
|
|
147
|
+
|
|
148
|
+
for (const name of ['main', 'master']) {
|
|
149
|
+
if (git(['show-ref', '--verify', '--quiet', `refs/heads/${name}`]) !== null) return name
|
|
150
|
+
}
|
|
151
|
+
return 'main'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolve a spec argument to its identity + isolation coordinates.
|
|
156
|
+
* Throws a clear Error when the spec folder can't be found.
|
|
157
|
+
*
|
|
158
|
+
* `opts.searchDirs` adds fallback checkout roots to look under (after `dir`) when
|
|
159
|
+
* locating the spec folder; identity/coordinate tokens still expand against `dir`
|
|
160
|
+
* (the primary checkout), so a worktree-only spec resolves to the right base.
|
|
161
|
+
*/
|
|
162
|
+
function resolveSpec(specArg, dir, config, opts = {}) {
|
|
163
|
+
const found = findSpecFolder(specArg, dir, opts.searchDirs || [])
|
|
164
|
+
if (!found) {
|
|
165
|
+
throw new Error(`spec not found under specs/**: ${specArg}`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const { type, slug } = splitPrefix(found.folder)
|
|
169
|
+
const { repo, repoSlug } = repoInfo(dir)
|
|
170
|
+
const tokens = { repo, repoSlug, slug }
|
|
171
|
+
|
|
172
|
+
const stack = readStackField(found.path, config)
|
|
173
|
+
const spec = { folder: found.folder, bucket: found.bucket, path: found.path, type, slug, stack }
|
|
174
|
+
const branch = branchFor(spec, config)
|
|
175
|
+
|
|
176
|
+
const worktreeRoot = expandTokens(config.worktree.root, tokens)
|
|
177
|
+
const worktreeFolder = expandTokens(config.worktree.folderPattern, tokens)
|
|
178
|
+
const worktreePath = path.resolve(dir, worktreeRoot, worktreeFolder)
|
|
179
|
+
const projectName = expandTokens(config.docker.projectNamePattern, tokens)
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
...spec,
|
|
183
|
+
repo,
|
|
184
|
+
repoSlug,
|
|
185
|
+
branch,
|
|
186
|
+
worktreeRoot,
|
|
187
|
+
worktreeFolder,
|
|
188
|
+
worktreePath,
|
|
189
|
+
projectName,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = {
|
|
194
|
+
resolveSpec,
|
|
195
|
+
resolveBaseBranch,
|
|
196
|
+
branchFor,
|
|
197
|
+
splitPrefix,
|
|
198
|
+
repoInfo,
|
|
199
|
+
expandTokens,
|
|
200
|
+
findSpecFolder,
|
|
201
|
+
readStackField,
|
|
202
|
+
}
|