@skitterbyte/skitterspec-jira 0.0.0-stage → 2.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.
Files changed (113) hide show
  1. package/MIGRATION.md +1266 -0
  2. package/README.md +44 -2
  3. package/assets/claude-md-section.md +85 -0
  4. package/assets/commands/allow-main.md +33 -0
  5. package/assets/commands/spec-connect.md +13 -0
  6. package/assets/commands/spec-live.md +14 -0
  7. package/assets/commands/spec-remote-review.md +37 -0
  8. package/assets/commands/spec-skip.md +24 -0
  9. package/assets/core/SETUP.md +192 -0
  10. package/assets/core/env.config.json.example +56 -0
  11. package/assets/core/env.config.md +606 -0
  12. package/assets/core/gating.config.json.example +4 -0
  13. package/assets/core/gating.config.md +81 -0
  14. package/assets/core/jira.config.json.example +18 -0
  15. package/assets/core/jira.config.md +88 -0
  16. package/assets/hooks/main-guard.cjs +162 -0
  17. package/assets/hooks/review-gate.cjs +146 -0
  18. package/assets/review/page.html +3384 -0
  19. package/assets/rules/commit-trailers.md +96 -0
  20. package/assets/rules/negative-checks.md +69 -0
  21. package/assets/rules/offered-unblocks.md +100 -0
  22. package/assets/rules/spec-planning.md +702 -0
  23. package/assets/rules/spec-reports.md +572 -0
  24. package/assets/skills/no-spec/SKILL.md +249 -0
  25. package/assets/skills/spec/SKILL.md +714 -0
  26. package/assets/skills/spec-bug/SKILL.md +585 -0
  27. package/assets/skills/spec-cancel/SKILL.md +261 -0
  28. package/assets/skills/spec-complete/SKILL.md +451 -0
  29. package/assets/skills/spec-diff/SKILL.md +870 -0
  30. package/assets/skills/spec-hotfix/SKILL.md +607 -0
  31. package/assets/skills/spec-init/SKILL.md +178 -0
  32. package/assets/skills/spec-jira-setup/SKILL.md +221 -0
  33. package/assets/skills/spec-list/SKILL.md +173 -0
  34. package/assets/skills/spec-next/SKILL.md +678 -0
  35. package/assets/skills/spec-push/SKILL.md +306 -0
  36. package/assets/skills/spec-review/SKILL.md +256 -0
  37. package/assets/skills/spec-reviewed/SKILL.md +284 -0
  38. package/assets/skills/spec-start/SKILL.md +496 -0
  39. package/assets/skills/spec-status/SKILL.md +114 -0
  40. package/assets/skills/spec-sync/SKILL.md +220 -0
  41. package/assets/skills/spec-to-main/SKILL.md +141 -0
  42. package/bin/skitterspec-jira.js +68 -0
  43. package/package.json +40 -4
  44. package/src/cli.js +5967 -0
  45. package/src/deprecate.js +138 -0
  46. package/src/env/bootstrap.js +188 -0
  47. package/src/env/building.js +143 -0
  48. package/src/env/classify.js +176 -0
  49. package/src/env/commitcmd.js +108 -0
  50. package/src/env/config.js +850 -0
  51. package/src/env/dev.js +63 -0
  52. package/src/env/glob.js +34 -0
  53. package/src/env/hooks.js +226 -0
  54. package/src/env/hotfix.js +413 -0
  55. package/src/env/hotfixland.js +302 -0
  56. package/src/env/integrate.js +167 -0
  57. package/src/env/live.js +479 -0
  58. package/src/env/mainguard.js +299 -0
  59. package/src/env/provision.js +466 -0
  60. package/src/env/proxy.js +225 -0
  61. package/src/env/prune.js +119 -0
  62. package/src/env/registry.js +159 -0
  63. package/src/env/render.js +17 -0
  64. package/src/env/resolve.js +716 -0
  65. package/src/env/review.js +2622 -0
  66. package/src/env/reviewers/coderabbit.js +228 -0
  67. package/src/env/reviewers.js +517 -0
  68. package/src/env/serve.js +1044 -0
  69. package/src/env/supervise.js +157 -0
  70. package/src/env/teardown.js +248 -0
  71. package/src/env/trust.js +87 -0
  72. package/src/gating.js +155 -0
  73. package/src/init.js +1150 -0
  74. package/src/lines-diff.js +114 -0
  75. package/src/prompts.js +129 -0
  76. package/src/vendor/jira/adf.js +414 -0
  77. package/src/vendor/jira/api.js +295 -0
  78. package/src/vendor/jira/cli-sync.js +1252 -0
  79. package/src/vendor/jira/commands.js +44 -0
  80. package/src/vendor/jira/config.js +286 -0
  81. package/src/vendor/jira/credentials.js +108 -0
  82. package/src/vendor/jira/dialect.js +45 -0
  83. package/src/vendor/jira/mcp.js +129 -0
  84. package/src/vendor/provider-kit/index.js +41 -0
  85. package/src/vendor/provider-kit/src/adapter.js +23 -0
  86. package/src/vendor/provider-kit/src/apply.js +207 -0
  87. package/src/vendor/provider-kit/src/args.js +68 -0
  88. package/src/vendor/provider-kit/src/assignment.js +277 -0
  89. package/src/vendor/provider-kit/src/config.js +251 -0
  90. package/src/vendor/provider-kit/src/credentials.js +404 -0
  91. package/src/vendor/provider-kit/src/doctor.js +35 -0
  92. package/src/vendor/provider-kit/src/http.js +63 -0
  93. package/src/vendor/provider-kit/src/identity.js +93 -0
  94. package/src/vendor/provider-kit/src/list-assignee.js +71 -0
  95. package/src/vendor/provider-kit/src/list-rows.js +35 -0
  96. package/src/vendor/provider-kit/src/md-compare.js +39 -0
  97. package/src/vendor/provider-kit/src/md-tables.js +94 -0
  98. package/src/vendor/provider-kit/src/prompt.js +50 -0
  99. package/src/vendor/provider-kit/src/released.js +219 -0
  100. package/src/vendor/provider-kit/src/specs.js +248 -0
  101. package/src/vendor/provider-kit/src/transport.js +22 -0
  102. package/src/vendor/sync-core/index.js +92 -0
  103. package/src/vendor/sync-core/src/base.js +81 -0
  104. package/src/vendor/sync-core/src/compare.js +338 -0
  105. package/src/vendor/sync-core/src/dialect.js +51 -0
  106. package/src/vendor/sync-core/src/fields.js +36 -0
  107. package/src/vendor/sync-core/src/legacy.js +100 -0
  108. package/src/vendor/sync-core/src/normalize.js +971 -0
  109. package/src/vendor/sync-core/src/push.js +85 -0
  110. package/src/vendor/sync-core/src/retarget.js +278 -0
  111. package/src/vendor/sync-core/src/task-block.js +308 -0
  112. package/src/vendor/sync-core/src/verify.js +67 -0
  113. package/src/vendor/sync-core/src/write.js +191 -0
