@skitterbyte/skitterspec 22.5.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.
package/MIGRATION.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Migration guide
2
2
 
3
+ ## `@skitterbyte/skitterspec` v22 → v23 (the engine runs the worktree bootstrap)
4
+
5
+ ### Breaking change — `spec-env up` no longer prints the worktree steps
6
+
7
+ `spec-env up` (and `spec-env nospec`) used to end with a
8
+ `then, in the worktree, run:` block of shell commands: one per seeded file, the
9
+ env-file write, each `setup` command, and `docker compose … up -d`. They now end
10
+ with one line instead:
11
+
12
+ ```
13
+ then, run:
14
+ skitterspec spec-env bootstrap <name>
15
+ ```
16
+
17
+ **`spec-env bootstrap [<spec>]` runs those steps itself**, with the worktree
18
+ as its cwd: it seeds the gitignored files, writes `docker.envFile`, runs `setup`
19
+ and brings the stack up, in that order. The behaviour is the same as before: it
20
+ is idempotent, prints `seeded …` / `exists — skipped` / `not in main — skipped`
21
+ per seed, refuses to write through a symlinked env file, and stops at the first
22
+ failing step, naming it. `--docs` seeds only. `--plan` describes the steps
23
+ without running them, and `--json` reports each step and its outcome.
24
+
25
+ **Why.** The printed commands had to be copied word for word, and agents did
26
+ not copy them faithfully. A project's secrets hook refused any Bash call naming
27
+ a `.env` file, so every rewritten seed command was blocked and bootstrap stopped
28
+ half-way. The new command line names no file.
29
+
30
+ **What to change.** The shipped skills already call `bootstrap`; run
31
+ `skitterspec update` to take them. Anything of your own that ran the printed
32
+ block (a script, or a skill you edited) should run
33
+ `skitterspec spec-env bootstrap <name>` after the `git worktree add` instead.
34
+ Nothing in `env.config.json` changes.
35
+
36
+ **If you added a secrets-hook exception for the printed seed commands**, you
37
+ can remove it now. Nothing shipped prints them any more.
38
+
3
39
  ## `@skitterbyte/skitterspec` v21 → v22 (a review port per repo, and every render serves)
4
40
 
5
41
  ### Breaking change 1 — `review.serveOnRemote` is now `review.serve`
@@ -127,6 +163,12 @@ can run it.
127
163
  committed file, so it changes for everyone who pulls and leaves that tree dirty.
128
164
  The engine says so when it does.
129
165
 
166
+ ## `@skitterbyte/skitterspec-linear` v17 → v18 (the engine runs the worktree bootstrap)
167
+
168
+ The same change as `@skitterbyte/skitterspec` v22 → v23 above — this
169
+ distribution composes the same engine. Read that entry; nothing here is
170
+ Linear-specific.
171
+
130
172
  ## `@skitterbyte/skitterspec-linear` v16 → v17 (a review port per repo, and every render serves)
131
173
 
132
174
  The same two changes as `@skitterbyte/skitterspec` v21 → v22 above — this
@@ -5,7 +5,7 @@ stack + host dev servers + a front-door proxy per in-progress spec).
5
5
  Provisioning is folded into `/spec-start`, teardown into `/spec-complete` ·
6
6
  `/spec-cancel`, traffic diversion is `/spec-connect`, and reading a spec's diff
