@skitterbyte/skitterspec 18.0.0 → 19.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 +208 -10
- package/README.md +53 -4
- package/assets/claude-md-section.md +38 -2
- package/assets/commands/spec-connect.md +2 -2
- package/assets/commands/spec-live.md +2 -2
- package/assets/core/env.config.json.example +6 -3
- package/assets/core/env.config.md +77 -30
- package/assets/review/page.html +1501 -0
- package/assets/rules/spec-planning.md +198 -10
- package/assets/rules/spec-reports.md +269 -0
- package/assets/skills/spec/SKILL.md +32 -4
- package/assets/skills/spec-bug/SKILL.md +113 -8
- package/assets/skills/spec-cancel/SKILL.md +84 -19
- package/assets/skills/spec-complete/SKILL.md +80 -23
- package/assets/skills/spec-diff/SKILL.md +564 -0
- package/assets/skills/spec-hotfix/SKILL.md +113 -10
- package/assets/skills/spec-init/SKILL.md +34 -7
- package/assets/skills/spec-next/SKILL.md +288 -6
- package/assets/skills/spec-review/SKILL.md +26 -3
- package/assets/skills/spec-reviewed/SKILL.md +241 -0
- package/assets/skills/spec-start/SKILL.md +283 -106
- package/assets/skills/spec-to-main/SKILL.md +28 -6
- package/package.json +11 -7
- package/src/cli.js +1513 -89
- package/src/env/building.js +143 -0
- package/src/env/config.js +42 -9
- package/src/env/provision.js +54 -15
- package/src/env/proxy.js +34 -1
- package/src/env/render.js +3 -12
- package/src/env/resolve.js +295 -9
- package/src/env/review.js +1329 -0
- package/src/env/serve.js +549 -0
- package/src/env/teardown.js +13 -6
- package/src/init.js +96 -1
- package/LICENSE +0 -21
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The primary-checkout leak guard.
|
|
5
|
+
*
|
|
6
|
+
* A phase can be built in a worktree from a session standing somewhere else —
|
|
7
|
+
* every write an absolute path, every command `cd`-prefixed. That works;
|
|
8
|
+
* `spec-env review` already reads a worktree exactly that way. What it cannot do
|
|
9
|
+
* is *prove* it worked: one relative path and the edit lands in the primary
|
|
10
|
+
* checkout, on the base branch, silently.
|
|
11
|
+
*
|
|
12
|
+
* So record what the primary checkout looked like before the build
|
|
13
|
+
* (`--record-primary`) and compare after it (`--assert-primary-clean`).
|
|
14
|
+
*
|
|
15
|
+
* The baseline is the whole point. `negative-checks.md` rule 1 asks for a
|
|
16
|
+
* positive signal rather than an absence, and "these were the dirty paths at a
|
|
17
|
+
* known moment" is one; a bare "the primary must be clean" check would accuse
|
|
18
|
+
* anyone who left an unrelated edit open in another window. Rule 4 supplies the
|
|
19
|
+
* third state: when the baseline is missing, or is for another spec, the check
|
|
20
|
+
* cannot tell — and cannot-tell reports and exits 0 rather than accusing.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const path = require('node:path')
|
|
24
|
+
|
|
25
|
+
// The state dir sits beside the registry file, e.g. `.spec-env` — the same
|
|
26
|
+
// convention `dev.js` uses for logs and pids.
|
|
27
|
+
function stateDir(config) {
|
|
28
|
+
return path.posix.dirname(config.registry) || '.spec-env'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Where the baseline lives. Gitignored with the rest of `.spec-env/`. */
|
|
32
|
+
function baselinePath(dir, config) {
|
|
33
|
+
return path.join(dir, stateDir(config), 'building.json')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Merge one or more newline-separated git path listings into a sorted, deduped
|
|
38
|
+
* array. Blank lines are dropped; nothing is parsed out of the line.
|
|
39
|
+
*
|
|
40
|
+
* DELIBERATELY NOT `git status --porcelain`, for two reasons, both found by
|
|
41
|
+
* running this against a real worktree:
|
|
42
|
+
*
|
|
43
|
+
* 1. Porcelain puts the path behind two fixed status columns and a space, and
|
|
44
|
+
* the repo's `gitReader` TRIMS its stdout — so the first line loses its
|
|
45
|
+
* leading space and a fixed `slice(3)` eats the first character of the
|
|
46
|
+
* first path. A guard that renames the file it is accusing you of is worse
|
|
47
|
+
* than no guard.
|
|
48
|
+
* 2. Porcelain reports stat-dirty entries: a file whose mtime moved but whose
|
|
49
|
+
* content is identical to HEAD. One was observed here, reported as ` M`
|
|
50
|
+
* with an empty `git diff`. Accusing someone of leaking a file they never
|
|
51
|
+
* changed is exactly the false accusation `negative-checks.md` exists to
|
|
52
|
+
* prevent.
|
|
53
|
+
*
|
|
54
|
+
* `git diff --name-only HEAD` compares CONTENT, and
|
|
55
|
+
* `git ls-files --others --exclude-standard` lists genuinely new files. Between
|
|
56
|
+
* them they answer "what did this tree gain?" without a column to miscount.
|
|
57
|
+
*/
|
|
58
|
+
function mergePaths(...listings) {
|
|
59
|
+
const out = new Set()
|
|
60
|
+
for (const listing of listings) {
|
|
61
|
+
for (const line of String(listing || '').split('\n')) {
|
|
62
|
+
const p = line.replace(/\r$/, '').trim()
|
|
63
|
+
if (p !== '') out.add(p)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return [...out].sort()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Are these two paths the same tree? */
|
|
70
|
+
function sameTree(a, b) {
|
|
71
|
+
return path.resolve(a) === path.resolve(b)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The record written by `--record-primary`. Pure. */
|
|
75
|
+
function buildBaseline({ spec, worktreePath, primary, paths }) {
|
|
76
|
+
return { spec, worktreePath, primary, paths: [...paths].sort() }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compare a recorded baseline against the primary checkout's current state.
|
|
81
|
+
*
|
|
82
|
+
* Three verdicts, never two (`negative-checks.md` rule 4):
|
|
83
|
+
* - `clean` — nothing new appeared; say nothing and exit 0.
|
|
84
|
+
* - `leaked` — these paths appeared since; name them and exit non-zero.
|
|
85
|
+
* - `unknown` — the check cannot tell; say why and exit 0.
|
|
86
|
+
*
|
|
87
|
+
* Pure: every input is supplied, nothing is read from disk or git.
|
|
88
|
+
*
|
|
89
|
+
* WHAT WOULD FOOL THIS CHECK, deliberately unhandled:
|
|
90
|
+
* - Work the build COMMITTED in the primary checkout. A commit empties the
|
|
91
|
+
* porcelain, so a leak that was tidied away looks identical to no leak. The
|
|
92
|
+
* guard is aimed at the actual failure mode — writes going astray mid-build,
|
|
93
|
+
* while nothing has been committed yet — and runs before the phase commit
|
|
94
|
+
* for that reason.
|
|
95
|
+
* - A write to a GITIGNORED path in the primary (`.spec-env/` itself, build
|
|
96
|
+
* output, `node_modules`). `git status` cannot see it, by design, and the
|
|
97
|
+
* baseline file lives in exactly such a path so that recording it is not
|
|
98
|
+
* itself a change the next comparison trips over.
|
|
99
|
+
* - A path dirty at record time and then edited FURTHER by the build. It is in
|
|
100
|
+
* the baseline, so it stays silent. That is the deliberate trade: silence
|
|
101
|
+
* there costs a missed edit to a file someone was already working on, and
|
|
102
|
+
* the opposite default accuses every healthy concurrent edit.
|
|
103
|
+
*/
|
|
104
|
+
function compare(baseline, currentPaths, { spec, worktreePath, primary }) {
|
|
105
|
+
if (sameTree(primary, worktreePath)) {
|
|
106
|
+
return {
|
|
107
|
+
verdict: 'unknown',
|
|
108
|
+
paths: [],
|
|
109
|
+
reason: 'the worktree IS the primary checkout — there is no second tree to leak into',
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!baseline) {
|
|
113
|
+
return { verdict: 'unknown', paths: [], reason: 'no baseline recorded' }
|
|
114
|
+
}
|
|
115
|
+
if (baseline.spec !== spec) {
|
|
116
|
+
return {
|
|
117
|
+
verdict: 'unknown',
|
|
118
|
+
paths: [],
|
|
119
|
+
reason: `the baseline was recorded for ${baseline.spec}, not ${spec}`,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!sameTree(baseline.worktreePath, worktreePath)) {
|
|
123
|
+
return {
|
|
124
|
+
verdict: 'unknown',
|
|
125
|
+
paths: [],
|
|
126
|
+
reason: 'the baseline records a different worktree for this spec',
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const before = new Set(baseline.paths || [])
|
|
130
|
+
const appeared = [...currentPaths].filter((p) => !before.has(p)).sort()
|
|
131
|
+
return appeared.length === 0
|
|
132
|
+
? { verdict: 'clean', paths: [], reason: null }
|
|
133
|
+
: { verdict: 'leaked', paths: appeared, reason: null }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
stateDir,
|
|
138
|
+
baselinePath,
|
|
139
|
+
mergePaths,
|
|
140
|
+
sameTree,
|
|
141
|
+
buildBaseline,
|
|
142
|
+
compare,
|
|
143
|
+
}
|
package/src/env/config.js
CHANGED
|
@@ -27,7 +27,6 @@
|
|
|
27
27
|
* dev: [ { name, command, portVar, health?, frontPort? } ], // host dev
|
|
28
28
|
* // servers started on the spec's port block (empty = none)
|
|
29
29
|
* proxy: { enabled, host }, // bundled front-door proxy (spec-env connect)
|
|
30
|
-
* open: { command }, // optional, editor/terminal-agnostic opener
|
|
31
30
|
* registry: ".spec-env/registry.json",
|
|
32
31
|
* branch: { pattern, identifierField }, // git branch naming (provider-neutral)
|
|
33
32
|
* spec: { companionPaths: [ "path", ... ] }, // paths that belong to a
|
|
@@ -86,7 +85,6 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
86
85
|
// Front-door proxy (`spec-env connect`): a bundled Node reverse proxy that
|
|
87
86
|
// exposes one connected spec's frontPort processes on the canonical ports.
|
|
88
87
|
proxy: Object.freeze({ enabled: true, host: '127.0.0.1' }),
|
|
89
|
-
open: Object.freeze({ command: '' }),
|
|
90
88
|
registry: '.spec-env/registry.json',
|
|
91
89
|
// Git branch naming, provider-neutral. `pattern` expands {type}/{slug} and,
|
|
92
90
|
// when a tracker provider is linked, {identifier}; `identifierField` names the
|
|
@@ -104,11 +102,31 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
104
102
|
baseBranch: '',
|
|
105
103
|
guards: Object.freeze({ refuseTeardownIfDirty: true, refuseTeardownIfUnpushed: true }),
|
|
106
104
|
// Teardown cleanup beyond this machine. `deleteRemoteBranch` decides what
|
|
107
|
-
// `spec-env down` does about
|
|
105
|
+
// `spec-env down` does about a branch the USER published by hand — nothing
|
|
106
|
+
// publishes one at provisioning, so there is often no remote ref at all and
|
|
107
|
+
// the plan then says nothing: "prompt" (default)
|
|
108
108
|
// plans the delete in its own confirm-first section for the skill to ask about,
|
|
109
109
|
// "never" omits it, "always" folds it into the run-blind command list. Only ever
|
|
110
110
|
// planned for a LANDED branch — see teardown.js.
|
|
111
111
|
teardown: Object.freeze({ deleteRemoteBranch: 'prompt' }),
|
|
112
|
+
|
|
113
|
+
// `reader` decides how a diff's location is worded, and — via
|
|
114
|
+
// `serveOnRemote` — whether the engine stands the local server up so a remote
|
|
115
|
+
// reader gets a link that opens. It never decides to PUBLISH: publishing
|
|
116
|
+
// leaves a page this tooling cannot remove, so it stays an explicit ask.
|
|
117
|
+
// `detect` sniffs; `local`/`remote` are the operator's own answer and are
|
|
118
|
+
// believed without sniffing, because they know where they are reading and no
|
|
119
|
+
// signal can outrank that.
|
|
120
|
+
// `commitWith` names the skill a COMMITTING verdict hands off to. `/commit`
|
|
121
|
+
// ships with skittership, a different package — so it may not be installed,
|
|
122
|
+
// and skitterspec must never vendor a copy of it.
|
|
123
|
+
//
|
|
124
|
+
// There is no off switch, and that is deliberate: `"none"` existed and was
|
|
125
|
+
// removed, because it produced the one thing a review page must not have —
|
|
126
|
+
// a verdict that records itself and does nothing. A review is the guard in
|
|
127
|
+
// front of an action; recording an approval for SOMEONE ELSE to act on is a
|
|
128
|
+
// different mechanism, not a value of this key.
|
|
129
|
+
review: Object.freeze({ reader: 'detect', servePort: 7777, serveOnRemote: true, commitWith: '/commit' }),
|
|
112
130
|
// Live overlay (`spec-env live`). `migrations` is a list of globs marking
|
|
113
131
|
// migration files; a branch that changes any of them is treated as stateful and
|
|
114
132
|
// `live take` refuses it (code-only v1). Default: none (nothing is stateful).
|
|
@@ -135,13 +153,13 @@ function defaults() {
|
|
|
135
153
|
setup: [],
|
|
136
154
|
dev: [],
|
|
137
155
|
proxy: { ...DEFAULT_CONFIG.proxy },
|
|
138
|
-
open: { ...DEFAULT_CONFIG.open },
|
|
139
156
|
registry: DEFAULT_CONFIG.registry,
|
|
140
157
|
branch: { ...DEFAULT_CONFIG.branch },
|
|
141
158
|
spec: { companionPaths: [] },
|
|
142
159
|
baseBranch: DEFAULT_CONFIG.baseBranch,
|
|
143
160
|
guards: { ...DEFAULT_CONFIG.guards },
|
|
144
161
|
teardown: { ...DEFAULT_CONFIG.teardown },
|
|
162
|
+
review: { ...DEFAULT_CONFIG.review },
|
|
145
163
|
live: { migrations: [] },
|
|
146
164
|
hotfix: { ...DEFAULT_CONFIG.hotfix, targets: [] },
|
|
147
165
|
}
|
|
@@ -269,11 +287,6 @@ function mergeConfig(base, parsed) {
|
|
|
269
287
|
assign(base.proxy, parsed.proxy, 'host', 'string')
|
|
270
288
|
}
|
|
271
289
|
|
|
272
|
-
if (isObject(parsed.open)) {
|
|
273
|
-
// command may be intentionally empty (no auto-open)
|
|
274
|
-
assign(base.open, parsed.open, 'command', 'string?')
|
|
275
|
-
}
|
|
276
|
-
|
|
277
290
|
if (isObject(parsed.branch)) {
|
|
278
291
|
assign(base.branch, parsed.branch, 'pattern', 'string')
|
|
279
292
|
assign(base.branch, parsed.branch, 'identifierField', 'string')
|
|
@@ -307,6 +320,26 @@ function mergeConfig(base, parsed) {
|
|
|
307
320
|
}
|
|
308
321
|
}
|
|
309
322
|
|
|
323
|
+
// An unrecognised reader falls through to `detect`, which is the state that
|
|
324
|
+
// claims least: it can answer "unknown", and unknown is wired to today's
|
|
325
|
+
// behaviour. A typo must never become a confident `local`, because a confident
|
|
326
|
+
// `local` is exactly the dead `file://` link this key exists to prevent.
|
|
327
|
+
if (isObject(parsed.review)) {
|
|
328
|
+
const reader = parsed.review.reader
|
|
329
|
+
if (reader === 'local' || reader === 'remote' || reader === 'detect') {
|
|
330
|
+
base.review.reader = reader
|
|
331
|
+
}
|
|
332
|
+
assign(base.review, parsed.review, 'servePort', 'number')
|
|
333
|
+
// Opting OUT is the only thing this key can do — a non-boolean leaves the
|
|
334
|
+
// default in place rather than being read as a refusal, so a typo cannot
|
|
335
|
+
// quietly restore the dead `file://` link on a remote reader.
|
|
336
|
+
assign(base.review, parsed.review, 'serveOnRemote', 'boolean')
|
|
337
|
+
// An empty string leaves `/commit` standing, like every other string key
|
|
338
|
+
// here. There is nothing it could mean instead: the hand-off has no off
|
|
339
|
+
// switch, so a blank value is a typo rather than an instruction.
|
|
340
|
+
assign(base.review, parsed.review, 'commitWith', 'string')
|
|
341
|
+
}
|
|
342
|
+
|
|
310
343
|
if (isObject(parsed.spec) && Array.isArray(parsed.spec.companionPaths)) {
|
|
311
344
|
base.spec.companionPaths = normalizeFileList(parsed.spec.companionPaths)
|
|
312
345
|
}
|
package/src/env/provision.js
CHANGED
|
@@ -5,15 +5,15 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Given a resolved spec and its allocated slot, `planUp` returns the exact
|
|
7
7
|
* side-effecting commands the `/spec-env` skill runs (`git worktree add`,
|
|
8
|
-
* `docker compose up`)
|
|
9
|
-
*
|
|
8
|
+
* `docker compose up`) and the rendered `.env` contents — but performs no side
|
|
9
|
+
* 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
|
*/
|
|
13
13
|
|
|
14
14
|
const { classifyDirtyTree } = require('./classify.js')
|
|
15
15
|
const { portOffset } = require('./registry.js')
|
|
16
|
-
const { renderEnvFile
|
|
16
|
+
const { renderEnvFile } = require('./render.js')
|
|
17
17
|
const { expandTokens } = require('./resolve.js')
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -114,11 +114,20 @@ function listPaths(paths) {
|
|
|
114
114
|
* tell (an unreadable git, or a hotfix, which forks from a tag predating its own
|
|
115
115
|
* spec), and routes to carrying on, never to refusing.
|
|
116
116
|
*
|
|
117
|
+
* FOREIGN DIRT IS THE SAME ASYMMETRY, for the same reason. Another spec's
|
|
118
|
+
* uncommitted work refuses in checkout mode, where `git switch -c` would carry
|
|
119
|
+
* it onto the new branch, and does not in worktree mode, where nothing carries
|
|
120
|
+
* anywhere. Refusing there fired on the commonest tree this workflow produces —
|
|
121
|
+
* author spec B while spec A is still uncommitted, then start B — and prevented
|
|
122
|
+
* nothing, since the only write to the primary checkout is a pathspec-limited
|
|
123
|
+
* commit of this spec's own paths. It is reported instead, on `foreign`.
|
|
124
|
+
*
|
|
117
125
|
* ctx: { dirtyPaths?, clean?, specOnFork?, specFoundOn?, forkRef?, specUntracked? }
|
|
118
|
-
* @returns {{blocked: boolean, reason: string|null, commands: string[]
|
|
126
|
+
* @returns {{blocked: boolean, reason: string|null, commands: string[],
|
|
127
|
+
* owned: string[], foreign: string[]}}
|
|
119
128
|
*/
|
|
120
129
|
function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
|
|
121
|
-
const ok = { blocked: false, reason: null, commands: [], owned: [], verb: null }
|
|
130
|
+
const ok = { blocked: false, reason: null, commands: [], owned: [], verb: null, foreign: [] }
|
|
122
131
|
const c = ctx || {}
|
|
123
132
|
|
|
124
133
|
if (!Array.isArray(c.dirtyPaths)) {
|
|
@@ -140,15 +149,30 @@ function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
|
|
|
140
149
|
|
|
141
150
|
const { owned, foreign } = classifyDirtyTree(spec, c.dirtyPaths, config)
|
|
142
151
|
|
|
143
|
-
|
|
152
|
+
// FOREIGN DIRT REFUSES ONLY WHERE IT CAN REACH THE BRANCH, which is checkout
|
|
153
|
+
// mode and nowhere else. `git switch -c` carries the working tree onto the new
|
|
154
|
+
// branch, silently, so there it is someone else's work being moved without
|
|
155
|
+
// them asking. `git worktree add` carries nothing and forks from a commit, so
|
|
156
|
+
// in worktree mode the same tree is simply none of this run's business.
|
|
157
|
+
//
|
|
158
|
+
// WHAT WOULD MAKE THIS UNSAFE AGAIN: an unbounded commit. The only thing this
|
|
159
|
+
// run does to the primary checkout is the spec commit below, and it is safe to
|
|
160
|
+
// leave a colleague's files sitting beside it only because that commit is
|
|
161
|
+
// pathspec-limited on BOTH halves (`git add --`, `git commit … --`) and so
|
|
162
|
+
// cannot reach a path it does not own — not even one another session has
|
|
163
|
+
// already staged into the shared index. Drop the `--` and this stops being a
|
|
164
|
+
// false guard and starts being a missing one.
|
|
165
|
+
if (foreign.length && carriesChanges) {
|
|
144
166
|
return {
|
|
145
167
|
blocked: true,
|
|
146
168
|
reason:
|
|
147
169
|
`the primary checkout has uncommitted changes that are not ${spec.folder}'s — ` +
|
|
148
|
-
'commit or stash them first' +
|
|
149
|
-
(carriesChanges ? ' (switching would carry them onto the new branch)' : '') +
|
|
170
|
+
'commit or stash them first (switching would carry them onto the new branch)' +
|
|
150
171
|
`: ${listPaths(foreign)}`,
|
|
151
172
|
commands: [],
|
|
173
|
+
owned: [],
|
|
174
|
+
verb: null,
|
|
175
|
+
foreign,
|
|
152
176
|
}
|
|
153
177
|
}
|
|
154
178
|
|
|
@@ -165,20 +189,34 @@ function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
|
|
|
165
189
|
? c.specUntracked
|
|
166
190
|
: owned.includes(`specs/${spec.bucket}/${spec.folder}`)
|
|
167
191
|
const verb = isNew ? 'add' : 'update'
|
|
192
|
+
// BOTH HALVES ARE PATHSPEC-LIMITED, and they answer different failures.
|
|
193
|
+
// `add` is what makes a path git has never seen committable at all — every
|
|
194
|
+
// brand-new spec folder is untracked. The `--` on the COMMIT is what bounds
|
|
195
|
+
// what lands: a checkout has one `.git/index` and every session standing in
|
|
196
|
+
// it shares that index, so a bare `git commit` takes whatever another
|
|
197
|
+
// session has already staged, however exactly this one named its own paths.
|
|
198
|
+
// Dropping the `--` would leave the naming above as decoration.
|
|
199
|
+
const paths = owned.map((p) => `"${p}"`).join(' ')
|
|
168
200
|
return {
|
|
169
201
|
blocked: false,
|
|
170
202
|
reason: null,
|
|
171
203
|
owned,
|
|
172
204
|
verb,
|
|
205
|
+
foreign,
|
|
173
206
|
commands: [
|
|
174
|
-
`git add
|
|
175
|
-
`git commit -m "chore(spec): ${verb} ${spec.folder}"`,
|
|
207
|
+
`git add -- ${paths}`,
|
|
208
|
+
`git commit -m "chore(spec): ${verb} ${spec.folder}" -- ${paths}`,
|
|
176
209
|
],
|
|
177
210
|
}
|
|
178
211
|
}
|
|
179
212
|
|
|
180
213
|
// Tree is clean. The spec must already be in the base branch's tree, or the
|
|
181
214
|
// worktree forks without it.
|
|
215
|
+
// Nothing of this spec's is uncommitted, but somebody else's may be — say so
|
|
216
|
+
// rather than returning the bare `ok`, or the caller has no way to report what
|
|
217
|
+
// it left alone.
|
|
218
|
+
if (foreign.length && c.specOnFork !== false) return { ...ok, foreign }
|
|
219
|
+
|
|
182
220
|
if (c.specOnFork === false) {
|
|
183
221
|
return {
|
|
184
222
|
blocked: true,
|
|
@@ -201,7 +239,7 @@ function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
|
|
|
201
239
|
* existed in the registry (re-run → attach, don't clobber).
|
|
202
240
|
* @param {object} config normalised env config.
|
|
203
241
|
* @returns {object} { worktreePath, branch, projectName, slot, portOffset,
|
|
204
|
-
* envContents,
|
|
242
|
+
* envContents, commands, seedCommands,
|
|
205
243
|
* setupCommands, attached }
|
|
206
244
|
*/
|
|
207
245
|
function planUp(spec, alloc, config, ctx) {
|
|
@@ -228,7 +266,6 @@ function planUp(spec, alloc, config, ctx) {
|
|
|
228
266
|
portOffset: offset === null ? '' : String(offset),
|
|
229
267
|
}
|
|
230
268
|
|
|
231
|
-
const openCommand = expandOpenCommand(config.open.command, tokens)
|
|
232
269
|
|
|
233
270
|
// File seeding runs *in the worktree* after `git worktree add`, before the
|
|
234
271
|
// setup commands (which may depend on the seeded .env). Each entry becomes an
|
|
@@ -276,13 +313,16 @@ function planUp(spec, alloc, config, ctx) {
|
|
|
276
313
|
blocked: gate.blocked,
|
|
277
314
|
reason: gate.reason,
|
|
278
315
|
specCommit: gate.owned && gate.owned.length ? { paths: gate.owned, verb: gate.verb } : null,
|
|
316
|
+
// What this run deliberately did not touch. Carried out rather than dropped
|
|
317
|
+
// because "provisioned, and left four of your files alone" is a different
|
|
318
|
+
// report from "provisioned", and the caller cannot reconstruct it.
|
|
319
|
+
untouched: gate.foreign || [],
|
|
279
320
|
worktreePath: spec.worktreePath,
|
|
280
321
|
branch: spec.branch,
|
|
281
322
|
projectName: spec.projectName,
|
|
282
323
|
slot: wantsDocker ? slot : null,
|
|
283
324
|
portOffset: offset,
|
|
284
325
|
envContents,
|
|
285
|
-
openCommand,
|
|
286
326
|
commands: gate.blocked ? [] : [...gate.commands, ...commands],
|
|
287
327
|
seedCommands: gate.blocked ? [] : seedCommands,
|
|
288
328
|
setupCommands: gate.blocked ? [] : setupCommands,
|
|
@@ -306,8 +346,7 @@ function planUp(spec, alloc, config, ctx) {
|
|
|
306
346
|
* about someone else's unfinished spec. Being on THIS spec's branch is not a
|
|
307
347
|
* refusal — it is the re-run, and the answer is "already attached".
|
|
308
348
|
*
|
|
309
|
-
* There is no bootstrap
|
|
310
|
-
* dependencies, and no new session is being opened.
|
|
349
|
+
* There is no bootstrap: the primary checkout already has its dependencies.
|
|
311
350
|
*/
|
|
312
351
|
function planCheckoutUp(spec, ctx, config) {
|
|
313
352
|
const base = ctx.base || (config && config.baseBranch) || 'main'
|
package/src/env/proxy.js
CHANGED
|
@@ -141,7 +141,40 @@ async function waitListening(
|
|
|
141
141
|
return false
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Ask whether `port` is free on EVERY host it will need, one host at a time.
|
|
147
|
+
*
|
|
148
|
+
* SEQUENTIAL IS THE WHOLE POINT, and it is not a style choice. The probe works
|
|
149
|
+
* by binding, so two probes of the same port running concurrently contend with
|
|
150
|
+
* each other — and on Linux a wildcard bind and a loopback bind of one port are
|
|
151
|
+
* mutually exclusive, so the pair reports a completely free port as busy. The
|
|
152
|
+
* review server then refused to start on every Linux machine, which is to say
|
|
153
|
+
* on CI, while every macOS run stayed green because BSD lets the two coexist.
|
|
154
|
+
*
|
|
155
|
+
* Probing one host at a time asks the question that was always intended — is
|
|
156
|
+
* anything ELSE holding this port — instead of racing the caller against
|
|
157
|
+
* itself. Verified on node:24-alpine: parallel reports busy on a free port,
|
|
158
|
+
* sequential does not; both still refuse a real squatter on either address.
|
|
159
|
+
*
|
|
160
|
+
* `probe` is injectable so the concurrency can be asserted without sockets.
|
|
161
|
+
*/
|
|
162
|
+
async function portsInUseOn(port, hosts, probe = portsInUse) {
|
|
163
|
+
const busy = []
|
|
164
|
+
for (const host of [...new Set(hosts)]) {
|
|
165
|
+
busy.push(...(await probe([port], host)))
|
|
166
|
+
}
|
|
167
|
+
return [...new Set(busy)]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
renderRoutes,
|
|
172
|
+
createRouteServer,
|
|
173
|
+
startProxy,
|
|
174
|
+
portsInUse,
|
|
175
|
+
portsInUseOn,
|
|
176
|
+
waitListening,
|
|
177
|
+
}
|
|
145
178
|
|
|
146
179
|
// Entry point: run as a detached process by the CLI. Reads its routes from a
|
|
147
180
|
// file so a re-`connect` just rewrites the file and restarts this (tiny) process.
|
package/src/env/render.js
CHANGED
|
@@ -3,24 +3,15 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Pure renderers for per-spec isolation artifacts.
|
|
5
5
|
*
|
|
6
|
-
* `renderEnvFile` produces the worktree's `.env` body
|
|
7
|
-
* writes
|
|
8
|
-
* `open.command` template. No side effects — unit-testable in isolation.
|
|
6
|
+
* `renderEnvFile` produces the worktree's `.env` body — the only file the engine
|
|
7
|
+
* writes. No side effects — unit-testable in isolation.
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
|
-
const { expandTokens } = require('./resolve.js')
|
|
12
|
-
|
|
13
10
|
// The worktree `.env`: COMPOSE_PROJECT_NAME namespaces the Docker stack and its
|
|
14
11
|
// named volumes; PORT_OFFSET shifts the spec's reserved port block.
|
|
15
12
|
function renderEnvFile({ projectName, portOffset }) {
|
|
16
13
|
return `COMPOSE_PROJECT_NAME=${projectName}\nPORT_OFFSET=${portOffset}\n`
|
|
17
14
|
}
|
|
18
15
|
|
|
19
|
-
// Expand the opener template with the provided tokens. An empty/whitespace-only
|
|
20
|
-
// template means "no auto-open" → returns null.
|
|
21
|
-
function expandOpenCommand(template, tokens) {
|
|
22
|
-
if (typeof template !== 'string' || !template.trim()) return null
|
|
23
|
-
return expandTokens(template, tokens)
|
|
24
|
-
}
|
|
25
16
|
|
|
26
|
-
module.exports = { renderEnvFile
|
|
17
|
+
module.exports = { renderEnvFile }
|