@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.
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',
@@ -94,6 +95,9 @@ const {
94
95
  reviewUrlPath,
95
96
  reviewPublishPath,
96
97
  readReviewUrl,
98
+ readRemoteAllow,
99
+ writeRemoteAllow,
100
+ withSpecRemote,
97
101
  publishedPageNotice,
98
102
  reviewServerNotice,
99
103
  renderReviewFragment,
@@ -144,7 +148,8 @@ const {
144
148
  cacheHit,
145
149
  asCached,
146
150
  } = require('./env/reviewers.js')
147
- const { planUp, planCheckoutUp, worktreeCd, resolveStack } = require('./env/provision.js')
151
+ const { planUp, planCheckoutUp, resolveStack, worktreeSteps } = require('./env/provision.js')
152
+ const { runBootstrap, describeStep } = require('./env/bootstrap.js')
148
153
  const { classifyDirtyTree, dirtyPaths, specDocsIn } = require('./env/classify.js')
149
154
  const { isGitCommit } = require('./env/commitcmd.js')
150
155
  const { planDown, planDownCheckout } = require('./env/teardown.js')
@@ -210,6 +215,10 @@ Usage:
210
215
  Docker stack (prints commands; creates nothing)
211
216
  --docs: a tree to write documents in — no
212
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)
213
222
  nospec <name> record a branch with no spec document
214
223
  (/no-spec work) and print its plan
215
224
  main <check|allow|status>
