opencode-turnstile 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,86 @@
1
+ // CI guard: the marker examples in README.md must be accepted by the parser
2
+ // regexes in turnstile.ts. Exits non-zero on drift.
3
+ import { readdirSync, readFileSync } from "node:fs"
4
+ import { dirname, join } from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+
7
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..")
8
+ // The marker regex literals live in src/markers.ts (the marker boundary);
9
+ // turnstile.ts stays in the list so older single-file layouts still guard.
10
+ const ts = ["turnstile.ts", "src/markers.ts"]
11
+ .map((f) => readFileSync(join(root, f), "utf8"))
12
+ .join("\n")
13
+ const readme = readFileSync(join(root, "README.md"), "utf8")
14
+
15
+ function extractRegex(name) {
16
+ const m = ts.match(new RegExp(`const ${name} = /(.+)/([a-z]*)`))
17
+ if (!m) throw new Error(`could not find ${name} in turnstile.ts or src/markers.ts`)
18
+ // strip the g flag: RegExp.test is stateful with /g (lastIndex advances)
19
+ return new RegExp(m[1], m[2].replace(/g/g, ""))
20
+ }
21
+
22
+ const gateRe = extractRegex("GATE_RE")
23
+ const verdictRe = extractRegex("VERDICT_RE")
24
+
25
+ // Every fenced ```-block line that looks like a marker must match a regex.
26
+ const lines = [...readme.matchAll(/```[a-z]*\n([\s\S]*?)```/g)]
27
+ .flatMap((m) => m[1].split("\n"))
28
+ .filter((l) => /^(GATE|VERDICT): /.test(l.trim()) && !/[<>]/.test(l))
29
+
30
+ if (lines.length === 0) {
31
+ console.error("check-markers: no concrete marker examples found in README.md")
32
+ process.exit(1)
33
+ }
34
+
35
+ let failed = 0
36
+ for (const line of lines) {
37
+ const ok = gateRe.test(line) || verdictRe.test(line)
38
+ if (!ok) {
39
+ console.error(`check-markers: README marker not accepted by parser: ${line}`)
40
+ failed++
41
+ }
42
+ }
43
+
44
+ // Agent templates: the backticked GATE:/VERDICT: examples in
45
+ // .opencode/agents/*.md are the prompt-side half of the marker contract.
46
+ // Templates spell the numeric fields <n>; they must parse once the
47
+ // placeholder stands in for digits.
48
+ const agentsDir = join(root, ".opencode", "agents")
49
+ let agentTemplates = 0
50
+ for (const f of readdirSync(agentsDir).filter((f) => f.endsWith(".md"))) {
51
+ const text = readFileSync(join(agentsDir, f), "utf8")
52
+ for (const m of text.matchAll(/`((?:GATE|VERDICT): [^\n`]*)`/g)) {
53
+ const line = m[1].replaceAll("<n>", "1")
54
+ const re = line.startsWith("GATE") ? gateRe : verdictRe
55
+ agentTemplates++
56
+ if (!re.test(line)) {
57
+ console.error(`check-markers: agent marker template not accepted by parser: ${f}: ${line}`)
58
+ failed++
59
+ }
60
+ }
61
+ }
62
+ if (agentTemplates === 0) {
63
+ console.error("check-markers: no marker templates found in .opencode/agents")
64
+ failed++
65
+ }
66
+
67
+ // Inverse check: the regexes must also reject malformed markers.
68
+ for (const [re, bad] of [
69
+ [gateRe, "GATE: PASS tests=12"],
70
+ [gateRe, "GATE: OK tests=1 passed=1 failed=0"],
71
+ [verdictRe, "VERDICT: APPROVE crit=1"],
72
+ [verdictRe, "VERDICT: MAYBE crit=0 high=0 med=0 low=0"],
73
+ ]) {
74
+ if (re.test(bad)) {
75
+ console.error(`check-markers: parser accepted malformed marker: ${bad}`)
76
+ failed++
77
+ }
78
+ }
79
+
80
+ if (failed) {
81
+ console.error(`check-markers: ${failed} drift finding(s)`)
82
+ process.exit(1)
83
+ }
84
+ console.log(
85
+ `check-markers: OK (${lines.length} README examples, ${agentTemplates} agent templates verified against parser)`,
86
+ )
@@ -0,0 +1,504 @@
1
+ #!/usr/bin/env node
2
+ // Installs the turnstile contract block into a global AGENTS.md file.
3
+ // Idempotent: only the sentinel-delimited managed block is written or
4
+ // replaced; surrounding personal content is preserved. (Agent definitions
5
+ // are installed as symlinks by scripts/install.mjs; this script no longer
6
+ // copies them.)
7
+ //
8
+ // Usage:
9
+ // node scripts/install-agents.mjs [--file <path>] [--check] [--remove] [--no-adopt]
10
+ //
11
+ // --file Target AGENTS.md (default: ~/.config/opencode/AGENTS.md)
12
+ // --check Report the adoption decision without writing; exit 0 only when
13
+ // the managed block matches the template.
14
+ // --remove Delete the sentinel-delimited managed block (surrounding
15
+ // content preserved); refuses a start-sentinel-without-end.
16
+ // Idempotent: running on a file without the block is a no-op.
17
+ // --no-adopt Refuse to replace a differing legacy section: print
18
+ // reconciliation instructions and exit 1 instead of adopting.
19
+
20
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"
21
+ import { homedir } from "node:os"
22
+ import { dirname, join } from "node:path"
23
+ import { fileURLToPath, pathToFileURL } from "node:url"
24
+
25
+ const START = "<!-- turnstile:start"
26
+ const END = "<!-- turnstile:end -->"
27
+
28
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..")
29
+ const templatePath = join(root, "templates", "global-AGENTS.md")
30
+
31
+ // Legacy pipeline-chain signature: the current pipeline order (planner →
32
+ // plan-reviewer → executor → test-runner → reviewer). It is one of two section
33
+ // locators; the other is a heading matching /turnstile/i.
34
+ const CHAIN_RE = /planner\s*→\s*plan-reviewer\s*→\s*executor\s*→\s*test-runner\s*→\s*reviewer/
35
+
36
+ // Distinct errors so the caller can tell "ambiguous (multiple sections)" apart
37
+ // from "corrupted (start sentinel without end sentinel)". Both exit 1 but with
38
+ // different advice.
39
+ class AmbiguousError extends Error {
40
+ constructor(message) {
41
+ super(message)
42
+ this.name = "AmbiguousError"
43
+ this.ambiguous = true
44
+ }
45
+ }
46
+
47
+ class CorruptedError extends Error {
48
+ constructor(message) {
49
+ super(message)
50
+ this.name = "CorruptedError"
51
+ this.corrupted = true
52
+ }
53
+ }
54
+
55
+ function parseArgs(argv) {
56
+ const args = {
57
+ file: join(homedir(), ".config/opencode/AGENTS.md"),
58
+ check: false,
59
+ remove: false,
60
+ noAdopt: false,
61
+ }
62
+ for (let i = 2; i < argv.length; i++) {
63
+ if (argv[i] === "--file") args.file = argv[++i]
64
+ else if (argv[i] === "--check") args.check = true
65
+ else if (argv[i] === "--remove") args.remove = true
66
+ else if (argv[i] === "--no-adopt") args.noAdopt = true
67
+ else {
68
+ console.error(`install-agents: unknown argument ${argv[i]}`)
69
+ process.exit(2)
70
+ }
71
+ }
72
+ return args
73
+ }
74
+
75
+ /**
76
+ * Extracts the managed block (including sentinel markers) from the template.
77
+ * @returns {string} The exact block to install.
78
+ */
79
+ function readBlock() {
80
+ const template = readFileSync(templatePath, "utf8")
81
+ const start = template.indexOf(START)
82
+ const end = template.indexOf(END)
83
+ if (start === -1 || end === -1 || end < start) {
84
+ throw new Error(`template is missing a complete managed block: ${templatePath}`)
85
+ }
86
+ return template.slice(start, end + END.length) + "\n"
87
+ }
88
+
89
+ /**
90
+ * The managed block minus its sentinel comment lines: the canonical inner
91
+ * content that a legacy section is compared against for equivalence. The
92
+ * start comment may span several lines; inner begins at the first "-->" that
93
+ * closes it and ends at the end sentinel. Pure.
94
+ * @param {string} block The full managed block (sentinels included).
95
+ * @returns {string} The inner content between the sentinel comments.
96
+ */
97
+ function templateInner(block) {
98
+ const start = block.indexOf(START)
99
+ const endCommentStart = block.lastIndexOf(END)
100
+ const startCommentEnd = block.indexOf("-->", start) + "-->".length
101
+ return block.slice(startCommentEnd, endCommentStart)
102
+ }
103
+
104
+ /**
105
+ * Removes the sentinel-delimited managed block from the given file content,
106
+ * preserving the surrounding content. When the block is absent this is a
107
+ * no-op; a start sentinel without an end sentinel is refused (throw), the
108
+ * same corruption guard `merge()` applies. Pure: no filesystem access.
109
+ * @param {string} current Existing file content.
110
+ * @param {string} [file] File label for the refusal message.
111
+ * @returns {{removed: boolean, next: string}} Whether a block was found and the new content.
112
+ */
113
+ function removeBlock(current, file) {
114
+ const start = current.indexOf(START)
115
+ if (start === -1) return { removed: false, next: current }
116
+ const end = current.indexOf(END, start)
117
+ if (end === -1) {
118
+ throw new Error(
119
+ `${file ?? "file"}: found a start sentinel without an end sentinel — refusing to touch it; fix the file manually`,
120
+ )
121
+ }
122
+ const prefix = current.slice(0, start).replace(/\n*$/, "")
123
+ const suffix = current.slice(end + END.length).replace(/^\n/, "")
124
+ const next = prefix && suffix ? `${prefix}\n${suffix}` : suffix || (prefix ? `${prefix}\n` : "")
125
+ return { removed: true, next }
126
+ }
127
+
128
+ /**
129
+ * Replaces or inserts the managed block in the given file content.
130
+ * @param {string} current Existing file content ("" when the file is new).
131
+ * @param {string} block The managed block to write.
132
+ * @param {string} [file] File label for the refusal message.
133
+ * @returns {string} The new file content.
134
+ */
135
+ function merge(current, block, file) {
136
+ const start = current.indexOf(START)
137
+ if (start === -1) {
138
+ const separator = current.trim().length ? "\n\n" : ""
139
+ return current.replace(/\n*$/, "\n") + separator + block
140
+ }
141
+ const end = current.indexOf(END, start)
142
+ if (end === -1) {
143
+ throw new Error(
144
+ `${file ?? "file"}: found a start sentinel without an end sentinel — refusing to touch it; fix the file manually`,
145
+ )
146
+ }
147
+ // The block ends with its own newline; strip one leading newline from
148
+ // the suffix so an unchanged file stays byte-identical on rerun.
149
+ const suffix = current.slice(end + END.length).replace(/^\n/, "")
150
+ return current.slice(0, start) + block + suffix
151
+ }
152
+
153
+ /**
154
+ * Splices the managed block into `content` at the given span, preserving
155
+ * everything outside the span. Pure.
156
+ * @param {string} content Existing file content.
157
+ * @param {{start: number, end: number}} span The span to replace.
158
+ * @param {string} block The managed block to insert.
159
+ * @returns {string} The new content.
160
+ */
161
+ function installBlock(content, span, block) {
162
+ return content.slice(0, span.start) + block + content.slice(span.end)
163
+ }
164
+
165
+ /**
166
+ * Locates a legacy (unmanaged) turnstile contract section in `content`.
167
+ * Returns { start, end, level } spanning the section, or null when none is
168
+ * present. Throws AmbiguousError when more than one section matches (the
169
+ * caller distinguishes this from the corrupted case, which `merge()`/
170
+ * `removeBlock()` refuse). Pure: no filesystem access.
171
+ * @param {string} content Existing file content.
172
+ * @returns {{start: number, end: number, level: number} | null} The section span, or null.
173
+ */
174
+ function findLegacySection(content) {
175
+ if (content.includes(START)) return null // a managed block is not "legacy"
176
+ const headings = []
177
+ for (const m of content.matchAll(/^#{1,3}\s+/gm)) {
178
+ const start = m.index
179
+ const lineEnd = content.indexOf("\n", start)
180
+ const end = lineEnd === -1 ? content.length : lineEnd
181
+ const level = m[0].match(/^#*/)[0].length
182
+ headings.push({ start, end, level, text: content.slice(start, end) })
183
+ }
184
+ const candidates = []
185
+ for (const h of headings) {
186
+ // Span first (next heading of <= level), then probe inside it. Nested
187
+ // turnstile-named subsections are excluded from the probe: their content
188
+ // belongs to them, not to an enclosing higher-level heading; otherwise
189
+ // a `# Title` above a turnstile section would swallow the chain and look
190
+ // like a second section.
191
+ let spanEnd = content.length
192
+ for (const x of headings) {
193
+ if (x.start > h.start && x.level <= h.level) {
194
+ spanEnd = x.start
195
+ break
196
+ }
197
+ }
198
+ let probeText = content.slice(h.start, spanEnd)
199
+ for (const x of headings) {
200
+ if (x.start <= h.start || x.start >= spanEnd) continue
201
+ if (!/turnstile/i.test(x.text)) continue
202
+ let xEnd = content.length
203
+ for (const y of headings) {
204
+ if (y.start > x.start && y.level <= x.level) {
205
+ xEnd = y.start
206
+ break
207
+ }
208
+ }
209
+ probeText = probeText.replace(content.slice(x.start, xEnd), "")
210
+ }
211
+ const isTurnstileHeading = /turnstile/i.test(h.text)
212
+ const hasChain = CHAIN_RE.test(probeText)
213
+ if (isTurnstileHeading || hasChain) candidates.push(h)
214
+ }
215
+ if (candidates.length === 0) return null
216
+ if (candidates.length > 1) {
217
+ throw new AmbiguousError(
218
+ "found more than one turnstile contract section — ambiguous; reconcile manually.",
219
+ )
220
+ }
221
+ const h = candidates[0]
222
+ let end = content.length
223
+ for (const x of headings) {
224
+ if (x.start > h.start && x.level <= h.level) {
225
+ end = x.start
226
+ break
227
+ }
228
+ }
229
+ return { start: h.start, end, level: h.level }
230
+ }
231
+
232
+ /**
233
+ * Normalizes a section for equivalence comparison: drops turnstile sentinel
234
+ * comment lines, collapses every whitespace run to a single space, and trims.
235
+ * Two sections are "equivalent" when their normalizations match. Pure.
236
+ * @param {string} text A section of content.
237
+ * @returns {string} The normalized form.
238
+ */
239
+ function normalizeForCompare(text) {
240
+ return text
241
+ .split("\n")
242
+ .filter((line) => !line.trim().startsWith("<!-- turnstile:"))
243
+ .join("\n")
244
+ .replace(/\s+/g, " ")
245
+ .trim()
246
+ }
247
+
248
+ /**
249
+ * True when two contract sections carry the same content (modulo sentinel
250
+ * comments and whitespace). Pure.
251
+ * @param {string} a First section.
252
+ * @param {string} b Second section.
253
+ * @returns {boolean}
254
+ */
255
+ function sectionsEquivalent(a, b) {
256
+ return normalizeForCompare(a) === normalizeForCompare(b)
257
+ }
258
+
259
+ /**
260
+ * Classifies the adoption decision for `content` against the managed `block`.
261
+ * Pure. Returns one of:
262
+ * { decision: "ok" } managed block present and matches the template
263
+ * { decision: "append" } no block and no legacy section
264
+ * { decision: "adopt", span } legacy section equivalent to the template
265
+ * { decision: "replace", span } legacy section differs from the template
266
+ * { decision: "drift" } managed block present but out of date
267
+ * Throws AmbiguousError (multiple sections) or CorruptedError (start sentinel
268
+ * without end sentinel); callers convert these to exit-1 refusals with
269
+ * distinct advice.
270
+ * @param {string} content Existing file content ("" when the file is new).
271
+ * @param {string} block The managed block to install.
272
+ * @returns {{decision: string, span?: {start: number, end: number}}}
273
+ */
274
+ function analyze(content, block) {
275
+ const start = content.indexOf(START)
276
+ if (start !== -1) {
277
+ const end = content.indexOf(END, start)
278
+ if (end === -1) {
279
+ throw new CorruptedError(
280
+ `found a start sentinel without an end sentinel — refusing to touch it; fix the file manually`,
281
+ )
282
+ }
283
+ const installed = content.slice(start, end + END.length) + "\n"
284
+ return installed === block ? { decision: "ok" } : { decision: "drift" }
285
+ }
286
+ const span = findLegacySection(content)
287
+ if (!span) return { decision: "append" }
288
+ const section = content.slice(span.start, span.end)
289
+ return sectionsEquivalent(section, templateInner(block))
290
+ ? { decision: "adopt", span }
291
+ : { decision: "replace", span }
292
+ }
293
+
294
+ /**
295
+ * Resolves the write-path action for a classified analysis. Pure: mirrors
296
+ * exactly what `main()` performs, so the --no-adopt refusal and the
297
+ * adopt-vs-backup-replace choice are unit-testable without the filesystem.
298
+ * @param {{decision: string, span?: {start: number, end: number}}} analysis Result of analyze().
299
+ * @param {{noAdopt: boolean}} args Parsed arguments.
300
+ * @returns {{action: "up-to-date"|"append"|"adopt"|"replace"|"resync"|"refuse"}} The action to take.
301
+ */
302
+ function applyPlan(analysis, args) {
303
+ switch (analysis.decision) {
304
+ case "ok":
305
+ return { action: "up-to-date" }
306
+ case "append":
307
+ return { action: args.noAdopt ? "refuse" : "append" }
308
+ case "adopt":
309
+ return { action: args.noAdopt ? "refuse" : "adopt" }
310
+ case "replace":
311
+ return args.noAdopt ? { action: "refuse" } : { action: "replace" }
312
+ case "drift":
313
+ return { action: args.noAdopt ? "refuse" : "resync" }
314
+ default:
315
+ throw new Error(`unknown adoption decision: ${analysis.decision}`)
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Turns a classified analysis into the check-mode report line and its exit
321
+ * code. Pure.
322
+ * @param {{decision: string, span?: {start: number, end: number}}} analysis Result of analyze().
323
+ * @param {string} file Target file label.
324
+ * @returns {{message: string, code: number}}
325
+ */
326
+ function checkReport(analysis, file) {
327
+ switch (analysis.decision) {
328
+ case "ok":
329
+ return { message: `check-agents: OK (${file})`, code: 0 }
330
+ case "append":
331
+ return {
332
+ message: `check-agents: drift: no managed contract block in ${file} — the contract block would be appended`,
333
+ code: 1,
334
+ }
335
+ case "adopt":
336
+ return {
337
+ message:
338
+ `check-agents: would-adopt-equivalent (${file}) — the existing turnstile section matches ` +
339
+ `the template; adopting would wrap it with sentinels`,
340
+ code: 1,
341
+ }
342
+ case "replace":
343
+ return {
344
+ message:
345
+ `check-agents: would-replace (${file}) — the existing turnstile section differs from the ` +
346
+ `template; adopting would back up and replace it`,
347
+ code: 1,
348
+ }
349
+ case "drift":
350
+ return {
351
+ message: `check-agents: drift: the managed block in ${file} differs from the template`,
352
+ code: 1,
353
+ }
354
+ default:
355
+ throw new Error(`unknown adoption decision: ${analysis.decision}`)
356
+ }
357
+ }
358
+
359
+ function reconcileAdvice() {
360
+ return (
361
+ "Reconcile first: wrap the existing section with <!-- turnstile:start --> and " +
362
+ "<!-- turnstile:end --> (updating its content to match templates/global-AGENTS.md), " +
363
+ "or remove the stale section, then re-run."
364
+ )
365
+ }
366
+
367
+ function writeResult(file, next, verb) {
368
+ mkdirSync(dirname(file), { recursive: true })
369
+ writeFileSync(file, next)
370
+ console.log(`install-agents: ${verb} ${file}`)
371
+ console.log("restart OpenCode sessions to pick up the change.")
372
+ }
373
+
374
+ function main() {
375
+ const args = parseArgs(process.argv)
376
+ const block = readBlock()
377
+ const current = existsSync(args.file) ? readFileSync(args.file, "utf8") : ""
378
+
379
+ // --remove only deletes the managed block; it must not be blocked by the
380
+ // adopt analysis (a corrupted file is refused inside removeBlock instead,
381
+ // mirroring the uninstall contract).
382
+ if (args.remove) {
383
+ let result
384
+ try {
385
+ result = removeBlock(current, args.file)
386
+ } catch (error) {
387
+ console.error(`install-agents: ${error instanceof Error ? error.message : String(error)}`)
388
+ process.exit(1)
389
+ }
390
+ if (!result.removed || result.next === current) {
391
+ console.log(`install-agents: up to date (${args.file})`)
392
+ process.exit(0)
393
+ }
394
+ writeResult(args.file, result.next, "removed managed block in")
395
+ process.exit(0)
396
+ }
397
+
398
+ let analysis
399
+ try {
400
+ analysis = analyze(current, block)
401
+ } catch (error) {
402
+ const message = error instanceof Error ? error.message : String(error)
403
+ if (error instanceof AmbiguousError) {
404
+ console.error(
405
+ `install-agents: refusing to write ${args.file} — ${message} ${reconcileAdvice()}`,
406
+ )
407
+ } else {
408
+ console.error(`install-agents: refusing to write ${args.file} — ${message}`)
409
+ }
410
+ process.exit(1)
411
+ }
412
+
413
+ if (args.check) {
414
+ const { message, code } = checkReport(analysis, args.file)
415
+ if (code === 0) console.log(message)
416
+ else console.error(message)
417
+ process.exit(code)
418
+ }
419
+
420
+ const plan = applyPlan(analysis, args)
421
+ const code = performAction(plan, analysis, current, block, args.file)
422
+ process.exit(code)
423
+ }
424
+
425
+ /**
426
+ * Performs the chosen write action for a classified, non-check analysis and
427
+ * returns the process exit code (0 = success, 1 = refusal). Single exit keeps
428
+ * each branch a plain return rather than a fall-through.
429
+ * @param {{action: string}} plan Result of applyPlan().
430
+ * @param {{decision: string, span?: {start: number, end: number}}} analysis Result of analyze().
431
+ * @param {string} current Existing file content.
432
+ * @param {string} block The managed block to install.
433
+ * @param {string} file Target file path.
434
+ * @returns {number} The exit code.
435
+ */
436
+ function performAction(plan, analysis, current, block, file) {
437
+ switch (plan.action) {
438
+ case "up-to-date":
439
+ console.log(`install-agents: already up to date (${file})`)
440
+ return 0
441
+ case "refuse":
442
+ console.error(
443
+ `install-agents: refusing to write ${file} — --no-adopt is set and the target needs ` +
444
+ `adoption or re-sync. ${reconcileAdvice()}`,
445
+ )
446
+ return 1
447
+ case "append": {
448
+ const next = merge(current, block, file)
449
+ writeResult(file, next, current.trim().length ? "updated managed block in" : "created")
450
+ return 0
451
+ }
452
+ case "adopt": {
453
+ // Legacy section equivalent to the template: splice the block in place,
454
+ // preserving the section's content and any surrounding content.
455
+ const next = installBlock(current, analysis.span, block)
456
+ writeResult(file, next, `adopted existing turnstile section in (wrapped with sentinels)`)
457
+ return 0
458
+ }
459
+ case "replace": {
460
+ // Legacy section differs: adopt-with-backup by default. The backup is
461
+ // written first and guarded: a failed backup must not leave a
462
+ // half-adopted file, and its loss must not crash with a stack trace.
463
+ const stamp = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14)
464
+ let backupPath = `${file}.bak.${stamp}`
465
+ for (let n = 1; existsSync(backupPath); n++) backupPath = `${file}.bak.${stamp}-${n}`
466
+ mkdirSync(dirname(file), { recursive: true })
467
+ try {
468
+ writeFileSync(backupPath, current)
469
+ } catch (error) {
470
+ console.error(
471
+ `install-agents: could not write backup ${backupPath} — refusing to adopt ` +
472
+ `(${error instanceof Error ? error.message : String(error)}); fix the file manually`,
473
+ )
474
+ return 1
475
+ }
476
+ const next = installBlock(current, analysis.span, block)
477
+ writeResult(file, next, "replaced differing turnstile section in")
478
+ console.log(`backup: ${backupPath}`)
479
+ return 0
480
+ }
481
+ case "resync": {
482
+ // The managed block is present but out of date; re-sync it in place
483
+ // (no backup: it is turnstile's own managed content).
484
+ const next = merge(current, block, file)
485
+ writeResult(file, next, "updated managed block in")
486
+ return 0
487
+ }
488
+ }
489
+ return 0
490
+ }
491
+
492
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main()
493
+
494
+ // Pure, side-effect-free helpers exported for unit tests: tests can
495
+ // import these directly rather than spawning the CLI.
496
+ export {
497
+ removeBlock,
498
+ installBlock,
499
+ findLegacySection,
500
+ sectionsEquivalent,
501
+ analyze,
502
+ applyPlan,
503
+ checkReport,
504
+ }