@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/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,
|
|
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), {
|
|
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 (
|
|
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,
|
|
1101
|
-
//
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
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
|
|
2720
|
+
* `review allow <network|remote> [<spec>] [--off | --set <on|off|>] [--project]`
|
|
2721
|
+
* — permit or withdraw a review tier.
|
|
2530
2722
|
*
|
|
2531
|
-
*
|
|
2532
|
-
*
|
|
2533
|
-
*
|
|
2534
|
-
*
|
|
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
|
-
*
|
|
2537
|
-
*
|
|
2538
|
-
*
|
|
2539
|
-
*
|
|
2540
|
-
*
|
|
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
|
-
*
|
|
2543
|
-
*
|
|
2544
|
-
*
|
|
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
|
-
|
|
2560
|
-
//
|
|
2561
|
-
//
|
|
2562
|
-
//
|
|
2563
|
-
//
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
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
|
-
|
|
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.
|
|
2590
|
-
//
|
|
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
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
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
|
|
2630
|
-
//
|
|
2631
|
-
//
|
|
2632
|
-
|
|
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
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
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
|
|
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' +
|