@skitterbyte/skitterspec 22.4.0 → 23.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.
@@ -0,0 +1,188 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Run a worktree's bootstrap steps — the engine half of `spec-env bootstrap`.
5
+ *
6
+ * `planUp` decides WHAT a fresh worktree needs (`worktreeSteps`); this module
7
+ * does it. Seeds and the env file are plain filesystem work, done here with
8
+ * `fs` and no shell at all. `setup` commands and the stack are spawned with the
9
+ * worktree as their cwd.
10
+ *
11
+ * WHY THE ENGINE RUNS THEM. These steps used to be printed as shell for the
12
+ * agent to run, and an agent does not copy shell faithfully. A consumer's
13
+ * secrets hook refuses any Bash call naming a `.env` file, with one exception
14
+ * for the exact printed shape — so every rewrite was refused, and the retry was
15
+ * refused again by auto mode as a workaround. The call that replaces them,
16
+ * `skitterspec spec-env bootstrap <name>`, names no file at all.
17
+ *
18
+ * Behaviour is the printed commands' behaviour, kept exactly: idempotent, one
19
+ * `seeded …` / `… — skipped` line per seed, a seed missing from the primary
20
+ * checkout is skipped rather than failed, a symlinked env file is refused before
21
+ * anything is written through it, and the run stops at the first failing step.
22
+ *
23
+ * Every side effect goes through `io`, so the tests drive it with a real temp
24
+ * directory and a fake spawn — no docker, no package manager.
25
+ */
26
+
27
+ const realFs = require('node:fs')
28
+ const path = require('node:path')
29
+ const { spawnSync } = require('node:child_process')
30
+
31
+ /** One line describing a step, for `--plan` and for the failure report. */
32
+ function describeStep(step) {
33
+ switch (step.kind) {
34
+ case 'seed':
35
+ return `seed ${step.file} (${step.mode} from the primary checkout)`
36
+ case 'envFile':
37
+ return `env file ${step.file} (COMPOSE_PROJECT_NAME, PORT_OFFSET)`
38
+ case 'setup':
39
+ return `setup: ${step.command}`
40
+ case 'docker':
41
+ return `docker: ${step.argv.join(' ')}`
42
+ default:
43
+ return `unknown step ${step.kind}`
44
+ }
45
+ }
46
+
47
+ // `lstat` that answers null for a missing path rather than throwing.
48
+ function lstatOrNull(fs, p) {
49
+ try {
50
+ return fs.lstatSync(p)
51
+ } catch {
52
+ return null
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Seed one gitignored file from the primary checkout.
58
+ *
59
+ * The source is tested with `existsSync`, which follows links — a dangling
60
+ * link in the primary checkout is "not in main", exactly as `[ -e ]` read it.
61
+ * The target is tested with `lstat`, which does not: an existing link, even a
62
+ * dangling one, is somebody's deliberate state and is left alone.
63
+ */
64
+ function runSeed(step, { cwd, mainRoot, fs }) {
65
+ const src = path.join(mainRoot, step.file)
66
+ const dst = path.join(cwd, step.file)
67
+ if (!fs.existsSync(src)) {
68
+ return { outcome: 'skipped', line: `seed ${step.file}: not in main — skipped` }
69
+ }
70
+ if (lstatOrNull(fs, dst)) {
71
+ return { outcome: 'skipped', line: `seed ${step.file}: exists — skipped` }
72
+ }
73
+ fs.mkdirSync(path.dirname(dst), { recursive: true })
74
+ if (step.mode === 'copy') fs.copyFileSync(src, dst)
75
+ else fs.symlinkSync(src, dst)
76
+ return { outcome: 'seeded', line: `seeded ${step.file} → ${src}` }
77
+ }
78
+
79
+ /**
80
+ * Make sure the spec's env file sets its project name and port block.
81
+ *
82
+ * Additive: a key is appended only when no line already sets it, so a re-run
83
+ * writes nothing and a file `setup` already wrote to keeps every value it holds
84
+ * — a deliberate `PORT_OFFSET` of its own included.
85
+ *
86
+ * A SYMLINK IS REFUSED, before anything is written. Seeding links gitignored
87
+ * files from the primary checkout, so a seeded env file IS the primary
88
+ * checkout's own, and appending through it would write this spec's port block
89
+ * into the main stack's configuration. The fix is a `docker.envFile` naming a
90
+ * file of its own.
91
+ */
92
+ function runEnvFile(step, { cwd, fs }) {
93
+ const p = path.join(cwd, step.file)
94
+ const st = lstatOrNull(fs, p)
95
+ if (st && st.isSymbolicLink()) {
96
+ return {
97
+ outcome: 'refused',
98
+ line:
99
+ `env file ${step.file} is a symlink — docker.envFile must name a file of its own, ` +
100
+ "not a seeded one; refusing to write this spec's port block through it",
101
+ }
102
+ }
103
+ const before = st ? fs.readFileSync(p, 'utf8') : ''
104
+ let add = ''
105
+ // A file without a trailing newline would glue the first appended key onto
106
+ // its last line.
107
+ const sep = before.length && !before.endsWith('\n') ? '\n' : ''
108
+ for (const [key, value] of [
109
+ ['COMPOSE_PROJECT_NAME', step.projectName],
110
+ ['PORT_OFFSET', step.offset],
111
+ ]) {
112
+ if (!new RegExp(`^${key}=`, 'm').test(before)) add += `${key}=${value}\n`
113
+ }
114
+ if (!st || add) {
115
+ fs.mkdirSync(path.dirname(p), { recursive: true })
116
+ fs.writeFileSync(p, before + (add ? sep + add : ''))
117
+ }
118
+ return { outcome: add ? 'written' : 'skipped', line: `env file ${step.file}: ready` }
119
+ }
120
+
121
+ /**
122
+ * Spawn a setup command (through the shell — it is the project's own command
123
+ * line) or the stack (an argv, no shell).
124
+ *
125
+ * `stdout` is where the child's own output goes: inherited normally, so an
126
+ * install streams as it runs, and moved to stderr under `--json` so the payload
127
+ * on stdout stays parseable.
128
+ */
129
+ function runSpawned(step, { cwd, spawn, childStdout }) {
130
+ const stdio = ['ignore', childStdout, 'inherit']
131
+ const res =
132
+ step.kind === 'setup'
133
+ ? spawn(step.command, { cwd, shell: true, stdio })
134
+ : spawn(step.argv[0], step.argv.slice(1), { cwd, stdio })
135
+ if (res.error) {
136
+ return { outcome: 'failed', line: `${describeStep(step)} — ${res.error.message}` }
137
+ }
138
+ if (res.status !== 0) {
139
+ const why = res.status === null ? `killed by ${res.signal || 'a signal'}` : `exit ${res.status}`
140
+ return { outcome: 'failed', line: `${describeStep(step)} — ${why}` }
141
+ }
142
+ return { outcome: 'ran', line: null }
143
+ }
144
+
145
+ /**
146
+ * Run `steps` in order in `cwd`, stopping at the first that fails.
147
+ *
148
+ * io: { cwd, mainRoot, fs?, spawn?, write?, childStdout? }
149
+ * - `write(line)` receives every report line as it happens
150
+ * - `childStdout` is the stdio slot for spawned output ('inherit' default)
151
+ *
152
+ * Returns { ok, results: [{ step, outcome, line }], failedAt } — `failedAt` is
153
+ * the 1-based index of the step that stopped the run, or null.
154
+ */
155
+ function runBootstrap(steps, io) {
156
+ const ctx = {
157
+ cwd: io.cwd,
158
+ mainRoot: io.mainRoot,
159
+ fs: io.fs || realFs,
160
+ spawn: io.spawn || spawnSync,
161
+ childStdout: io.childStdout || 'inherit',
162
+ }
163
+ const write = io.write || (() => {})
164
+ const results = []
165
+ for (let i = 0; i < steps.length; i++) {
166
+ const step = steps[i]
167
+ // A spawned step announces itself BEFORE it runs, so the streamed output
168
+ // that follows has a heading.
169
+ if (step.kind === 'setup' || step.kind === 'docker') write(describeStep(step))
170
+ let r
171
+ try {
172
+ if (step.kind === 'seed') r = runSeed(step, ctx)
173
+ else if (step.kind === 'envFile') r = runEnvFile(step, ctx)
174
+ else if (step.kind === 'setup' || step.kind === 'docker') r = runSpawned(step, ctx)
175
+ else r = { outcome: 'failed', line: describeStep(step) }
176
+ } catch (err) {
177
+ r = { outcome: 'failed', line: `${describeStep(step)} — ${err.message}` }
178
+ }
179
+ results.push({ step, ...r })
180
+ if (r.line && r.outcome !== 'failed' && r.outcome !== 'refused') write(r.line)
181
+ if (r.outcome === 'failed' || r.outcome === 'refused') {
182
+ return { ok: false, results, failedAt: i + 1 }
183
+ }
184
+ }
185
+ return { ok: true, results, failedAt: null }
186
+ }
187
+
188
+ module.exports = { runBootstrap, describeStep }
package/src/env/config.js CHANGED
@@ -24,6 +24,9 @@
24
24
  * // setup runs (mode: "symlink" default | "copy"); empty = none
25
25
  * setup: [ "cmd", ... ], // bootstrap commands run in the worktree right
26
26
  * // after `git worktree add` (e.g. install deps); empty = none
27
+ * afterLand:[ "cmd", ... ], // commands run in the PRIMARY checkout after a
28
+ * // land (integrate's ff, hotfix land's main cherry-pick), e.g. a
29
+ * // data migration; literal, no token expansion; empty = none
27
30
  * dev: [ { name, command, portVar, health?, frontPort? } ], // host dev
28
31
  * // servers started on the spec's port block (empty = none)
29
32
  * proxy: { enabled, host }, // bundled front-door proxy (spec-env connect)
@@ -166,6 +169,12 @@ const DEFAULT_CONFIG = Object.freeze({
166
169
  // `git worktree add` (before Docker/dev), on every provision. Array of shell
167
170
  // strings (e.g. "pnpm install"); {slug}/{branch}/… expand. Default: none.
168
171
  setup: Object.freeze([]),
172
+ // Commands run in the primary checkout after a land, printed by `spec-env
173
+ // integrate` (including its already-landed no-op) and by `hotfix land` when
174
+ // it cherry-picks onto the base branch. The skill runs them; the engine only
175
+ // plans. Literal strings — no {slug}/{port} tokens, since they run on the base
176
+ // branch where a spec's tokens mean nothing. Default: none.
177
+ afterLand: Object.freeze([]),
169
178
  // Host dev servers started on the spec's port block by `spec-env dev up`.
170
179
  // Each: { name, command, portVar, health?, frontPort? }. Default: none.
171
180
  dev: Object.freeze([]),
@@ -332,6 +341,7 @@ function defaults() {
332
341
  docker: { ...DEFAULT_CONFIG.docker },
333
342
  seedFiles: { mode: DEFAULT_CONFIG.seedFiles.mode, files: [] },
334
343
  setup: [],
344
+ afterLand: [],
335
345
  dev: [],
336
346
  proxy: { ...DEFAULT_CONFIG.proxy },
337
347
  registry: DEFAULT_CONFIG.registry,
@@ -542,6 +552,11 @@ function mergeConfig(base, parsed) {
542
552
  base.setup = normalizeSetup(parsed.setup)
543
553
  }
544
554
 
555
+ // Same rule as `setup`: a stray entry is dropped, never thrown on.
556
+ if (Array.isArray(parsed.afterLand)) {
557
+ base.afterLand = normalizeSetup(parsed.afterLand)
558
+ }
559
+
545
560
  if (Array.isArray(parsed.dev)) {
546
561
  base.dev = normalizeDev(parsed.dev)
547
562
  }
package/src/env/hotfix.js CHANGED
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  const path = require('node:path')
24
+ const { afterLandFor } = require('./integrate.js')
24
25
 
25
26
  /**
26
27
  * Parse a semver-ish tag and return it with its PATCH bumped by one. Preserves any
@@ -50,7 +51,7 @@ function tagSlug(tag) {
50
51
  * @param {object} config normalised env config (reads `config.hotfix`).
51
52
  * @param {object} ctx { worktreeState:{dirty}, aheadOfBase, fixRange, mainRepoPath,
52
53
  * base, extraTargets: string[], existingTags: string[] }
53
- * @returns {object} { blocked, noop, reason, commands, prodTag, targets, branch, base }
54
+ * @returns {object} { blocked, noop, reason, commands, afterLand, prodTag, targets, branch, base }
54
55
  */
55
56
  function planHotfixLand(spec, config, ctx) {
56
57
  const c = ctx || {}
@@ -62,6 +63,7 @@ function planHotfixLand(spec, config, ctx) {
62
63
  noop: false,
63
64
  reason: null,
64
65
  commands: [],
66
+ afterLand: afterLandFor(null),
65
67
  prodTag: null,
66
68
  targets: [],
67
69
  branch,
@@ -122,12 +124,16 @@ function planHotfixLand(spec, config, ctx) {
122
124
  }
123
125
 
124
126
  // 3. Cherry-pick the fix onto the base branch (main) for the next release.
127
+ // Only then does the primary checkout gain anything, so only then do the
128
+ // after-land commands apply — a prod-only land leaves main untouched.
129
+ let afterLand = result.afterLand
125
130
  if (hotfixCfg.cherryPickMain !== false) {
126
131
  commands.push(`git -C ${c.mainRepoPath} cherry-pick ${fixRange}`)
127
132
  targets.push({ kind: 'main', base: c.base })
133
+ afterLand = afterLandFor(config, c.mainRepoPath)
128
134
  }
129
135
 
130
- return { ...result, commands, prodTag, targets, fixRange }
136
+ return { ...result, commands, afterLand, prodTag, targets, fixRange }
131
137
  }
132
138
 
133
139
  module.exports = { bumpPatch, tagSlug, planHotfixLand }
@@ -15,28 +15,32 @@
15
15
  * hands back — so the planner never needs to reason about conflicts.
16
16
  *
17
17
  * @param {object} spec resolved spec: { branch, worktreePath, folder, ... }
18
- * @param {object} config normalised env config (unused today; kept for symmetry).
18
+ * @param {object} config normalised env config (reads `afterLand`).
19
19
  * In CHECKOUT mode there is no worktree: the branch is already checked out in the
20
20
  * primary checkout, so landing is a rebase in place, a switch to base, and the
21
21
  * fast-forward. `planIntegrateCheckout` below covers that; the shape of what it
22
22
  * returns is identical so callers need no second code path.
23
23
  *
24
24
  * @param {object} ctx { worktreeState: { dirty }, base, aheadOfBase, mainRepoPath }
25
- * @returns {object} { blocked, noop, reason, commands, base, branch }
25
+ * @returns {object} { blocked, noop, reason, commands, afterLand, base, branch }
26
26
  */
27
27
  function planIntegrate(spec, config, ctx) {
28
28
  const { worktreeState = {}, base, aheadOfBase, mainRepoPath } = ctx || {}
29
29
  const branch = spec.branch
30
- const result = { blocked: false, noop: false, reason: null, commands: [], base, branch }
30
+ const result = {
31
+ blocked: false, noop: false, reason: null, commands: [], afterLand: noAfterLand(), base, branch,
32
+ }
31
33
 
32
34
  // The completion edits must be committed first — never rebase a dirty tree.
33
35
  if (worktreeState.dirty) {
34
36
  return { ...result, blocked: true, reason: 'worktree has uncommitted changes — commit the completion first' }
35
37
  }
36
38
 
37
- // Nothing on the branch that isn't already on base → already landed.
39
+ // Nothing on the branch that isn't already on base → already landed. The
40
+ // after-land commands still apply: the land may have happened on another
41
+ // machine or in an earlier session, leaving them pending in THIS checkout.
38
42
  if (!aheadOfBase) {
39
- return { ...result, noop: true }
43
+ return { ...result, noop: true, afterLand: afterLandFor(config, mainRepoPath) }
40
44
  }
41
45
 
42
46
  return {
@@ -45,6 +49,7 @@ function planIntegrate(spec, config, ctx) {
45
49
  `git -C ${spec.worktreePath} rebase ${base}`,
46
50
  `git -C ${mainRepoPath} merge --ff-only ${branch}`,
47
51
  ],
52
+ afterLand: afterLandFor(config, mainRepoPath),
48
53
  }
49
54
  }
50
55
 
@@ -65,7 +70,9 @@ function planIntegrate(spec, config, ctx) {
65
70
  function planIntegrateCheckout(spec, config, ctx) {
66
71
  const { dirty, base, aheadOfBase, checkoutPath, onBranch } = ctx || {}
67
72
  const branch = spec.branch
68
- const result = { blocked: false, noop: false, reason: null, commands: [], base, branch }
73
+ const result = {
74
+ blocked: false, noop: false, reason: null, commands: [], afterLand: noAfterLand(), base, branch,
75
+ }
69
76
 
70
77
  // "Already landed" is answered FIRST, before any refusal. A landed spec needs
71
78
  // no action wherever the checkout happens to be standing — and after a
@@ -73,7 +80,7 @@ function planIntegrateCheckout(spec, config, ctx) {
73
80
  // would refuse the very spec this just finished landing. /spec-complete calls
74
81
  // integrate again on exactly that state.
75
82
  if (!aheadOfBase) {
76
- return { ...result, noop: true }
83
+ return { ...result, noop: true, afterLand: afterLandFor(config, checkoutPath) }
77
84
  }
78
85
  if (dirty) {
79
86
  return {
@@ -100,7 +107,20 @@ function planIntegrateCheckout(spec, config, ctx) {
100
107
  `git -C ${checkoutPath} switch ${base}`,
101
108
  `git -C ${checkoutPath} merge --ff-only ${branch}`,
102
109
  ],
110
+ afterLand: afterLandFor(config, checkoutPath),
103
111
  }
104
112
  }
105
113
 
106
- module.exports = { planIntegrate, planIntegrateCheckout }
114
+ // The after-land half of a plan: `config.afterLand`, to run in `cwd` (the
115
+ // primary checkout, standing on base) once the land commands have succeeded.
116
+ // A blocked plan carries the empty form — nothing landed, so nothing follows.
117
+ function afterLandFor(config, cwd) {
118
+ const commands = (config && Array.isArray(config.afterLand)) ? [...config.afterLand] : []
119
+ return { cwd: commands.length ? cwd : null, commands }
120
+ }
121
+
122
+ function noAfterLand() {
123
+ return { cwd: null, commands: [] }
124
+ }
125
+
126
+ module.exports = { planIntegrate, planIntegrateCheckout, afterLandFor }
@@ -139,12 +139,61 @@ function judgeMainWrite(ctx) {
139
139
  }
140
140
  }
141
141
 
142
+ /**
143
+ * The checkout a path lives in: the `--show-toplevel` of its nearest existing
144
+ * ancestor, or null when that is in no git repo at all.
145
+ *
146
+ * NEAREST EXISTING ANCESTOR, because a `Write` usually creates its file and
147
+ * often its folder too — `git -C` on a directory that does not exist yet fails,
148
+ * and reading that failure as "not in a repo" would wave through exactly the
149
+ * new file the guard exists for.
150
+ */
151
+ function checkoutOf(target, gitAt) {
152
+ let at = path.resolve(target)
153
+ while (!fs.existsSync(at)) {
154
+ const up = path.dirname(at)
155
+ if (up === at) return null
156
+ at = up
157
+ }
158
+ if (!fs.statSync(at).isDirectory()) at = path.dirname(at)
159
+ return gitAt(at)(['rev-parse', '--show-toplevel'])
160
+ }
161
+
162
+ // Two spellings of one directory — `/tmp` and `/private/tmp` on macOS — are
163
+ // the same directory. Cannot-tell is "not the same", the branch that allows.
164
+ function sameDir(a, b) {
165
+ try {
166
+ return fs.realpathSync(a) === fs.realpathSync(b)
167
+ } catch {
168
+ return false
169
+ }
170
+ }
171
+
172
+ const ALLOWS = { refuse: false, reason: null, branch: null, baseBranch: null }
173
+
142
174
  /**
143
175
  * Gather the facts and judge. The only IO in the module's hot path.
144
176
  *
145
177
  * `git` is an injected reader, as everywhere in this package: the caller owns
146
178
  * the child-process boundary, which is what keeps `judgeMainWrite` testable
147
179
  * without one.
180
+ *
181
+ * `opts.target` is the path being written, and when it is given IT decides
182
+ * where the write lands — not `dir`, which is only where the session stands.
183
+ * Judging the session's directory refused every write a session on the base
184
+ * branch made anywhere, its own scratchpad under /tmp included, while a session
185
+ * standing in a worktree could write into the primary checkout unseen
186
+ * (bug-main-guard-judges-cwd). `opts.gitAt(dir)` makes a reader for another
187
+ * directory; it is required alongside `target`.
188
+ *
189
+ * This reads the write's LOCATION, never its name, so it is not the exception
190
+ * list `judgeMainWrite` refuses to grow: a path inside the primary checkout is
191
+ * judged exactly as before, whatever it is called.
192
+ *
193
+ * WHAT WOULD FOOL THIS: a target in ANOTHER configured repo is judged against
194
+ * this session's repo, so it is allowed — that repo's guard is its own sessions'
195
+ * business. And without `target` (an older hook, a tool with no path) the
196
+ * session's directory is judged as it always was.
148
197
  */
149
198
  function checkMainWrite(dir, config, git, opts = {}) {
150
199
  // `present` from `loadEnvConfig` — the caller has it and this module must not
@@ -159,27 +208,37 @@ function checkMainWrite(dir, config, git, opts = {}) {
159
208
  // looking for the absence of a worktree marker.
160
209
  let isPrimary = false
161
210
  let primary = null
211
+ // Which checkout's HEAD decides "on the base branch". With a target that is
212
+ // the primary checkout's own, wherever the session is standing.
213
+ let branchGit = git
162
214
  try {
163
215
  primary = resolvePrimaryCheckout(dir, git)
164
- isPrimary = Boolean(primary) && path.resolve(primary) === path.resolve(dir)
216
+ if (opts.target && typeof opts.gitAt === 'function') {
217
+ const top = checkoutOf(opts.target, opts.gitAt)
218
+ isPrimary = Boolean(top) && Boolean(primary) && sameDir(top, primary)
219
+ if (!isPrimary) return ALLOWS
220
+ branchGit = opts.gitAt(primary)
221
+ } else {
222
+ isPrimary = Boolean(primary) && path.resolve(primary) === path.resolve(dir)
223
+ }
165
224
  } catch {
166
225
  // Not a git repo, or git unreadable. Cannot tell → allow.
167
- return { refuse: false, reason: null, branch: null, baseBranch: null }
226
+ return ALLOWS
168
227
  }
169
228
 
170
229
  let onBase = false
171
230
  let branch = null
172
231
  let baseBranch = null
173
232
  try {
174
- const state = assertPrimaryOnMain(config, git)
233
+ const state = assertPrimaryOnMain(config, branchGit)
175
234
  onBase = state.onBase
176
235
  branch = state.branch
177
236
  baseBranch = state.baseBranch
178
237
  } catch {
179
- return { refuse: false, reason: null, branch: null, baseBranch: null }
238
+ return ALLOWS
180
239
  }
181
240
 
182
- const root = isPrimary ? dir : primary || dir
241
+ const root = primary || dir
183
242
  const verdict = judgeMainWrite({
184
243
  configured,
185
244
  enabled,