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.
- package/.opencode/agents/debugger.md +70 -0
- package/.opencode/agents/executor.md +108 -0
- package/.opencode/agents/plan-reviewer.md +91 -0
- package/.opencode/agents/planner.md +111 -0
- package/.opencode/agents/reviewer.md +92 -0
- package/.opencode/agents/test-runner.md +55 -0
- package/LICENSE +21 -0
- package/README.md +414 -2
- package/bin/cli.mjs +86 -0
- package/install.json +42 -0
- package/package.json +58 -4
- package/scripts/check-markers.mjs +86 -0
- package/scripts/install-agents.mjs +504 -0
- package/scripts/install.mjs +559 -0
- package/skills/turnstile-setup/SKILL.md +76 -0
- package/src/config.ts +272 -0
- package/src/gates.ts +295 -0
- package/src/journal.ts +157 -0
- package/src/markers.ts +113 -0
- package/src/setup.ts +512 -0
- package/src/state.ts +181 -0
- package/src/types.ts +176 -0
- package/templates/global-AGENTS.md +55 -0
- package/turnstile.ts +82 -0
|
@@ -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.
|