7
7
  is `/spec-diff`; the `skitterspec spec-env
8
- <up|nospec|main|down|prune|dev|connect|integrate|hotfix|live|review|stage|status|resolve>` CLI is
8
+ <up|bootstrap|nospec|main|down|prune|dev|connect|integrate|hotfix|live|review|stage|status|resolve>` CLI is
9
9
  the engine beneath them.
10
10
 
11
11
  **Once this file is present, isolation is the default policy:** `/spec-start` gives
@@ -90,8 +90,9 @@ through to a documented conservative default; see the field notes below.
90
90
  "projectNamePattern": "{repoSlug}_{slug}", // → COMPOSE_PROJECT_NAME
91
91
  "portBase": 3000, // first port of slot 0's block
92
92
  "portsPerSpec": 10, // block width; slot n → portBase + n*portsPerSpec
93
- // Written into the worktree, and passed to compose as `--env-file`. A
94
- // Docker spec's worktree steps run in this order: seedFiles, this env file
93
+ // Written into the worktree by `spec-env bootstrap`, and passed to compose
94
+ // as `--env-file`. A Docker spec's worktree steps run in this order:
95
+ // seedFiles, this env file
95
96
  // (COMPOSE_PROJECT_NAME and PORT_OFFSET appended only where missing, so a
96
97
  // value `setup` or you put there is kept), `setup`, then
97
98
  // `docker compose … up -d` — all from inside the worktree, so the stack
@@ -106,7 +107,7 @@ through to a documented conservative default; see the field notes below.
106
107
  },
107
108
 
108
109
  // Gitignored files seeded from the primary checkout into a fresh worktree by
109
- // `spec-env up`, right after `git worktree add` and BEFORE `setup` runs — so a
110
+ // `spec-env bootstrap`, after `git worktree add` and BEFORE `setup` runs — so a
110
111
  // fresh linked worktree (which starts with none of the repo's gitignored files)
111
112
  // has the .env / local secret overrides / local config that setup steps and
112
113
  // git hooks depend on. Without this any setup step that reads .env — a schema
@@ -116,9 +117,9 @@ through to a documented conservative default; see the field notes below.
116
117
  // stays in sync; "copy" makes an independent copy.
117
118
  // files repo-relative paths to seed. A source absent in main is a printed
118
119
  // no-op (not an error); a target that already exists is left untouched
119
- // (idempotent — safe when `spec-env up` re-attaches an existing
120
- // worktree). The main checkout is resolved robustly at run time via
121
- // `git rev-parse --git-common-dir` — no hardcoded repo name or path.
120
+ // (idempotent — safe to re-run `spec-env bootstrap` over an existing
121
+ // worktree). Seeding is done by the engine itself, never printed as
122
+ // shell, so no command line ever names one of these files.
122
123
  // Shorthand: `"seedFiles": [".env", …]` == `{ "mode": "symlink", "files": […] }`.
123
124
  // `.env` is an ILLUSTRATION, not an expectation — skitterspec never reads any
124
125
  // of these files, it only links or copies them. Name whatever your stack keeps
@@ -134,12 +135,12 @@ through to a documented conservative default; see the field notes below.
134
135
  "files": [".env"]
135
136
  },
136
137
 
137
- // Bootstrap commands `spec-env up <spec>` runs IN the worktree, right after
138
- // `git worktree add` (before Docker/dev), on every provision including
138
+ // Bootstrap commands `spec-env bootstrap <spec>` runs IN the worktree, after
139
+ // the seeds and the env file and before the stack, on every provision including
139
140
  // re-attach — so a fresh worktree's dependencies exist and git hooks,
140
141
  // typechecks, builds and tests work immediately instead of failing on a
141
- // missing node_modules. An array, run in order; [] = none. Each string is a
142
- // shell command; {slug}/{branch}/{worktreePath}/{projectName}/{portOffset}
142
+ // missing node_modules. An array, run in order, stopping at the first that
143
+ // exits non-zero; [] = none. Each string is a shell command; {slug}/{branch}/{worktreePath}/{projectName}/{portOffset}
143
144
  // expand (the cwd is already the worktree, so {worktreePath} is usually
144
145
  // redundant). Example: ["pnpm install --frozen-lockfile"].
145
146
  "setup": [],
@@ -107,19 +107,36 @@ function findEngine(projectDir) {
107
107
  // skills carry the rule for everything this matcher cannot see.
108
108
  const WRITE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
109
109
 
110
+ // The path a write tool names: `file_path` for Edit, Write and MultiEdit,
111
+ // `notebook_path` for NotebookEdit. Anything else is no path at all.
112
+ function writeTarget(input) {
113
+ if (!input || typeof input !== 'object') return null
114
+ for (const key of ['file_path', 'notebook_path']) {
115
+ if (typeof input[key] === 'string' && input[key]) return input[key]
116
+ }
117
+ return null
118
+ }
119
+
110
120
  function main() {
111
121
  const payload = readPayload()
112
122
  if (!payload) allow()
113
123
 
114
124
  if (!WRITE_TOOLS.has(payload.tool_name)) allow()
115
125
 
116
- // The write's own directory decides the answer — a worktree is its own
117
- // checkout, and only the primary one is this guard's business.
126
+ // A worktree is its own checkout, and only the primary one is this guard's
127
+ // business. `cwd` is where the session stands; the target below is where the
128
+ // write lands, and the engine prefers it.
118
129
  const cwd = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd()
119
130
  const session = typeof payload.session_id === 'string' ? payload.session_id : ''
120
131
 
121
132
  const argv = ['spec-env', 'main', 'check', '--dir', cwd]
122
133
  if (session) argv.push('--session', session)
134
+ // The FILE decides where the write lands, not the session's directory. Judged
135
+ // on `cwd` alone, a session on the base branch was refused its own scratchpad
136
+ // under /tmp, and a session in a worktree could write into the primary
137
+ // checkout unseen. No path in the payload → the engine judges `cwd`, as before.
138
+ const target = writeTarget(payload.tool_input)
139
+ if (target) argv.push('--path', target)
123
140
 
124
141
  const result = spawnSync(findEngine(cwd), argv, { cwd, encoding: 'utf8', timeout: 8000 })
125
142
 
@@ -142,4 +159,4 @@ if (require.main === module) {
142
159
  }
143
160
  }
144
161
 
145
- module.exports = { findEngine, WRITE_TOOLS }
162
+ module.exports = { findEngine, WRITE_TOOLS, writeTarget }
@@ -178,8 +178,11 @@ your canonical `localhost` ports so you can test it at the normal URL
178
178
  move, header edits, the code) happens on the spec's branch in the worktree; `main`
179
179
  changes only when it merges. Teardown is folded into `/spec-complete` ·
180
180
  `/spec-cancel`. Beneath the skills, `skitterspec spec-env
181
- <up|nospec|main|down|prune|dev|connect|integrate|hotfix|live|review|stage|status|resolve>` is the CLI
182
- engine. **Omit the spec name anywhere and it uses the worktree you are standing in**,
181
+ <up|bootstrap|nospec|main|down|prune|dev|connect|integrate|hotfix|live|review|stage|status|resolve>` is the CLI
182
+ engine. `up` plans the fork;
183
+ **`bootstrap` then runs the worktree's own steps itself** — seeding the gitignored files, the env file, `setup`, the stack — so
184
+ the command a skill runs names no file, and a secrets hook that refuses any
185
+ `.env` path has nothing to refuse. **Omit the spec name anywhere and it uses the worktree you are standing in**,
183
186
  else the sole provisioned spec — with no exceptions left: a bare
184
187
  `/spec-connect` connects, and a bare `/spec-live` takes, where both once meant
185
188
  `main`. Teardown drops
@@ -46,9 +46,23 @@ skitterspec spec-env nospec <name>
46
46
  ```
47
47
 
48
48
  It records the name as a **specless branch** and prints the plan. Run the
49
- commands it prints, then move this session into the worktree with a plain `cd`
50
- — the Bash working directory persists between calls, so from here on the work
51
- happens on its own branch.
49
+ `git worktree add` it prints, then move this session into the worktree with a
50
+ plain `cd` and run the `then, run:` line where there is one:
51
+
52
+ ```
53
+ cd "<worktreePath>" && skitterspec spec-env bootstrap <name>
54
+ ```
55
+
56
+ That runs the project's `setup` in the worktree and stops at the first failing
57
+ command, naming it. The Bash working directory persists between calls, so from
58
+ here on the work happens on its own branch.
59
+
60
+ **The worktree steps are the engine's to run, never yours.** Seeding the
61
+ gitignored files, the env file, `setup` and the stack all happen inside
62
+ `skitterspec spec-env bootstrap <name>` — one call that names no file. Do not
63
+ reconstruct them as shell: they used to be printed that way, an agent's copy was
64
+ never faithful, and a project's secrets hook refused every rewrite that named a
65
+ `.env` path. `bootstrap --plan` says what it would do, for reading only.
52
66
 
53
67
  **Confirm the move landed** rather than reading silence as success
54
68
  (`.claude/rules/negative-checks.md` rule 1): `skitterspec spec-env resolve` with
@@ -87,9 +87,11 @@ a worktree costs nothing to obtain and everything is still ahead of it:
87
87
  skitterspec spec-env up <name> --docs
88
88
  ```
89
89
 
90
- Run the commands it prints, then move this session into the worktree with a
91
- plain `cd` — the Bash working directory persists between calls, so from here on
92
- the spec is written on its own branch. Confirm the move landed rather than
90
+ Run the commands it prints under `to provision, run:`, and the
91
+ `skitterspec spec-env bootstrap <name> --docs` under `then, run:` where there is
92
+ one (it seeds the gitignored files, nothing more). Then move this session into
93
+ the worktree with a plain `cd` — the Bash working directory persists between
94
+ calls, so from here on the spec is written on its own branch. Confirm the move landed rather than
93
95
  reading silence as success (`.claude/rules/negative-checks.md` rule 1):
