@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
package/src/cli.js
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const { execFileSync } = require('child_process')
|
|
6
|
+
const { init } = require('./init.js')
|
|
7
|
+
const {
|
|
8
|
+
detectReleaseTooling,
|
|
9
|
+
removeReleaseTooling,
|
|
10
|
+
releaseToolingNotice,
|
|
11
|
+
} = require('./deprecate.js')
|
|
12
|
+
const { loadEnvConfig } = require('./env/config.js')
|
|
13
|
+
const {
|
|
14
|
+
readRegistry,
|
|
15
|
+
writeRegistry,
|
|
16
|
+
allocateSlot,
|
|
17
|
+
freeSlot,
|
|
18
|
+
portOffset,
|
|
19
|
+
} = require('./env/registry.js')
|
|
20
|
+
const { resolveSpec, resolveBaseBranch, repoInfo, expandTokens, splitPrefix } = require('./env/resolve.js')
|
|
21
|
+
const { ensureWorktreeDirTrusted } = require('./env/trust.js')
|
|
22
|
+
const { planUp } = require('./env/provision.js')
|
|
23
|
+
const { planDown } = require('./env/teardown.js')
|
|
24
|
+
const { planIntegrate } = require('./env/integrate.js')
|
|
25
|
+
|
|
26
|
+
const pkg = require('../package.json')
|
|
27
|
+
|
|
28
|
+
const HELP = `skitterspec — spec-driven-development for Claude Code
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
skitterspec init [dir] Install the spec lifecycle skills, rule, and specs/
|
|
32
|
+
folders into a project
|
|
33
|
+
skitterspec update [dir] Re-copy skills + rule (overwrites), leaves specs/
|
|
34
|
+
and specs/.core/ config alone
|
|
35
|
+
skitterspec spec-env <cmd> Per-spec isolation engine (opt-in; needs
|
|
36
|
+
specs/.core/env.config.json). Subcommands:
|
|
37
|
+
up <spec> plan a worktree + Docker stack + opener
|
|
38
|
+
down <spec> tear down (guards; --keep-volumes, --force)
|
|
39
|
+
integrate <spec> plan rebase + fast-forward onto the base branch
|
|
40
|
+
status list provisioned specs + port blocks
|
|
41
|
+
resolve <spec> print resolved slug/type/branch/paths
|
|
42
|
+
skitterspec --help Show this help
|
|
43
|
+
skitterspec --version Print version
|
|
44
|
+
|
|
45
|
+
Options (init / update):
|
|
46
|
+
--force Overwrite skill/rule/script files that already exist
|
|
47
|
+
--dir <path> Target project dir (default: positional arg or cwd)
|
|
48
|
+
--no-claude-md Skip creating/patching CLAUDE.md
|
|
49
|
+
--yes, -y Accept defaults; skip the interactive setup prompts
|
|
50
|
+
--isolation / --no-isolation Enable/skip per-spec isolation (a git
|
|
51
|
+
worktree per spec; writes env.config.json)
|
|
52
|
+
--remove-release-tooling (update) Remove leftover release tooling
|
|
53
|
+
non-interactively (moved to skittership)
|
|
54
|
+
|
|
55
|
+
Examples:
|
|
56
|
+
npx @skitterbyte/skitterspec init
|
|
57
|
+
npx @skitterbyte/skitterspec init ./my-app --yes
|
|
58
|
+
npx @skitterbyte/skitterspec init --isolation
|
|
59
|
+
npx @skitterbyte/skitterspec update --force
|
|
60
|
+
`
|
|
61
|
+
|
|
62
|
+
function parse(argv) {
|
|
63
|
+
const opts = {
|
|
64
|
+
force: false,
|
|
65
|
+
claudeMd: true,
|
|
66
|
+
dir: null,
|
|
67
|
+
yes: false,
|
|
68
|
+
isolation: undefined,
|
|
69
|
+
removeReleaseTooling: false,
|
|
70
|
+
}
|
|
71
|
+
const positional = []
|
|
72
|
+
for (let i = 0; i < argv.length; i++) {
|
|
73
|
+
const a = argv[i]
|
|
74
|
+
if (a === '--force') opts.force = true
|
|
75
|
+
else if (a === '--no-claude-md') opts.claudeMd = false
|
|
76
|
+
else if (a === '--yes' || a === '-y') opts.yes = true
|
|
77
|
+
else if (a === '--isolation') opts.isolation = true
|
|
78
|
+
else if (a === '--no-isolation') opts.isolation = false
|
|
79
|
+
else if (a === '--remove-release-tooling') opts.removeReleaseTooling = true
|
|
80
|
+
else if (a === '--dir') opts.dir = argv[++i]
|
|
81
|
+
else if (a.startsWith('--')) throw new Error(`unknown option: ${a}`)
|
|
82
|
+
else positional.push(a)
|
|
83
|
+
}
|
|
84
|
+
return { opts, positional }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// After an `update`, clean up release tooling left by an older skitterspec (it
|
|
88
|
+
// now lives in @skitterbyte/skittership). Deletes only on an explicit interactive
|
|
89
|
+
// "yes" or --remove-release-tooling; a non-TTY/--yes run only prints the pointer,
|
|
90
|
+
// so CI never mutates files. Nothing to do when no release tooling is present.
|
|
91
|
+
async function cleanupReleaseTooling(dir, opts) {
|
|
92
|
+
const detection = detectReleaseTooling(dir)
|
|
93
|
+
if (!detection.present) return
|
|
94
|
+
|
|
95
|
+
const printRemoved = (removed) => {
|
|
96
|
+
process.stdout.write('\nRemoved release tooling (moved to @skitterbyte/skittership):\n')
|
|
97
|
+
for (const it of removed) process.stdout.write(` ${it}\n`)
|
|
98
|
+
process.stdout.write('Your CHANGELOG.md / RELEASES.md content was left untouched.\n')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (opts.removeReleaseTooling) {
|
|
102
|
+
printRemoved(removeReleaseTooling(dir, detection).removed)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const interactive = Boolean(process.stdin.isTTY) && !opts.yes
|
|
107
|
+
if (!interactive) {
|
|
108
|
+
process.stdout.write(`\n${releaseToolingNotice()}\n`)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const { confirmRemoveReleaseTooling } = require('./prompts.js')
|
|
113
|
+
if (await confirmRemoveReleaseTooling(detection)) {
|
|
114
|
+
printRemoved(removeReleaseTooling(dir, detection).removed)
|
|
115
|
+
} else {
|
|
116
|
+
process.stdout.write(`\n${releaseToolingNotice()}\n`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- spec-env: per-spec isolation engine (Phase 1: status + resolve) --------
|
|
121
|
+
|
|
122
|
+
// Print provisioned specs, their slots, and port blocks from the registry.
|
|
123
|
+
function specEnvStatus(dir, config) {
|
|
124
|
+
const registry = readRegistry(dir, config)
|
|
125
|
+
const names = Object.keys(registry.slots)
|
|
126
|
+
if (!names.length) {
|
|
127
|
+
process.stdout.write('spec-env: no provisioned specs.\n')
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
process.stdout.write('Provisioned specs:\n')
|
|
131
|
+
names
|
|
132
|
+
.sort((a, b) => registry.slots[a] - registry.slots[b])
|
|
133
|
+
.forEach((name) => {
|
|
134
|
+
const slot = registry.slots[name]
|
|
135
|
+
const off = portOffset(slot, config)
|
|
136
|
+
const hi = off + config.docker.portsPerSpec - 1
|
|
137
|
+
process.stdout.write(` ${name} slot ${slot} ports ${off}-${hi}\n`)
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Provision: allocate the slot, persist the registry, and print the plan the
|
|
142
|
+
// /spec-env skill executes (git worktree add, docker compose up, .env, opener).
|
|
143
|
+
function specEnvUp(dir, config, specArg) {
|
|
144
|
+
if (!specArg) {
|
|
145
|
+
process.stdout.write('Usage: skitterspec spec-env up <spec>\n')
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
const spec = resolveSpec(specArg, dir, config)
|
|
149
|
+
|
|
150
|
+
// Trust the shared worktree root so edits into the freshly-provisioned worktree
|
|
151
|
+
// don't prompt. One absolute entry (the root) covers every spec; self-heals on
|
|
152
|
+
// every provision for teammates who only cloned and ran /spec-go.
|
|
153
|
+
const worktreeRootAbs = path.dirname(spec.worktreePath)
|
|
154
|
+
const trust = ensureWorktreeDirTrusted(dir, worktreeRootAbs)
|
|
155
|
+
|
|
156
|
+
const wantsDocker = spec.stack === 'docker' && config.docker.enabled
|
|
157
|
+
|
|
158
|
+
// Slot allocation is Docker-only: a worktree-only spec never touches the
|
|
159
|
+
// registry (no slot, no port block). Its re-run signal is the worktree already
|
|
160
|
+
// existing on disk (attach the branch, don't `-b`); a Docker spec's is its slot.
|
|
161
|
+
let slot = null
|
|
162
|
+
let attached
|
|
163
|
+
if (wantsDocker) {
|
|
164
|
+
const before = readRegistry(dir, config)
|
|
165
|
+
attached = Object.prototype.hasOwnProperty.call(before.slots, spec.folder)
|
|
166
|
+
const alloc = allocateSlot(before, spec.folder)
|
|
167
|
+
slot = alloc.slot
|
|
168
|
+
writeRegistry(dir, config, alloc.registry) // the engine's only write (Docker path)
|
|
169
|
+
} else {
|
|
170
|
+
attached = fs.existsSync(spec.worktreePath)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const plan = planUp(spec, { slot, attached }, config)
|
|
174
|
+
|
|
175
|
+
const out = []
|
|
176
|
+
out.push(`spec-env up: ${spec.folder} ${attached ? '(attached — existing)' : '(provisioned)'}`)
|
|
177
|
+
out.push('')
|
|
178
|
+
out.push(` worktree: ${plan.worktreePath}`)
|
|
179
|
+
out.push(` branch: ${plan.branch}`)
|
|
180
|
+
if (plan.slot !== null) {
|
|
181
|
+
const hi = plan.portOffset + config.docker.portsPerSpec - 1
|
|
182
|
+
out.push(` project: ${plan.projectName}`)
|
|
183
|
+
out.push(` slot: ${plan.slot} (ports ${plan.portOffset}-${hi})`)
|
|
184
|
+
} else {
|
|
185
|
+
out.push(' stack: worktree-only (no docker, no port block)')
|
|
186
|
+
}
|
|
187
|
+
if (trust.reason === 'malformed') {
|
|
188
|
+
out.push(
|
|
189
|
+
' trusted: ! .claude/settings.local.json is not valid JSON — left it;' +
|
|
190
|
+
`\n add ${worktreeRootAbs} to permissions.additionalDirectories yourself`,
|
|
191
|
+
)
|
|
192
|
+
} else {
|
|
193
|
+
out.push(
|
|
194
|
+
` trusted: ${worktreeRootAbs} ` +
|
|
195
|
+
`(${trust.changed ? 'added to' : 'already in'} .claude/settings.local.json)`,
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
out.push('')
|
|
199
|
+
out.push(' run these:')
|
|
200
|
+
for (const cmd of plan.commands) out.push(` ${cmd}`)
|
|
201
|
+
if (plan.openCommand) out.push(` ${plan.openCommand}`)
|
|
202
|
+
if (plan.envContents) {
|
|
203
|
+
out.push('')
|
|
204
|
+
out.push(` write ${config.docker.envFile} in the worktree:`)
|
|
205
|
+
for (const line of plan.envContents.replace(/\n$/, '').split('\n')) {
|
|
206
|
+
out.push(` ${line}`)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// A read-only git reader over `cwd`: returns trimmed stdout, or null on failure.
|
|
213
|
+
function gitReader(cwd) {
|
|
214
|
+
return (argv) => {
|
|
215
|
+
try {
|
|
216
|
+
return execFileSync('git', ['-C', cwd, ...argv], {
|
|
217
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
218
|
+
})
|
|
219
|
+
.toString()
|
|
220
|
+
.trim()
|
|
221
|
+
} catch {
|
|
222
|
+
return null
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Query a worktree's git state (side-effecting — kept in the CLI, not the pure
|
|
228
|
+
// planner). A missing worktree → nothing to lose (safe to tear down). `base` is
|
|
229
|
+
// the resolved integration branch; `merged` is true when HEAD is already an
|
|
230
|
+
// ancestor of it (fully landed), which lets teardown skip the unpushed guard.
|
|
231
|
+
function worktreeGitState(worktreePath, base) {
|
|
232
|
+
if (!fs.existsSync(worktreePath)) return { dirty: false, unpushed: false, merged: true }
|
|
233
|
+
const git = gitReader(worktreePath)
|
|
234
|
+
|
|
235
|
+
const status = git(['status', '--porcelain'])
|
|
236
|
+
const dirty = status !== null && status.length > 0
|
|
237
|
+
|
|
238
|
+
let unpushed = false
|
|
239
|
+
const ahead = git(['rev-list', '--count', '@{u}..HEAD'])
|
|
240
|
+
if (ahead !== null) {
|
|
241
|
+
// commits on HEAD's upstream branch not yet pushed
|
|
242
|
+
unpushed = Number(ahead) > 0
|
|
243
|
+
} else {
|
|
244
|
+
// no upstream configured → any commit on HEAD not on a remote counts
|
|
245
|
+
const local = git(['log', '--oneline', 'HEAD', '--not', '--remotes'])
|
|
246
|
+
unpushed = local !== null && local.length > 0
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// merged = HEAD is an ancestor of base (every commit already landed). The
|
|
250
|
+
// worktree shares the object store, so `base` is visible here. `--is-ancestor`
|
|
251
|
+
// exits 0 when true; gitReader maps a non-zero exit to null.
|
|
252
|
+
const merged = base != null && git(['merge-base', '--is-ancestor', 'HEAD', base]) !== null
|
|
253
|
+
|
|
254
|
+
return { dirty, unpushed, merged }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// A deterministic-enough compact timestamp for backup filenames (CLI-only; the
|
|
258
|
+
// pure planner receives this as input so it stays testable).
|
|
259
|
+
function compactTimestamp() {
|
|
260
|
+
return new Date()
|
|
261
|
+
.toISOString()
|
|
262
|
+
.replace(/[-:]/g, '')
|
|
263
|
+
.replace(/\.\d+Z$/, '')
|
|
264
|
+
.replace('T', '-')
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Teardown: evaluate guards, print the plan, free the slot. Idempotent no-op
|
|
268
|
+
// when the spec was never provisioned / already torn down. Deliberately does NOT
|
|
269
|
+
// touch the trusted worktree root in .claude/settings.local.json — that entry is
|
|
270
|
+
// the shared parent of every spec's worktree and harmless when empty; removing it
|
|
271
|
+
// would just re-prompt on the next /spec-go (see spec: isolation-trusts-worktree-dir).
|
|
272
|
+
function specEnvDown(dir, config, specArg, flags) {
|
|
273
|
+
if (!specArg) {
|
|
274
|
+
process.stdout.write('Usage: skitterspec spec-env down <spec> [--keep-volumes] [--force]\n')
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
const spec = resolveSpec(specArg, dir, config)
|
|
278
|
+
|
|
279
|
+
// A worktree-only spec never held a slot but its worktree still needs removing,
|
|
280
|
+
// so "nothing to do" means neither a slot nor a worktree exists.
|
|
281
|
+
const registry = readRegistry(dir, config)
|
|
282
|
+
const hasSlot = Object.prototype.hasOwnProperty.call(registry.slots, spec.folder)
|
|
283
|
+
if (!hasSlot && !fs.existsSync(spec.worktreePath)) {
|
|
284
|
+
process.stdout.write(`spec-env down: ${spec.folder} is not provisioned — nothing to do.\n`)
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const base = resolveBaseBranch(config, gitReader(dir))
|
|
289
|
+
const worktreeState = worktreeGitState(spec.worktreePath, base)
|
|
290
|
+
const plan = planDown(spec, config, flags, { worktreeState, timestamp: compactTimestamp() })
|
|
291
|
+
|
|
292
|
+
if (plan.blocked) {
|
|
293
|
+
process.stdout.write(
|
|
294
|
+
`spec-env down: blocked — ${plan.reason}.\n` +
|
|
295
|
+
'Re-run with --force to tear down anyway (destroys the worktree).\n',
|
|
296
|
+
)
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Free the slot (the engine's only write on down) — only if one was held; a
|
|
301
|
+
// worktree-only teardown never touches the registry.
|
|
302
|
+
if (hasSlot) {
|
|
303
|
+
writeRegistry(dir, config, freeSlot(registry, spec.folder))
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const out = []
|
|
307
|
+
out.push(`spec-env down: ${spec.folder}${hasSlot ? ' (slot freed)' : ''}`)
|
|
308
|
+
out.push('')
|
|
309
|
+
out.push(` worktree: ${spec.worktreePath}`)
|
|
310
|
+
out.push(` volumes: ${plan.volumesDropped ? 'dropped' : 'kept'}`)
|
|
311
|
+
if (plan.backupPath) out.push(` backup: ${plan.backupPath}`)
|
|
312
|
+
else if (plan.volumesDropped) out.push(' backup: none (no docker.backupCommand set)')
|
|
313
|
+
out.push('')
|
|
314
|
+
out.push(' run these:')
|
|
315
|
+
for (const cmd of plan.commands) out.push(` ${cmd}`)
|
|
316
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Integrate: land a spec's worktree branch onto the base branch (rebase + ff).
|
|
320
|
+
// Queries git for the facts, prints the plan / block / no-op. The /spec-complete
|
|
321
|
+
// skill executes the printed commands (and aborts a conflicting rebase).
|
|
322
|
+
function specEnvIntegrate(dir, config, specArg) {
|
|
323
|
+
if (!specArg) {
|
|
324
|
+
process.stdout.write('Usage: skitterspec spec-env integrate <spec>\n')
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// /spec-complete runs this from inside the worktree, but the spec's coordinates
|
|
329
|
+
// (worktreePath via {repo}, the base branch) must resolve against the PRIMARY
|
|
330
|
+
// checkout. Resolve it first (parent of the shared git dir) and anchor
|
|
331
|
+
// everything to it, so integrate works whether invoked from main or a worktree.
|
|
332
|
+
const commonDir = gitReader(dir)(['rev-parse', '--git-common-dir'])
|
|
333
|
+
const mainRepoPath = commonDir ? path.dirname(path.resolve(dir, commonDir)) : dir
|
|
334
|
+
|
|
335
|
+
// A spec authored entirely on its branch may not exist in the primary
|
|
336
|
+
// checkout's specs/** (it was never committed to base) — but its worktree
|
|
337
|
+
// does, and the worktree path is derivable from config without the folder.
|
|
338
|
+
// Offer it as a fallback search location so integrate can still find the spec.
|
|
339
|
+
const { slug } = splitPrefix(path.basename(specArg))
|
|
340
|
+
const { repo, repoSlug } = repoInfo(mainRepoPath)
|
|
341
|
+
const wtTokens = { repo, repoSlug, slug }
|
|
342
|
+
const worktreeGuess = path.resolve(
|
|
343
|
+
mainRepoPath,
|
|
344
|
+
expandTokens(config.worktree.root, wtTokens),
|
|
345
|
+
expandTokens(config.worktree.folderPattern, wtTokens),
|
|
346
|
+
)
|
|
347
|
+
const spec = resolveSpec(specArg, mainRepoPath, config, { searchDirs: [worktreeGuess] })
|
|
348
|
+
|
|
349
|
+
if (!fs.existsSync(spec.worktreePath)) {
|
|
350
|
+
process.stdout.write(
|
|
351
|
+
`spec-env integrate: ${spec.folder} has no worktree — nothing to integrate.\n`,
|
|
352
|
+
)
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const base = resolveBaseBranch(config, gitReader(mainRepoPath))
|
|
357
|
+
const wtGit = gitReader(spec.worktreePath)
|
|
358
|
+
const status = wtGit(['status', '--porcelain'])
|
|
359
|
+
const dirty = status !== null && status.length > 0
|
|
360
|
+
const ahead = wtGit(['rev-list', '--count', `${base}..HEAD`])
|
|
361
|
+
const aheadOfBase = ahead !== null && Number(ahead) > 0
|
|
362
|
+
|
|
363
|
+
const plan = planIntegrate(spec, config, { worktreeState: { dirty }, base, aheadOfBase, mainRepoPath })
|
|
364
|
+
|
|
365
|
+
if (plan.blocked) {
|
|
366
|
+
process.stdout.write(`spec-env integrate: blocked — ${plan.reason}.\n`)
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
if (plan.noop) {
|
|
370
|
+
process.stdout.write(
|
|
371
|
+
`spec-env integrate: ${spec.folder} already landed on ${base} — nothing to integrate.\n`,
|
|
372
|
+
)
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const out = []
|
|
377
|
+
out.push(`spec-env integrate: ${spec.folder}`)
|
|
378
|
+
out.push('')
|
|
379
|
+
out.push(` base: ${plan.base}`)
|
|
380
|
+
out.push(` branch: ${plan.branch}`)
|
|
381
|
+
out.push(` worktree: ${spec.worktreePath}`)
|
|
382
|
+
out.push('')
|
|
383
|
+
out.push(' run these (abort the rebase on conflict):')
|
|
384
|
+
for (const cmd of plan.commands) out.push(` ${cmd}`)
|
|
385
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Print the resolved identity/coordinates for a single spec.
|
|
389
|
+
function specEnvResolve(dir, config, specArg) {
|
|
390
|
+
if (!specArg) {
|
|
391
|
+
process.stdout.write('Usage: skitterspec spec-env resolve <spec>\n')
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
const r = resolveSpec(specArg, dir, config)
|
|
395
|
+
process.stdout.write(
|
|
396
|
+
`spec: ${r.folder} (${r.bucket})\n` +
|
|
397
|
+
`type/slug: ${r.type} / ${r.slug}\n` +
|
|
398
|
+
`branch: ${r.branch}\n` +
|
|
399
|
+
`worktree: ${r.worktreePath}\n` +
|
|
400
|
+
`project: ${r.projectName}\n`,
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Dispatch `skitterspec spec-env <sub> [args] [--dir path]`. No-ops with a clear
|
|
405
|
+
// message when the feature isn't enabled (no specs/.core/env.config.json).
|
|
406
|
+
function specEnv(rest) {
|
|
407
|
+
const [sub, ...args] = rest
|
|
408
|
+
let dir = process.cwd()
|
|
409
|
+
const positional = []
|
|
410
|
+
const flags = { keepVolumes: false, force: false }
|
|
411
|
+
for (let i = 0; i < args.length; i++) {
|
|
412
|
+
if (args[i] === '--dir') dir = path.resolve(args[++i])
|
|
413
|
+
else if (args[i] === '--keep-volumes') flags.keepVolumes = true
|
|
414
|
+
else if (args[i] === '--force') flags.force = true
|
|
415
|
+
else positional.push(args[i])
|
|
416
|
+
}
|
|
417
|
+
dir = path.resolve(dir)
|
|
418
|
+
|
|
419
|
+
const { config, present } = loadEnvConfig(dir)
|
|
420
|
+
if (!present) {
|
|
421
|
+
process.stdout.write(
|
|
422
|
+
'spec-env: isolation not enabled (no specs/.core/env.config.json).\n' +
|
|
423
|
+
'Opt in by copying specs/.core/env.config.json.example → env.config.json.\n',
|
|
424
|
+
)
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
switch (sub) {
|
|
429
|
+
case 'up':
|
|
430
|
+
specEnvUp(dir, config, positional[0])
|
|
431
|
+
break
|
|
432
|
+
case 'down':
|
|
433
|
+
specEnvDown(dir, config, positional[0], flags)
|
|
434
|
+
break
|
|
435
|
+
case 'integrate':
|
|
436
|
+
specEnvIntegrate(dir, config, positional[0])
|
|
437
|
+
break
|
|
438
|
+
case 'status':
|
|
439
|
+
specEnvStatus(dir, config)
|
|
440
|
+
break
|
|
441
|
+
case 'resolve':
|
|
442
|
+
specEnvResolve(dir, config, positional[0])
|
|
443
|
+
break
|
|
444
|
+
default:
|
|
445
|
+
process.stdout.write(
|
|
446
|
+
'Usage: skitterspec spec-env <up|down|integrate|status|resolve> [spec] [--keep-volumes] [--force]\n',
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function run(argv) {
|
|
452
|
+
if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) {
|
|
453
|
+
process.stdout.write(HELP)
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
if (argv.includes('--version') || argv.includes('-v')) {
|
|
457
|
+
process.stdout.write(`${pkg.version}\n`)
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const [cmd, ...rest] = argv
|
|
462
|
+
|
|
463
|
+
if (cmd === 'spec-env') {
|
|
464
|
+
specEnv(rest)
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const { opts, positional } = parse(rest)
|
|
469
|
+
const dir = path.resolve(opts.dir || positional[0] || process.cwd())
|
|
470
|
+
|
|
471
|
+
switch (cmd) {
|
|
472
|
+
case 'init': {
|
|
473
|
+
// Isolation defaults OFF; a flag or an interactive "yes" opts in.
|
|
474
|
+
let isolation = opts.isolation === true
|
|
475
|
+
|
|
476
|
+
const interactive = Boolean(process.stdin.isTTY) && !opts.yes
|
|
477
|
+
if (interactive) {
|
|
478
|
+
const { promptSetup } = require('./prompts.js')
|
|
479
|
+
const result = await promptSetup({ isolationSeed: isolation })
|
|
480
|
+
isolation = result.isolation
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
await init({ dir, force: opts.force, claudeMd: opts.claudeMd, mode: 'init', isolation })
|
|
484
|
+
break
|
|
485
|
+
}
|
|
486
|
+
case 'update':
|
|
487
|
+
await init({ dir, force: true, claudeMd: opts.claudeMd, mode: 'update' })
|
|
488
|
+
await cleanupReleaseTooling(dir, opts)
|
|
489
|
+
break
|
|
490
|
+
default:
|
|
491
|
+
throw new Error(`unknown command: ${cmd} (try --help)`)
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
module.exports = { run, parse }
|
package/src/deprecate.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Cleanup path for projects that installed release tooling from an older
|
|
4
|
+
// skitterspec (before it moved to @skitterbyte/skittership). `skitterspec update`
|
|
5
|
+
// detects the leftover files and — only on an explicit interactive "yes" or the
|
|
6
|
+
// --remove-release-tooling flag — removes exactly what skitterspec used to
|
|
7
|
+
// install. It never touches the user's generated CHANGELOG.md / RELEASES.md, nor
|
|
8
|
+
// any script it didn't add.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs')
|
|
11
|
+
const path = require('path')
|
|
12
|
+
|
|
13
|
+
const SKITTERSHIP = '@skitterbyte/skittership'
|
|
14
|
+
|
|
15
|
+
// Files/dirs skitterspec used to install for release tooling (repo-relative).
|
|
16
|
+
const RELEASE_PATHS = [
|
|
17
|
+
'skitterspec.config.json',
|
|
18
|
+
path.join('scripts', 'generate-changelog.js'),
|
|
19
|
+
path.join('scripts', 'generate-releases.js'),
|
|
20
|
+
path.join('scripts', 'lib', 'git-commits.js'),
|
|
21
|
+
path.join('scripts', 'lib', 'config.js'),
|
|
22
|
+
path.join('.claude', 'skills', 'commit'),
|
|
23
|
+
path.join('.claude', 'rules', 'commit-messages.md'),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
// npm scripts skitterspec used to wire; only removed when their value still
|
|
27
|
+
// matches the generator command (so a user's custom override is preserved).
|
|
28
|
+
const HELPER_SCRIPTS = {
|
|
29
|
+
changelog: 'node scripts/generate-changelog.js',
|
|
30
|
+
'changelog:retro': 'node scripts/generate-changelog.js --retro',
|
|
31
|
+
releases: 'node scripts/generate-releases.js',
|
|
32
|
+
'releases:retro': 'node scripts/generate-releases.js --retro',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readPkg(dir) {
|
|
36
|
+
const pkgPath = path.join(dir, 'package.json')
|
|
37
|
+
if (!fs.existsSync(pkgPath)) return null
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Does package.json have a `version` script that runs the release generators?
|
|
46
|
+
function versionHookReferencesGenerators(pkg) {
|
|
47
|
+
const v = pkg && pkg.scripts && pkg.scripts.version
|
|
48
|
+
return typeof v === 'string' && /generate-(changelog|releases)\.js/.test(v)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Is skittership the source of the release tooling here (rather than a leftover
|
|
52
|
+
// legacy skitterspec install)? True when the project has adopted skittership —
|
|
53
|
+
// its config file is present, or it's a declared dependency. In that case the
|
|
54
|
+
// release files are skittership's current install and must NOT be offered for
|
|
55
|
+
// removal (they'd just come back on the next `skittership init`).
|
|
56
|
+
function skittershipAdopted(dir) {
|
|
57
|
+
if (fs.existsSync(path.join(dir, 'skittership.config.json'))) return true
|
|
58
|
+
const pkg = readPkg(dir)
|
|
59
|
+
const deps = Object.assign({}, pkg && pkg.dependencies, pkg && pkg.devDependencies)
|
|
60
|
+
return Boolean(deps['@skitterbyte/skittership'])
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Report which release-tooling artifacts are present. `present` is true only when
|
|
64
|
+
// there are legacy artifacts to clean up (a file/dir or a generator-driven version
|
|
65
|
+
// hook) AND skittership hasn't been adopted — otherwise the files belong to a
|
|
66
|
+
// live skittership install, not an old bundled-skitterspec one.
|
|
67
|
+
function detectReleaseTooling(dir) {
|
|
68
|
+
const files = RELEASE_PATHS.filter((rel) => fs.existsSync(path.join(dir, rel)))
|
|
69
|
+
const pkg = readPkg(dir)
|
|
70
|
+
const versionHook = versionHookReferencesGenerators(pkg)
|
|
71
|
+
const adopted = skittershipAdopted(dir)
|
|
72
|
+
return { present: (files.length > 0 || versionHook) && !adopted, files, versionHook, adopted }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Remove an emptied directory, walking up while parents are left empty. Never
|
|
76
|
+
// climbs out of `dir`.
|
|
77
|
+
function pruneEmptyDirs(dir, startAbs) {
|
|
78
|
+
let cur = startAbs
|
|
79
|
+
while (cur.startsWith(dir) && cur !== dir && fs.existsSync(cur)) {
|
|
80
|
+
if (fs.readdirSync(cur).length > 0) break
|
|
81
|
+
fs.rmdirSync(cur)
|
|
82
|
+
cur = path.dirname(cur)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Remove exactly the detected artifacts + unwire the version hook. Returns a
|
|
87
|
+
// report of what was removed. Scoped and non-destructive: only skitterspec's own
|
|
88
|
+
// files, and only npm scripts whose value still matches the generator command.
|
|
89
|
+
function removeReleaseTooling(dir, detection = detectReleaseTooling(dir)) {
|
|
90
|
+
const removed = []
|
|
91
|
+
|
|
92
|
+
for (const rel of detection.files) {
|
|
93
|
+
const abs = path.join(dir, rel)
|
|
94
|
+
if (!fs.existsSync(abs)) continue
|
|
95
|
+
fs.rmSync(abs, { recursive: true, force: true })
|
|
96
|
+
removed.push(rel)
|
|
97
|
+
pruneEmptyDirs(dir, path.dirname(abs))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const pkg = readPkg(dir)
|
|
101
|
+
if (pkg && pkg.scripts) {
|
|
102
|
+
let changed = false
|
|
103
|
+
if (versionHookReferencesGenerators(pkg)) {
|
|
104
|
+
delete pkg.scripts.version
|
|
105
|
+
removed.push('package.json (version hook)')
|
|
106
|
+
changed = true
|
|
107
|
+
}
|
|
108
|
+
for (const [name, cmd] of Object.entries(HELPER_SCRIPTS)) {
|
|
109
|
+
if (pkg.scripts[name] === cmd) {
|
|
110
|
+
delete pkg.scripts[name]
|
|
111
|
+
changed = true
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (changed) {
|
|
115
|
+
if (Object.keys(pkg.scripts).length === 0) delete pkg.scripts
|
|
116
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n')
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { removed }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// One-line pointer shown when we detect release tooling but don't remove it
|
|
124
|
+
// (declined, or a non-interactive run).
|
|
125
|
+
function releaseToolingNotice() {
|
|
126
|
+
return (
|
|
127
|
+
`Release tooling has moved to ${SKITTERSHIP} — run ` +
|
|
128
|
+
`npx ${SKITTERSHIP} init to keep it (your config is migrated automatically).`
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
detectReleaseTooling,
|
|
134
|
+
removeReleaseTooling,
|
|
135
|
+
releaseToolingNotice,
|
|
136
|
+
RELEASE_PATHS,
|
|
137
|
+
SKITTERSHIP,
|
|
138
|
+
}
|