@skitterbyte/skitterspec 22.4.0 → 23.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,10 +3,10 @@
3
3
  /**
4
4
  * Pure provisioning planner for `spec-env up`.
5
5
  *
6
- * Given a resolved spec and its allocated slot, `planUp` returns the exact
7
- * side-effecting commands the `/spec-env` skill runs (`git worktree add`, the
8
- * env file, `docker compose up`) and the rendered `.env` contents — but performs no side
9
- * effects itself. The caller (the CLI) reads/allocates the
6
+ * Given a resolved spec and its allocated slot, `planUp` returns the commands
7
+ * the caller runs from the primary checkout (the spec commit, `git worktree
8
+ * add`) and the worktree steps `spec-env bootstrap` runs afterwards (seeds, the
9
+ * env file, `setup`, the stack) — but performs no side effects itself. The caller (the CLI) reads/allocates the
10
10
  * registry and passes the slot; this stays deterministic and unit-testable with
11
11
  * no live git/docker.
12
12
  */
@@ -16,107 +16,6 @@ const { portOffset } = require('./registry.js')
16
16
  const { renderEnvFile } = require('./render.js')
17
17
  const { expandTokens } = require('./resolve.js')
18
18
 
19
- /**
20
- * Build the POSIX-sh prefix that puts a command in the spec's worktree, or stops.
21
- *
22
- * Every command under the "then, in the worktree, run:" heading carries this.
23
- * Without it those commands stay silent when run from the wrong place: in the
24
- * primary checkout `$m` resolves to the checkout itself, so a seed's source and
25
- * target are the same path and it prints `exists — skipped` — indistinguishable
26
- * from a correctly-provisioned re-run — while a setup command like an install
27
- * runs against the main checkout and "succeeds" too. Three layers then agree that
28
- * nothing is wrong, and the caller carries on committing to `main`.
29
- *
30
- * A `cd` is deliberately chosen over comparing `git rev-parse --show-toplevel`
31
- * against `worktreePath`: the planned path is a lexical `path.resolve` while git
32
- * reports the symlink-resolved one (`/tmp` vs `/private/tmp` on macOS), so a
33
- * string compare would refuse a perfectly good worktree. `cd` sidesteps that, and
34
- * does better than refusing — it *positions* the command, so a caller who is in
35
- * the wrong directory still gets correct behaviour. When the worktree was never
36
- * created — the reported case — the `cd` fails and takes the whole command with
37
- * it, non-zero and loud.
38
- *
39
- * Per-command, not once at the top of the block: a single leading `cd` only
40
- * protects the sequence if the caller chains it with `&&`, and the reported
41
- * failure is precisely a caller that ran the steps as separate statements.
42
- */
43
- function worktreeCd(worktreePath) {
44
- return (
45
- `cd "${worktreePath}" 2>/dev/null || ` +
46
- `{ echo "no worktree at ${worktreePath} — run the provisioning commands first"; exit 1; }`
47
- )
48
- }
49
-
50
- /**
51
- * Build one idempotent POSIX-sh command that seeds a gitignored file from the
52
- * main checkout into the current worktree (the cwd when the skill runs it).
53
- *
54
- * The main checkout is resolved at run time from inside the worktree via
55
- * `git rev-parse --git-common-dir` (absolute `<main>/.git` in a linked worktree)
56
- * and its dirname — never a hardcoded repo name or a `../..` hop. The command is
57
- * safe to re-run: a source absent in main is a printed no-op, an already-seeded
58
- * target (real file or symlink) is left untouched, and only a genuinely missing
59
- * target is created. `mode` is 'symlink' (points at main, stays in sync) or
60
- * 'copy' (an independent copy). Output mirrors the setup style: `seeded <f> → …`.
61
- */
62
- function seedCommandFor(file, mode) {
63
- const op =
64
- mode === 'copy'
65
- ? `cp "$m/${file}" "${file}"`
66
- : `ln -s "$m/${file}" "${file}"`
67
- return (
68
- 'm="$(dirname "$(git rev-parse --git-common-dir)")"; ' +
69
- `if [ ! -e "$m/${file}" ]; then echo "seed ${file}: not in main — skipped"; ` +
70
- `elif [ -e "${file}" ] || [ -L "${file}" ]; then echo "seed ${file}: exists — skipped"; ` +
71
- `else mkdir -p "$(dirname "${file}")" && ${op} && echo "seeded ${file} → $m/${file}"; fi`
72
- )
73
- }
74
-
75
-
76
- // Single-quote a value for POSIX sh: the only character that needs escaping
77
- // inside single quotes is the single quote itself.
78
- function shq(value) {
79
- return `'${String(value).replace(/'/g, `'\\''`)}'`
80
- }
81
-
82
- /**
83
- * Build one idempotent POSIX-sh command that writes the spec's env file in the
84
- * current worktree (the cwd when the skill runs it).
85
- *
86
- * It was prose once — "write <envFile> in the worktree:" — which left the write
87
- * to whoever read the plan, and a secrets guard that blocks any tool call naming
88
- * `.env` refused it. As a command it is one more step the plan runs.
89
- *
90
- * Idempotent and additive: it `touch`es the file and appends each key only when
91
- * no line already sets it, so a re-run changes nothing and a file `setup` has
92
- * already written to keeps every value it holds — including a deliberate
93
- * `PORT_OFFSET` of its own.
94
- *
95
- * A SYMLINK IS REFUSED, loudly. Seeding links gitignored files from the primary
96
- * checkout, so a seeded env file is the primary checkout's own; appending
97
- * through it would write this spec's port block into the main stack's
98
- * configuration. It runs after the seeds precisely so that collision is caught
99
- * rather than hidden. The fix is a `docker.envFile` naming a file of its own.
100
- */
101
- function envFileCommandFor(envFile, projectName, offset) {
102
- const f = shq(envFile)
103
- const ensure = (key, value) =>
104
- `{ grep -q '^${key}=' ${f} || echo ${shq(`${key}=${value}`)} >> ${f}; }`
105
- return (
106
- `if [ -L ${f} ]; then echo ${shq(
107
- `env file ${envFile} is a symlink — docker.envFile must name a file of its ` +
108
- "own, not a seeded one; refusing to write this spec's port block through it",
109
- )} >&2; exit 1; fi; ` +
110
- `touch ${f} && ` +
111
- // A file without a trailing newline would glue the first appended key onto
112
- // its last line.
113
- `{ [ ! -s ${f} ] || [ -z "$(tail -c 1 ${f})" ] || echo >> ${f}; } && ` +
114
- `${ensure('COMPOSE_PROJECT_NAME', projectName)} && ` +
115
- `${ensure('PORT_OFFSET', offset)} && ` +
116
- `echo ${shq(`env file ${envFile}: ready`)}`
117
- )
118
- }
119
-
120
19
  // Cap a path list in a message: enough to recognise, not a wall of text.
121
20
  function listPaths(paths) {
122
21
  const shown = paths.slice(0, 5).join(', ')
@@ -328,6 +227,71 @@ function resolveStack(spec, config, { docs = false, composeFilePresent } = {}) {
328
227
  return { stack, wantsDocker: stack === 'docker' && config.docker.enabled && !docs }
329
228
  }
330
229
 
230
+ /**
231
+ * The steps that bring a fresh worktree to ready, as data — the list
232
+ * `spec-env bootstrap` runs. In the order each depends on the last: seeds (the
233
+ * env file may be one), the env file, `setup` (which may derive values into
234
+ * it), and only then the stack.
235
+ *
236
+ * DATA AND NOT SHELL, and that is the reason the verb exists. These steps were
237
+ * printed as POSIX-sh one-liners for the agent to copy, and the copy was never
238
+ * faithful: merged into a loop, its echo text reworded, two steps glued into one
239
+ * call. A consumer's secrets hook — which refuses any Bash call naming a `.env`
240
+ * file and makes an exception only for the exact printed shape — then refused
241
+ * the rewrite, and bootstrap stopped with the user running it by hand. A step
242
+ * the engine runs itself has no text for anything to rewrite or refuse.
243
+ *
244
+ * `setup` entries stay shell strings: they are the project's own commands,
245
+ * written by the project. The docker step is an argv, run without a shell.
246
+ *
247
+ * Pure: `offset` is the spec's port offset (null without a stack), and every
248
+ * filesystem fact arrives in `opts`.
249
+ */
250
+ function worktreeSteps(spec, offset, config, opts = {}) {
251
+ const { docs = false, wantsDocker = false, composeFilePresent: compose } = opts
252
+ const seed = config.seedFiles || { mode: 'symlink', files: [] }
253
+ const mode = seed.mode === 'copy' ? 'copy' : 'symlink'
254
+ const tokens = {
255
+ worktreePath: spec.worktreePath,
256
+ slug: spec.slug,
257
+ branch: spec.branch,
258
+ projectName: spec.projectName,
259
+ portOffset: offset === null || offset === undefined ? '' : String(offset),
260
+ }
261
+ const envFile = (config.docker && config.docker.envFile) || '.env'
262
+ const steps = (seed.files || []).map((file) => ({ kind: 'seed', file, mode }))
263
+ if (wantsDocker) {
264
+ steps.push({ kind: 'envFile', file: envFile, projectName: spec.projectName, offset })
265
+ }
266
+ // Documents mode drops `setup` and nothing else: seeding is cheap and a spec
267
+ // author may well want the gitignored files present, while `setup` is the
268
+ // expensive half that exists to make the tree run. `wantsDocker` is already
269
+ // false under `--docs` (`resolveStack`), so the stack drops with it.
270
+ if (!docs) {
271
+ for (const cmd of config.setup || []) {
272
+ steps.push({ kind: 'setup', command: expandTokens(cmd, tokens) })
273
+ }
274
+ }
275
+ if (wantsDocker) {
276
+ const file = composeFileArg(config, compose).trim().split(/\s+/).filter(Boolean)
277
+ steps.push({
278
+ kind: 'docker',
279
+ argv: [
280
+ 'docker',
281
+ 'compose',
282
+ ...file,
283
+ '--env-file',
284
+ envFile,
285
+ '--project-name',
286
+ spec.projectName,
287
+ 'up',
288
+ '-d',
289
+ ],
290
+ })
291
+ }
292
+ return steps
293
+ }
294
+
331
295
  /**
332
296
  * Plan a provisioning run.
333
297
  *
@@ -338,10 +302,10 @@ function resolveStack(spec, config, { docs = false, composeFilePresent } = {}) {
338
302
  * @param {object} config normalised env config.
339
303
  * @param {object} opts { docs } — documents mode (see below). Optional.
340
304
  * @returns {object} { worktreePath, branch, projectName, slot, portOffset,
341
- * envContents, commands, seedCommands, envCommands,
342
- * setupCommands, dockerCommands, docs, attached }
305
+ * envContents, commands, steps, docs, attached }
343
306
  *
344
- * The worktree steps run in that order: seeds, env file, setup, compose.
307
+ * `commands` run from the primary checkout (the spec commit, the fork); `steps`
308
+ * are what `spec-env bootstrap` runs in the worktree afterwards.
345
309
  */
346
310
  function planUp(spec, alloc, config, ctx, opts = {}) {
347
311
  const { slot, attached } = alloc
@@ -374,33 +338,13 @@ function planUp(spec, alloc, config, ctx, opts = {}) {
374
338
  ? renderEnvFile({ projectName: spec.projectName, portOffset: offset })
375
339
  : null
376
340
 
377
- const tokens = {
378
- worktreePath: spec.worktreePath,
379
- slug: spec.slug,
380
- branch: spec.branch,
381
- projectName: spec.projectName,
382
- portOffset: offset === null ? '' : String(offset),
383
- }
384
-
385
-
386
- // File seeding runs *in the worktree* after `git worktree add`, before the
387
- // setup commands (which may depend on the seeded .env). Each entry becomes an
388
- // idempotent shell command resolving the main checkout at run time. Absent
389
- // config ⇒ no commands ⇒ current behaviour.
390
- const seed = config.seedFiles || { mode: 'symlink', files: [] }
391
- const seedMode = seed.mode === 'copy' ? 'copy' : 'symlink'
392
- const guard = worktreeCd(spec.worktreePath)
393
- const seedCommands = (seed.files || []).map(
394
- (file) => `${guard}; ${seedCommandFor(file, seedMode)}`,
395
- )
396
-
397
- // Bootstrap commands run *in the worktree* after `git worktree add` (before
398
- // Docker/dev), on every provision including re-attach — deps must exist for
399
- // the worktree to be usable. Kept separate from `commands` (run from the
400
- // primary checkout root); the CLI prints them under an "in the worktree" head.
401
- const setupCommands = (config.setup || []).map(
402
- (cmd) => `${guard}; ${expandTokens(cmd, tokens)}`,
403
- )
341
+ // Everything after the fork runs in the worktree, and `spec-env bootstrap`
342
+ // runs it — the steps are data, not shell for a caller to copy.
343
+ const steps = worktreeSteps(spec, offset, config, {
344
+ docs,
345
+ wantsDocker,
346
+ composeFilePresent: ctx ? ctx.composeFilePresent : undefined,
347
+ })
404
348
 
405
349
  const commands = []
406
350
  // Fresh branch → -b; attach an existing branch/slot → plain form (never clobber).
@@ -413,25 +357,6 @@ function planUp(spec, alloc, config, ctx, opts = {}) {
413
357
  ? `git worktree add ${spec.worktreePath} ${spec.branch}`
414
358
  : `git worktree add ${spec.worktreePath} -b ${spec.branch}${forkPoint}`,
415
359
  )
416
- // The stack comes up LAST, in the worktree: after the seeds, after the env
417
- // file, after `setup` — which may derive values into that file. It once ran
418
- // from the primary checkout before the worktree existed, so compose read the
419
- // primary checkout's env file, fell back to its defaults and collided with the
420
- // main stack. `--env-file` is always passed: with the default `.env` it is what
421
- // compose would have found anyway, and with any other name it is the only way
422
- // compose finds it.
423
- const envFile = (config.docker && config.docker.envFile) || '.env'
424
- const envCommands = wantsDocker
425
- ? [`${guard}; ${envFileCommandFor(envFile, spec.projectName, offset)}`]
426
- : []
427
- const dockerCommands = wantsDocker
428
- ? [
429
- `${guard}; docker compose` +
430
- composeFileArg(config, ctx ? ctx.composeFilePresent : undefined) +
431
- ` --env-file ${shq(envFile)} --project-name ${spec.projectName} up -d`,
432
- ]
433
- : []
434
-
435
360
  // The tree gate. A worktree forks from the base branch's tree, so an
436
361
  // uncommitted spec would produce a branch missing the spec it is for — this is
437
362
  // where that gets committed, or refused. Worktree mode had NO clean gate before
@@ -455,18 +380,13 @@ function planUp(spec, alloc, config, ctx, opts = {}) {
455
380
  portOffset: offset,
456
381
  envContents,
457
382
  commands: gate.blocked ? [] : [...gate.commands, ...commands],
458
- seedCommands: gate.blocked ? [] : seedCommands,
459
- envCommands: gate.blocked ? [] : envCommands,
460
- // Documents mode drops these, and keeps the seed commands above. The two are
461
- // not the same kind of thing: seeding links the gitignored files a worktree
462
- // has none of — cheap, and a spec author may well want `.env` present —
463
- // while `setup` is the expensive half that exists to make the tree run.
464
- setupCommands: gate.blocked || docs ? [] : setupCommands,
465
- dockerCommands: gate.blocked ? [] : dockerCommands,
383
+ // The worktree steps, in the order `spec-env bootstrap` runs them. Documents
384
+ // mode drops `setup` and the stack and keeps the seeds (`worktreeSteps`).
385
+ steps: gate.blocked ? [] : steps,
466
386
  // Reported so a caller reading `--json` can tell a documents worktree from a
467
- // full one POSITIVELY, rather than inferring it from what is missing. An
468
- // empty `setupCommands` also describes a project that configured no setup
469
- // at all, and those two must not read alike.
387
+ // full one POSITIVELY, rather than inferring it from what is missing. A step
388
+ // list with no `setup` also describes a project that configured none, and
389
+ // those two must not read alike.
470
390
  docs,
471
391
  attached,
472
392
  }
@@ -540,9 +460,7 @@ module.exports = {
540
460
  planUp,
541
461
  planCheckoutUp,
542
462
  planSpecCommit,
543
- seedCommandFor,
544
- envFileCommandFor,
545
- worktreeCd,
463
+ worktreeSteps,
546
464
  resolveStack,
547
465
  composeFileArg,
548
466
  }
package/src/env/review.js CHANGED
@@ -668,7 +668,13 @@ function surfacesFor({ live, tiers, buttons = null }) {
668
668
  // the primary checkout — changing behaviour for everyone who pulls and
669
669
  // leaving that tree dirty. A setting change of that reach must not land
670
670
  // because someone tapped a button labelled only `Allow remote`.
671
- note: t.note || 'writes env.config.json — committed, and shared',
671
+ // `remote` is per spec now (bug-remote-review-toggle): a gitignored
672
+ // sidecar, so the base branch is untouched and no other spec may publish.
673
+ note:
674
+ t.note ||
675
+ (t.tier === 'remote'
676
+ ? 'this spec only — nothing committed'
677
+ : 'writes env.config.json — committed, and shared'),
672
678
  // The same thing from a terminal, for the reader who is nowhere near the
673
679
  // page. Only `remote` has a command of its own: `network` is ON by
674
680
  // default, so turning it off is a rare deliberate act and a slash command
@@ -2391,6 +2397,46 @@ function readReviewUrl(outPath) {
2391
2397
  }
2392
2398
  }
2393
2399
 
2400
+ /**
2401
+ * Whether ONE SPEC may publish, beside its page in the gitignored store.
2402
+ *
2403
+ * WHY PER SPEC. `review.allowRemote` in the committed config permits every
2404
+ * spec in the repo at once, and writing it from a worktree dirtied the base
2405
+ * branch (bug-remote-review-toggle). Publishing sends a diff to a third party,
2406
+ * so what may generate an artifact is decided per worktree; the committed key
2407
+ * stays as the project default beneath it.
2408
+ */
2409
+ function reviewRemotePath(outPath) {
2410
+ return outPath.replace(/\.html$/, '') + '.remote.json'
2411
+ }
2412
+
2413
+ // true / false when the sidecar says so, null for every cannot-tell — absent,
2414
+ // unreadable, a non-boolean — so the committed default stands
2415
+ // (`.claude/rules/negative-checks.md` rule 4: a typo must not permit a publish).
2416
+ function readRemoteAllow(outPath) {
2417
+ try {
2418
+ const v = JSON.parse(fs.readFileSync(reviewRemotePath(outPath), 'utf8')).allowRemote
2419
+ return typeof v === 'boolean' ? v : null
2420
+ } catch {
2421
+ return null
2422
+ }
2423
+ }
2424
+
2425
+ function writeRemoteAllow(outPath, on, at = new Date().toISOString()) {
2426
+ const file = reviewRemotePath(outPath)
2427
+ fs.mkdirSync(path.dirname(file), { recursive: true })
2428
+ fs.writeFileSync(file, JSON.stringify({ allowRemote: on, at }, null, 2) + '\n')
2429
+ return file
2430
+ }
2431
+
2432
+ // The config one spec's render reads: the project's, with this spec's own
2433
+ // remote permission over it. A new object — the caller's config is shared.
2434
+ function withSpecRemote(config, outPath) {
2435
+ const own = readRemoteAllow(outPath)
2436
+ if (own === null) return config
2437
+ return { ...config, review: { ...config.review, allowRemote: own } }
2438
+ }
2439
+
2394
2440
  // Default output path for a spec's page, under the gitignored `.spec-env/`.
2395
2441
  function reviewOutPath(dir, specFolder, out) {
2396
2442
  if (out) return path.resolve(dir, out)
@@ -2512,6 +2558,10 @@ module.exports = {
2512
2558
  reviewUrlPath,
2513
2559
  reviewPublishPath,
2514
2560
  readReviewUrl,
2561
+ reviewRemotePath,
2562
+ readRemoteAllow,
2563
+ writeRemoteAllow,
2564
+ withSpecRemote,
2515
2565
  publishedPageNotice,
2516
2566
  reviewServerNotice,
2517
2567
  fragmentTemplate,
package/src/env/serve.js CHANGED
@@ -46,6 +46,7 @@ const {
46
46
  renderReviewPage,
47
47
  renderReviewBlock,
48
48
  reviewOutPath,
49
+ withSpecRemote,
49
50
  validateNotesBlob,
50
51
  readPending,
51
52
  passState: readPassState,
@@ -595,7 +596,9 @@ function renderSpecPage(dir, config, spec, { branch = false } = {}) {
595
596
  receipt: null,
596
597
  worktreeExists: Boolean(spec.worktreePath && fs.existsSync(spec.worktreePath)),
597
598
  })
598
- const tiers = pageTiers(config)
599
+ // This spec's own remote permission, read per request so a toggle shows on
600
+ // the next refresh without restarting the server.
601
+ const tiers = pageTiers(withSpecRemote(config, out))
599
602
 
600
603
  let data = collectReview({
601
604
  spec,