94
96
  `skitterspec spec-env resolve` with no argument must name this spec. If it does
95
97
  not, say so and write the spec where you are standing instead; a spec in the
@@ -378,7 +380,7 @@ get the land through.
378
380
 
379
381
  **The worktree lifetime follows the verdict, and the asymmetry is deliberate.**
380
382
  `commit-start` keeps it because you are carrying straight on, and `/spec-start`
381
- re-runs `up` over it without `--docs` — which is where the setup commands
383
+ re-runs `up` and `bootstrap` over it without `--docs`, which is where the setup commands
382
384
  Phase B skipped finally run. Plain `commit` tears it down with
383
385
  `skitterspec spec-env down <name>`, because a spec parked in the backlog should
384
386
  hold no worktree and no `node_modules`; `/spec-start` provisions from scratch
@@ -59,8 +59,9 @@ skitterspec spec-env up bug-<name> --docs
59
59
  **The spec does not exist yet, and that is the lane.** `up` opens its authoring
60
60
  branch on exactly three signals — a spec it could not find, `--docs`, and a name
61
61
  shaped `bug-<something>` — records the branch so the name resolves from here on,
62
- and prints a `git worktree add … -b bug/<name>` forked from `main`. Run what it
63
- prints, then move this session into the worktree with a plain `cd`; the Bash
62
+ and prints a `git worktree add … -b bug/<name>` forked from `main`. Run it, and
63
+ the `skitterspec spec-env bootstrap bug-<name> --docs` under `then, run:` where
64
+ there is one, then move this session into the worktree with a plain `cd`; the Bash
64
65
  working directory persists between calls, so from here on the test, the fix and
65
66
  the spec are all written on the bug's own branch. Confirm the move landed rather
66
67
  than reading silence as success (`.claude/rules/negative-checks.md` rule 1):
@@ -80,7 +81,15 @@ tree *runnable* — because provisioning somewhere to write markdown should cost
80
81
  steps from here, in this same tree, and
81
82
  **a tree that cannot run the suite cannot go red**.
82
83
  The flagless re-run re-attaches the worktree that now exists
83
- (no second `-b` fork) and prints the bootstrap steps the flag deferred.
84
+ (no second `-b` fork) and prints `skitterspec spec-env bootstrap bug-<name>` —
85
+ run it, and it runs the steps the flag deferred.
86
+
87
+ **The worktree steps are the engine's to run, never yours.** Seeding the
88
+ gitignored files, the env file, `setup` and the stack all happen inside
89
+ `skitterspec spec-env bootstrap <name>` — one call that names no file. Do not
90
+ reconstruct them as shell: they used to be printed that way, an agent's copy was
91
+ never faithful, and a project's secrets hook refused every rewrite that named a
92
+ `.env` path. `bootstrap --plan` says what it would do, for reading only.
84
93
 
85
94
  **Why the stub is no longer seeded on the base branch.** This step used to open
86
95
  by writing `specs/in-progress/bug-<name>/00-overview.md` in the primary checkout
@@ -79,7 +79,9 @@ through to `git worktree add`, which refuses far more loudly than a planner
79
79
  guessing from an absence.
80
80
 
81
81
  Run what it prints — the `git worktree add … -b hotfix/<slug> <tag>` is checked
82
- out **at the tag**, not at `main` — then move this session into the worktree
82
+ out **at the tag**, not at `main`, and the
83
+ `skitterspec spec-env bootstrap hotfix-<name> --docs` under `then, run:` where
84
+ there is one — then move this session into the worktree
83
85
  with a plain `cd`; the Bash working directory persists between calls, so from
84
86
  here on the test, the fix and the spec are all written on the hotfix's own
85
87
  branch. Confirm the move landed rather than reading silence as success
@@ -100,7 +102,15 @@ to: §4 runs the project's suite in this same tree, and
100
102
  **a tree that cannot run the suite cannot go red**.
