@skitterbyte/skitterspec 22.5.0 → 23.0.1
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 +42 -0
- package/assets/core/env.config.md +12 -11
- package/assets/hooks/main-guard.cjs +20 -3
- package/assets/rules/spec-planning.md +5 -2
- package/assets/skills/no-spec/SKILL.md +18 -3
- package/assets/skills/spec/SKILL.md +6 -4
- package/assets/skills/spec-bug/SKILL.md +12 -3
- package/assets/skills/spec-hotfix/SKILL.md +12 -2
- package/assets/skills/spec-start/SKILL.md +19 -8
- package/package.json +1 -1
- package/src/cli.js +207 -23
- package/src/env/bootstrap.js +188 -0
- package/src/env/mainguard.js +64 -5
- package/src/env/provision.js +86 -168
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
|
|
94
|
-
// Docker spec's worktree steps run in this order:
|
|
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
|
|
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
|
|
120
|
-
// worktree).
|
|
121
|
-
//
|
|
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
|
|
138
|
-
//
|
|
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
|
|
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
|
-
//
|
|
117
|
-
//
|
|
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.
|
|
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,24 @@ 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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 seeds the project's gitignored files into the worktree, then runs its
|
|
57
|
+
`setup`, and stops at the first failing step, naming it. There is no stack: a
|
|
58
|
+
specless branch never gets an env file or Docker. The Bash working directory persists between calls, so from
|
|
59
|
+
here on the work happens on its own branch.
|
|
60
|
+
|
|
61
|
+
**The worktree steps are the engine's to run, never yours.** Seeding the
|
|
62
|
+
gitignored files, the env file, `setup` and the stack all happen inside
|
|
63
|
+
`skitterspec spec-env bootstrap <name>` — one call that names no file. Do not
|
|
64
|
+
reconstruct them as shell: they used to be printed that way, an agent's copy was
|
|
65
|
+
never faithful, and a project's secrets hook refused every rewrite that named a
|
|
66
|
+
`.env` path. `bootstrap --plan` says what it would do, for reading only.
|
|
52
67
|
|
|
53
68
|
**Confirm the move landed** rather than reading silence as success
|
|
54
69
|
(`.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
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
|
63
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
86
|
-
`setup` commands `--docs` deliberately skipped
|
|
87
|
-
|
|
88
|
-
used for code — and report a re-attach rather than a fresh
|
|
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>" &&
|
|
168
|
+
cd "<worktreePath>" && skitterspec spec-env bootstrap <name>
|
|
168
169
|
```
|
|
169
170
|
|
|
170
|
-
|
|
171
|
-
|
|
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": "
|
|
3
|
+
"version": "23.0.1",
|
|
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,
|
|
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), {
|
|
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,8 @@ 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
|
-
|
|
839
|
-
|
|
840
|
-
out.push(' then, in the worktree, run:')
|
|
841
|
-
for (const cmd of setupCommands) out.push(` ${cmd}`)
|
|
842
|
-
}
|
|
850
|
+
const seeds = (config.seedFiles && config.seedFiles.files) || []
|
|
851
|
+
if (seeds.length || (config.setup || []).length) out.push(...bootstrapLines(name))
|
|
843
852
|
if (flags.json) {
|
|
844
853
|
process.stdout.write(
|
|
845
854
|
JSON.stringify(
|
|
@@ -1100,21 +1109,191 @@ function specEnvUp(dir, config, specArg, flags = {}) {
|
|
|
1100
1109
|
out.push('')
|
|
1101
1110
|
out.push(' to provision, run:')
|
|
1102
1111
|
for (const cmd of plan.commands) out.push(` ${cmd}`)
|
|
1103
|
-
// Everything after the fork runs in the worktree,
|
|
1104
|
-
//
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1112
|
+
// Everything after the fork runs in the worktree, and the engine runs it: one
|
|
1113
|
+
// line naming the verb, never the steps as shell (see `specEnvBootstrap`).
|
|
1114
|
+
if (plan.steps.length) out.push(...bootstrapLines(spec.folder, { docs }))
|
|
1115
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* `spec-env bootstrap [<spec>]` — bring a provisioned worktree to ready.
|
|
1120
|
+
*
|
|
1121
|
+
* Runs the steps `up` once printed as shell for the caller to copy —
|
|
1122
|
+
* seeds, the env file, `setup`, the stack — itself, with the worktree as cwd.
|
|
1123
|
+
* The caller's command line is `skitterspec spec-env bootstrap <name>` and
|
|
1124
|
+
* nothing more, so no `.env` path ever appears in it: there is nothing for a
|
|
1125
|
+
* secrets hook to refuse, and nothing for an agent to rewrite on the way.
|
|
1126
|
+
*
|
|
1127
|
+
* IT DOES NOT FORK. `git worktree add` and the spec's own commit stay `up`'s,
|
|
1128
|
+
* printed and run as before; this verb starts where they finish, so re-running
|
|
1129
|
+
* one step later — `setup` after a lockfile change — costs no re-plan.
|
|
1130
|
+
*
|
|
1131
|
+
* Refuses BEFORE ACTING, non-zero, when there is nothing to bootstrap yet: no
|
|
1132
|
+
* worktree on disk, or a docker spec with no registry slot (its port block
|
|
1133
|
+
* would be derived from nothing). Both name `spec-env up`.
|
|
1134
|
+
*
|
|
1135
|
+
* Specless (`/no-spec`) work runs `setup` only — `nospec` has never seeded and
|
|
1136
|
+
* still does not. Checkout mode has no worktree and says so, exit 0: the
|
|
1137
|
+
* primary checkout already has its dependencies.
|
|
1138
|
+
*/
|
|
1139
|
+
function specEnvBootstrap(dir, config, specArg, flags = {}) {
|
|
1140
|
+
const docs = flags.docs === true
|
|
1141
|
+
const json = flags.json === true
|
|
1142
|
+
const say = (line) => process.stdout.write(line + '\n')
|
|
1143
|
+
const refuse = (msg) => {
|
|
1144
|
+
if (json) say(JSON.stringify({ ok: false, refused: msg }, null, 2))
|
|
1145
|
+
else say(`spec-env bootstrap: ${msg}`)
|
|
1146
|
+
process.exitCode = 1
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (config.mode === 'checkout') {
|
|
1150
|
+
say(
|
|
1151
|
+
'spec-env bootstrap: checkout mode has no worktree to bootstrap — the primary ' +
|
|
1152
|
+
'checkout already has its dependencies.',
|
|
1153
|
+
)
|
|
1154
|
+
return
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
1158
|
+
const name = spec.folder
|
|
1159
|
+
|
|
1160
|
+
if (!fs.existsSync(spec.worktreePath)) {
|
|
1161
|
+
refuse(
|
|
1162
|
+
`no worktree at ${spec.worktreePath} — run \`skitterspec spec-env up ${name}\` ` +
|
|
1163
|
+
'and the worktree add it prints first.',
|
|
1164
|
+
)
|
|
1165
|
+
return
|
|
1166
|
+
}
|
|
1167
|
+
// The primary checkout answering as the worktree would seed every file onto
|
|
1168
|
+
// itself and report `exists — skipped`, indistinguishable from a healthy
|
|
1169
|
+
// re-run, while `setup` ran against the base branch.
|
|
1170
|
+
if (sameDir(spec.worktreePath, dir)) {
|
|
1171
|
+
refuse(`${spec.worktreePath} is the primary checkout, not ${name}'s worktree.`)
|
|
1172
|
+
return
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
let steps
|
|
1176
|
+
// A `/no-spec` record — not the authoring record `up --docs` writes for a
|
|
1177
|
+
// spec not yet on disk, which is a spec and plans like one. It seeds and runs
|
|
1178
|
+
// `setup` exactly as a spec does, since a `setup` that reads a seeded `.env`
|
|
1179
|
+
// fails without it; what it never has is a stack, so no slot is consulted.
|
|
1180
|
+
if (spec.specless && !spec.unwritten) {
|
|
1181
|
+
steps = worktreeSteps(spec, null, config, { docs, wantsDocker: false })
|
|
1182
|
+
} else {
|
|
1183
|
+
const compose = composeFilePresent(dir, config)
|
|
1184
|
+
const { wantsDocker } = resolveStack(spec, config, { docs, composeFilePresent: compose })
|
|
1185
|
+
let offset = null
|
|
1186
|
+
if (wantsDocker) {
|
|
1187
|
+
const slots = readRegistry(dir, config).slots || {}
|
|
1188
|
+
if (!Object.prototype.hasOwnProperty.call(slots, name)) {
|
|
1189
|
+
refuse(
|
|
1190
|
+
`${name} wants a Docker stack and has no slot yet — run ` +
|
|
1191
|
+
`\`skitterspec spec-env up ${name}\` first.`,
|
|
1192
|
+
)
|
|
1193
|
+
return
|
|
1194
|
+
}
|
|
1195
|
+
offset = portOffset(slots[name], config)
|
|
1196
|
+
}
|
|
1197
|
+
steps = worktreeSteps(spec, offset, config, {
|
|
1198
|
+
docs,
|
|
1199
|
+
wantsDocker,
|
|
1200
|
+
composeFilePresent: compose,
|
|
1201
|
+
})
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
if (flags.plan) {
|
|
1205
|
+
if (json) {
|
|
1206
|
+
say(
|
|
1207
|
+
JSON.stringify(
|
|
1208
|
+
{ spec: name, worktree: spec.worktreePath, docs, planned: true, steps },
|
|
1209
|
+
null,
|
|
1210
|
+
2,
|
|
1211
|
+
),
|
|
1212
|
+
)
|
|
1213
|
+
return
|
|
1214
|
+
}
|
|
1215
|
+
say(`spec-env bootstrap: ${name} (plan — nothing run)`)
|
|
1216
|
+
say('')
|
|
1217
|
+
say(` worktree: ${spec.worktreePath}`)
|
|
1218
|
+
if (!steps.length) say(' steps: none — nothing configured to run')
|
|
1219
|
+
else steps.forEach((st, i) => say(` ${i + 1}. ${describeStep(st)}`))
|
|
1220
|
+
return
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
if (!json) {
|
|
1224
|
+
say(`spec-env bootstrap: ${name}`)
|
|
1225
|
+
say(` worktree: ${spec.worktreePath}`)
|
|
1226
|
+
}
|
|
1227
|
+
const result = runBootstrap(steps, {
|
|
1228
|
+
cwd: spec.worktreePath,
|
|
1229
|
+
mainRoot: dir,
|
|
1230
|
+
// Report lines go to stdout as plain text; under --json they are carried in
|
|
1231
|
+
// the payload instead, and spawned output is moved to stderr with them.
|
|
1232
|
+
write: json ? () => {} : (line) => say(` ${line}`),
|
|
1233
|
+
childStdout: json ? process.stderr : 'inherit',
|
|
1234
|
+
})
|
|
1235
|
+
|
|
1236
|
+
if (json) {
|
|
1237
|
+
say(
|
|
1238
|
+
JSON.stringify(
|
|
1239
|
+
{
|
|
1240
|
+
spec: name,
|
|
1241
|
+
worktree: spec.worktreePath,
|
|
1242
|
+
docs,
|
|
1243
|
+
planned: false,
|
|
1244
|
+
ok: result.ok,
|
|
1245
|
+
failedAt: result.failedAt,
|
|
1246
|
+
steps: result.results.map((r) => ({ ...r.step, outcome: r.outcome, line: r.line })),
|
|
1247
|
+
remaining: steps.slice(result.results.length),
|
|
1248
|
+
},
|
|
1249
|
+
null,
|
|
1250
|
+
2,
|
|
1251
|
+
),
|
|
1252
|
+
)
|
|
1253
|
+
} else if (result.ok) {
|
|
1254
|
+
say(
|
|
1255
|
+
steps.length
|
|
1256
|
+
? `spec-env bootstrap: ${name} ready (${steps.length} step${steps.length === 1 ? '' : 's'})`
|
|
1257
|
+
: `spec-env bootstrap: ${name} — nothing to do (no seedFiles, setup or stack configured)`,
|
|
1258
|
+
)
|
|
1259
|
+
} else {
|
|
1260
|
+
const failed = result.results[result.results.length - 1]
|
|
1261
|
+
process.stderr.write(` ${failed.line}\n`)
|
|
1262
|
+
say(
|
|
1263
|
+
`spec-env bootstrap: stopped at step ${result.failedAt} of ${steps.length} ` +
|
|
1264
|
+
`(${describeStep(failed.step)}) — nothing after it ran.`,
|
|
1265
|
+
)
|
|
1266
|
+
}
|
|
1267
|
+
if (!result.ok) process.exitCode = 1
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* The hand-off from a planner to `spec-env bootstrap`: the one command a caller
|
|
1272
|
+
* runs once the worktree exists.
|
|
1273
|
+
*
|
|
1274
|
+
* ONE LINE, AND NO SHELL. The worktree steps used to be printed here as
|
|
1275
|
+
* POSIX-sh one-liners, and an agent copying them rewrote them — merged two seeds
|
|
1276
|
+
* into a loop, reworded the echo text — at which point a consumer's secrets hook
|
|
1277
|
+
* refused the `.env` paths in the rewrite. This line names no file at all, so it
|
|
1278
|
+
* has nothing to rewrite and nothing to refuse. `bootstrap --plan` is where the
|
|
1279
|
+
* steps can still be read.
|
|
1280
|
+
*/
|
|
1281
|
+
function bootstrapLines(name, { docs = false } = {}) {
|
|
1282
|
+
return [
|
|
1283
|
+
'',
|
|
1284
|
+
' then, run:',
|
|
1285
|
+
` skitterspec spec-env bootstrap ${name}${docs ? ' --docs' : ''}`,
|
|
1111
1286
|
]
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// Two spellings of one directory — `/tmp` and `/private/tmp` on macOS — are
|
|
1290
|
+
// the same directory. Cannot-tell (either missing) is "not the same".
|
|
1291
|
+
function sameDir(a, b) {
|
|
1292
|
+
try {
|
|
1293
|
+
return fs.realpathSync(a) === fs.realpathSync(b)
|
|
1294
|
+
} catch {
|
|
1295
|
+
return false
|
|
1116
1296
|
}
|
|
1117
|
-
process.stdout.write(out.join('\n') + '\n')
|
|
1118
1297
|
}
|
|
1119
1298
|
|
|
1120
1299
|
/**
|
|
@@ -5164,8 +5343,10 @@ async function specEnv(rest) {
|
|
|
5164
5343
|
else if (args[i] === '--phase') flags.phase = args[++i]
|
|
5165
5344
|
else if (args[i] === '--record-primary') flags.recordPrimary = true
|
|
5166
5345
|
else if (args[i] === '--docs') flags.docs = true
|
|
5346
|
+
else if (args[i] === '--plan') flags.plan = true
|
|
5167
5347
|
else if (args[i] === '--from') flags.from = args[++i]
|
|
5168
5348
|
else if (args[i] === '--session') flags.session = args[++i]
|
|
5349
|
+
else if (args[i] === '--path') flags.path = args[++i]
|
|
5169
5350
|
else if (args[i] === '--assert-primary-clean') flags.assertPrimaryClean = true
|
|
5170
5351
|
else positional.push(args[i])
|
|
5171
5352
|
}
|
|
@@ -5238,6 +5419,9 @@ async function specEnv(rest) {
|
|
|
5238
5419
|
case 'up':
|
|
5239
5420
|
specEnvUp(dir, config, positional[0], flags)
|
|
5240
5421
|
break
|
|
5422
|
+
case 'bootstrap':
|
|
5423
|
+
specEnvBootstrap(dir, config, positional[0], flags)
|
|
5424
|
+
break
|
|
5241
5425
|
case 'nospec':
|
|
5242
5426
|
specEnvNospec(dir, config, positional[0], flags)
|
|
5243
5427
|
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 }
|
package/src/env/mainguard.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
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
|
|
238
|
+
return ALLOWS
|
|
180
239
|
}
|
|
181
240
|
|
|
182
|
-
const root =
|
|
241
|
+
const root = primary || dir
|
|
183
242
|
const verdict = judgeMainWrite({
|
|
184
243
|
configured,
|
|
185
244
|
enabled,
|
package/src/env/provision.js
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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,
|
|
342
|
-
* setupCommands, dockerCommands, docs, attached }
|
|
305
|
+
* envContents, commands, steps, docs, attached }
|
|
343
306
|
*
|
|
344
|
-
*
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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.
|
|
468
|
-
//
|
|
469
|
-
//
|
|
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
|
-
|
|
544
|
-
envFileCommandFor,
|
|
545
|
-
worktreeCd,
|
|
463
|
+
worktreeSteps,
|
|
546
464
|
resolveStack,
|
|
547
465
|
composeFileArg,
|
|
548
466
|
}
|