@@ -0,0 +1,466 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Pure provisioning planner for `spec-env up`.
5
+ *
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
+ * registry and passes the slot; this stays deterministic and unit-testable with
11
+ * no live git/docker.
12
+ */
13
+
14
+ const { classifyDirtyTree } = require('./classify.js')
15
+ const { portOffset } = require('./registry.js')
16
+ const { renderEnvFile } = require('./render.js')
17
+ const { expandTokens } = require('./resolve.js')
18
+
19
+ // Cap a path list in a message: enough to recognise, not a wall of text.
20
+ function listPaths(paths) {
21
+ const shown = paths.slice(0, 5).join(', ')
22
+ return paths.length > 5 ? `${shown}, and ${paths.length - 5} more` : shown
23
+ }
24
+
25
+ /**
26
+ * Decide what an uncommitted tree means for a provisioning run: commit it, refuse
27
+ * it, or ignore it.
28
+ *
29
+ * `/spec-start` refuses a dirty tree so another spec's unfinished work is never
30
+ * moved without its author asking. But the commonest dirty tree of all is the
31
+ * spec you just wrote and are now starting, and refusing THAT costs a round trip
32
+ * through `/commit` on every start. So the question asked here is not "does this
33
+ * dirt look important?" — a judgement the gate rightly declines to make — but
34
+ * membership in an exactly-known set (see `classify.js`). One foreign path
35
+ * disqualifies the whole tree.
36
+ *
37
+ * WHAT WOULD FOOL THIS: `ctx.dirtyPaths` being absent. That is not "the tree is
38
+ * clean" — it is "nobody looked", which happens on every legacy caller and
39
+ * whenever git itself could not be read. Either way it is never permission to
40
+ * commit: with no classification there is no owned set, so no commit is planned.
41
+ *
42
+ * Whether it also REFUSES depends on `carriesChanges`, and the asymmetry is the
43
+ * two modes' actual risk, not an inconsistency:
44
+ *
45
+ * - checkout mode (`carriesChanges`) — `git switch -c` silently carries
46
+ * uncommitted work onto the new branch. Unable to see the tree means unable
47
+ * to rule that out, so it refuses, exactly as it did before this gate existed.
48
+ * - worktree mode — `git worktree add` carries nothing, and forks from a commit
49
+ * regardless. Refusing here would fire on a healthy repo whose git we merely
50
+ * could not read, which is a cost paid by someone who did nothing wrong. So
51
+ * it plans no commit and provisions as before.
52
+ *
53
+ * `specOnFork` is the other half, and a POSITIVE signal rather than an absence:
54
+ * the worktree forks from a specific commit, so a spec absent from it produces a
55
+ * branch missing the very spec it is for. A clean tree cannot detect that — the
56
+ * spec may be committed, just somewhere else. `null` means the caller could not
57
+ * tell (an unreadable git, or a hotfix, which forks from a tag predating its own
58
+ * spec), and routes to carrying on, never to refusing.
59
+ *
60
+ * FOREIGN DIRT IS THE SAME ASYMMETRY, for the same reason. Another spec's
61
+ * uncommitted work refuses in checkout mode, where `git switch -c` would carry
62
+ * it onto the new branch, and does not in worktree mode, where nothing carries
63
+ * anywhere. Refusing there fired on the commonest tree this workflow produces —
64
+ * author spec B while spec A is still uncommitted, then start B — and prevented
65
+ * nothing, since the only write to the primary checkout is a pathspec-limited
66
+ * commit of this spec's own paths. It is reported instead, on `foreign`.
67
+ *
68
+ * ctx: { dirtyPaths?, clean?, specOnFork?, specFoundOn?, forkRef?, specUntracked? }
69
+ * @returns {{blocked: boolean, reason: string|null, commands: string[],
70
+ * owned: string[], foreign: string[]}}
71
+ */
72
+ function planSpecCommit(spec, ctx, config, { carriesChanges = false } = {}) {
73
+ const ok = { blocked: false, reason: null, commands: [], owned: [], verb: null, foreign: [] }
74
+ const c = ctx || {}
75
+
76
+ if (!Array.isArray(c.dirtyPaths)) {
77
+ // Nobody looked: never commit, and refuse only where switching could carry
78
+ // work we cannot see (see the asymmetry above).
79
+ if (carriesChanges && c.clean === false) {
80
+ return {
81
+ blocked: true,
82
+ reason:
83
+ 'the primary checkout has uncommitted changes — commit or stash them first' +
84
+ (carriesChanges ? ' (switching would carry them onto the new branch)' : ''),
85
+ commands: [],
86
+ owned: [],
87
+ verb: null,
88
+ }
89
+ }
90
+ return ok
91
+ }
92
+
93
+ const { owned, foreign } = classifyDirtyTree(spec, c.dirtyPaths, config)
94
+
95
+ // FOREIGN DIRT REFUSES ONLY WHERE IT CAN REACH THE BRANCH, which is checkout
96
+ // mode and nowhere else. `git switch -c` carries the working tree onto the new
97
+ // branch, silently, so there it is someone else's work being moved without
98
+ // them asking. `git worktree add` carries nothing and forks from a commit, so
99
+ // in worktree mode the same tree is simply none of this run's business.
100
+ //
101
+ // WHAT WOULD MAKE THIS UNSAFE AGAIN: an unbounded commit. The only thing this
102
+ // run does to the primary checkout is the spec commit below, and it is safe to
103
+ // leave a colleague's files sitting beside it only because that commit is
104
+ // pathspec-limited on BOTH halves (`git add --`, `git commit … --`) and so
105
+ // cannot reach a path it does not own — not even one another session has
106
+ // already staged into the shared index. Drop the `--` and this stops being a
107
+ // false guard and starts being a missing one.
108
+ if (foreign.length && carriesChanges) {
109
+ return {
110
+ blocked: true,
111
+ reason:
112
+ `the primary checkout has uncommitted changes that are not ${spec.folder}'s — ` +
113
+ 'commit or stash them first (switching would carry them onto the new branch)' +
114
+ `: ${listPaths(foreign)}`,
115
+ commands: [],
116
+ owned: [],
117
+ verb: null,
118
+ foreign,
119
+ }
120
+ }
121
+
122
+ if (owned.length) {
123
+ // `add` or `update`? Asked of git (`ctx.specUntracked`, from `git ls-files`)
124
+ // rather than inferred from the shape of the status output. The inference —
125
+ // "a bare directory entry means the folder is wholly untracked" — was wrong in
126
+ // the ordinary case of the FIRST spec in a bucket, where git reports the
127
+ // bucket as the untracked directory instead. The old inference stays as a
128
+ // fallback for callers that pass no fact; unknown yields `update`, which is
129
+ // never a false claim.
130
+ const isNew =
131
+ typeof c.specUntracked === 'boolean'
132
+ ? c.specUntracked
133
+ : owned.includes(`specs/${spec.bucket}/${spec.folder}`)
134
+ const verb = isNew ? 'add' : 'update'
135
+ // BOTH HALVES ARE PATHSPEC-LIMITED, and they answer different failures.
136
+ // `add` is what makes a path git has never seen committable at all — every
137
+ // brand-new spec folder is untracked. The `--` on the COMMIT is what bounds
138
+ // what lands: a checkout has one `.git/index` and every session standing in
139
+ // it shares that index, so a bare `git commit` takes whatever another
140
+ // session has already staged, however exactly this one named its own paths.
141
+ // Dropping the `--` would leave the naming above as decoration.
142
+ const paths = owned.map((p) => `"${p}"`).join(' ')
143
+ return {
144
+ blocked: false,
145
+ reason: null,
146
+ owned,
147
+ verb,
148
+ foreign,
149
+ commands: [
150
+ `git add -- ${paths}`,
151
+ `git commit -m "chore(spec): ${verb} ${spec.folder}" -- ${paths}`,
152
+ ],
153
+ }
154
+ }
155
+
156
+ // Tree is clean. The spec must already be in the base branch's tree, or the
157
+ // worktree forks without it.
158
+ // Nothing of this spec's is uncommitted, but somebody else's may be — say so
159
+ // rather than returning the bare `ok`, or the caller has no way to report what
160
+ // it left alone.
161
+ if (foreign.length && c.specOnFork !== false) return { ...ok, foreign }
162
+
163
+ if (c.specOnFork === false) {
164
+ return {
165
+ blocked: true,
166
+ reason:
167
+ `${spec.folder} is not committed in ${c.forkRef || 'the fork point'}` +
168
+ (c.specFoundOn ? ` — it is on ${c.specFoundOn}` : '') +
169
+ ' — the worktree would fork without the spec it is for',
170
+ commands: [],
171
+ }
172
+ }
173
+ return ok
174
+ }
175
+
176
+ /**
177
+ * The `-f <composeFile>` argument for a `docker compose` invocation.
178
+ *
179
+ * ONLY when the configured file is known to be present, and that condition is
180
+ * the whole point. `docker.composeFile` was read, normalised and validated by
181
+ * the config loader and then never passed to docker at all, so a project naming
182
+ * a non-default file was silently relying on docker's own discovery instead.
183
+ * Passing it unconditionally would fix that project and BREAK the mirror image
184
+ * of it — one whose compose file is `compose.yaml` (a name docker finds by
185
+ * itself) while this key sits at its untouched default of `docker-compose.yml`.
186
+ *
187
+ * So the flag is added on the positive signal and omitted otherwise, which
188
+ * leaves docker's discovery exactly as it is for everyone it was already
189
+ * working for.
190
+ */
191
+ function composeFileArg(config, present) {
192
+ const file = config && config.docker && config.docker.composeFile
193
+ return present === true && file ? ` -f ${file}` : ''
194
+ }
195
+
196
+ /**
197
+ * Does this spec want a Docker stack?
198
+ *
199
+ * ONE function, called by `planUp` and by the `up` command in `cli.js`. They
200
+ * used to each carry their own copy and the copies disagreed: the CLI read
201
+ * `spec.stack === 'docker'` with no fallback, so a header-less spec allocated no
202
+ * registry slot, while the planner fell back to the master switch and emitted
203
+ * `docker compose up -d` anyway — a stack brought up on a port offset derived
204
+ * from a slot that was never allocated.
205
+ *
206
+ * A SPEC WITH NO `Stack:` HEADER NEEDS THE COMPOSE FILE TO BE PRESENT. The
207
+ * master switch alone is not evidence that there is a stack to bring up: it is
208
+ * a project-wide default, and `/spec` has written the header on every spec for
209
+ * some time, so the specs reaching this fallback are legacy or hand-written. The
210
+ * compose file is the positive signal that the escalation would land on
211
+ * something real (`.claude/rules/negative-checks.md` rule 1).
212
+ *
213
+ * WHAT WOULD FOOL THIS, deliberately unhandled: a project whose compose file is
214
+ * generated during `setup` rather than committed. It does not exist when the
215
+ * plan is built, so a header-less spec there resolves to `worktree`. The answer
216
+ * is an explicit `> **Stack:** worktree + docker` header, which is checked first
217
+ * and never consults this at all — so being wrong costs a header, not a stack.
218
+ *
219
+ * `composeFilePresent` undefined means the caller did not look, which is
220
+ * cannot-tell rather than false. It routes to no escalation, the harmless
221
+ * branch: a missing stack surfaces as a header to add, where a spurious one
222
+ * surfaces as `docker compose` failing mid-provision (rule 4).
223
+ */
224
+ function resolveStack(spec, config, { docs = false, composeFilePresent } = {}) {
225
+ const stack =
226
+ spec.stack || (config.docker.enabled && composeFilePresent === true ? 'docker' : 'worktree')
227
+ return { stack, wantsDocker: stack === 'docker' && config.docker.enabled && !docs }
228
+ }
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
+
295
+ /**
296
+ * Plan a provisioning run.
297
+ *
298
+ * @param {object} spec resolved spec (from resolveSpec): { slug, type, branch,
299
+ * worktreePath, projectName, ... }
300
+ * @param {object} alloc { slot, attached } — attached:true when the slot already
301
+ * existed in the registry (re-run → attach, don't clobber).
302
+ * @param {object} config normalised env config.
303
+ * @param {object} opts { docs } — documents mode (see below). Optional.
304
+ * @returns {object} { worktreePath, branch, projectName, slot, portOffset,
305
+ * envContents, commands, steps, docs, attached }
306
+ *
307
+ * `commands` run from the primary checkout (the spec commit, the fork); `steps`
308
+ * are what `spec-env bootstrap` runs in the worktree afterwards.
309
+ */
310
+ function planUp(spec, alloc, config, ctx, opts = {}) {
311
+ const { slot, attached } = alloc
312
+
313
+ // DOCUMENTS MODE — a worktree to write a spec in, not to run code in.
314
+ //
315
+ // `/spec` provisions before it writes the spec folder, so that authoring never
316
+ // touches the base branch. What it needs is a checkout on a branch; what it
317
+ // does not need is `pnpm install --frozen-lockfile` and a database, to write
318
+ // markdown. So this mode keeps the fork and drops everything whose only
319
+ // purpose is making the tree runnable.
320
+ //
321
+ // It is not a lesser provision that has to be undone: `/spec-start` re-runs
322
+ // `up` over the same worktree WITHOUT this flag, and the setup commands run
323
+ // then — at the first moment the tree is used for code.
324
+ const docs = opts.docs === true
325
+
326
+ // Per-spec escalation, decided in ONE place (`resolveStack`) so the planner and
327
+ // the CLI cannot answer it differently — which they did, for exactly the
328
+ // header-less spec this gate is about.
329
+ const { wantsDocker } = resolveStack(spec, config, {
330
+ docs,
331
+ composeFilePresent: ctx ? ctx.composeFilePresent : undefined,
332
+ })
333
+
334
+ // Slot, port block and `.env` are Docker-only. A worktree-only spec takes none
335
+ // of them: no registry slot, no PORT_OFFSET, no `.env`.
336
+ const offset = wantsDocker ? portOffset(slot, config) : null
337
+ const envContents = wantsDocker
338
+ ? renderEnvFile({ projectName: spec.projectName, portOffset: offset })
339
+ : null
340
+
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
+ })
348
+
349
+ const commands = []
350
+ // Fresh branch → -b; attach an existing branch/slot → plain form (never clobber).
351
+ // A hotfix forks its fresh branch from a release tag (`spec.baseRef`, e.g.
352
+ // `v33.16.4`) instead of base HEAD — so the fix is built on the exact commit
353
+ // line prod runs. Attaching an existing branch ignores baseRef (already forked).
354
+ const forkPoint = !attached && spec.baseRef ? ` ${spec.baseRef}` : ''
355
+ commands.push(
356
+ attached
357
+ ? `git worktree add ${spec.worktreePath} ${spec.branch}`
358
+ : `git worktree add ${spec.worktreePath} -b ${spec.branch}${forkPoint}`,
359
+ )
360
+ // The tree gate. A worktree forks from the base branch's tree, so an
361
+ // uncommitted spec would produce a branch missing the spec it is for — this is
362
+ // where that gets committed, or refused. Worktree mode had NO clean gate before
363
+ // this: `git worktree add` does not carry uncommitted changes anywhere, so the
364
+ // failure surfaced three steps later as a live-overlay refusal naming the wrong
365
+ // stage. An absent `ctx` means no caller looked, and changes nothing.
366
+ const gate = planSpecCommit(spec, ctx, config)
367
+
368
+ return {
369
+ blocked: gate.blocked,
370
+ reason: gate.reason,
371
+ specCommit: gate.owned && gate.owned.length ? { paths: gate.owned, verb: gate.verb } : null,
372
+ // What this run deliberately did not touch. Carried out rather than dropped
373
+ // because "provisioned, and left four of your files alone" is a different
374
+ // report from "provisioned", and the caller cannot reconstruct it.
375
+ untouched: gate.foreign || [],
376
+ worktreePath: spec.worktreePath,
377
+ branch: spec.branch,
378
+ projectName: spec.projectName,
379
+ slot: wantsDocker ? slot : null,
380
+ portOffset: offset,
381
+ envContents,
382
+ commands: gate.blocked ? [] : [...gate.commands, ...commands],
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,
386
+ // Reported so a caller reading `--json` can tell a documents worktree from a
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.
390
+ docs,
391
+ attached,
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Plan `spec-env up` in CHECKOUT mode — the branch is built in the primary
397
+ * checkout and there is no worktree at all.
398
+ *
399
+ * Pure: every git fact it needs arrives in `ctx`, and it writes nothing.
400
+ *
401
+ * ctx: { current, base, onBase, clean, branchExists }
402
+ *
403
+ * What it refuses, and why each is a refusal rather than a warning:
404
+ * - a dirty tree, because `git switch -c` CARRIES uncommitted changes onto the
405
+ * new branch. That is silent and it is the operator's work, so it is theirs
406
+ * to place, not ours.
407
+ * - standing on another branch, because switching away from it is a decision
408
+ * about someone else's unfinished spec. Being on THIS spec's branch is not a
409
+ * refusal — it is the re-run, and the answer is "already attached".
410
+ *
411
+ * There is no bootstrap: the primary checkout already has its dependencies.
412
+ *
413
+ * AND THAT IS WHY `--docs` IS INERT HERE, rather than unimplemented. Documents
414
+ * mode exists to skip making a fresh tree runnable; this mode makes no fresh
415
+ * tree, so there is nothing for it to skip. The flag is accepted and changes
416
+ * nothing, because a caller passing it is asking for a cheap checkout and gets
417
+ * one — refusing would make `/spec` mode-aware for no behavioural difference.
418
+ */
419
+ function planCheckoutUp(spec, ctx, config) {
420
+ const base = ctx.base || (config && config.baseBranch) || 'main'
421
+ const result = {
422
+ mode: 'checkout',
423
+ blocked: false,
424
+ reason: null,
425
+ attached: false,
426
+ branch: spec.branch,
427
+ checkoutPath: ctx.checkoutPath || null,
428
+ commands: [],
429
+ }
430
+ const block = (reason) => ({ ...result, blocked: true, reason })
431
+
432
+ // Order matters: report "already attached" before anything else, so a re-run
433
+ // on the spec's own branch is never refused for a dirty tree it legitimately
434
+ // has — you are mid-phase, with the phase's own edits in progress.
435
+ if (ctx.current && ctx.current === spec.branch) {
436
+ return { ...result, attached: true }
437
+ }
438
+ // Same gate as worktree mode, but `git switch -c` genuinely CARRIES uncommitted
439
+ // work onto the new branch, so the refusal keeps saying so.
440
+ const gate = planSpecCommit(spec, ctx, config, { carriesChanges: true })
441
+ if (gate.blocked) return block(gate.reason)
442
+ if (!ctx.onBase) {
443
+ return block(
444
+ `the primary checkout is on ${ctx.current || '(detached)'}, not ${base} — ` +
445
+ 'finish or park that branch first; checkout mode holds one spec at a time',
446
+ )
447
+ }
448
+
449
+ if (gate.owned && gate.owned.length) {
450
+ result.specCommit = { paths: gate.owned, verb: gate.verb }
451
+ }
452
+ result.commands.push(...gate.commands)
453
+ result.commands.push(
454
+ ctx.branchExists ? `git switch ${spec.branch}` : `git switch -c ${spec.branch}`,
455
+ )
456
+ return result
457
+ }
458
+
459
+ module.exports = {
460
+ planUp,
461
+ planCheckoutUp,
462
+ planSpecCommit,
463
+ worktreeSteps,
464
+ resolveStack,
465
+ composeFileArg,
466
+ }
@@ -0,0 +1,225 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Front-door reverse proxy for `spec-env connect` — a small, dependency-free
5
+ * Node proxy that exposes the ONE connected spec's warm dev servers on the
6
+ * canonical ports (exclusive model: no cookie, one target at a time).
7
+ *
8
+ * `renderRoutes` is pure (procs → routes). `startProxy` builds one `http` server
9
+ * per route, forwarding HTTP and `upgrade` (WebSocket/HMR) to the spec's port.
10
+ * Run directly (`node proxy.js <routesFile>`) it becomes the long-lived,
11
+ * detached proxy process the CLI supervises with the Phase 1 seam.
12
+ */
13
+
14
+ const http = require('node:http')
15
+ const net = require('node:net')
16
+ const fs = require('node:fs')
17
+
18
+ /**
19
+ * The connected spec's `dev` processes that declare a `frontPort` → proxy routes.
20
+ * @param {Array} procs from `planDev(...).procs`
21
+ * @returns {Array<{name, frontPort, targetPort}>}
22
+ */
23
+ function renderRoutes(procs) {
24
+ return (procs || [])
25
+ .filter((p) => typeof p.frontPort === 'number')
26
+ .map((p) => ({ name: p.name, frontPort: p.frontPort, targetPort: p.port }))
27
+ }
28
+
29
+ // Build (but don't listen on) one proxy server: frontPort → targetPort. Forwards
30
+ // regular requests and transparently pipes `upgrade` (WebSocket) connections.
31
+ function createRouteServer(route, host = '127.0.0.1') {
32
+ const { targetPort } = route
33
+
34
+ const server = http.createServer((req, res) => {
35
+ const upstream = http.request(
36
+ { host, port: targetPort, method: req.method, path: req.url, headers: req.headers },
37
+ (ur) => {
38
+ res.writeHead(ur.statusCode || 502, ur.headers)
39
+ ur.pipe(res)
40
+ },
41
+ )
42
+ upstream.on('error', () => {
43
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain' })
44
+ res.end(`spec-connect: upstream 127.0.0.1:${targetPort} not reachable\n`)
45
+ })
46
+ req.pipe(upstream)
47
+ })
48
+
49
+ // WebSocket / HTTP upgrade passthrough — reconstruct the request line + headers
50
+ // onto a raw TCP connection to the upstream, then pipe both ways.
51
+ server.on('upgrade', (req, socket, head) => {
52
+ const upstream = net.connect(targetPort, host, () => {
53
+ let raw = `${req.method} ${req.url} HTTP/1.1\r\n`
54
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
55
+ raw += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`
56
+ }
57
+ raw += '\r\n'
58
+ upstream.write(raw)
59
+ if (head && head.length) upstream.write(head)
60
+ socket.pipe(upstream)
61
+ upstream.pipe(socket)
62
+ })
63
+ upstream.on('error', () => socket.destroy())
64
+ socket.on('error', () => upstream.destroy())
65
+ })
66
+
67
+ return server
68
+ }
69
+
70
+ /**
71
+ * Start every route's server listening. Returns `{ servers, close() }` where
72
+ * `close()` resolves once all servers are shut. Rejects (via the returned
73
+ * promise on the server 'error') if a port can't be bound.
74
+ */
75
+ function startProxy(routes, { host = '127.0.0.1' } = {}) {
76
+ const servers = routes.map((route) => {
77
+ const server = createRouteServer(route, host)
78
+ server.listen(route.frontPort, host)
79
+ return server
80
+ })
81
+ return {
82
+ servers,
83
+ close() {
84
+ return Promise.all(
85
+ servers.map((s) => new Promise((resolve) => s.close(() => resolve()))),
86
+ )
87
+ },
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Which of `ports` are already bound (so `connect` can tell the operator to stop
93
+ * main first). Resolves to the list of busy ports. `host` scopes the check.
94
+ */
95
+ function portsInUse(ports, host = '127.0.0.1') {
96
+ return Promise.all(
97
+ ports.map(
98
+ (port) =>
99
+ new Promise((resolve) => {
100
+ const tester = net
101
+ .createServer()
102
+ .once('error', () => resolve(port)) // EADDRINUSE → busy
103
+ .once('listening', () => tester.close(() => resolve(null)))
104
+ .listen(port, host)
105
+ }),
106
+ ),
107
+ ).then((results) => results.filter((p) => p !== null))
108
+ }
109
+
110
+ // Does `port` accept a TCP connection right now?
111
+ function checkListening(port, host) {
112
+ return new Promise((resolve) => {
113
+ const sock = net.connect(port, host)
114
+ sock.once('connect', () => {
115
+ sock.destroy()
116
+ resolve(true)
117
+ })
118
+ sock.once('error', () => {
119
+ sock.destroy()
120
+ resolve(false)
121
+ })
122
+ })
123
+ }
124
+
125
+ /**
126
+ * Poll until every port in `ports` accepts a connection (the detached proxy has
127
+ * finished binding), or `timeoutMs` elapses. Resolves true when all are up.
128
+ * `now`/`wait` are injectable for tests.
129
+ */
130
+ async function waitListening(
131
+ ports,
132
+ { host = '127.0.0.1', timeoutMs = 3000, intervalMs = 50, now = () => Date.now(), wait } = {},
133
+ ) {
134
+ const sleep = wait || ((ms) => new Promise((r) => setTimeout(r, ms)))
135
+ const deadline = now() + timeoutMs
136
+ while (now() < deadline) {
137
+ const results = await Promise.all(ports.map((p) => checkListening(p, host)))
138
+ if (results.every(Boolean)) return true
139
+ await sleep(intervalMs)
140
+ }
141
+ return false
142
+ }
143
+
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
+ /**
171
+ * Which of `ports` accept a connection on `host` — "is a dev server running?".
172
+ *
173
+ * CONNECTS, never binds. A bind probe on 127.0.0.1 SUCCEEDS on macOS beside a
174
+ * server listening on every address (Vite `host: true`, Fastify `0.0.0.0`),
175
+ * because BSD lets the two coexist, so it read a running phone-on-wifi dev
176
+ * server as absent. Linux refuses the second bind, which is why CI never saw
177
+ * it. A server that answers is running on any OS. What could still fool it: a
178
+ * server bound only to a non-loopback address, which `host` does not reach.
179
+ */
180
+ async function portsAnswering(ports, host = '127.0.0.1') {
181
+ const results = await Promise.all(ports.map((p) => checkListening(p, host)))
182
+ return ports.filter((_, i) => results[i])
183
+ }
184
+
185
+ /**
186
+ * Which of `ports` are taken for a new listener on `host` — "may the proxy
187
+ * have these?". Busy when the bind fails OR something already answers.
188
+ *
189
+ * The bind alone is not enough on macOS: beside a wildcard listener it
190
+ * succeeds, and the proxy would then own `localhost` while LAN clients still
191
+ * reach the old server — two apps behind one port number. The connect catches
192
+ * that. SEQUENTIAL, bind first: the bind probe's own listener is closed before
193
+ * the connect runs, so the connect can never find the probe itself.
194
+ */
195
+ async function portsHeld(ports, host = '127.0.0.1') {
196
+ const bound = await portsInUse(ports, host)
197
+ const answering = await portsAnswering(ports, host)
198
+ return ports.filter((p) => bound.includes(p) || answering.includes(p))
199
+ }
200
+
201
+ module.exports = {
202
+ renderRoutes,
203
+ createRouteServer,
204
+ startProxy,
205
+ portsInUse,
206
+ portsInUseOn,
207
+ portsAnswering,
208
+ portsHeld,
209
+ waitListening,
210
+ }
211
+
212
+ // Entry point: run as a detached process by the CLI. Reads its routes from a
213
+ // file so a re-`connect` just rewrites the file and restarts this (tiny) process.
214
+ if (require.main === module) {
215
+ const routesFile = process.argv[2]
216
+ if (!routesFile) {
217
+ process.stderr.write('proxy: usage: node proxy.js <routesFile>\n')
218
+ process.exit(1)
219
+ }
220
+ const routes = JSON.parse(fs.readFileSync(routesFile, 'utf-8'))
221
+ const { close } = startProxy(routes)
222
+ const shutdown = () => close().then(() => process.exit(0))
223
+ process.on('SIGTERM', shutdown)
224
+ process.on('SIGINT', shutdown)
225
+ }