101
103
  The flagless re-run re-attaches the worktree that now exists (no second `-b`
102
104
  fork, and no fork point — the branch is already on the tag's line) and prints
103
- the bootstrap steps the flag deferred.
105
+ `skitterspec spec-env bootstrap hotfix-<name>` — run it, and it runs the steps
106
+ the flag deferred.
107
+
108
+ **The worktree steps are the engine's to run, never yours.** Seeding the
109
+ gitignored files, the env file, `setup` and the stack all happen inside
110
+ `skitterspec spec-env bootstrap <name>` — one call that names no file. Do not
111
+ reconstruct them as shell: they used to be printed that way, an agent's copy was
112
+ never faithful, and a project's secrets hook refused every rewrite that named a
113
+ `.env` path. `bootstrap --plan` says what it would do, for reading only.
104
114
 
105
115
  **Why nothing is seeded on the base branch, and nothing is moved.** This step
106
116
  used to write `specs/in-progress/hotfix-<name>/00-overview.md` in the primary
@@ -82,10 +82,11 @@ nothing to commit" as a sign something was skipped: it means the landing worked.
82
82
 
83
83
  **And the worktree may already exist**, because `Commit & Start` keeps it. That
84
84
  is a re-attach, not a clash: `spec-env up` plans the attach form rather than a
85
- second `-b` fork, and its `then, in the worktree, run:` step is where the
86
- `setup` commands `--docs` deliberately skipped finally run. So run the plan as
87
- printed — the install belongs to this moment, the first at which the tree is
88
- used for code — and report a re-attach rather than a fresh provision.
85
+ second `-b` fork, and its `then, run:` line — `spec-env bootstrap <name>`,
86
+ without `--docs` — is where the `setup` commands `--docs` deliberately skipped
87
+ finally run. So run it — the install belongs to this moment, the first at which
88
+ the tree is used for code — and report a re-attach rather than a fresh
89
+ provision.
89
90
 
90
91
  ## 2. Identify the spec
91
92
 
@@ -164,12 +165,22 @@ render says so. On **`--plan`** this step does not run at all.
164
165
  spec on its own:
165
166
 
166
167
  ```
167
- cd "<worktreePath>" && <the planner's "then, in the worktree, run:" steps>
168
+ cd "<worktreePath>" && skitterspec spec-env bootstrap <name>
168
169
  ```
169
170
 
170
- Run the seeding steps before `setup`: a fresh worktree has no dependencies and
171
- none of the repo's gitignored files, so hooks, typechecks and tests fail until
172
- both have happened.
171
+ That is the planner's `then, run:` line, with the `cd` in front. It seeds
172
+ the gitignored files before `setup` itself — a fresh worktree has neither, so
173
+ hooks, typechecks and tests fail until both have happened — and stops at the
174
+ first step that fails, naming it. Relay its step lines; a non-zero exit is a
175
+ failure to report at the moment it happens, not a reason to run the steps by
176
+ hand.
177
+
178
+ **The worktree steps are the engine's to run, never yours.** Seeding the
179
+ gitignored files, the env file, `setup` and the stack all happen inside
180
+ `skitterspec spec-env bootstrap <name>` — one call that names no file. Do not
181
+ reconstruct them as shell: they used to be printed that way, an agent's copy was
182
+ never faithful, and a project's secrets hook refused every rewrite that named a
183
+ `.env` path. `bootstrap --plan` says what it would do, for reading only.
173
184
 
174
185
  **Then confirm the move landed — never assume it.** Ask for a positive signal
175
186
  rather than reading silence as success (`.claude/rules/negative-checks.md`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec",
3
- "version": "22.5.0",
3
+ "version": "23.0.0",
4
4
  "description": "Spec-driven development for Claude Code \u2014 a tracker-free filesystem workflow: lifecycle skills and per-spec isolation. For Linear sync, install @skitterbyte/skitterspec-linear instead.",
5
5
  "keywords": [
6
6
  "claude",
package/src/cli.js CHANGED
@@ -29,6 +29,7 @@ const ENV_CONFIG_LABEL = 'env.config.json'
29
29
  // their own lines below.
30
30
  const SPEC_ENV_VERBS = Object.freeze([
31
31
  'up',
32
+ 'bootstrap',
32
33
  'down',
33
34
  'prune',
34
35
  'dev',
@@ -147,7 +148,8 @@ const {
147
148
  cacheHit,
148
149
  asCached,
149
150
  } = require('./env/reviewers.js')
150
- const { planUp, planCheckoutUp, worktreeCd, resolveStack } = require('./env/provision.js')
151
+ const { planUp, planCheckoutUp, resolveStack, worktreeSteps } = require('./env/provision.js')
152
+ const { runBootstrap, describeStep } = require('./env/bootstrap.js')
151
153
  const { classifyDirtyTree, dirtyPaths, specDocsIn } = require('./env/classify.js')
152
154
  const { isGitCommit } = require('./env/commitcmd.js')
153
155
  const { planDown, planDownCheckout } = require('./env/teardown.js')
@@ -213,6 +215,10 @@ Usage:
213
215
  Docker stack (prints commands; creates nothing)
214
216
  --docs: a tree to write documents in — no
215
217
  setup commands, no Docker
218
+ bootstrap [spec] run the worktree's seeds, env file, setup
219
+ and stack itself, in the worktree — after
220
+ \`up\`'s worktree add (--docs: seeds only;
221
+ --plan: describe, run nothing; --json)
216
222
  nospec <name> record a branch with no spec document
217
223
  (/no-spec work) and print its plan
218
224
  main <check|allow|status>
@@ -669,8 +675,17 @@ function specEnvMain(dir, config, positional, flags = {}, present = true, invoke
669
675
  // checkout before it runs, so asking `dir` whether it is the primary
670
676
  // checkout always answers yes — and the guard would fire on a write inside
671
677
  // somebody's worktree, which is the one place it must never fire.
678
+ //
679
+ // `--path` is the file being written, and where it is given it decides: a
680
+ // write outside the primary checkout is none of this guard's business,
681
+ // whichever directory the session happens to be standing in.
672
682
  const from = invokedFrom || dir
673
- const verdict = checkMainWrite(from, config, gitReader(from), { sessionId, present })
683
+ const verdict = checkMainWrite(from, config, gitReader(from), {
684
+ sessionId,
685
+ present,
686
+ target: flags.path ? path.resolve(from, flags.path) : null,
687
+ gitAt: gitReader,
688
+ })
674
689
  if (!verdict.refuse) return
675
690
  process.stdout.write(`spec-env main: ${verdict.reason}\n`)
676
691
  process.exitCode = 1
@@ -805,9 +820,6 @@ function specEnvNospec(dir, config, name, flags = {}) {
805
820
  const trust = ensureWorktreeDirTrusted(dir, worktreeRootAbs)
806
821
  const attached = fs.existsSync(spec.worktreePath)
807
822
 
808
- const guard = worktreeCd(spec.worktreePath)
809
- const setupCommands = (config.setup || []).map((cmd) => `${guard}; ${cmd}`)
810
-
811
823
  const out = [
812
824
  `spec-env nospec: ${name} ` +
813
825
  (attached ? '(plan — worktree exists; will attach)' : '(plan — nothing created yet)'),
@@ -835,11 +847,7 @@ function specEnvNospec(dir, config, name, flags = {}) {
835
847
  ? ` git worktree add ${spec.worktreePath} ${spec.branch}`
836
848
  : ` git worktree add ${spec.worktreePath} -b ${spec.branch}`,
837
849
  )
838
- if (setupCommands.length) {
839
- out.push('')
840
- out.push(' then, in the worktree, run:')
841
- for (const cmd of setupCommands) out.push(` ${cmd}`)
842
- }
850
+ if ((config.setup || []).length) out.push(...bootstrapLines(name))
843
851
  if (flags.json) {
844
852
  process.stdout.write(
845
853
  JSON.stringify(
@@ -1100,21 +1108,189 @@ function specEnvUp(dir, config, specArg, flags = {}) {
1100
1108
  out.push('')
1101
1109
  out.push(' to provision, run:')
1102
1110
  for (const cmd of plan.commands) out.push(` ${cmd}`)
1103
- // Everything after the fork runs in the worktree, under one heading, in the
1104
- // order each step depends on the last: seeds (the env file may be one), the
1105
- // env file, setup (which may derive values into it), and only then the stack.
1106
- const worktreeSteps = [
1107
- ...plan.seedCommands,
1108
- ...(plan.envCommands || []),
1109
- ...plan.setupCommands,
1110
- ...(plan.dockerCommands || []),
1111
+ // Everything after the fork runs in the worktree, and the engine runs it: one
1112
+ // line naming the verb, never the steps as shell (see `specEnvBootstrap`).
1113
+ if (plan.steps.length) out.push(...bootstrapLines(spec.folder, { docs }))
1114
+ process.stdout.write(out.join('\n') + '\n')
1115
+ }
1116
+
1117
+ /**
1118
+ * `spec-env bootstrap [<spec>]` — bring a provisioned worktree to ready.
1119
+ *
1120
+ * Runs the steps `up` once printed as shell for the caller to copy —
1121
+ * seeds, the env file, `setup`, the stack — itself, with the worktree as cwd.
1122
+ * The caller's command line is `skitterspec spec-env bootstrap <name>` and
1123
+ * nothing more, so no `.env` path ever appears in it: there is nothing for a
1124
+ * secrets hook to refuse, and nothing for an agent to rewrite on the way.
1125
+ *
1126
+ * IT DOES NOT FORK. `git worktree add` and the spec's own commit stay `up`'s,
1127
+ * printed and run as before; this verb starts where they finish, so re-running
1128
+ * one step later — `setup` after a lockfile change — costs no re-plan.
1129
+ *
1130
+ * Refuses BEFORE ACTING, non-zero, when there is nothing to bootstrap yet: no
1131
+ * worktree on disk, or a docker spec with no registry slot (its port block
1132
+ * would be derived from nothing). Both name `spec-env up`.
1133
+ *
1134
+ * Specless (`/no-spec`) work runs `setup` only — `nospec` has never seeded and
1135
+ * still does not. Checkout mode has no worktree and says so, exit 0: the
1136
+ * primary checkout already has its dependencies.
1137
+ */
1138
+ function specEnvBootstrap(dir, config, specArg, flags = {}) {
1139
+ const docs = flags.docs === true
1140
+ const json = flags.json === true
1141
+ const say = (line) => process.stdout.write(line + '\n')
1142
+ const refuse = (msg) => {
1143
+ if (json) say(JSON.stringify({ ok: false, refused: msg }, null, 2))
1144
+ else say(`spec-env bootstrap: ${msg}`)
1145
+ process.exitCode = 1
1146
+ }
1147
+
1148
+ if (config.mode === 'checkout') {
1149
+ say(
1150
+ 'spec-env bootstrap: checkout mode has no worktree to bootstrap — the primary ' +
1151
+ 'checkout already has its dependencies.',
1152
+ )
1153
+ return
1154
+ }
1155
+
1156
+ const spec = resolveSpecWithWorktree(dir, config, specArg)
1157
+ const name = spec.folder
1158
+
1159
+ if (!fs.existsSync(spec.worktreePath)) {
1160
+ refuse(
1161
+ `no worktree at ${spec.worktreePath} — run \`skitterspec spec-env up ${name}\` ` +
1162
+ 'and the worktree add it prints first.',
1163
+ )
1164
+ return
1165
+ }
1166
+ // The primary checkout answering as the worktree would seed every file onto
1167
+ // itself and report `exists — skipped`, indistinguishable from a healthy
1168
+ // re-run, while `setup` ran against the base branch.
1169
+ if (sameDir(spec.worktreePath, dir)) {
1170
+ refuse(`${spec.worktreePath} is the primary checkout, not ${name}'s worktree.`)
1171
+ return
1172
+ }
1173
+
1174
+ let steps
1175
+ // A `/no-spec` record — not the authoring record `up --docs` writes for a
1176
+ // spec not yet on disk, which is a spec and plans like one.
1177
+ if (spec.specless && !spec.unwritten) {
1178
+ steps = docs ? [] : (config.setup || []).map((command) => ({ kind: 'setup', command }))
1179
+ } else {
1180
+ const compose = composeFilePresent(dir, config)
1181
+ const { wantsDocker } = resolveStack(spec, config, { docs, composeFilePresent: compose })
1182
+ let offset = null
1183
+ if (wantsDocker) {
1184
+ const slots = readRegistry(dir, config).slots || {}
1185
+ if (!Object.prototype.hasOwnProperty.call(slots, name)) {
1186
+ refuse(
1187
+ `${name} wants a Docker stack and has no slot yet — run ` +
1188
+ `\`skitterspec spec-env up ${name}\` first.`,
1189
+ )
1190
+ return
1191
+ }
1192
+ offset = portOffset(slots[name], config)
1193
+ }
1194
+ steps = worktreeSteps(spec, offset, config, {
1195
+ docs,
1196
+ wantsDocker,
1197
+ composeFilePresent: compose,
1198
+ })
1199
+ }
1200
+
1201
+ if (flags.plan) {
1202
+ if (json) {
1203
+ say(
1204
+ JSON.stringify(
1205
+ { spec: name, worktree: spec.worktreePath, docs, planned: true, steps },
1206
+ null,
1207
+ 2,
1208
+ ),
1209
+ )
1210
+ return
1211
+ }
1212
+ say(`spec-env bootstrap: ${name} (plan — nothing run)`)
1213
+ say('')
1214
+ say(` worktree: ${spec.worktreePath}`)
1215
+ if (!steps.length) say(' steps: none — nothing configured to run')
1216
+ else steps.forEach((st, i) => say(` ${i + 1}. ${describeStep(st)}`))
1217
+ return
1218
+ }
1219
+
1220
+ if (!json) {
1221
+ say(`spec-env bootstrap: ${name}`)
1222
+ say(` worktree: ${spec.worktreePath}`)
1223
+ }
1224
+ const result = runBootstrap(steps, {
1225
+ cwd: spec.worktreePath,
1226
+ mainRoot: dir,
1227
+ // Report lines go to stdout as plain text; under --json they are carried in
1228
+ // the payload instead, and spawned output is moved to stderr with them.
1229
+ write: json ? () => {} : (line) => say(` ${line}`),
1230
+ childStdout: json ? process.stderr : 'inherit',
1231
+ })
1232
+
1233
+ if (json) {
1234
+ say(
1235
+ JSON.stringify(
1236
+ {
1237
+ spec: name,
1238
+ worktree: spec.worktreePath,
1239
+ docs,
1240
+ planned: false,
1241
+ ok: result.ok,
1242
+ failedAt: result.failedAt,
1243
+ steps: result.results.map((r) => ({ ...r.step, outcome: r.outcome, line: r.line })),
1244
+ remaining: steps.slice(result.results.length),
1245
+ },
1246
+ null,
1247
+ 2,
1248
+ ),
1249
+ )
1250
+ } else if (result.ok) {
1251
+ say(
1252
+ steps.length
1253
+ ? `spec-env bootstrap: ${name} ready (${steps.length} step${steps.length === 1 ? '' : 's'})`
1254
+ : `spec-env bootstrap: ${name} — nothing to do (no seedFiles, setup or stack configured)`,
1255
+ )
1256
+ } else {
1257
+ const failed = result.results[result.results.length - 1]
1258
+ process.stderr.write(` ${failed.line}\n`)
1259
+ say(
1260
+ `spec-env bootstrap: stopped at step ${result.failedAt} of ${steps.length} ` +
1261
+ `(${describeStep(failed.step)}) — nothing after it ran.`,
1262
+ )
1263
+ }
1264
+ if (!result.ok) process.exitCode = 1
1265
+ }
1266
+
1267
+ /**
1268
+ * The hand-off from a planner to `spec-env bootstrap`: the one command a caller
1269
+ * runs once the worktree exists.
1270
+ *
1271
+ * ONE LINE, AND NO SHELL. The worktree steps used to be printed here as
1272
+ * POSIX-sh one-liners, and an agent copying them rewrote them — merged two seeds
1273
+ * into a loop, reworded the echo text — at which point a consumer's secrets hook
1274
+ * refused the `.env` paths in the rewrite. This line names no file at all, so it
1275
+ * has nothing to rewrite and nothing to refuse. `bootstrap --plan` is where the
1276
+ * steps can still be read.
1277
+ */
1278
+ function bootstrapLines(name, { docs = false } = {}) {
1279
+ return [
1280
+ '',
1281
+ ' then, run:',
1282
+ ` skitterspec spec-env bootstrap ${name}${docs ? ' --docs' : ''}`,
1111
1283
  ]
1112
- if (worktreeSteps.length) {
1113
- out.push('')
1114
- out.push(' then, in the worktree, run:')
1115
- for (const cmd of worktreeSteps) out.push(` ${cmd}`)
1284
+ }
1285
+
1286
+ // Two spellings of one directory — `/tmp` and `/private/tmp` on macOS — are
1287
+ // the same directory. Cannot-tell (either missing) is "not the same".
1288
+ function sameDir(a, b) {
1289
+ try {
1290
+ return fs.realpathSync(a) === fs.realpathSync(b)
1291
+ } catch {
1292
+ return false
1116
1293
  }
1117
- process.stdout.write(out.join('\n') + '\n')
1118
1294
  }
1119
1295
 
1120
1296
  /**
@@ -5164,8 +5340,10 @@ async function specEnv(rest) {
5164
5340
  else if (args[i] === '--phase') flags.phase = args[++i]
5165
5341
  else if (args[i] === '--record-primary') flags.recordPrimary = true
5166
5342
  else if (args[i] === '--docs') flags.docs = true
5343
+ else if (args[i] === '--plan') flags.plan = true
5167
5344
  else if (args[i] === '--from') flags.from = args[++i]
5168
5345
  else if (args[i] === '--session') flags.session = args[++i]
5346
+ else if (args[i] === '--path') flags.path = args[++i]
5169
5347
  else if (args[i] === '--assert-primary-clean') flags.assertPrimaryClean = true
5170
5348
  else positional.push(args[i])
5171
5349
  }
@@ -5238,6 +5416,9 @@ async function specEnv(rest) {
5238
5416
  case 'up':
5239
5417
  specEnvUp(dir, config, positional[0], flags)
5240
5418
  break
5419
+ case 'bootstrap':
5420
+ specEnvBootstrap(dir, config, positional[0], flags)
5421
+ break
5241
5422
  case 'nospec':
5242
5423
  specEnvNospec(dir, config, positional[0], flags)
5243
5424
  break
@@ -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 }
@@ -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,
@@ -3,10 +3,10 @@
3
3
  /**
4
4
  * Pure provisioning planner for `spec-env up`.
5
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`, the
8
- * env file, `docker compose up`) and the rendered `.env` contents — but performs no side
9
- * effects itself. The caller (the CLI) reads/allocates the
6
+ * Given a resolved spec and its allocated slot, `planUp` returns the commands
7
+ * the caller runs from the primary checkout (the spec commit, `git worktree
8
+ * add`) and the worktree steps `spec-env bootstrap` runs afterwards (seeds, the
9
+ * env file, `setup`, the stack) — but performs no side effects itself. The caller (the CLI) reads/allocates the
10
10
  * registry and passes the slot; this stays deterministic and unit-testable with
11
11
  * no live git/docker.
12
12
  */
@@ -16,107 +16,6 @@ const { portOffset } = require('./registry.js')
16
16
  const { renderEnvFile } = require('./render.js')
17
17
  const { expandTokens } = require('./resolve.js')
18
18
 
19
- /**
20
- * Build the POSIX-sh prefix that puts a command in the spec's worktree, or stops.
21
- *
22
- * Every command under the "then, in the worktree, run:" heading carries this.
23
- * Without it those commands stay silent when run from the wrong place: in the
24
- * primary checkout `$m` resolves to the checkout itself, so a seed's source and
25
- * target are the same path and it prints `exists — skipped` — indistinguishable
26
- * from a correctly-provisioned re-run — while a setup command like an install
27
- * runs against the main checkout and "succeeds" too. Three layers then agree that
28
- * nothing is wrong, and the caller carries on committing to `main`.
29
- *
30
- * A `cd` is deliberately chosen over comparing `git rev-parse --show-toplevel`
31
- * against `worktreePath`: the planned path is a lexical `path.resolve` while git
32
- * reports the symlink-resolved one (`/tmp` vs `/private/tmp` on macOS), so a
33
- * string compare would refuse a perfectly good worktree. `cd` sidesteps that, and
34
- * does better than refusing — it *positions* the command, so a caller who is in
35
- * the wrong directory still gets correct behaviour. When the worktree was never
36
- * created — the reported case — the `cd` fails and takes the whole command with
37
- * it, non-zero and loud.
38
- *
39
- * Per-command, not once at the top of the block: a single leading `cd` only
40
- * protects the sequence if the caller chains it with `&&`, and the reported
41
- * failure is precisely a caller that ran the steps as separate statements.
42
- */
43
- function worktreeCd(worktreePath) {
44
- return (
45
- `cd "${worktreePath}" 2>/dev/null || ` +
46
- `{ echo "no worktree at ${worktreePath} — run the provisioning commands first"; exit 1; }`
47
- )
48
- }
49
-
50
- /**
51
- * Build one idempotent POSIX-sh command that seeds a gitignored file from the
52
- * main checkout into the current worktree (the cwd when the skill runs it).
53
- *
54
- * The main checkout is resolved at run time from inside the worktree via
55
- * `git rev-parse --git-common-dir` (absolute `<main>/.git` in a linked worktree)
56
- * and its dirname — never a hardcoded repo name or a `../..` hop. The command is
57
- * safe to re-run: a source absent in main is a printed no-op, an already-seeded
58
- * target (real file or symlink) is left untouched, and only a genuinely missing
59
- * target is created. `mode` is 'symlink' (points at main, stays in sync) or
60
- * 'copy' (an independent copy). Output mirrors the setup style: `seeded <f> → …`.
61
- */
62
- function seedCommandFor(file, mode) {
63
- const op =
64
- mode === 'copy'
65
- ? `cp "$m/${file}" "${file}"`
66
- : `ln -s "$m/${file}" "${file}"`
67
- return (
68
- 'm="$(dirname "$(git rev-parse --git-common-dir)")"; ' +
69
- `if [ ! -e "$m/${file}" ]; then echo "seed ${file}: not in main — skipped"; ` +
70
- `elif [ -e "${file}" ] || [ -L "${file}" ]; then echo "seed ${file}: exists — skipped"; ` +
71
- `else mkdir -p "$(dirname "${file}")" && ${op} && echo "seeded ${file} → $m/${file}"; fi`
72
- )
73
- }
74
-
75
-
76
- // Single-quote a value for POSIX sh: the only character that needs escaping
77
- // inside single quotes is the single quote itself.
78
- function shq(value) {
79
- return `'${String(value).replace(/'/g, `'\\''`)}'`
80
- }
81
-
82
- /**
83
- * Build one idempotent POSIX-sh command that writes the spec's env file in the
84
- * current worktree (the cwd when the skill runs it).
85
- *
86
- * It was prose once — "write <envFile> in the worktree:" — which left the write
87
- * to whoever read the plan, and a secrets guard that blocks any tool call naming
88
- * `.env` refused it. As a command it is one more step the plan runs.
89
- *
90
- * Idempotent and additive: it `touch`es the file and appends each key only when
91
- * no line already sets it, so a re-run changes nothing and a file `setup` has
92
- * already written to keeps every value it holds — including a deliberate
93
- * `PORT_OFFSET` of its own.
94
- *
95
- * A SYMLINK IS REFUSED, loudly. Seeding links gitignored files from the primary
96
- * checkout, so a seeded env file is the primary checkout's own; appending
97
- * through it would write this spec's port block into the main stack's
98
- * configuration. It runs after the seeds precisely so that collision is caught
99
- * rather than hidden. The fix is a `docker.envFile` naming a file of its own.
100
- */
101
- function envFileCommandFor(envFile, projectName, offset) {
102
- const f = shq(envFile)
103
- const ensure = (key, value) =>
104
- `{ grep -q '^${key}=' ${f} || echo ${shq(`${key}=${value}`)} >> ${f}; }`
105
- return (
106
- `if [ -L ${f} ]; then echo ${shq(
107
- `env file ${envFile} is a symlink — docker.envFile must name a file of its ` +
108
- "own, not a seeded one; refusing to write this spec's port block through it",
109
- )} >&2; exit 1; fi; ` +
110
- `touch ${f} && ` +
111
- // A file without a trailing newline would glue the first appended key onto
112
- // its last line.
113
- `{ [ ! -s ${f} ] || [ -z "$(tail -c 1 ${f})" ] || echo >> ${f}; } && ` +
114
- `${ensure('COMPOSE_PROJECT_NAME', projectName)} && ` +
115
- `${ensure('PORT_OFFSET', offset)} && ` +
116
- `echo ${shq(`env file ${envFile}: ready`)}`
117
- )
118
- }
119
-
120
19
  // Cap a path list in a message: enough to recognise, not a wall of text.
121
20
  function listPaths(paths) {
122
21
  const shown = paths.slice(0, 5).join(', ')
@@ -328,6 +227,71 @@ function resolveStack(spec, config, { docs = false, composeFilePresent } = {}) {
328
227
  return { stack, wantsDocker: stack === 'docker' && config.docker.enabled && !docs }
329
228
  }
330
229
 
230
+ /**
231
+ * The steps that bring a fresh worktree to ready, as data — the list
232
+ * `spec-env bootstrap` runs. In the order each depends on the last: seeds (the
233
+ * env file may be one), the env file, `setup` (which may derive values into
234
+ * it), and only then the stack.
235
+ *
236
+ * DATA AND NOT SHELL, and that is the reason the verb exists. These steps were
237
+ * printed as POSIX-sh one-liners for the agent to copy, and the copy was never
238
+ * faithful: merged into a loop, its echo text reworded, two steps glued into one
239
+ * call. A consumer's secrets hook — which refuses any Bash call naming a `.env`
240
+ * file and makes an exception only for the exact printed shape — then refused
241
+ * the rewrite, and bootstrap stopped with the user running it by hand. A step
242
+ * the engine runs itself has no text for anything to rewrite or refuse.
243
+ *
244
+ * `setup` entries stay shell strings: they are the project's own commands,
245
+ * written by the project. The docker step is an argv, run without a shell.
246
+ *
247
+ * Pure: `offset` is the spec's port offset (null without a stack), and every
248
+ * filesystem fact arrives in `opts`.
249
+ */
250
+ function worktreeSteps(spec, offset, config, opts = {}) {
251
+ const { docs = false, wantsDocker = false, composeFilePresent: compose } = opts
252
+ const seed = config.seedFiles || { mode: 'symlink', files: [] }
253
+ const mode = seed.mode === 'copy' ? 'copy' : 'symlink'
254
+ const tokens = {
255
+ worktreePath: spec.worktreePath,
256
+ slug: spec.slug,
257
+ branch: spec.branch,
258
+ projectName: spec.projectName,
259
+ portOffset: offset === null || offset === undefined ? '' : String(offset),
260
+ }
261
+ const envFile = (config.docker && config.docker.envFile) || '.env'
262
+ const steps = (seed.files || []).map((file) => ({ kind: 'seed', file, mode }))
263
+ if (wantsDocker) {
264
+ steps.push({ kind: 'envFile', file: envFile, projectName: spec.projectName, offset })
265
+ }
266
+ // Documents mode drops `setup` and nothing else: seeding is cheap and a spec
267
+ // author may well want the gitignored files present, while `setup` is the
268
+ // expensive half that exists to make the tree run. `wantsDocker` is already
269
+ // false under `--docs` (`resolveStack`), so the stack drops with it.
270
+ if (!docs) {
271
+ for (const cmd of config.setup || []) {
272
+ steps.push({ kind: 'setup', command: expandTokens(cmd, tokens) })
273
+ }
274
+ }
275
+ if (wantsDocker) {
276
+ const file = composeFileArg(config, compose).trim().split(/\s+/).filter(Boolean)
277
+ steps.push({
278
+ kind: 'docker',
279
+ argv: [
280
+ 'docker',
281
+ 'compose',
282
+ ...file,
283
+ '--env-file',
284
+ envFile,
285
+ '--project-name',
286
+ spec.projectName,
287
+ 'up',
288
+ '-d',
289
+ ],
290
+ })
291
+ }
292
+ return steps
293
+ }
294
+
331
295
  /**
332
296
  * Plan a provisioning run.
333
297
  *
@@ -338,10 +302,10 @@ function resolveStack(spec, config, { docs = false, composeFilePresent } = {}) {
338
302
  * @param {object} config normalised env config.
339
303
  * @param {object} opts { docs } — documents mode (see below). Optional.
340
304
  * @returns {object} { worktreePath, branch, projectName, slot, portOffset,
341
- * envContents, commands, seedCommands, envCommands,
342
- * setupCommands, dockerCommands, docs, attached }
305
+ * envContents, commands, steps, docs, attached }
343
306
  *
344
- * The worktree steps run in that order: seeds, env file, setup, compose.
307
+ * `commands` run from the primary checkout (the spec commit, the fork); `steps`
308
+ * are what `spec-env bootstrap` runs in the worktree afterwards.
345
309
  */
346
310
  function planUp(spec, alloc, config, ctx, opts = {}) {
347
311
  const { slot, attached } = alloc
@@ -374,33 +338,13 @@ function planUp(spec, alloc, config, ctx, opts = {}) {
374
338
  ? renderEnvFile({ projectName: spec.projectName, portOffset: offset })
375
339
  : null
376
340
 
377
- const tokens = {
378
- worktreePath: spec.worktreePath,
379
- slug: spec.slug,
380
- branch: spec.branch,
381
- projectName: spec.projectName,
382
- portOffset: offset === null ? '' : String(offset),
383
- }
384
-
385
-
386
- // File seeding runs *in the worktree* after `git worktree add`, before the
387
- // setup commands (which may depend on the seeded .env). Each entry becomes an
388
- // idempotent shell command resolving the main checkout at run time. Absent
389
- // config ⇒ no commands ⇒ current behaviour.
390
- const seed = config.seedFiles || { mode: 'symlink', files: [] }
391
- const seedMode = seed.mode === 'copy' ? 'copy' : 'symlink'
392
- const guard = worktreeCd(spec.worktreePath)
393
- const seedCommands = (seed.files || []).map(
394
- (file) => `${guard}; ${seedCommandFor(file, seedMode)}`,
395
- )
396
-
397
- // Bootstrap commands run *in the worktree* after `git worktree add` (before
398
- // Docker/dev), on every provision including re-attach — deps must exist for
399
- // the worktree to be usable. Kept separate from `commands` (run from the
400
- // primary checkout root); the CLI prints them under an "in the worktree" head.
401
- const setupCommands = (config.setup || []).map(
402
- (cmd) => `${guard}; ${expandTokens(cmd, tokens)}`,
403
- )
341
+ // Everything after the fork runs in the worktree, and `spec-env bootstrap`
342
+ // runs it — the steps are data, not shell for a caller to copy.
343
+ const steps = worktreeSteps(spec, offset, config, {
344
+ docs,
345
+ wantsDocker,
346
+ composeFilePresent: ctx ? ctx.composeFilePresent : undefined,
347
+ })
404
348
 
405
349
  const commands = []
406
350
  // Fresh branch → -b; attach an existing branch/slot → plain form (never clobber).
@@ -413,25 +357,6 @@ function planUp(spec, alloc, config, ctx, opts = {}) {
413
357
  ? `git worktree add ${spec.worktreePath} ${spec.branch}`
414
358
  : `git worktree add ${spec.worktreePath} -b ${spec.branch}${forkPoint}`,
415
359
  )
416
- // The stack comes up LAST, in the worktree: after the seeds, after the env
417
- // file, after `setup` — which may derive values into that file. It once ran
418
- // from the primary checkout before the worktree existed, so compose read the
419
- // primary checkout's env file, fell back to its defaults and collided with the
420
- // main stack. `--env-file` is always passed: with the default `.env` it is what
421
- // compose would have found anyway, and with any other name it is the only way
422
- // compose finds it.
423
- const envFile = (config.docker && config.docker.envFile) || '.env'
424
- const envCommands = wantsDocker
425
- ? [`${guard}; ${envFileCommandFor(envFile, spec.projectName, offset)}`]
426
- : []
427
- const dockerCommands = wantsDocker
428
- ? [
429
- `${guard}; docker compose` +
430
- composeFileArg(config, ctx ? ctx.composeFilePresent : undefined) +
431
- ` --env-file ${shq(envFile)} --project-name ${spec.projectName} up -d`,
432
- ]
433
- : []
434
-
435
360
  // The tree gate. A worktree forks from the base branch's tree, so an
436
361
  // uncommitted spec would produce a branch missing the spec it is for — this is
437
362
  // where that gets committed, or refused. Worktree mode had NO clean gate before
@@ -455,18 +380,13 @@ function planUp(spec, alloc, config, ctx, opts = {}) {
455
380
  portOffset: offset,
456
381
  envContents,
457
382
  commands: gate.blocked ? [] : [...gate.commands, ...commands],
458
- seedCommands: gate.blocked ? [] : seedCommands,
459
- envCommands: gate.blocked ? [] : envCommands,
460
- // Documents mode drops these, and keeps the seed commands above. The two are
461
- // not the same kind of thing: seeding links the gitignored files a worktree
462
- // has none of — cheap, and a spec author may well want `.env` present —
463
- // while `setup` is the expensive half that exists to make the tree run.
464
- setupCommands: gate.blocked || docs ? [] : setupCommands,
465
- dockerCommands: gate.blocked ? [] : dockerCommands,
383
+ // The worktree steps, in the order `spec-env bootstrap` runs them. Documents
384
+ // mode drops `setup` and the stack and keeps the seeds (`worktreeSteps`).
385
+ steps: gate.blocked ? [] : steps,
466
386
  // Reported so a caller reading `--json` can tell a documents worktree from a
467
- // full one POSITIVELY, rather than inferring it from what is missing. An
468
- // empty `setupCommands` also describes a project that configured no setup
469
- // at all, and those two must not read alike.
387
+ // full one POSITIVELY, rather than inferring it from what is missing. A step
388
+ // list with no `setup` also describes a project that configured none, and
389
+ // those two must not read alike.
470
390
  docs,
471
391
  attached,
472
392
  }
@@ -540,9 +460,7 @@ module.exports = {
540
460
  planUp,
541
461
  planCheckoutUp,
542
462
  planSpecCommit,
543
- seedCommandFor,
544
- envFileCommandFor,
545
- worktreeCd,
463
+ worktreeSteps,
546
464
  resolveStack,
547
465
  composeFileArg,
548
466
  }