@@ -666,8 +675,17 @@ function specEnvMain(dir, config, positional, flags = {}, present = true, invoke
666
675
  // checkout before it runs, so asking `dir` whether it is the primary
667
676
  // checkout always answers yes — and the guard would fire on a write inside
668
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.
669
682
  const from = invokedFrom || dir
670
- const verdict = checkMainWrite(from, config, gitReader(from), { sessionId, present })
683
+ const verdict = checkMainWrite(from, config, gitReader(from), {
684
+ sessionId,
685
+ present,
686
+ target: flags.path ? path.resolve(from, flags.path) : null,
687
+ gitAt: gitReader,
688
+ })
671
689
  if (!verdict.refuse) return
672
690
  process.stdout.write(`spec-env main: ${verdict.reason}\n`)
673
691
  process.exitCode = 1
@@ -802,9 +820,6 @@ function specEnvNospec(dir, config, name, flags = {}) {
802
820
  const trust = ensureWorktreeDirTrusted(dir, worktreeRootAbs)
803
821
  const attached = fs.existsSync(spec.worktreePath)
804
822
 
805
- const guard = worktreeCd(spec.worktreePath)
806
- const setupCommands = (config.setup || []).map((cmd) => `${guard}; ${cmd}`)
807
-
808
823
  const out = [
809
824
  `spec-env nospec: ${name} ` +
810
825
  (attached ? '(plan — worktree exists; will attach)' : '(plan — nothing created yet)'),
@@ -832,11 +847,7 @@ function specEnvNospec(dir, config, name, flags = {}) {
832
847
  ? ` git worktree add ${spec.worktreePath} ${spec.branch}`
833
848
  : ` git worktree add ${spec.worktreePath} -b ${spec.branch}`,
834
849
  )
835
- if (setupCommands.length) {
836
- out.push('')
837
- out.push(' then, in the worktree, run:')
838
- for (const cmd of setupCommands) out.push(` ${cmd}`)
839
- }
850
+ if ((config.setup || []).length) out.push(...bootstrapLines(name))
840
851
  if (flags.json) {
841
852
  process.stdout.write(
842
853
  JSON.stringify(
@@ -1097,21 +1108,189 @@ function specEnvUp(dir, config, specArg, flags = {}) {
1097
1108
  out.push('')
1098
1109
  out.push(' to provision, run:')
1099
1110
  for (const cmd of plan.commands) out.push(` ${cmd}`)
1100
- // Everything after the fork runs in the worktree, under one heading, in the
1101
- // order each step depends on the last: seeds (the env file may be one), the
1102
- // env file, setup (which may derive values into it), and only then the stack.
1103
- const worktreeSteps = [
1104
- ...plan.seedCommands,
1105
- ...(plan.envCommands || []),
1106
- ...plan.setupCommands,
1107
- ...(plan.dockerCommands || []),
1111
+ // Everything after the fork runs in the worktree, and the engine runs it: one
1112
+ // line naming the verb, never the steps as shell (see `specEnvBootstrap`).
1113
+ if (plan.steps.length) out.push(...bootstrapLines(spec.folder, { docs }))
1114
+ process.stdout.write(out.join('\n') + '\n')
1115
+ }
1116
+
1117
+ /**
1118
+ * `spec-env bootstrap [<spec>]` — bring a provisioned worktree to ready.
1119
+ *
1120
+ * Runs the steps `up` once printed as shell for the caller to copy —
1121
+ * seeds, the env file, `setup`, the stack — itself, with the worktree as cwd.
1122
+ * The caller's command line is `skitterspec spec-env bootstrap <name>` and
1123
+ * nothing more, so no `.env` path ever appears in it: there is nothing for a
1124
+ * secrets hook to refuse, and nothing for an agent to rewrite on the way.
1125
+ *
1126
+ * IT DOES NOT FORK. `git worktree add` and the spec's own commit stay `up`'s,
1127
+ * printed and run as before; this verb starts where they finish, so re-running
1128
+ * one step later — `setup` after a lockfile change — costs no re-plan.
1129
+ *
1130
+ * Refuses BEFORE ACTING, non-zero, when there is nothing to bootstrap yet: no
1131
+ * worktree on disk, or a docker spec with no registry slot (its port block
1132
+ * would be derived from nothing). Both name `spec-env up`.
1133
+ *
1134
+ * Specless (`/no-spec`) work runs `setup` only — `nospec` has never seeded and
1135
+ * still does not. Checkout mode has no worktree and says so, exit 0: the
1136
+ * primary checkout already has its dependencies.
1137
+ */
1138
+ function specEnvBootstrap(dir, config, specArg, flags = {}) {
1139
+ const docs = flags.docs === true
1140
+ const json = flags.json === true
1141
+ const say = (line) => process.stdout.write(line + '\n')
1142
+ const refuse = (msg) => {
1143
+ if (json) say(JSON.stringify({ ok: false, refused: msg }, null, 2))
1144
+ else say(`spec-env bootstrap: ${msg}`)
1145
+ process.exitCode = 1
1146
+ }
1147
+
1148
+ if (config.mode === 'checkout') {
1149
+ say(
1150
+ 'spec-env bootstrap: checkout mode has no worktree to bootstrap — the primary ' +
1151
+ 'checkout already has its dependencies.',
1152
+ )
1153
+ return
1154
+ }
1155
+
1156
+ const spec = resolveSpecWithWorktree(dir, config, specArg)
1157
+ const name = spec.folder
1158
+
1159
+ if (!fs.existsSync(spec.worktreePath)) {
1160
+ refuse(
1161
+ `no worktree at ${spec.worktreePath} — run \`skitterspec spec-env up ${name}\` ` +
1162
+ 'and the worktree add it prints first.',
1163
+ )
1164
+ return
1165
+ }
1166
+ // The primary checkout answering as the worktree would seed every file onto
1167
+ // itself and report `exists — skipped`, indistinguishable from a healthy
1168
+ // re-run, while `setup` ran against the base branch.
1169
+ if (sameDir(spec.worktreePath, dir)) {
1170
+ refuse(`${spec.worktreePath} is the primary checkout, not ${name}'s worktree.`)
1171
+ return
1172
+ }
1173
+
1174
+ let steps
1175
+ // A `/no-spec` record — not the authoring record `up --docs` writes for a
1176
+ // spec not yet on disk, which is a spec and plans like one.
1177
+ if (spec.specless && !spec.unwritten) {
1178
+ steps = docs ? [] : (config.setup || []).map((command) => ({ kind: 'setup', command }))
1179
+ } else {
1180
+ const compose = composeFilePresent(dir, config)
1181
+ const { wantsDocker } = resolveStack(spec, config, { docs, composeFilePresent: compose })
1182
+ let offset = null
1183
+ if (wantsDocker) {
1184
+ const slots = readRegistry(dir, config).slots || {}
1185
+ if (!Object.prototype.hasOwnProperty.call(slots, name)) {
1186
+ refuse(
1187
+ `${name} wants a Docker stack and has no slot yet — run ` +
1188
+ `\`skitterspec spec-env up ${name}\` first.`,
1189
+ )
1190
+ return
1191
+ }
1192
+ offset = portOffset(slots[name], config)
1193
+ }
1194
+ steps = worktreeSteps(spec, offset, config, {
1195
+ docs,
1196
+ wantsDocker,
1197
+ composeFilePresent: compose,
1198
+ })
1199
+ }
1200
+
1201
+ if (flags.plan) {
1202
+ if (json) {
1203
+ say(
1204
+ JSON.stringify(
1205
+ { spec: name, worktree: spec.worktreePath, docs, planned: true, steps },
1206
+ null,
1207
+ 2,
1208
+ ),
1209
+ )
1210
+ return
1211
+ }
1212
+ say(`spec-env bootstrap: ${name} (plan — nothing run)`)
1213
+ say('')
1214
+ say(` worktree: ${spec.worktreePath}`)
1215
+ if (!steps.length) say(' steps: none — nothing configured to run')
1216
+ else steps.forEach((st, i) => say(` ${i + 1}. ${describeStep(st)}`))
1217
+ return
1218
+ }
1219
+
1220
+ if (!json) {
1221
+ say(`spec-env bootstrap: ${name}`)
1222
+ say(` worktree: ${spec.worktreePath}`)
1223
+ }
1224
+ const result = runBootstrap(steps, {
1225
+ cwd: spec.worktreePath,
1226
+ mainRoot: dir,
1227
+ // Report lines go to stdout as plain text; under --json they are carried in
1228
+ // the payload instead, and spawned output is moved to stderr with them.
1229
+ write: json ? () => {} : (line) => say(` ${line}`),
1230
+ childStdout: json ? process.stderr : 'inherit',
1231
+ })
1232
+
1233
+ if (json) {
1234
+ say(
1235
+ JSON.stringify(
1236
+ {
1237
+ spec: name,
1238
+ worktree: spec.worktreePath,
1239
+ docs,
1240
+ planned: false,
1241
+ ok: result.ok,
1242
+ failedAt: result.failedAt,
1243
+ steps: result.results.map((r) => ({ ...r.step, outcome: r.outcome, line: r.line })),
1244
+ remaining: steps.slice(result.results.length),
1245
+ },
1246
+ null,
1247
+ 2,
1248
+ ),
1249
+ )
1250
+ } else if (result.ok) {
1251
+ say(
1252
+ steps.length
1253
+ ? `spec-env bootstrap: ${name} ready (${steps.length} step${steps.length === 1 ? '' : 's'})`
1254
+ : `spec-env bootstrap: ${name} — nothing to do (no seedFiles, setup or stack configured)`,
1255
+ )
1256
+ } else {
1257
+ const failed = result.results[result.results.length - 1]
1258
+ process.stderr.write(` ${failed.line}\n`)
1259
+ say(
1260
+ `spec-env bootstrap: stopped at step ${result.failedAt} of ${steps.length} ` +
1261
+ `(${describeStep(failed.step)}) — nothing after it ran.`,
1262
+ )
1263
+ }
1264
+ if (!result.ok) process.exitCode = 1
1265
+ }
1266
+
1267
+ /**
1268
+ * The hand-off from a planner to `spec-env bootstrap`: the one command a caller
1269
+ * runs once the worktree exists.
1270
+ *
1271
+ * ONE LINE, AND NO SHELL. The worktree steps used to be printed here as
1272
+ * POSIX-sh one-liners, and an agent copying them rewrote them — merged two seeds
1273
+ * into a loop, reworded the echo text — at which point a consumer's secrets hook
1274
+ * refused the `.env` paths in the rewrite. This line names no file at all, so it
1275
+ * has nothing to rewrite and nothing to refuse. `bootstrap --plan` is where the
1276
+ * steps can still be read.
1277
+ */
1278
+ function bootstrapLines(name, { docs = false } = {}) {
1279
+ return [
1280
+ '',
1281
+ ' then, run:',
1282
+ ` skitterspec spec-env bootstrap ${name}${docs ? ' --docs' : ''}`,
1108
1283
  ]
1109
- if (worktreeSteps.length) {
1110
- out.push('')
1111
- out.push(' then, in the worktree, run:')
1112
- for (const cmd of worktreeSteps) out.push(` ${cmd}`)
1284
+ }
1285
+
1286
+ // Two spellings of one directory — `/tmp` and `/private/tmp` on macOS — are
1287
+ // the same directory. Cannot-tell (either missing) is "not the same".
1288
+ function sameDir(a, b) {
1289
+ try {
1290
+ return fs.realpathSync(a) === fs.realpathSync(b)
1291
+ } catch {
1292
+ return false
1113
1293
  }
1114
- process.stdout.write(out.join('\n') + '\n')
1115
1294
  }
1116
1295
 
1117
1296
  /**
@@ -1732,7 +1911,8 @@ function specEnvIntegrate(dir, config, specArg) {
1732
1911
  }
1733
1912
  if (cplan.noop) {
1734
1913
  process.stdout.write(
1735
- `spec-env integrate: ${spec.folder} already landed on ${base} — nothing to integrate.\n`,
1914
+ `spec-env integrate: ${spec.folder} already landed on ${base} — nothing to integrate.\n` +
1915
+ afterLandSection(cplan.afterLand),
1736
1916
  )
1737
1917
  return
1738
1918
  }
@@ -1740,7 +1920,7 @@ function specEnvIntegrate(dir, config, specArg) {
1740
1920
  ` base: ${base}`, ` branch: ${cplan.branch}`, '',
1741
1921
  ' run these (abort the rebase on conflict):']
1742
1922
  for (const cmd of cplan.commands) cout.push(` ${cmd}`)
1743
- process.stdout.write(cout.join('\n') + '\n')
1923
+ process.stdout.write(cout.join('\n') + '\n' + afterLandSection(cplan.afterLand))
1744
1924
  return
1745
1925
  }
1746
1926
 
@@ -1829,7 +2009,8 @@ function specEnvIntegrate(dir, config, specArg) {
1829
2009
  }
1830
2010
  if (plan.noop) {
1831
2011
  process.stdout.write(
1832
- `spec-env integrate: ${spec.folder} already landed on ${base} — nothing to integrate.\n`,
2012
+ `spec-env integrate: ${spec.folder} already landed on ${base} — nothing to integrate.\n` +
2013
+ afterLandSection(plan.afterLand),
1833
2014
  )
1834
2015
  return
1835
2016
  }
@@ -1843,7 +2024,17 @@ function specEnvIntegrate(dir, config, specArg) {
1843
2024
  out.push('')
1844
2025
  out.push(' run these (abort the rebase on conflict):')
1845
2026
  for (const cmd of plan.commands) out.push(` ${cmd}`)
1846
- process.stdout.write(out.join('\n') + '\n')
2027
+ process.stdout.write(out.join('\n') + '\n' + afterLandSection(plan.afterLand))
2028
+ }
2029
+
2030
+ // The `afterLand` commands, as a section printed after a land plan — or after
2031
+ // integrate's already-landed line. Empty string when none apply, so a project
2032
+ // without the key sees exactly the output it always has.
2033
+ function afterLandSection(afterLand) {
2034
+ if (!afterLand || !afterLand.commands || afterLand.commands.length === 0) return ''
2035
+ const out = ['', ` then, in the primary checkout (${afterLand.cwd}), run (stop at the first failure):`]
2036
+ for (const cmd of afterLand.commands) out.push(` ${cmd}`)
2037
+ return out.join('\n') + '\n'
1847
2038
  }
1848
2039
 
1849
2040
  // Land a hotfix: tag the branch with the patch-bumped base tag (the prod deploy
@@ -1931,7 +2122,7 @@ function specEnvHotfix(dir, config, positional, flags) {
1931
2122
  for (const cmd of plan.commands) out.push(` ${cmd}`)
1932
2123
  out.push('')
1933
2124
  out.push(` then push the deploy tag yourself: git push origin ${plan.prodTag}`)
1934
- process.stdout.write(out.join('\n') + '\n')
2125
+ process.stdout.write(out.join('\n') + '\n' + afterLandSection(plan.afterLand))
1935
2126
  }
1936
2127
 
1937
2128
  // What the primary checkout has GAINED right now, by content. `dir` is already
@@ -2526,24 +2717,32 @@ function specEnvReviewGate(dir, config, specArg, flags, invokedFrom = dir) {
2526
2717
  }
2527
2718
 
2528
2719
  /**
2529
- * `review allow <network|remote> [--off]` — permit or withdraw a review tier.
2720
+ * `review allow <network|remote> [<spec>] [--off | --set <on|off|>] [--project]`
2721
+ * — permit or withdraw a review tier.
2530
2722
  *
2531
- * THE FIRST THING IN THIS ENGINE TO WRITE `env.config.json`, and that is why it
2532
- * is narrow: it reads, sets one key under `review`, and writes back with the
2533
- * indent the file already uses. It never reorders, never adds a key nobody
2534
- * asked for, and never touches another section.
2723
+ * TWO SCOPES, because the two tiers are different kinds of decision.
2724
+ *
2725
+ * `network` is a BIND, and one server binds for every spec at once — so it is a
2726
+ * project setting and lives in the committed `env.config.json`.
2535
2727
  *
2536
- * WHAT WOULD FOOL THIS: a config written with comments. `loadEnvConfig` parses
2537
- * it with `JSON.parse`, so such a file already fails to load and there is no
2538
- * comment-preserving case to protect — but a file indented with anything other
2539
- * than two spaces IS reformatted, which is cosmetic and worth knowing before it
2540
- * shows up in someone's diff.
2728
+ * `remote` PERMITS PUBLISHING, which sends one spec's diff to a third party. It
2729
+ * was project-wide too, and that was bug-remote-review-toggle: typed from a
2730
+ * worktree with a review waiting, it dirtied the base branch's committed config
2731
+ * and permitted every other spec in the repo to publish as well. So it is now
2732
+ * PER SPEC — a sidecar in the gitignored review store, layered over the
2733
+ * committed key by `withSpecRemote`. `--project` keeps the committed write for
2734
+ * a team that wants it everywhere.
2541
2735
  *
2542
- * IT EDITS A COMMITTED FILE. Turning network reviews on for yourself turns them
2543
- * on for everyone who pulls, so the output says so rather than leaving it to be
2544
- * discovered by a colleague's render.
2736
+ * THE COMMITTED WRITE IS NARROW: it reads, sets one key under `review`, and
2737
+ * writes back with two-space indent. It never reorders, never adds a key nobody
2738
+ * asked for, and never touches another section.
2739
+ *
2740
+ * WHAT WOULD FOOL THIS: a config indented with anything other than two spaces
2741
+ * IS reformatted by the committed write — cosmetic, but worth knowing before it
2742
+ * shows up in someone's diff. A config with comments already fails to load, so
2743
+ * there is no comment-preserving case to protect.
2545
2744
  */
2546
- function specEnvReviewAllow(dir, config, tier, flags) {
2745
+ function specEnvReviewAllow(dir, config, tier, specArg, flags) {
2547
2746
  const TIERS = { network: 'allowNetwork', remote: 'allowRemote' }
2548
2747
  const key = TIERS[String(tier || '').trim()]
2549
2748
  if (!key) {
@@ -2556,39 +2755,63 @@ function specEnvReviewAllow(dir, config, tier, flags) {
2556
2755
  process.exitCode = 1
2557
2756
  return
2558
2757
  }
2559
- // THREE WAYS TO SAY WHAT THE TIER SHOULD BE, and only the third is new.
2560
- // Bare `allow <tier>` turns it ON and `--off` turns it off, exactly as they
2561
- // always have — every existing caller and test reads those.
2562
- //
2563
- // `--set` exists because a SLASH COMMAND can only make one static
2564
- // substitution: `/spec-remote-review on` has to reach the engine as the word
2565
- // the person typed, not as a flag the command file worked out. An EMPTY value
2566
- // is the bare form of that command, and it toggles — which is the common case,
2567
- // because a reader flipping a tier is looking at the line that says which way
2568
- // it currently is.
2758
+
2759
+ // WHICH SPEC, for the per-spec scope — resolved before anything is read, so a
2760
+ // refusal changes nothing. An unknown name throws rather than falling back:
2761
+ // permitting the wrong spec to publish looks exactly like permitting the
2762
+ // right one.
2763
+ const perSpec = key === 'allowRemote' && !flags.project
2764
+ let spec = null
2765
+ let out = null
2766
+ if (perSpec) {
2767
+ try {
2768
+ spec = resolveSpecWithWorktree(dir, config, specArg)
2769
+ } catch (err) {
2770
+ process.stdout.write(
2771
+ `spec-env review allow: ${err.message}\n` +
2772
+ ' remote is permitted per spec — name it, or run this from its worktree.\n' +
2773
+ ' --project permits every spec instead (writes the committed env.config.json).\n' +
2774
+ ' Nothing changed.\n',
2775
+ )
2776
+ process.exitCode = 1
2777
+ return
2778
+ }
2779
+ out = reviewOutPath(dir, spec.folder, null)
2780
+ }
2781
+
2782
+ // What the tier is now, through the same precedence the render uses — so a
2783
+ // toggle means "the other thing the render showed".
2784
+ const file = path.resolve(dir, 'specs/.core/env.config.json')
2785
+ const committed = () => {
2786
+ try {
2787
+ const now = JSON.parse(fs.readFileSync(file, 'utf8'))
2788
+ return now.review && now.review[key] !== undefined ? now.review[key] : config.review[key]
2789
+ } catch {
2790
+ return config.review[key]
2791
+ }
2792
+ }
2793
+ const current = () => {
2794
+ if (perSpec) {
2795
+ const own = readRemoteAllow(out)
2796
+ if (own !== null) return own
2797
+ }
2798
+ return Boolean(committed())
2799
+ }
2800
+
2801
+ // THREE WAYS TO SAY WHAT THE TIER SHOULD BE. Bare `allow <tier>` turns it ON
2802
+ // and `--off` turns it off, exactly as they always have. `--set` exists
2803
+ // because a SLASH COMMAND can only make one static substitution: the word the
2804
+ // person typed reaches the engine as a word. An EMPTY value toggles.
2569
2805
  let on = !flags.off
2570
2806
  if (flags.set !== undefined) {
2571
2807
  const said = String(flags.set).trim().toLowerCase()
2572
2808
  if (said === '') {
2573
- // Toggle. Read through the same precedence the report below uses, so
2574
- // "turn it to the other thing" means the other thing the render showed.
2575
- const current = Boolean(
2576
- (() => {
2577
- try {
2578
- const now = JSON.parse(fs.readFileSync(path.resolve(dir, 'specs/.core/env.config.json'), 'utf8'))
2579
- return now.review && now.review[key] !== undefined ? now.review[key] : config.review[key]
2580
- } catch {
2581
- return config.review[key]
2582
- }
2583
- })(),
2584
- )
2585
- on = !current
2809
+ on = !current()
2586
2810
  } else if (said === 'on' || said === 'off') {
2587
2811
  on = said === 'on'
2588
2812
  } else {
2589
- // Refused by name, like an unknown tier. A misspelt state coerced to a
2590
- // default is the one outcome worth more than a round trip — and the
2591
- // wrong default here opens a port or permits a publish.
2813
+ // Refused by name, like an unknown tier. The wrong default here opens a
2814
+ // port or permits a publish.
2592
2815
  process.stdout.write(
2593
2816
  `spec-env review allow: ${JSON.stringify(String(flags.set))} is not a state — ` +
2594
2817
  'one of on, off, or nothing at all to toggle. Nothing changed.\n',
@@ -2597,58 +2820,77 @@ function specEnvReviewAllow(dir, config, tier, flags) {
2597
2820
  return
2598
2821
  }
2599
2822
  }
2600
- const file = path.resolve(dir, 'specs/.core/env.config.json')
2601
- let parsed
2602
- try {
2603
- parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
2604
- } catch (err) {
2605
- // Cannot tell what is in there, so write nothing. Overwriting a config we
2606
- // could not read is unrecoverable, and the alternative costs one message.
2607
- //
2608
- // NARROWER THAN IT LOOKS: `loadEnvConfig` already refuses the whole
2609
- // `spec-env` command on an unparseable config, naming the file and the
2610
- // parse position — so this branch is reachable only if the file changes
2611
- // between that load and this write. It is kept for that race rather than
2612
- // deleted as dead, and the upstream refusal is what the test asserts.
2613
- process.stdout.write(
2614
- `spec-env review allow: ${file} could not be read as JSON (${err.message}) — ` +
2615
- 'nothing changed.\n',
2616
- )
2617
- process.exitCode = 1
2618
- return
2823
+
2824
+ const before = current()
2825
+ let wrote
2826
+ if (perSpec) {
2827
+ wrote = writeRemoteAllow(out, on)
2828
+ } else {
2829
+ let parsed
2830
+ try {
2831
+ parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
2832
+ } catch (err) {
2833
+ // Cannot tell what is in there, so write nothing. Reachable only if the
2834
+ // file changes between `loadEnvConfig` and here — that load already
2835
+ // refuses an unparseable config.
2836
+ process.stdout.write(
2837
+ `spec-env review allow: ${file} could not be read as JSON (${err.message}) — ` +
2838
+ 'nothing changed.\n',
2839
+ )
2840
+ process.exitCode = 1
2841
+ return
2842
+ }
2843
+ parsed.review = { ...(parsed.review || {}), [key]: on }
2844
+ fs.writeFileSync(file, JSON.stringify(parsed, null, 2) + '\n')
2845
+ wrote = file
2619
2846
  }
2620
- const before = Boolean(
2621
- parsed.review && parsed.review[key] !== undefined ? parsed.review[key] : config.review[key],
2622
- )
2623
- parsed.review = { ...(parsed.review || {}), [key]: on }
2624
- fs.writeFileSync(file, JSON.stringify(parsed, null, 2) + '\n')
2625
2847
 
2626
2848
  const lines = [
2627
- `spec-env review allow: ${tier} reviews are now ${on ? 'ON' : 'OFF'}` +
2849
+ `spec-env review allow: ${tier} reviews${spec ? ` for ${spec.folder}` : ''} are now ${on ? 'ON' : 'OFF'}` +
2628
2850
  (before === on ? ' (unchanged)' : ''),
2629
- // THE ABSOLUTE PATH, not one relative to `dir`. `dir` is re-anchored to the
2630
- // primary checkout, so run from inside a worktree this writes a file in a
2631
- // DIFFERENT TREE — and a relative path reads as the tree you are standing
2632
- // in. Found by running it from a worktree and reverting the surprise.
2633
- ` wrote: ${file}`,
2634
- ' that is the primary checkout, whichever tree you ran this from, and the file is',
2635
- ' COMMITTED — so it changes for everyone who pulls, and leaves that tree dirty.',
2851
+ // THE ABSOLUTE PATH. `dir` is re-anchored to the primary checkout, so run
2852
+ // from a worktree this writes in a different tree — and a relative path
2853
+ // reads as the tree you are standing in.
2854
+ ` wrote: ${wrote}`,
2636
2855
  ]
2856
+ if (perSpec) {
2857
+ lines.push(' this spec only, in the gitignored review store — the committed config is untouched.')
2858
+ } else {
2859
+ lines.push(
2860
+ ' that is the primary checkout, whichever tree you ran this from, and the file is',
2861
+ ' COMMITTED — so it changes for everyone who pulls, and leaves that tree dirty.',
2862
+ )
2863
+ }
2637
2864
  if (key === 'allowNetwork') {
2638
2865
  lines.push(
2639
2866
  on
2640
2867
  ? ' the next render binds every interface, so the page opens on your phone.'
2641
2868
  : ' the next render binds 127.0.0.1 only, reachable from this machine.',
2642
2869
  )
2870
+ } else if (!on) {
2871
+ lines.push(
2872
+ ' publishing is no longer permitted; any page already published stays up.\n' +
2873
+ ' turn it back on with: /spec-remote-review',
2874
+ )
2643
2875
  } else {
2644
2876
  lines.push(
2645
- on
2646
- ? ' this PERMITS publishing; it publishes nothing. A published page cannot be\n' +
2647
- ' deleted by skitterspec, and a verdict there needs /spec-reviewed.\n' +
2648
- ' turn it back off with: /spec-remote-review'
2649
- : ' publishing is no longer permitted; any page already published stays up.\n' +
2650
- ' turn it back on with: /spec-remote-review',
2877
+ ' a published page cannot be deleted by skitterspec,\n' +
2878
+ ' and a verdict there needs /spec-reviewed.\n' +
2879
+ ' turn it back off with: /spec-remote-review',
2651
2880
  )
2881
+ // THE REVIEW IT WAS TYPED FOR. The banner names this command beside a page
2882
+ // the reader cannot reach, so turning remote on is the ask to publish THAT
2883
+ // page — and saying nothing about it left the reader with no link at all.
2884
+ // A page on disk is the positive signal; with none there is nothing to
2885
+ // publish, and nothing is named.
2886
+ if (spec && fs.existsSync(out)) {
2887
+ lines.push(
2888
+ ` review: ${spec.folder} has a page — publish it and re-show the stack`,
2889
+ ` publish: skitterspec spec-env review ${spec.folder} --publish-copy`,
2890
+ )
2891
+ } else {
2892
+ lines.push(' this PERMITS publishing; it publishes nothing.')
2893
+ }
2652
2894
  }
2653
2895
  process.stdout.write(lines.join('\n') + '\n')
2654
2896
  }
@@ -2761,6 +3003,9 @@ async function specEnvReview(dir, config, specArg, flags, invokedFrom = dir) {
2761
3003
  // CHECKOUT, keyed by the spec's folder name; not one byte of it comes from
2762
3004
  // the worktree. `--out` moves the page and its sidecars together.
2763
3005
  const out = reviewOutPath(dir, spec.folder, flags.out)
3006
+ // This spec's own remote permission over the project default — a
3007
+ // per-worktree decision, never the committed config (bug-remote-review-toggle).
3008
+ config = withSpecRemote(config, out)
2764
3009
 
2765
3010
  // The other half of a confirmation: a pass the operator says is not theirs.
2766
3011
  // Left in the store it is reported on every render until they stop reading the
@@ -5064,6 +5309,7 @@ async function specEnv(rest) {
5064
5309
  else if (args[i] === '--stop') flags.stop = true
5065
5310
  else if (args[i] === '--rotate-token') flags.rotateToken = true
5066
5311
  else if (args[i] === '--off') flags.off = true
5312
+ else if (args[i] === '--project') flags.project = true
5067
5313
  else if (args[i] === '--status') flags.status = true
5068
5314
  else if (args[i] === '--port') flags.port = args[++i]
5069
5315
  else if (args[i] === '--host') flags.host = args[++i]
@@ -5094,8 +5340,10 @@ async function specEnv(rest) {
5094
5340
  else if (args[i] === '--phase') flags.phase = args[++i]
5095
5341
  else if (args[i] === '--record-primary') flags.recordPrimary = true
5096
5342
  else if (args[i] === '--docs') flags.docs = true
5343
+ else if (args[i] === '--plan') flags.plan = true
5097
5344
  else if (args[i] === '--from') flags.from = args[++i]
5098
5345
  else if (args[i] === '--session') flags.session = args[++i]
5346
+ else if (args[i] === '--path') flags.path = args[++i]
5099
5347
  else if (args[i] === '--assert-primary-clean') flags.assertPrimaryClean = true
5100
5348
  else positional.push(args[i])
5101
5349
  }
@@ -5168,6 +5416,9 @@ async function specEnv(rest) {
5168
5416
  case 'up':
5169
5417
  specEnvUp(dir, config, positional[0], flags)
5170
5418
  break
5419
+ case 'bootstrap':
5420
+ specEnvBootstrap(dir, config, positional[0], flags)
5421
+ break
5171
5422
  case 'nospec':
5172
5423
  specEnvNospec(dir, config, positional[0], flags)
5173
5424
  break
@@ -5229,7 +5480,7 @@ async function specEnv(rest) {
5229
5480
  break
5230
5481
  }
5231
5482
  if (positional[0] === 'allow') {
5232
- specEnvReviewAllow(dir, config, positional[1], flags)
5483
+ specEnvReviewAllow(dir, config, positional[1], positional[2], flags)
5233
5484
  break
5234
5485
  }
5235
5486
  if (positional[0] === 'skip') {
@@ -5249,7 +5500,8 @@ async function specEnv(rest) {
5249
5500
  `Usage: skitterspec spec-env <${SPEC_ENV_VERBS.join('|')}> [spec] [--keep-volumes] [--force] [--also <tag>] [--older-than <days>] [--branch] [--out <file>] [--review <json>] [--run-reviewers] [--notes <json>] [--verdict <word>] [--resolve <json>] [--outcome <text>] [--claim <code>] [--drop <code>] [--buttons <set>] [--json] [--record-primary] [--assert-primary-clean]\n` +
5250
5501
  ' review serve [--port <n>] [--host <addr>] [--stop] [--status] serve every diff locally\n' +
5251
5502
  ' review serve --rotate-token mint a new URL token; every handed-out link dies\n' +
5252
- ' review allow <network|remote> [--off] permit a review tier (writes env.config.json)\n' +
5503
+ ' review allow <network|remote> [<spec>] [--off] [--project] permit a review tier\n' +
5504
+ ' (network: env.config.json; remote: this spec only)\n' +
5253
5505
  ' review arm [spec] [--phase <n>] a phase ended — its diff now owes a verdict\n' +
5254
5506
  ' review gate [spec] [--check] [--json] is one owed? --check exits non-zero if so\n' +
5255
5507
  ' [--for-command <cmdline>] ...but only when that command is a git commit\n' +