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,559 @@
1
+ #!/usr/bin/env node
2
+ // Installs this repo's artifacts into ~/.config/opencode, driven by
3
+ // install.json. The repo is the source of truth: symlink mode (auto-detected
4
+ // on a git checkout) links targets into the checkout so repo edits are live;
5
+ // copy mode duplicates files and records what it installed in a ledger file
6
+ // so later syncs can tell "repo changed" from "user edited".
7
+ //
8
+ // Usage:
9
+ // node scripts/install.mjs [--manifest <path>] [--check] [--force]
10
+ // [--uninstall] [--link | --copy]
11
+ //
12
+ // --manifest Manifest to install (default: install.json at the repo root)
13
+ // --check Verify all targets without writing; exit 1 on drift
14
+ // --force Convert conflicting targets after backing them up
15
+ // --uninstall Reverse the manifest: remove repo-owned links / ledger-owned
16
+ // copies for every entry (then retire leftovers); exit 1 only
17
+ // when something could not be removed (conflict/unowned).
18
+ // --link/--copy Override the mode. Resolution: CLI flag > manifest "mode"
19
+ // field > auto-detect (.git present => link, else copy)
20
+ //
21
+ // Ownership model: symlink targets are repo-owned iff the link resolves
22
+ // inside this checkout; copied files are repo-owned iff the ledger records
23
+ // them. Unowned files are never read, modified, or removed. Conflicts
24
+ // (real files differing from the repo, or user-edited copies) fail with a
25
+ // summary unless --force converts them, backing up to <name>.bak.<ts>.
26
+
27
+ import {
28
+ readFileSync,
29
+ writeFileSync,
30
+ existsSync,
31
+ mkdirSync,
32
+ symlink,
33
+ unlink,
34
+ rename,
35
+ copyFile,
36
+ lstat,
37
+ realpath,
38
+ } from "node:fs"
39
+ import { createHash } from "node:crypto"
40
+ import { homedir } from "node:os"
41
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path"
42
+ import { fileURLToPath, pathToFileURL } from "node:url"
43
+ import { promisify } from "node:util"
44
+
45
+ const symlinkP = promisify(symlink)
46
+ const unlinkP = promisify(unlink)
47
+ const renameP = promisify(rename)
48
+ const copyFileP = promisify(copyFile)
49
+ const lstatP = promisify(lstat)
50
+ const realpathP = promisify(realpath)
51
+
52
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..")
53
+ const defaultLedger = "~/.config/opencode/.turnstile-install.json"
54
+ // Single read of the package version at startup; recorded in every copy-mode
55
+ // ledger entry so `check` can report an outdated install. Guarded:
56
+ // a missing or malformed package.json must not crash the module at import
57
+ // time (fail-open contract); version records as null and `check` treats
58
+ // null as "no version info" rather than an outdated install.
59
+ let pkgVersion = null
60
+ try {
61
+ pkgVersion = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")).version ?? null
62
+ } catch {
63
+ // Fall through with null: metadata is advisory, not load-bearing.
64
+ }
65
+
66
+ /**
67
+ * Parses command-line arguments.
68
+ * @param {string[]} argv Raw argv (process.argv).
69
+ * @returns {{manifest: string, check: boolean, force: boolean, uninstall: boolean, mode: "link"|"copy"|null}} Parsed arguments.
70
+ */
71
+ function parseArgs(argv) {
72
+ const args = {
73
+ manifest: join(repoRoot, "install.json"),
74
+ check: false,
75
+ force: false,
76
+ uninstall: false,
77
+ mode: null,
78
+ }
79
+ for (let i = 2; i < argv.length; i++) {
80
+ if (argv[i] === "--manifest") args.manifest = argv[++i]
81
+ else if (argv[i] === "--check") args.check = true
82
+ else if (argv[i] === "--force") args.force = true
83
+ else if (argv[i] === "--uninstall") args.uninstall = true
84
+ else if (argv[i] === "--link") args.mode = "link"
85
+ else if (argv[i] === "--copy") args.mode = "copy"
86
+ else {
87
+ console.error(`install: unknown argument ${argv[i]}`)
88
+ process.exit(2)
89
+ }
90
+ }
91
+ return args
92
+ }
93
+
94
+ /**
95
+ * Auto-detects the install mode from the filesystem: a git checkout links
96
+ * (working tree is the source of truth, edits are live); an extracted
97
+ * package copies (installed files are the source of truth).
98
+ * @returns {"link"|"copy"} The detected mode.
99
+ */
100
+ function detectMode() {
101
+ return existsSync(join(repoRoot, ".git")) ? "link" : "copy"
102
+ }
103
+
104
+ /**
105
+ * Resolves the install mode by precedence: an explicit CLI flag wins, then
106
+ * the manifest's "mode" field, then filesystem auto-detect. A manifest
107
+ * mode outside {"link","copy"} is ignored (with a warning) rather than
108
+ * silently degrading to link behavior.
109
+ * @param {"link"|"copy"|null} modeOverride Mode from the command line, if any.
110
+ * @param {"link"|"copy"|undefined} manifestMode The manifest's "mode" field, if any.
111
+ * @returns {"link"|"copy"} The resolved mode.
112
+ */
113
+ function resolveMode(modeOverride, manifestMode) {
114
+ if (manifestMode !== undefined && manifestMode !== "link" && manifestMode !== "copy") {
115
+ console.warn(`install: ignoring unknown manifest mode "${manifestMode}"`)
116
+ return modeOverride ?? detectMode()
117
+ }
118
+ return modeOverride ?? manifestMode ?? detectMode()
119
+ }
120
+
121
+ /**
122
+ * Reads and validates the install manifest.
123
+ * @param {string} file Path to install.json.
124
+ * @param {"link"|"copy"|null} modeOverride Mode from the command line, if any.
125
+ * @returns {{mode: "link"|"copy", ledgerPath: string, links: Array<{src: string, dest: string}>, retired: string[]}} Validated manifest.
126
+ */
127
+ function readManifest(file, modeOverride) {
128
+ const raw = JSON.parse(readFileSync(file, "utf8"))
129
+ const mode = resolveMode(modeOverride, raw.mode)
130
+ const links = (raw.links ?? []).map((entry) => ({
131
+ src: join(repoRoot, entry.src),
132
+ dest: expand(entry.dest),
133
+ }))
134
+ const retired = (raw.retired ?? []).map((entry) => expand(entry.dest ?? entry))
135
+ if (!links.length && !retired.length)
136
+ throw new Error(`manifest declares nothing to install: ${file}`)
137
+ return { mode, ledgerPath: expand(raw.ledger ?? defaultLedger), links, retired }
138
+ }
139
+
140
+ /**
141
+ * Expands a leading ~ in a destination path.
142
+ * @param {string} path Destination path, possibly ~-prefixed.
143
+ * @returns {string} Absolute path.
144
+ */
145
+ function expand(path) {
146
+ if (path === "~") return homedir()
147
+ if (path.startsWith("~/")) return join(homedir(), path.slice(2))
148
+ return resolve(path)
149
+ }
150
+
151
+ /**
152
+ * Computes the content hash used by the copy-mode ledger.
153
+ * @param {string} content File content.
154
+ * @returns {string} SHA-256 hex digest.
155
+ */
156
+ function hashContent(content) {
157
+ return createHash("sha256").update(content).digest("hex")
158
+ }
159
+
160
+ /**
161
+ * Normalizes one ledger entry to the current shape: missing `version`
162
+ * defaults to `null`, missing `mode` defaults to `"copy"` (the legacy
163
+ * copy-mode layout). Path transforms are intentionally skipped:
164
+ * `relative(repoRoot, src)` already equals the manifest-relative string for
165
+ * every recorded entry. Pure: no filesystem access.
166
+ * @param {Record<string, unknown>} ledger Raw ledger keyed by destination.
167
+ * @returns {Record<string, {src: string, hash: string, version: string | null, mode: "link" | "copy"}>} Normalized ledger.
168
+ */
169
+ function normalizeLedger(ledger) {
170
+ const normalized = {}
171
+ for (const [dest, record] of Object.entries(ledger)) {
172
+ normalized[dest] = {
173
+ src: record.src,
174
+ hash: record.hash,
175
+ version: record.version ?? null,
176
+ mode: record.mode ?? "copy",
177
+ }
178
+ }
179
+ return normalized
180
+ }
181
+
182
+ /**
183
+ * Loads the copy-mode ownership ledger, normalizing legacy entries on read.
184
+ * An absent ledger is an empty record; the written form stays normalized,
185
+ * so `saveLedger` persists the migration permanently.
186
+ * @param {string} file Ledger path.
187
+ * @returns {Record<string, {src: string, hash: string, version: string | null, mode: "link" | "copy"}>} Ledger keyed by destination.
188
+ */
189
+ function loadLedger(file) {
190
+ if (!existsSync(file)) return {}
191
+ return normalizeLedger(JSON.parse(readFileSync(file, "utf8")))
192
+ }
193
+
194
+ /**
195
+ * Persists the ownership ledger, creating parent directories as needed.
196
+ * @param {string} file Ledger path.
197
+ * @param {Record<string, {src: string, hash: string, version: string | null, mode: "link" | "copy"}>} ledger Ledger to write.
198
+ * @returns {void}
199
+ */
200
+ function saveLedger(file, ledger) {
201
+ mkdirSync(dirname(file), { recursive: true })
202
+ writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`)
203
+ }
204
+
205
+ /**
206
+ * Resolves what sits at a destination path without following symlinks.
207
+ * @param {string} dest Destination path.
208
+ * @returns {Promise<"missing"|"link"|"file"|"other">} The kind of entry present.
209
+ */
210
+ async function kindAt(dest) {
211
+ try {
212
+ const st = await lstatP(dest)
213
+ if (st.isSymbolicLink()) return "link"
214
+ if (st.isFile()) return "file"
215
+ return "other"
216
+ } catch {
217
+ return "missing"
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Returns a symlink's resolved target, or null when unavailable.
223
+ * @param {string} dest Destination path.
224
+ * @returns {Promise<string|null>} Resolved link target or null.
225
+ */
226
+ async function linkTarget(dest) {
227
+ try {
228
+ return await realpathP(dest)
229
+ } catch {
230
+ return null
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Decides whether a resolved path belongs to this checkout.
236
+ * @param {string|null} resolved Absolute resolved path or null.
237
+ * @returns {boolean} True when the path is inside the repo root.
238
+ */
239
+ function ownedByRepo(resolved) {
240
+ if (!resolved) return false
241
+ const rel = relative(repoRoot, resolved)
242
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)
243
+ }
244
+
245
+ /**
246
+ * Computes the drift of a symlink entry against the desired state.
247
+ * @param {{src: string, dest: string}} entry Manifest link entry.
248
+ * @returns {Promise<{code: "ok"|"stale"|"conflict"|"missing"|"invalid", detail?: string}>} Drift report.
249
+ */
250
+ async function inspectLink(entry) {
251
+ if (!existsSync(entry.src)) return { code: "invalid", detail: `source missing: ${entry.src}` }
252
+ const kind = await kindAt(entry.dest)
253
+ if (kind === "missing") return { code: "missing" }
254
+ if (kind === "link") {
255
+ const target = await linkTarget(entry.dest)
256
+ if (target === entry.src) return { code: "ok" }
257
+ if (!ownedByRepo(target))
258
+ return { code: "conflict", detail: `symlink points outside the repo: ${target}` }
259
+ return { code: "stale", detail: target }
260
+ }
261
+ if (kind === "file") {
262
+ const same = readFileSync(entry.dest, "utf8") === readFileSync(entry.src, "utf8")
263
+ if (same) return { code: "missing" }
264
+ return { code: "conflict", detail: "real file differs from repo source" }
265
+ }
266
+ return { code: "conflict", detail: `${kind} exists at destination` }
267
+ }
268
+
269
+ /**
270
+ * Computes the drift of a copy entry against the desired state and ledger.
271
+ * A copied file that no longer matches its last-installed hash is treated
272
+ * as user-edited: the repo wins only via --force.
273
+ * @param {{src: string, dest: string}} entry Manifest link entry.
274
+ * @param {Record<string, {src: string, hash: string, version: string | null, mode: "link" | "copy"}>} ledger Ownership ledger.
275
+ * @returns {{code: "ok"|"adopt"|"stale"|"conflict"|"missing"|"invalid", detail?: string}} Drift report.
276
+ */
277
+ function inspectCopy(entry, ledger) {
278
+ if (!existsSync(entry.src)) return { code: "invalid", detail: `source missing: ${entry.src}` }
279
+ if (!existsSync(entry.dest)) return { code: "missing" }
280
+ const srcContent = readFileSync(entry.src, "utf8")
281
+ const record = ledger[entry.dest]
282
+ try {
283
+ const destContent = readFileSync(entry.dest, "utf8")
284
+ if (destContent === srcContent) return record ? { code: "ok" } : { code: "adopt" }
285
+ if (!record) return { code: "conflict", detail: "untracked real file differs from repo source" }
286
+ if (hashContent(destContent) !== record.hash) {
287
+ return { code: "conflict", detail: "installed copy was edited after install" }
288
+ }
289
+ return { code: "stale" }
290
+ } catch {
291
+ return { code: "conflict", detail: "destination is not a readable file" }
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Computes the drift of one manifest entry under the resolved mode.
297
+ * @param {{src: string, dest: string}} entry Manifest link entry.
298
+ * @param {"link"|"copy"} mode Install mode.
299
+ * @param {Record<string, {src: string, hash: string, version: string | null, mode: "link" | "copy"}>} ledger Ownership ledger.
300
+ * @returns {Promise<{code: string, detail?: string}>} Drift report.
301
+ */
302
+ async function inspectEntry(entry, mode, ledger) {
303
+ return mode === "copy" ? inspectCopy(entry, ledger) : inspectLink(entry)
304
+ }
305
+
306
+ /**
307
+ * Backs up a real file by renaming it aside with a timestamp suffix.
308
+ * @param {string} dest Destination path to back up.
309
+ * @returns {Promise<string>} The backup path.
310
+ */
311
+ async function backup(dest) {
312
+ const stamp = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14)
313
+ const target = `${dest}.bak.${stamp}`
314
+ await renameP(dest, target)
315
+ return target
316
+ }
317
+
318
+ /**
319
+ * Installs one entry as a symlink.
320
+ * @param {{src: string, dest: string}} entry Manifest link entry.
321
+ * @param {{force: boolean}} args Parsed arguments.
322
+ * @param {Record<string, {src: string, hash: string, version: string | null, mode: "link" | "copy"}>} ledger Ownership ledger (link installs prune their entry).
323
+ * @returns {Promise<{acted: boolean, note?: string}>} Whether a change was made.
324
+ */
325
+ async function installLink(entry, args, ledger) {
326
+ const report = await inspectLink(entry)
327
+ if (report.code === "invalid") throw new Error(`install: ${entry.dest} — ${report.detail}`)
328
+ if (report.code === "ok") {
329
+ delete ledger[entry.dest]
330
+ return { acted: false }
331
+ }
332
+ if (report.code === "conflict" && !args.force) {
333
+ throw new Error(
334
+ `install: ${entry.dest} — conflict (${report.detail}). Re-run with --force to back it up and replace, after reviewing.`,
335
+ )
336
+ }
337
+ mkdirSync(dirname(entry.dest), { recursive: true })
338
+ let note
339
+ if (report.code === "conflict") {
340
+ const saved = await backup(entry.dest)
341
+ note = `backed up to ${saved}`
342
+ }
343
+ await unlinkP(entry.dest).catch(() => {})
344
+ await symlinkP(entry.src, entry.dest)
345
+ delete ledger[entry.dest]
346
+ return { acted: true, note }
347
+ }
348
+
349
+ /**
350
+ * Installs one entry as a copy, recording ownership in the ledger.
351
+ * @param {{src: string, dest: string}} entry Manifest link entry.
352
+ * @param {{force: boolean}} args Parsed arguments.
353
+ * @param {Record<string, {src: string, hash: string, version: string | null, mode: "link" | "copy"}>} ledger Ownership ledger, mutated on success.
354
+ * @param {string} version Current package version, recorded for the `check` stale report.
355
+ * @param {"link"|"copy"} mode Install mode this record was written under.
356
+ * @returns {Promise<{acted: boolean, note?: string}>} Whether a change was made.
357
+ */
358
+ async function installCopy(entry, args, ledger, version, mode) {
359
+ const report = inspectCopy(entry, ledger)
360
+ if (report.code === "invalid") throw new Error(`install: ${entry.dest} — ${report.detail}`)
361
+ if (report.code === "ok") return { acted: false }
362
+ if (report.code === "conflict" && !args.force) {
363
+ throw new Error(
364
+ `install: ${entry.dest} — conflict (${report.detail}). Re-run with --force to back it up and replace, after reviewing.`,
365
+ )
366
+ }
367
+ mkdirSync(dirname(entry.dest), { recursive: true })
368
+ let note
369
+ if (report.code === "conflict") {
370
+ const saved = await backup(entry.dest)
371
+ note = `backed up to ${saved}`
372
+ }
373
+ await unlinkP(entry.dest).catch(() => {})
374
+ await copyFileP(entry.src, entry.dest)
375
+ ledger[entry.dest] = {
376
+ src: relative(repoRoot, entry.src),
377
+ hash: hashContent(readFileSync(entry.src, "utf8")),
378
+ version,
379
+ mode,
380
+ }
381
+ return { acted: true, note }
382
+ }
383
+
384
+ /**
385
+ * Removes a path when it is repo-owned: a symlink resolving into the
386
+ * checkout, or a copied file recorded in the ledger. Unowned files (a
387
+ * symlink pointing outside the repo, or an untracked file) are never touched;
388
+ * they are reported as left alone. Retired leftovers are handled by calling
389
+ * this with the retired destination.
390
+ * @param {string} dest Destination path.
391
+ * @param {{write: boolean}} mode Write when true, report-only otherwise.
392
+ * @param {Record<string, {src: string, hash: string, version: string | null, mode: "link" | "copy"}>} ledger Ownership ledger.
393
+ * @returns {Promise<{acted: boolean, note?: string}>} Whether a change was (or would be) made.
394
+ */
395
+ async function removeEntry(dest, mode, ledger) {
396
+ const kind = await kindAt(dest)
397
+ if (kind === "missing") return { acted: false }
398
+ if (kind === "link") {
399
+ const target = await linkTarget(dest)
400
+ if (!ownedByRepo(target)) return { acted: false, note: `symlink not repo-owned (${target})` }
401
+ if (!mode.write) return { acted: true, note: "link still present" }
402
+ await unlinkP(dest)
403
+ return { acted: true }
404
+ }
405
+ if (kind === "file") {
406
+ if (!ledger[dest]) return { acted: false, note: "untracked file left alone" }
407
+ if (!mode.write) return { acted: true, note: "copy still present" }
408
+ await unlinkP(dest)
409
+ delete ledger[dest]
410
+ return { acted: true }
411
+ }
412
+ return { acted: false, note: `left alone (${kind})` }
413
+ }
414
+
415
+ /**
416
+ * Runs the installer in apply mode and prints a per-target report. In
417
+ * uninstall mode it removes repo-owned links / ledger-owned copies for every
418
+ * manifest entry (reporting unowned ones as failures) before retiring
419
+ * leftovers; otherwise it installs as configured. Tracks whether any change
420
+ * was made so the caller can gate the "restart OpenCode sessions" note.
421
+ * @param {{mode: "link"|"copy", ledgerPath: string, links: Array<{src: string, dest: string}>, retired: string[]}} manifest Validated manifest.
422
+ * @param {{check: boolean, force: boolean, uninstall: boolean, mode: "link"|"copy"|null}} args Parsed arguments.
423
+ * @returns {Promise<{ok: boolean, changed: boolean}>} Success flag and whether anything changed.
424
+ */
425
+ async function apply(manifest, args) {
426
+ const ledger = loadLedger(manifest.ledgerPath)
427
+ let ok = true
428
+ let changed = false
429
+
430
+ if (args.uninstall) {
431
+ for (const entry of manifest.links) {
432
+ try {
433
+ const result = await removeEntry(entry.dest, { write: true }, ledger)
434
+ if (result.acted) {
435
+ changed = true
436
+ console.log(`install: removed ${entry.dest}`)
437
+ } else if (result.note) {
438
+ // Could not remove something that was left alone (unowned / conflict).
439
+ ok = false
440
+ console.log(`install: could not remove ${entry.dest} — ${result.note}`)
441
+ } else console.log(`install: up to date ${entry.dest}`)
442
+ } catch (error) {
443
+ ok = false
444
+ console.error(String(error.message ?? error))
445
+ }
446
+ }
447
+ for (const dest of manifest.retired) {
448
+ try {
449
+ const result = await removeEntry(dest, { write: true }, ledger)
450
+ if (result.acted) {
451
+ changed = true
452
+ console.log(`install: removed retired ${dest}`)
453
+ } else if (result.note) {
454
+ console.log(`install: retired ${dest} — ${result.note}`)
455
+ }
456
+ } catch (error) {
457
+ ok = false
458
+ console.error(String(error.message ?? error))
459
+ }
460
+ }
461
+ saveLedger(manifest.ledgerPath, ledger)
462
+ return { ok, changed }
463
+ }
464
+
465
+ for (const entry of manifest.links) {
466
+ try {
467
+ const result =
468
+ manifest.mode === "copy"
469
+ ? await installCopy(entry, args, ledger, pkgVersion, manifest.mode)
470
+ : await installLink(entry, args, ledger)
471
+ if (result.acted) {
472
+ changed = true
473
+ console.log(
474
+ `install: ${manifest.mode} ${entry.dest}${result.note ? ` (${result.note})` : ""}`,
475
+ )
476
+ } else console.log(`install: up to date ${entry.dest}`)
477
+ } catch (error) {
478
+ ok = false
479
+ console.error(String(error.message ?? error))
480
+ }
481
+ }
482
+ for (const dest of manifest.retired) {
483
+ const result = await removeEntry(dest, { write: true }, ledger)
484
+ if (result.acted) {
485
+ changed = true
486
+ console.log(`install: removed retired ${dest}`)
487
+ } else if (result.note) {
488
+ console.log(`install: retired ${dest} — ${result.note}`)
489
+ }
490
+ }
491
+ saveLedger(manifest.ledgerPath, ledger)
492
+ return { ok, changed }
493
+ }
494
+
495
+ /**
496
+ * Runs the installer in check mode: reports drift, writes nothing.
497
+ * A ledger record whose version is older than the current package version
498
+ * is reported as a warning and does not fail the check.
499
+ * @param {{mode: "link"|"copy", ledgerPath: string, links: Array<{src: string, dest: string}>, retired: string[]}} manifest Validated manifest.
500
+ * @param {string} version Current package version.
501
+ * @returns {Promise<boolean>} True when no drift was found.
502
+ */
503
+ async function check(manifest, version) {
504
+ const ledger = loadLedger(manifest.ledgerPath)
505
+ let ok = true
506
+ for (const entry of manifest.links) {
507
+ if (!existsSync(entry.src)) {
508
+ ok = false
509
+ console.error(`check: source missing: ${entry.src} (referenced by ${entry.dest})`)
510
+ continue
511
+ }
512
+ const report = await inspectEntry(entry, manifest.mode, ledger)
513
+ const record = ledger[entry.dest]
514
+ if (record && record.version && record.version !== version)
515
+ console.log(`check: outdated ${entry.dest} (installed ${record.version}, package ${version})`)
516
+ if (report.code === "ok") console.log(`check: OK ${entry.dest}`)
517
+ else {
518
+ ok = false
519
+ console.error(
520
+ `check: ${report.code} ${entry.dest}${report.detail ? ` — ${report.detail}` : ""}`,
521
+ )
522
+ }
523
+ }
524
+ for (const dest of manifest.retired) {
525
+ const result = await removeEntry(dest, { write: false }, ledger)
526
+ if (result.acted) {
527
+ ok = false
528
+ console.error(`check: retired link still present: ${dest}`)
529
+ } else console.log(`check: OK ${dest}${result.note ? ` — ${result.note}` : ""}`)
530
+ }
531
+ return ok
532
+ }
533
+
534
+ async function main() {
535
+ const args = parseArgs(process.argv)
536
+ const manifest = readManifest(args.manifest, args.mode)
537
+ console.log(
538
+ `install: mode=${manifest.mode} (${manifest.mode === "link" ? "checkout" : "package"})`,
539
+ )
540
+ if (args.check) return await check(manifest, pkgVersion)
541
+ const { ok, changed } = await apply(manifest, args)
542
+ if (changed) console.log("restart OpenCode sessions to pick up changes.")
543
+ return ok
544
+ }
545
+
546
+ // Run as a script only when invoked directly; importing the module (e.g. for
547
+ // unit tests) must not execute the installer or call process.exit.
548
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
549
+ main()
550
+ .then((ok) => process.exit(ok ? 0 : 1))
551
+ .catch((error) => {
552
+ console.error(`install: ${error instanceof Error ? error.message : String(error)}`)
553
+ process.exit(1)
554
+ })
555
+ }
556
+
557
+ // Pure, side-effect-free helpers exported for unit tests: tests can
558
+ // import these directly without spawning the CLI.
559
+ export { detectMode, resolveMode, normalizeLedger, removeEntry }
@@ -0,0 +1,76 @@
1
+ ---
2
+ name: turnstile-setup
3
+ description: Install or update the turnstile plugin, pipeline agents, and contract block into the user's ~/.config/opencode, then verify sync. Use when the user asks to set up turnstile, add pipeline gates to AGENTS.md, refresh the installed contract block, or after changing marker syntax in this repo.
4
+ ---
5
+
6
+ # Turnstile setup
7
+
8
+ Install the artifacts this repo owns into `~/.config/opencode` — the
9
+ plugin and pipeline agents as symlinks (`scripts/install.mjs`, driven by
10
+ `install.json`), and the contract block this plugin enforces into the
11
+ user's global instructions file (`scripts/install-agents.mjs`).
12
+
13
+ ## Steps
14
+
15
+ 1. Install the plugin and agents from the repo's root:
16
+
17
+ ```
18
+ npm run init
19
+ ```
20
+
21
+ - Default is symlink mode (repo edits live); use
22
+ `npm run init -- --copy` for real copies when the user refuses
23
+ symlinks — copy mode keeps a ledger recording what it installed.
24
+ - Targets come from `install.json`; unowned files are never touched.
25
+ - Conflicts (a real file differing from the repo, or a user-edited
26
+ copy) fail with a summary. Review with the user, then re-run with
27
+ `--force` to back up and replace.
28
+ - Verify with `npm run install:check` (add `-- --copy` for copy mode)
29
+ — must exit 0.
30
+
31
+ 2. Install the contract block into the global AGENTS.md:
32
+
33
+ ```
34
+ node scripts/install-agents.mjs
35
+ ```
36
+
37
+ - Default target is `~/.config/opencode/AGENTS.md`; pass
38
+ `--file <path>` only if the user asks for a different file.
39
+ - Preflight: adoption is automatic. When the target already contains
40
+ turnstile contract content but **no** `<!-- turnstile:start -->` …
41
+ `<!-- turnstile:end -->` sentinels, the installer adopts the section:
42
+ it wraps an equivalent section with the sentinels (no content
43
+ change), and for a section that differs it backs up the target first
44
+ (`<file>.bak.<timestamp>`) and replaces the span with the current
45
+ template. Run with `--check` to preview without writing, or
46
+ `--no-adopt` to refuse and get reconciliation instructions instead.
47
+ - The installer only refuses (exit 1, no write) for targets it cannot
48
+ resolve safely: a corrupted one — a start sentinel with no end
49
+ sentinel — or an ambiguous one — more than one turnstile section.
50
+ Fix one of those by hand, then re-run.
51
+ - The script touches only the managed block; everything else in the
52
+ target file is preserved.
53
+
54
+ 3. Verify the installed block matches the template:
55
+
56
+ ```
57
+ node scripts/install-agents.mjs --check
58
+ ```
59
+
60
+ Must exit 0. If it exits 1, re-run step 2 and investigate rather than
61
+ hand-editing the installed file.
62
+
63
+ 4. Verify the marker contract is still in sync with the parser:
64
+
65
+ ```
66
+ node scripts/check-markers.mjs
67
+ ```
68
+
69
+ Must pass. If it fails, the README's marker examples and the regex
70
+ literals in `src/markers.ts` have drifted — fix both together.
71
+
72
+ ## Report
73
+
74
+ Tell the user: which files were linked or written, whether each was
75
+ created or updated, and that OpenCode sessions restart to pick up the
76
+ change. Do not quote the whole installed block back; summarize.