@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/MIGRATION.md +42 -0
- package/README.md +3 -2
- package/assets/commands/spec-remote-review.md +24 -9
- package/assets/core/env.config.md +37 -15
- package/assets/hooks/main-guard.cjs +20 -3
- package/assets/rules/spec-planning.md +11 -4
- package/assets/skills/no-spec/SKILL.md +28 -4
- package/assets/skills/spec/SKILL.md +12 -4
- package/assets/skills/spec-bug/SKILL.md +12 -3
- package/assets/skills/spec-complete/SKILL.md +29 -7
- package/assets/skills/spec-diff/SKILL.md +14 -10
- package/assets/skills/spec-hotfix/SKILL.md +12 -2
- package/assets/skills/spec-start/SKILL.md +19 -8
- package/assets/skills/spec-to-main/SKILL.md +17 -2
- package/package.json +1 -1
- package/src/cli.js +360 -108
- package/src/env/bootstrap.js +188 -0
- package/src/env/config.js +15 -0
- package/src/env/hotfix.js +8 -2
- package/src/env/integrate.js +28 -8
- package/src/env/mainguard.js +64 -5
- package/src/env/provision.js +86 -168
- package/src/env/review.js +51 -1
- package/src/env/serve.js +4 -1
package/src/env/provision.js
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Pure provisioning planner for `spec-env up`.
|
|
5
5
|
*
|
|
6
|
-
* Given a resolved spec and its allocated slot, `planUp` returns the
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* effects itself. The caller (the CLI) reads/allocates the
|
|
6
|
+
* Given a resolved spec and its allocated slot, `planUp` returns the commands
|
|
7
|
+
* the caller runs from the primary checkout (the spec commit, `git worktree
|
|
8
|
+
* add`) and the worktree steps `spec-env bootstrap` runs afterwards (seeds, the
|
|
9
|
+
* env file, `setup`, the stack) — but performs no side effects itself. The caller (the CLI) reads/allocates the
|
|
10
10
|
* registry and passes the slot; this stays deterministic and unit-testable with
|
|
11
11
|
* no live git/docker.
|
|
12
12
|
*/
|
|
@@ -16,107 +16,6 @@ const { portOffset } = require('./registry.js')
|
|
|
16
16
|
const { renderEnvFile } = require('./render.js')
|
|
17
17
|
const { expandTokens } = require('./resolve.js')
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
* Build the POSIX-sh prefix that puts a command in the spec's worktree, or stops.
|
|
21
|
-
*
|
|
22
|
-
* Every command under the "then, in the worktree, run:" heading carries this.
|
|
23
|
-
* Without it those commands stay silent when run from the wrong place: in the
|
|
24
|
-
* primary checkout `$m` resolves to the checkout itself, so a seed's source and
|
|
25
|
-
* target are the same path and it prints `exists — skipped` — indistinguishable
|
|
26
|
-
* from a correctly-provisioned re-run — while a setup command like an install
|
|
27
|
-
* runs against the main checkout and "succeeds" too. Three layers then agree that
|
|
28
|
-
* nothing is wrong, and the caller carries on committing to `main`.
|
|
29
|
-
*
|
|
30
|
-
* A `cd` is deliberately chosen over comparing `git rev-parse --show-toplevel`
|
|
31
|
-
* against `worktreePath`: the planned path is a lexical `path.resolve` while git
|
|
32
|
-
* reports the symlink-resolved one (`/tmp` vs `/private/tmp` on macOS), so a
|
|
33
|
-
* string compare would refuse a perfectly good worktree. `cd` sidesteps that, and
|
|
34
|
-
* does better than refusing — it *positions* the command, so a caller who is in
|
|
35
|
-
* the wrong directory still gets correct behaviour. When the worktree was never
|
|
36
|
-
* created — the reported case — the `cd` fails and takes the whole command with
|
|
37
|
-
* it, non-zero and loud.
|
|
38
|
-
*
|
|
39
|
-
* Per-command, not once at the top of the block: a single leading `cd` only
|
|
40
|
-
* protects the sequence if the caller chains it with `&&`, and the reported
|
|
41
|
-
* failure is precisely a caller that ran the steps as separate statements.
|
|
42
|
-
*/
|
|
43
|
-
function worktreeCd(worktreePath) {
|
|
44
|
-
return (
|
|
45
|
-
`cd "${worktreePath}" 2>/dev/null || ` +
|
|
46
|
-
`{ echo "no worktree at ${worktreePath} — run the provisioning commands first"; exit 1; }`
|
|
47
|
-
)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Build one idempotent POSIX-sh command that seeds a gitignored file from the
|
|
52
|
-
* main checkout into the current worktree (the cwd when the skill runs it).
|
|
53
|
-
*
|
|
54
|
-
* The main checkout is resolved at run time from inside the worktree via
|
|
55
|
-
* `git rev-parse --git-common-dir` (absolute `<main>/.git` in a linked worktree)
|
|
56
|
-
* and its dirname — never a hardcoded repo name or a `../..` hop. The command is
|
|
57
|
-
* safe to re-run: a source absent in main is a printed no-op, an already-seeded
|
|
58
|
-
* target (real file or symlink) is left untouched, and only a genuinely missing
|
|
59
|
-
* target is created. `mode` is 'symlink' (points at main, stays in sync) or
|
|
60
|
-
* 'copy' (an independent copy). Output mirrors the setup style: `seeded <f> → …`.
|
|
61
|
-
*/
|
|
62
|
-
function seedCommandFor(file, mode) {
|
|
63
|
-
const op =
|
|
64
|
-
mode === 'copy'
|
|
65
|
-
? `cp "$m/${file}" "${file}"`
|
|
66
|
-
: `ln -s "$m/${file}" "${file}"`
|
|
67
|
-
return (
|
|
68
|
-
'm="$(dirname "$(git rev-parse --git-common-dir)")"; ' +
|
|
69
|
-
`if [ ! -e "$m/${file}" ]; then echo "seed ${file}: not in main — skipped"; ` +
|
|
70
|
-
`elif [ -e "${file}" ] || [ -L "${file}" ]; then echo "seed ${file}: exists — skipped"; ` +
|
|
71
|
-
`else mkdir -p "$(dirname "${file}")" && ${op} && echo "seeded ${file} → $m/${file}"; fi`
|
|
72
|
-
)
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// Single-quote a value for POSIX sh: the only character that needs escaping
|
|
77
|
-
// inside single quotes is the single quote itself.
|
|
78
|
-
function shq(value) {
|
|
79
|
-
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Build one idempotent POSIX-sh command that writes the spec's env file in the
|
|
84
|
-
* current worktree (the cwd when the skill runs it).
|
|
85
|
-
*
|
|
86
|
-
* It was prose once — "write <envFile> in the worktree:" — which left the write
|
|
87
|
-
* to whoever read the plan, and a secrets guard that blocks any tool call naming
|
|
88
|
-
* `.env` refused it. As a command it is one more step the plan runs.
|
|
89
|
-
*
|
|
90
|
-
* Idempotent and additive: it `touch`es the file and appends each key only when
|
|
91
|
-
* no line already sets it, so a re-run changes nothing and a file `setup` has
|
|
92
|
-
* already written to keeps every value it holds — including a deliberate
|
|
93
|
-
* `PORT_OFFSET` of its own.
|
|
94
|
-
*
|
|
95
|
-
* A SYMLINK IS REFUSED, loudly. Seeding links gitignored files from the primary
|
|
96
|
-
* checkout, so a seeded env file is the primary checkout's own; appending
|
|
97
|
-
* through it would write this spec's port block into the main stack's
|
|
98
|
-
* configuration. It runs after the seeds precisely so that collision is caught
|
|
99
|
-
* rather than hidden. The fix is a `docker.envFile` naming a file of its own.
|
|
100
|
-
*/
|
|
101
|
-
function envFileCommandFor(envFile, projectName, offset) {
|
|
102
|
-
const f = shq(envFile)
|
|
103
|
-
const ensure = (key, value) =>
|
|
104
|
-
`{ grep -q '^${key}=' ${f} || echo ${shq(`${key}=${value}`)} >> ${f}; }`
|
|
105
|
-
return (
|
|
106
|
-
`if [ -L ${f} ]; then echo ${shq(
|
|
107
|
-
`env file ${envFile} is a symlink — docker.envFile must name a file of its ` +
|
|
108
|
-
"own, not a seeded one; refusing to write this spec's port block through it",
|
|
109
|
-
)} >&2; exit 1; fi; ` +
|
|
110
|
-
`touch ${f} && ` +
|
|
111
|
-
// A file without a trailing newline would glue the first appended key onto
|
|
112
|
-
// its last line.
|
|
113
|
-
`{ [ ! -s ${f} ] || [ -z "$(tail -c 1 ${f})" ] || echo >> ${f}; } && ` +
|
|
114
|
-
`${ensure('COMPOSE_PROJECT_NAME', projectName)} && ` +
|
|
115
|
-
`${ensure('PORT_OFFSET', offset)} && ` +
|
|
116
|
-
`echo ${shq(`env file ${envFile}: ready`)}`
|
|
117
|
-
)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
19
|
// Cap a path list in a message: enough to recognise, not a wall of text.
|
|
121
20
|
function listPaths(paths) {
|
|
122
21
|
const shown = paths.slice(0, 5).join(', ')
|
|
@@ -328,6 +227,71 @@ function resolveStack(spec, config, { docs = false, composeFilePresent } = {}) {
|
|
|
328
227
|
return { stack, wantsDocker: stack === 'docker' && config.docker.enabled && !docs }
|
|
329
228
|
}
|
|
330
229
|
|
|
230
|
+
/**
|
|
231
|
+
* The steps that bring a fresh worktree to ready, as data — the list
|
|
232
|
+
* `spec-env bootstrap` runs. In the order each depends on the last: seeds (the
|
|
233
|
+
* env file may be one), the env file, `setup` (which may derive values into
|
|
234
|
+
* it), and only then the stack.
|
|
235
|
+
*
|
|
236
|
+
* DATA AND NOT SHELL, and that is the reason the verb exists. These steps were
|
|
237
|
+
* printed as POSIX-sh one-liners for the agent to copy, and the copy was never
|
|
238
|
+
* faithful: merged into a loop, its echo text reworded, two steps glued into one
|
|
239
|
+
* call. A consumer's secrets hook — which refuses any Bash call naming a `.env`
|
|
240
|
+
* file and makes an exception only for the exact printed shape — then refused
|
|
241
|
+
* the rewrite, and bootstrap stopped with the user running it by hand. A step
|
|
242
|
+
* the engine runs itself has no text for anything to rewrite or refuse.
|
|
243
|
+
*
|
|
244
|
+
* `setup` entries stay shell strings: they are the project's own commands,
|
|
245
|
+
* written by the project. The docker step is an argv, run without a shell.
|
|
246
|
+
*
|
|
247
|
+
* Pure: `offset` is the spec's port offset (null without a stack), and every
|
|
248
|
+
* filesystem fact arrives in `opts`.
|
|
249
|
+
*/
|
|
250
|
+
function worktreeSteps(spec, offset, config, opts = {}) {
|
|
251
|
+
const { docs = false, wantsDocker = false, composeFilePresent: compose } = opts
|
|
252
|
+
const seed = config.seedFiles || { mode: 'symlink', files: [] }
|
|
253
|
+
const mode = seed.mode === 'copy' ? 'copy' : 'symlink'
|
|
254
|
+
const tokens = {
|
|
255
|
+
worktreePath: spec.worktreePath,
|
|
256
|
+
slug: spec.slug,
|
|
257
|
+
branch: spec.branch,
|
|
258
|
+
projectName: spec.projectName,
|
|
259
|
+
portOffset: offset === null || offset === undefined ? '' : String(offset),
|
|
260
|
+
}
|
|
261
|
+
const envFile = (config.docker && config.docker.envFile) || '.env'
|
|
262
|
+
const steps = (seed.files || []).map((file) => ({ kind: 'seed', file, mode }))
|
|
263
|
+
if (wantsDocker) {
|
|
264
|
+
steps.push({ kind: 'envFile', file: envFile, projectName: spec.projectName, offset })
|
|
265
|
+
}
|
|
266
|
+
// Documents mode drops `setup` and nothing else: seeding is cheap and a spec
|
|
267
|
+
// author may well want the gitignored files present, while `setup` is the
|
|
268
|
+
// expensive half that exists to make the tree run. `wantsDocker` is already
|
|
269
|
+
// false under `--docs` (`resolveStack`), so the stack drops with it.
|
|
270
|
+
if (!docs) {
|
|
271
|
+
for (const cmd of config.setup || []) {
|
|
272
|
+
steps.push({ kind: 'setup', command: expandTokens(cmd, tokens) })
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (wantsDocker) {
|
|
276
|
+
const file = composeFileArg(config, compose).trim().split(/\s+/).filter(Boolean)
|
|
277
|
+
steps.push({
|
|
278
|
+
kind: 'docker',
|
|
279
|
+
argv: [
|
|
280
|
+
'docker',
|
|
281
|
+
'compose',
|
|
282
|
+
...file,
|
|
283
|
+
'--env-file',
|
|
284
|
+
envFile,
|
|
285
|
+
'--project-name',
|
|
286
|
+
spec.projectName,
|
|
287
|
+
'up',
|
|
288
|
+
'-d',
|
|
289
|
+
],
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
return steps
|
|
293
|
+
}
|
|
294
|
+
|
|
331
295
|
/**
|
|
332
296
|
* Plan a provisioning run.
|
|
333
297
|
*
|
|
@@ -338,10 +302,10 @@ function resolveStack(spec, config, { docs = false, composeFilePresent } = {}) {
|
|
|
338
302
|
* @param {object} config normalised env config.
|
|
339
303
|
* @param {object} opts { docs } — documents mode (see below). Optional.
|
|
340
304
|
* @returns {object} { worktreePath, branch, projectName, slot, portOffset,
|
|
341
|
-
* envContents, commands,
|
|
342
|
-
* setupCommands, dockerCommands, docs, attached }
|
|
305
|
+
* envContents, commands, steps, docs, attached }
|
|
343
306
|
*
|
|
344
|
-
*
|
|
307
|
+
* `commands` run from the primary checkout (the spec commit, the fork); `steps`
|
|
308
|
+
* are what `spec-env bootstrap` runs in the worktree afterwards.
|
|
345
309
|
*/
|
|
346
310
|
function planUp(spec, alloc, config, ctx, opts = {}) {
|
|
347
311
|
const { slot, attached } = alloc
|
|
@@ -374,33 +338,13 @@ function planUp(spec, alloc, config, ctx, opts = {}) {
|
|
|
374
338
|
? renderEnvFile({ projectName: spec.projectName, portOffset: offset })
|
|
375
339
|
: null
|
|
376
340
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
// File seeding runs *in the worktree* after `git worktree add`, before the
|
|
387
|
-
// setup commands (which may depend on the seeded .env). Each entry becomes an
|
|
388
|
-
// idempotent shell command resolving the main checkout at run time. Absent
|
|
389
|
-
// config ⇒ no commands ⇒ current behaviour.
|
|
390
|
-
const seed = config.seedFiles || { mode: 'symlink', files: [] }
|
|
391
|
-
const seedMode = seed.mode === 'copy' ? 'copy' : 'symlink'
|
|
392
|
-
const guard = worktreeCd(spec.worktreePath)
|
|
393
|
-
const seedCommands = (seed.files || []).map(
|
|
394
|
-
(file) => `${guard}; ${seedCommandFor(file, seedMode)}`,
|
|
395
|
-
)
|
|
396
|
-
|
|
397
|
-
// Bootstrap commands run *in the worktree* after `git worktree add` (before
|
|
398
|
-
// Docker/dev), on every provision including re-attach — deps must exist for
|
|
399
|
-
// the worktree to be usable. Kept separate from `commands` (run from the
|
|
400
|
-
// primary checkout root); the CLI prints them under an "in the worktree" head.
|
|
401
|
-
const setupCommands = (config.setup || []).map(
|
|
402
|
-
(cmd) => `${guard}; ${expandTokens(cmd, tokens)}`,
|
|
403
|
-
)
|
|
341
|
+
// Everything after the fork runs in the worktree, and `spec-env bootstrap`
|
|
342
|
+
// runs it — the steps are data, not shell for a caller to copy.
|
|
343
|
+
const steps = worktreeSteps(spec, offset, config, {
|
|
344
|
+
docs,
|
|
345
|
+
wantsDocker,
|
|
346
|
+
composeFilePresent: ctx ? ctx.composeFilePresent : undefined,
|
|
347
|
+
})
|
|
404
348
|
|
|
405
349
|
const commands = []
|
|
406
350
|
// Fresh branch → -b; attach an existing branch/slot → plain form (never clobber).
|
|
@@ -413,25 +357,6 @@ function planUp(spec, alloc, config, ctx, opts = {}) {
|
|
|
413
357
|
? `git worktree add ${spec.worktreePath} ${spec.branch}`
|
|
414
358
|
: `git worktree add ${spec.worktreePath} -b ${spec.branch}${forkPoint}`,
|
|
415
359
|
)
|
|
416
|
-
// The stack comes up LAST, in the worktree: after the seeds, after the env
|
|
417
|
-
// file, after `setup` — which may derive values into that file. It once ran
|
|
418
|
-
// from the primary checkout before the worktree existed, so compose read the
|
|
419
|
-
// primary checkout's env file, fell back to its defaults and collided with the
|
|
420
|
-
// main stack. `--env-file` is always passed: with the default `.env` it is what
|
|
421
|
-
// compose would have found anyway, and with any other name it is the only way
|
|
422
|
-
// compose finds it.
|
|
423
|
-
const envFile = (config.docker && config.docker.envFile) || '.env'
|
|
424
|
-
const envCommands = wantsDocker
|
|
425
|
-
? [`${guard}; ${envFileCommandFor(envFile, spec.projectName, offset)}`]
|
|
426
|
-
: []
|
|
427
|
-
const dockerCommands = wantsDocker
|
|
428
|
-
? [
|
|
429
|
-
`${guard}; docker compose` +
|
|
430
|
-
composeFileArg(config, ctx ? ctx.composeFilePresent : undefined) +
|
|
431
|
-
` --env-file ${shq(envFile)} --project-name ${spec.projectName} up -d`,
|
|
432
|
-
]
|
|
433
|
-
: []
|
|
434
|
-
|
|
435
360
|
// The tree gate. A worktree forks from the base branch's tree, so an
|
|
436
361
|
// uncommitted spec would produce a branch missing the spec it is for — this is
|
|
437
362
|
// where that gets committed, or refused. Worktree mode had NO clean gate before
|
|
@@ -455,18 +380,13 @@ function planUp(spec, alloc, config, ctx, opts = {}) {
|
|
|
455
380
|
portOffset: offset,
|
|
456
381
|
envContents,
|
|
457
382
|
commands: gate.blocked ? [] : [...gate.commands, ...commands],
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
// not the same kind of thing: seeding links the gitignored files a worktree
|
|
462
|
-
// has none of — cheap, and a spec author may well want `.env` present —
|
|
463
|
-
// while `setup` is the expensive half that exists to make the tree run.
|
|
464
|
-
setupCommands: gate.blocked || docs ? [] : setupCommands,
|
|
465
|
-
dockerCommands: gate.blocked ? [] : dockerCommands,
|
|
383
|
+
// The worktree steps, in the order `spec-env bootstrap` runs them. Documents
|
|
384
|
+
// mode drops `setup` and the stack and keeps the seeds (`worktreeSteps`).
|
|
385
|
+
steps: gate.blocked ? [] : steps,
|
|
466
386
|
// Reported so a caller reading `--json` can tell a documents worktree from a
|
|
467
|
-
// full one POSITIVELY, rather than inferring it from what is missing.
|
|
468
|
-
//
|
|
469
|
-
//
|
|
387
|
+
// full one POSITIVELY, rather than inferring it from what is missing. A step
|
|
388
|
+
// list with no `setup` also describes a project that configured none, and
|
|
389
|
+
// those two must not read alike.
|
|
470
390
|
docs,
|
|
471
391
|
attached,
|
|
472
392
|
}
|
|
@@ -540,9 +460,7 @@ module.exports = {
|
|
|
540
460
|
planUp,
|
|
541
461
|
planCheckoutUp,
|
|
542
462
|
planSpecCommit,
|
|
543
|
-
|
|
544
|
-
envFileCommandFor,
|
|
545
|
-
worktreeCd,
|
|
463
|
+
worktreeSteps,
|
|
546
464
|
resolveStack,
|
|
547
465
|
composeFileArg,
|
|
548
466
|
}
|
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
|
-
|
|
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
|
-
|
|
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,
|