opencode-turnstile 0.2.0 → 0.2.5
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/README.md +263 -99
- package/agents/debugger.md +3 -2
- package/agents/executor.md +7 -2
- package/bin/cli.mjs +34 -22
- package/package.json +5 -6
- package/scripts/branch-coverage.mjs +206 -0
- package/scripts/check-markers.mjs +23 -1
- package/scripts/install.mjs +113 -13
- package/scripts/status.mjs +333 -0
- package/skills/turnstile-setup/SKILL.md +22 -38
- package/src/config.ts +295 -6
- package/src/evidence.ts +162 -0
- package/src/gates.ts +338 -93
- package/src/journal.ts +10 -3
- package/src/markers.ts +117 -15
- package/src/policy.ts +33 -0
- package/src/setup.ts +1265 -96
- package/src/state.ts +316 -21
- package/src/tool-policy.ts +683 -0
- package/src/types.ts +300 -1
- package/turnstile.ts +150 -14
- package/scripts/install-agents.mjs +0 -504
- package/templates/global-AGENTS.md +0 -55
package/bin/cli.mjs
CHANGED
|
@@ -7,9 +7,14 @@
|
|
|
7
7
|
//
|
|
8
8
|
// opencode-turnstile init Install/sync artifacts into ~/.config/opencode
|
|
9
9
|
// opencode-turnstile check Verify artifacts against the manifest
|
|
10
|
-
// opencode-turnstile uninstall Remove installed artifacts (
|
|
10
|
+
// opencode-turnstile uninstall Remove installed artifacts (manifest artifacts)
|
|
11
|
+
// opencode-turnstile status Report gate/plan state from the JSONL journal
|
|
11
12
|
// opencode-turnstile version Print the installed package version
|
|
12
13
|
//
|
|
14
|
+
// `init -- --print-model-config` writes nothing: the router runs install.mjs,
|
|
15
|
+
// which prints the snippet and short-circuits before any install work, so the
|
|
16
|
+
// router returns install.mjs's exit code verbatim.
|
|
17
|
+
//
|
|
13
18
|
// Unknown subcommand or no args prints usage and exits 2.
|
|
14
19
|
|
|
15
20
|
import { spawn } from "node:child_process"
|
|
@@ -20,14 +25,19 @@ import { fileURLToPath } from "node:url"
|
|
|
20
25
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
21
26
|
const repoRoot = resolve(here, "..")
|
|
22
27
|
const installScript = join(repoRoot, "scripts/install.mjs")
|
|
23
|
-
const
|
|
28
|
+
const statusScript = join(repoRoot, "scripts/status.mjs")
|
|
24
29
|
|
|
25
30
|
const USAGE = `Usage: opencode-turnstile <command>
|
|
26
31
|
|
|
27
32
|
Commands:
|
|
28
33
|
init Install or sync artifacts into ~/.config/opencode
|
|
34
|
+
(with --print-model-config, prints a {"agents": {...}} snippet and writes nothing)
|
|
29
35
|
check Verify installed artifacts against the manifest
|
|
30
|
-
|
|
36
|
+
(check --repair prints which targets would be auto-repaired,
|
|
37
|
+
writing nothing — a dry run of the --repair apply mode)
|
|
38
|
+
uninstall Remove installed artifacts (manifest artifacts)
|
|
39
|
+
status Report per-session gate/plan state from the journal
|
|
40
|
+
(status --json prints machine-readable JSON; read-only, nothing is written)
|
|
31
41
|
version Print the installed package version
|
|
32
42
|
|
|
33
43
|
init also accepts convenience flags (passthrough to scripts/install.mjs); separate them from the subcommand with a leading "--":
|
|
@@ -63,40 +73,42 @@ async function main() {
|
|
|
63
73
|
|
|
64
74
|
switch (command) {
|
|
65
75
|
case "init": {
|
|
66
|
-
// Full install: manifest artifacts (plugin + agents)
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
76
|
+
// Full install: manifest artifacts (plugin + agents) via a single
|
|
77
|
+
// install.mjs spawn. This installer never writes to AGENTS.md — the
|
|
78
|
+
// legacy contract-block installer was deleted (R1); any pre-existing
|
|
79
|
+
// block is removed by hand by the user, not by npm/init.
|
|
70
80
|
//
|
|
71
|
-
// --print-model-config
|
|
72
|
-
// snippet and short-circuits
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
// scope for the write-free printer flag.
|
|
81
|
+
// Printer-mode short-circuit: --print-model-config makes init write
|
|
82
|
+
// completely free. install.mjs prints the snippet and short-circuits
|
|
83
|
+
// before any manifest work, so the router returns install.mjs's exit
|
|
84
|
+
// code verbatim. This is what makes "print mode writes nothing"
|
|
85
|
+
// literally true.
|
|
77
86
|
//
|
|
78
87
|
// A leading "--" separates the subcommand from its flags
|
|
79
88
|
// (`init -- --print-model-config`); install.mjs rejects a literal "--"
|
|
80
89
|
// as an unknown argument, so drop it before forwarding.
|
|
81
90
|
const initPassthrough = passthrough[0] === "--" ? passthrough.slice(1) : passthrough
|
|
82
|
-
|
|
83
|
-
const byAgents = await spawnInstaller(agentsScript, [])
|
|
84
|
-
return process.exit(byInstall || byAgents ? 1 : 0)
|
|
91
|
+
return process.exit(await spawnInstaller(installScript, initPassthrough))
|
|
85
92
|
}
|
|
86
93
|
case "check":
|
|
87
94
|
return spawnInstaller(installScript, ["--check", ...passthrough])
|
|
88
95
|
case "uninstall": {
|
|
89
|
-
// Reverse the copy/symlink install
|
|
90
|
-
// Inherited stdio surfaces
|
|
91
|
-
//
|
|
92
|
-
//
|
|
96
|
+
// Reverse the copy/symlink install via a single install.mjs spawn.
|
|
97
|
+
// Inherited stdio surfaces the report verbatim; exit non-zero only if
|
|
98
|
+
// it failed. No restart note is printed here; the child prints its own,
|
|
99
|
+
// gated on whether it changed anything.
|
|
93
100
|
const byInstall = await spawnInstaller(installScript, ["--uninstall"])
|
|
94
|
-
|
|
95
|
-
return process.exit(byInstall || byAgents ? 1 : 0)
|
|
101
|
+
return process.exit(byInstall ? 1 : 0)
|
|
96
102
|
}
|
|
97
103
|
case "version":
|
|
98
104
|
console.log(JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")).version)
|
|
99
105
|
return
|
|
106
|
+
case "status": {
|
|
107
|
+
// Read-only status surface over the JSONL journal: spawn scripts/status.mjs
|
|
108
|
+
// with passthrough args (mirrors the init case's leading-"--" stripping).
|
|
109
|
+
const statusPassthrough = passthrough[0] === "--" ? passthrough.slice(1) : passthrough
|
|
110
|
+
return process.exit(await spawnInstaller(statusScript, statusPassthrough))
|
|
111
|
+
}
|
|
100
112
|
default:
|
|
101
113
|
console.log(USAGE)
|
|
102
114
|
process.exit(2)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-turnstile",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "OpenCode plugin that mechanically enforces agent pipeline
|
|
3
|
+
"version": "0.2.5",
|
|
4
|
+
"description": "OpenCode plugin that mechanically enforces an agent pipeline (planner → plan-reviewer → executor → test-runner → reviewer) for OpenCode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
"scripts",
|
|
14
14
|
"bin",
|
|
15
15
|
"install.json",
|
|
16
|
-
"templates",
|
|
17
16
|
"skills",
|
|
18
17
|
"agents",
|
|
19
18
|
"README.md",
|
|
@@ -26,16 +25,16 @@
|
|
|
26
25
|
"format": "prettier --write .",
|
|
27
26
|
"format:check": "prettier --check .",
|
|
28
27
|
"check:markers": "node scripts/check-markers.mjs",
|
|
29
|
-
"test": "node --test --experimental-strip-types tests/*.test.ts",
|
|
28
|
+
"test": "node --test --experimental-strip-types --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --test-coverage-include='**/src/**' --test-coverage-include='**/turnstile.ts' tests/*.test.ts",
|
|
30
29
|
"init": "node scripts/install.mjs",
|
|
31
30
|
"init:copy": "node scripts/install.mjs --copy",
|
|
32
31
|
"init:check": "node scripts/install.mjs --check",
|
|
33
32
|
"install:copy": "node scripts/install.mjs --copy",
|
|
34
33
|
"install:check": "node scripts/install.mjs --check",
|
|
35
|
-
"install:agents": "node scripts/install-agents.mjs",
|
|
36
34
|
"pack:dry": "npm pack --dry-run",
|
|
37
35
|
"ci:host": "npm run lint && npm run format:check && npm run typecheck && npm run check:markers && npm test",
|
|
38
|
-
"ci": "npm run ci:host
|
|
36
|
+
"ci": "npm run ci:host",
|
|
37
|
+
"ci:installed": "npm run install:check"
|
|
39
38
|
},
|
|
40
39
|
"repository": {
|
|
41
40
|
"type": "git",
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview Branch-level coverage reporter for the turnstile test suite.
|
|
4
|
+
*
|
|
5
|
+
* Node's built-in coverage report prints percentages and uncovered *lines*
|
|
6
|
+
* only; the raw V8 JSON (via `NODE_V8_COVERAGE`) carries per-function *block*
|
|
7
|
+
* ranges whose zero counts pinpoint exactly which decision never executed —
|
|
8
|
+
* coverage.py-style arc diagnosis, derived from V8 block data.
|
|
9
|
+
*
|
|
10
|
+
* Usage: `node scripts/branch-coverage.mjs [test-file ...]`
|
|
11
|
+
* Runs each test file in its own child process (the same per-file isolation
|
|
12
|
+
* `node --test` uses) with `NODE_V8_COVERAGE` pointed at a shared temp dir,
|
|
13
|
+
* merges the V8 JSON across children, and prints every never-executed block
|
|
14
|
+
* inside a function that DID run, as `file:line:col-line` plus a snippet.
|
|
15
|
+
* Exit 0 always — this is a diagnostic, not a gate.
|
|
16
|
+
*
|
|
17
|
+
* Merge semantics (the non-obvious part — learned the hard way):
|
|
18
|
+
* - A function instance is identified by `(functionName, root range)`; the
|
|
19
|
+
* same closure re-recorded in several children merges into one entry.
|
|
20
|
+
* - V8 may record DIFFERENT block granularity for the same function across
|
|
21
|
+
* children (optimization feedback differs per run), so ranges merge by
|
|
22
|
+
* exact `(startOffset, endOffset)` span with max count. Never spread a
|
|
23
|
+
* coarse count across a finer record's blocks: a parent range's count is
|
|
24
|
+
* the *function/block entry* count, not evidence that nested blocks ran.
|
|
25
|
+
* - A merged zero block is only reported when no child has a POSITIVE range
|
|
26
|
+
* contained within it (code inside it demonstrably ran somewhere). Coarse
|
|
27
|
+
* zero spans that contain proven-covered sub-blocks are pruned — they are
|
|
28
|
+
* lumping artifacts, not branch evidence. This fails toward less noise.
|
|
29
|
+
* - Functions whose root never ran in any child are skipped (they show as
|
|
30
|
+
* uncovered lines in the standard report anyway).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { spawnSync } from "node:child_process"
|
|
34
|
+
import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"
|
|
35
|
+
import { tmpdir } from "node:os"
|
|
36
|
+
import { join } from "node:path"
|
|
37
|
+
|
|
38
|
+
/** Builds per-file line-offset tables for snippet and position rendering. */
|
|
39
|
+
function loadSources(files) {
|
|
40
|
+
const sources = {}
|
|
41
|
+
for (const file of files) {
|
|
42
|
+
try {
|
|
43
|
+
const text = readFileSync(file, "utf8")
|
|
44
|
+
const offsets = []
|
|
45
|
+
let off = 0
|
|
46
|
+
for (const line of text.split("\n")) {
|
|
47
|
+
offsets.push(off)
|
|
48
|
+
off += line.length + 1
|
|
49
|
+
}
|
|
50
|
+
sources[file] = { text, offsets }
|
|
51
|
+
} catch {
|
|
52
|
+
/* covered file may be outside this checkout; leave unknown */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return sources
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Converts a character offset to a `line:col` string via the offsets table. */
|
|
59
|
+
function toPos(source, offset) {
|
|
60
|
+
let lo = 0
|
|
61
|
+
let hi = source.offsets.length - 1
|
|
62
|
+
while (lo < hi) {
|
|
63
|
+
const mid = (lo + hi + 1) >> 1
|
|
64
|
+
if (source.offsets[mid] <= offset) lo = mid
|
|
65
|
+
else hi = mid - 1
|
|
66
|
+
}
|
|
67
|
+
return `${lo + 1}:${offset - source.offsets[lo] + 1}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Converts a character offset to a 1-based line number. */
|
|
71
|
+
function toLine(source, offset) {
|
|
72
|
+
return Number(toPos(source, offset).split(":")[0])
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const args = process.argv.slice(2)
|
|
76
|
+
const testFiles = args.filter((a) => !a.startsWith("--"))
|
|
77
|
+
const files = testFiles.length
|
|
78
|
+
? testFiles
|
|
79
|
+
: readdirSync("tests")
|
|
80
|
+
.filter((f) => f.endsWith(".test.ts"))
|
|
81
|
+
.map((f) => `tests/${f}`)
|
|
82
|
+
const covDir = mkdtempSync(join(tmpdir(), "branch-cov-"))
|
|
83
|
+
|
|
84
|
+
// One child per test file — exactly how `node --test` runs them — so module
|
|
85
|
+
// state isolation matches the real suite and the union over children is the
|
|
86
|
+
// same union the standard report computes. All children share one
|
|
87
|
+
// NODE_V8_COVERAGE dir; records are merged below.
|
|
88
|
+
let failures = 0
|
|
89
|
+
for (const file of files) {
|
|
90
|
+
const result = spawnSync(
|
|
91
|
+
process.execPath,
|
|
92
|
+
["--test", "--experimental-strip-types", "--experimental-test-coverage", file],
|
|
93
|
+
{
|
|
94
|
+
env: { ...process.env, NODE_V8_COVERAGE: covDir },
|
|
95
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
96
|
+
encoding: "utf8",
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
if (result.status !== 0) {
|
|
100
|
+
failures++
|
|
101
|
+
console.error(`${file}: child exited ${result.status}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const records = readdirSync(covDir).map((f) => JSON.parse(readFileSync(join(covDir, f), "utf8")))
|
|
106
|
+
rmSync(covDir, { recursive: true, force: true })
|
|
107
|
+
|
|
108
|
+
/** Normalizes a V8 URL to a filesystem path. */
|
|
109
|
+
const toFile = (url) => url.replace(/^file:\/\//, "")
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Merges one child's ranges into the accumulated span→maxCount map by exact
|
|
113
|
+
* span. Identical spans across children take the max count (a block executed
|
|
114
|
+
* in any child is covered); spans unique to one child are that child's own
|
|
115
|
+
* block structure and are kept for the containment pruning pass.
|
|
116
|
+
* @param acc The accumulated `Map<"start:end", { startOffset, endOffset, count }>` for a function.
|
|
117
|
+
* @param incoming One child's range list for that function.
|
|
118
|
+
*/
|
|
119
|
+
function mergeRanges(acc, incoming) {
|
|
120
|
+
for (const r of incoming) {
|
|
121
|
+
const key = `${r.startOffset}:${r.endOffset}`
|
|
122
|
+
const prior = acc.get(key)
|
|
123
|
+
if (prior) prior.count = Math.max(prior.count, r.count)
|
|
124
|
+
else acc.set(key, { ...r })
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Merges all records: url → (function key → span map). */
|
|
129
|
+
const scripts = new Map()
|
|
130
|
+
for (const record of records) {
|
|
131
|
+
for (const entry of record.result) {
|
|
132
|
+
if (!entry.functions) continue
|
|
133
|
+
let script = scripts.get(entry.url)
|
|
134
|
+
if (!script) {
|
|
135
|
+
script = new Map()
|
|
136
|
+
scripts.set(entry.url, script)
|
|
137
|
+
}
|
|
138
|
+
for (const fn of entry.functions) {
|
|
139
|
+
if (!fn.ranges) continue
|
|
140
|
+
const key = `${fn.functionName}@${fn.ranges[0].startOffset}`
|
|
141
|
+
const prior = script.get(key)
|
|
142
|
+
if (!prior) {
|
|
143
|
+
const spans = new Map()
|
|
144
|
+
const positives = []
|
|
145
|
+
for (const r of fn.ranges) {
|
|
146
|
+
const k = `${r.startOffset}:${r.endOffset}`
|
|
147
|
+
spans.set(k, { ...r })
|
|
148
|
+
if (r.count > 0 && r !== fn.ranges[0]) positives.push({ ...r })
|
|
149
|
+
}
|
|
150
|
+
script.set(key, { spans, positives })
|
|
151
|
+
} else {
|
|
152
|
+
mergeRanges(prior.spans, fn.ranges)
|
|
153
|
+
for (const r of fn.ranges) {
|
|
154
|
+
if (r.count > 0 && r !== fn.ranges[0]) prior.positives.push({ ...r })
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const sources = loadSources([...scripts.keys()].map(toFile))
|
|
162
|
+
const findings = []
|
|
163
|
+
for (const [url, fns] of scripts) {
|
|
164
|
+
const file = toFile(url)
|
|
165
|
+
if (!/\/src\//.test(file) && !file.endsWith("turnstile.ts")) continue
|
|
166
|
+
const source = sources[file]
|
|
167
|
+
if (!source) continue
|
|
168
|
+
for (const [key, { spans, positives }] of fns) {
|
|
169
|
+
const root = [...spans.values()].find(
|
|
170
|
+
(s) => s.startOffset === Math.min(...[...spans.values()].map((v) => v.startOffset)),
|
|
171
|
+
)
|
|
172
|
+
if (!root || root.count === 0) continue // whole function never ran in any child
|
|
173
|
+
for (const span of spans.values()) {
|
|
174
|
+
if (span.count !== 0) continue
|
|
175
|
+
// Prune lumping artifacts: a zero span that CONTAINS a proven-covered
|
|
176
|
+
// sub-range from another child is not branch evidence.
|
|
177
|
+
const containedPositive = positives.some(
|
|
178
|
+
(p) =>
|
|
179
|
+
p.startOffset >= span.startOffset &&
|
|
180
|
+
p.endOffset <= span.endOffset &&
|
|
181
|
+
!(p.startOffset === span.startOffset && p.endOffset === span.endOffset),
|
|
182
|
+
)
|
|
183
|
+
if (containedPositive) continue
|
|
184
|
+
const start = toPos(source, span.startOffset)
|
|
185
|
+
const endLine = toLine(source, span.endOffset)
|
|
186
|
+
const snippet = source.text.slice(span.startOffset, span.startOffset + 80).split("\n")[0]
|
|
187
|
+
findings.push({ file, key: key.split("@")[0], start, endLine, snippet })
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
findings.sort(
|
|
192
|
+
(a, b) =>
|
|
193
|
+
a.file.localeCompare(b.file) || a.start.localeCompare(b.start, undefined, { numeric: true }),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
console.log(`tests: ${files.length} files, child failures=${failures}`)
|
|
197
|
+
if (findings.length === 0) {
|
|
198
|
+
console.log("branch coverage: every executed function's blocks are covered")
|
|
199
|
+
} else {
|
|
200
|
+
console.log(`uncovered blocks: ${findings.length}`)
|
|
201
|
+
for (const f of findings) {
|
|
202
|
+
console.log(
|
|
203
|
+
` ${f.file.replace(/^.*turnstile\//, "")}:${f.start}-${f.endLine} [${f.key}] ${f.snippet}`,
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -64,6 +64,28 @@ if (agentTemplates === 0) {
|
|
|
64
64
|
failed++
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// Plugin policy: the backticked GATE:/VERDICT: examples in src/policy.ts are
|
|
68
|
+
// the orchestrator-side half of the marker contract — the runtime policy
|
|
69
|
+
// injected into the parent session. Same extraction rule as the agent
|
|
70
|
+
// templates: they spell <n> and must parse once the placeholder stands in
|
|
71
|
+
// for digits. (The template source that once lived here for this contract is
|
|
72
|
+
// gone; src/policy.ts is its successor, so its examples are validated too.)
|
|
73
|
+
const policyFile = join(root, "src", "policy.ts")
|
|
74
|
+
let policyTemplates = 0
|
|
75
|
+
for (const m of readFileSync(policyFile, "utf8").matchAll(/`((?:GATE|VERDICT): [^\n`]*)`/g)) {
|
|
76
|
+
const line = m[1].replaceAll("<n>", "1")
|
|
77
|
+
const re = line.startsWith("GATE") ? gateRe : verdictRe
|
|
78
|
+
policyTemplates++
|
|
79
|
+
if (!re.test(line)) {
|
|
80
|
+
console.error(`check-markers: policy marker template not accepted by parser: ${line}`)
|
|
81
|
+
failed++
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (policyTemplates === 0) {
|
|
85
|
+
console.error("check-markers: no marker templates found in src/policy.ts")
|
|
86
|
+
failed++
|
|
87
|
+
}
|
|
88
|
+
|
|
67
89
|
// Inverse check: the regexes must also reject malformed markers.
|
|
68
90
|
for (const [re, bad] of [
|
|
69
91
|
[gateRe, "GATE: PASS tests=12"],
|
|
@@ -82,5 +104,5 @@ if (failed) {
|
|
|
82
104
|
process.exit(1)
|
|
83
105
|
}
|
|
84
106
|
console.log(
|
|
85
|
-
`check-markers: OK (${lines.length} README examples, ${agentTemplates} agent templates verified against parser)`,
|
|
107
|
+
`check-markers: OK (${lines.length} README examples, ${agentTemplates} agent + ${policyTemplates} policy templates verified against parser)`,
|
|
86
108
|
)
|
package/scripts/install.mjs
CHANGED
|
@@ -7,14 +7,23 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Usage:
|
|
9
9
|
// node scripts/install.mjs [--manifest <path>] [--check] [--force]
|
|
10
|
-
// [--uninstall] [--link | --copy]
|
|
10
|
+
// [--uninstall] [--link | --copy] [--repair]
|
|
11
11
|
//
|
|
12
|
-
// --manifest Manifest to install (default: install.json at the repo root)
|
|
13
|
-
//
|
|
12
|
+
// --manifest Manifest to install (default: install.json at the repo root).
|
|
13
|
+
// Missing value, or a `--`-prefixed next token, exits 2 here
|
|
14
|
+
// ("requires a path") rather than crashing downstream.
|
|
15
|
+
// --check Verify all targets without writing; exit 1 on drift. With
|
|
16
|
+
// --repair, reportable drift is printed as `would repair`
|
|
17
|
+
// (write-free dry run) instead of failing.
|
|
14
18
|
// --force Convert conflicting targets after backing them up
|
|
15
19
|
// --uninstall Reverse the manifest: remove repo-owned links / ledger-owned
|
|
16
20
|
// copies for every entry (then retire leftovers); exit 1 only
|
|
17
21
|
// when something could not be removed (conflict/unowned).
|
|
22
|
+
// --repair Apply-mode: relink/copy each repairable entry (`missing`/`stale`
|
|
23
|
+
// link-mode; `missing`/ledger-`stale` copy-mode) via the same
|
|
24
|
+
// path as install, print `install: repair <dest> (<code>)`,
|
|
25
|
+
// exit 1 only if something unrepairable remains. `conflict`
|
|
26
|
+
// stays gated behind --force (no unprompted backup).
|
|
18
27
|
// --link/--copy Override the mode. Resolution: CLI flag > manifest "mode"
|
|
19
28
|
// field > auto-detect (.git present => link, else copy)
|
|
20
29
|
//
|
|
@@ -78,7 +87,7 @@ try {
|
|
|
78
87
|
/**
|
|
79
88
|
* Parses command-line arguments.
|
|
80
89
|
* @param {string[]} argv Raw argv (process.argv).
|
|
81
|
-
* @returns {{manifest: string, check: boolean, force: boolean, uninstall: boolean, mode: "link"|"copy"|null, model: string[], printModelConfig: boolean, validate: boolean, modelSpecs: Record<string,string
|
|
90
|
+
* @returns {{manifest: string, check: boolean, force: boolean, uninstall: boolean, mode: "link"|"copy"|null, model: string[], printModelConfig: boolean, validate: boolean, modelSpecs: Record<string,string>, repair: boolean}} Parsed arguments. A `--model`/`--validate` without `--print-model-config` is an error and exits 2 (checked before any parsing or I/O). Model specs are validated here only when the printer flag is set; a malformed or unknown-name spec prints an actionable message and exits 2 (matching the unknown-arg contract), and the parsed map is stashed as `modelSpecs` for `main`. A missing or flag-shaped value for `--manifest` or `--model` also exits 2 at the assignment site, naming the offending flag (so neither path reaches a late fs parse-crash).
|
|
82
91
|
*/
|
|
83
92
|
function parseArgs(argv) {
|
|
84
93
|
const args = {
|
|
@@ -91,15 +100,29 @@ function parseArgs(argv) {
|
|
|
91
100
|
printModelConfig: false,
|
|
92
101
|
validate: false,
|
|
93
102
|
modelSpecs: {},
|
|
103
|
+
repair: false,
|
|
94
104
|
}
|
|
95
105
|
for (let i = 2; i < argv.length; i++) {
|
|
96
|
-
if (argv[i] === "--manifest")
|
|
97
|
-
|
|
106
|
+
if (argv[i] === "--manifest") {
|
|
107
|
+
const val = argv[++i]
|
|
108
|
+
if (val === undefined || val.startsWith("--")) {
|
|
109
|
+
console.error("install: --manifest requires a path")
|
|
110
|
+
process.exit(2)
|
|
111
|
+
}
|
|
112
|
+
args.manifest = val
|
|
113
|
+
} else if (argv[i] === "--check") args.check = true
|
|
98
114
|
else if (argv[i] === "--force") args.force = true
|
|
99
115
|
else if (argv[i] === "--uninstall") args.uninstall = true
|
|
100
116
|
else if (argv[i] === "--link") args.mode = "link"
|
|
101
117
|
else if (argv[i] === "--copy") args.mode = "copy"
|
|
102
|
-
else if (argv[i] === "--model")
|
|
118
|
+
else if (argv[i] === "--model") {
|
|
119
|
+
const val = argv[++i]
|
|
120
|
+
if (val === undefined || val.startsWith("--")) {
|
|
121
|
+
console.error("install: --model requires a spec")
|
|
122
|
+
process.exit(2)
|
|
123
|
+
}
|
|
124
|
+
args.model.push(val)
|
|
125
|
+
} else if (argv[i] === "--repair") args.repair = true
|
|
103
126
|
else if (argv[i] === "--print-model-config") args.printModelConfig = true
|
|
104
127
|
else if (argv[i] === "--validate") args.validate = true
|
|
105
128
|
else {
|
|
@@ -475,6 +498,20 @@ async function inspectEntry(entry, mode, ledger) {
|
|
|
475
498
|
return mode === "copy" ? inspectCopy(entry, ledger) : inspectLink(entry)
|
|
476
499
|
}
|
|
477
500
|
|
|
501
|
+
/**
|
|
502
|
+
* Decides whether a drift code is repairable by `--repair` without prompting.
|
|
503
|
+
* Link mode reports `missing`/`stale`; `inspectCopy` only returns `stale` for
|
|
504
|
+
* a ledger-recorded match, so the same predicate covers both modes. A
|
|
505
|
+
* `conflict` is deliberately excluded: repairing it would back up user files,
|
|
506
|
+
* which `--repair` must never do unprompted (that path stays gated behind
|
|
507
|
+
* `--force`).
|
|
508
|
+
* @param {string} code Drift code from `inspectEntry`.
|
|
509
|
+
* @returns {boolean} True when `--repair` would fix it.
|
|
510
|
+
*/
|
|
511
|
+
function isRepairable(code) {
|
|
512
|
+
return code === "missing" || code === "stale"
|
|
513
|
+
}
|
|
514
|
+
|
|
478
515
|
/**
|
|
479
516
|
* Backs up a real file by renaming it aside with a timestamp suffix.
|
|
480
517
|
* @param {string} dest Destination path to back up.
|
|
@@ -664,15 +701,72 @@ async function apply(manifest, args) {
|
|
|
664
701
|
return { ok, changed }
|
|
665
702
|
}
|
|
666
703
|
|
|
704
|
+
/**
|
|
705
|
+
* Repairs repairable drift across every manifest entry, relinking `missing`
|
|
706
|
+
* /`stale` link-mode entries and re-copying `missing`/ledger-`stale` copy-mode
|
|
707
|
+
* entries via the same `installLink`/`installCopy` path `apply()` uses.
|
|
708
|
+
* `conflict` stays gated behind `--force`: `--repair` must never back up user
|
|
709
|
+
* files unprompted, so a conflict left without `--force` is reported as
|
|
710
|
+
* unrepairable and fails. Runs in apply mode; the `--check --repair` dry run
|
|
711
|
+
* is handled by `check()`.
|
|
712
|
+
* @param {{mode: "link"|"copy", ledgerPath: string, links: Array<{src: string, dest: string}>, retired: string[]}} manifest Validated manifest.
|
|
713
|
+
* @param {{force: boolean, repair: boolean}} args Parsed arguments.
|
|
714
|
+
* @returns {Promise<{ok: boolean, changed: boolean}>} Success flag and whether anything changed.
|
|
715
|
+
*/
|
|
716
|
+
async function repair(manifest, args) {
|
|
717
|
+
const ledger = loadLedger(manifest.ledgerPath)
|
|
718
|
+
let ok = true
|
|
719
|
+
let changed = false
|
|
720
|
+
|
|
721
|
+
for (const entry of manifest.links) {
|
|
722
|
+
const report = await inspectEntry(entry, manifest.mode, ledger)
|
|
723
|
+
if (report.code === "ok") {
|
|
724
|
+
console.log(`install: up to date ${entry.dest}`)
|
|
725
|
+
continue
|
|
726
|
+
}
|
|
727
|
+
// Conflict without --force, or an invalid (missing-source) entry, can not
|
|
728
|
+
// be repaired here: neither would back up user files or invent a source.
|
|
729
|
+
if (!isRepairable(report.code) && !args.force) {
|
|
730
|
+
ok = false
|
|
731
|
+
console.error(
|
|
732
|
+
`install: ${entry.dest} — ${report.code}${
|
|
733
|
+
report.detail ? `: ${report.detail}` : ""
|
|
734
|
+
} (unrepairable; rerun with --force for conflicts)`,
|
|
735
|
+
)
|
|
736
|
+
continue
|
|
737
|
+
}
|
|
738
|
+
try {
|
|
739
|
+
const result =
|
|
740
|
+
manifest.mode === "copy"
|
|
741
|
+
? await installCopy(entry, args, ledger, pkgVersion, manifest.mode)
|
|
742
|
+
: await installLink(entry, args, ledger)
|
|
743
|
+
if (result.acted) {
|
|
744
|
+
changed = true
|
|
745
|
+
console.log(`install: repair ${entry.dest} (${report.code})`)
|
|
746
|
+
} else console.log(`install: up to date ${entry.dest}`)
|
|
747
|
+
} catch (error) {
|
|
748
|
+
ok = false
|
|
749
|
+
console.error(String(error.message ?? error))
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
saveLedger(manifest.ledgerPath, ledger)
|
|
753
|
+
return { ok, changed }
|
|
754
|
+
}
|
|
755
|
+
|
|
667
756
|
/**
|
|
668
757
|
* Runs the installer in check mode: reports drift, writes nothing.
|
|
669
758
|
* A ledger record whose version is older than the current package version
|
|
670
|
-
* is reported as a warning and does not fail the check.
|
|
759
|
+
* is reported as a warning and does not fail the check. When `repairMode`
|
|
760
|
+
* is set (`--check --repair`), repairable drift (`missing`/`stale`) is
|
|
761
|
+
* reported as `would repair` instead of an error and does not fail the
|
|
762
|
+
* check (the entry is going to be fixed); unrepairable drift still fails
|
|
763
|
+
* as before. `check` alone (`repairMode` false) is byte-for-byte unchanged.
|
|
671
764
|
* @param {{mode: "link"|"copy", ledgerPath: string, links: Array<{src: string, dest: string}>, retired: string[]}} manifest Validated manifest.
|
|
672
765
|
* @param {string} version Current package version.
|
|
673
|
-
* @
|
|
766
|
+
* @param {boolean} [repairMode] When true, report repairable drift as `would repair` rather than failing.
|
|
767
|
+
* @returns {Promise<boolean>} True when no unrepairable drift was found.
|
|
674
768
|
*/
|
|
675
|
-
async function check(manifest, version) {
|
|
769
|
+
async function check(manifest, version, repairMode = false) {
|
|
676
770
|
const ledger = loadLedger(manifest.ledgerPath)
|
|
677
771
|
let ok = true
|
|
678
772
|
for (const entry of manifest.links) {
|
|
@@ -686,6 +780,8 @@ async function check(manifest, version) {
|
|
|
686
780
|
if (record && record.version && record.version !== version)
|
|
687
781
|
console.log(`check: outdated ${entry.dest} (installed ${record.version}, package ${version})`)
|
|
688
782
|
if (report.code === "ok") console.log(`check: OK ${entry.dest}`)
|
|
783
|
+
else if (repairMode && isRepairable(report.code))
|
|
784
|
+
console.log(`install: would repair ${entry.dest} (${report.code})`)
|
|
689
785
|
else {
|
|
690
786
|
ok = false
|
|
691
787
|
console.error(
|
|
@@ -710,8 +806,7 @@ async function main(argv = process.argv) {
|
|
|
710
806
|
// optionally validates them against `opencode models`, prints a ready-to
|
|
711
807
|
// paste snippet, and returns. This short-circuits before the manifest is
|
|
712
808
|
// even read (like --check), so no link/copy target or ledger entry is
|
|
713
|
-
// touched.
|
|
714
|
-
// separately; that is out of scope for this write-free flag.
|
|
809
|
+
// touched.
|
|
715
810
|
if (args.printModelConfig) {
|
|
716
811
|
if (args.validate) {
|
|
717
812
|
let available
|
|
@@ -735,7 +830,12 @@ async function main(argv = process.argv) {
|
|
|
735
830
|
console.log(
|
|
736
831
|
`install: mode=${manifest.mode} (${manifest.mode === "link" ? "checkout" : "package"})`,
|
|
737
832
|
)
|
|
738
|
-
if (args.check) return await check(manifest, pkgVersion)
|
|
833
|
+
if (args.check) return await check(manifest, pkgVersion, args.repair)
|
|
834
|
+
if (args.repair) {
|
|
835
|
+
const { ok, changed } = await repair(manifest, args)
|
|
836
|
+
if (changed) console.log("restart OpenCode sessions to pick up changes.")
|
|
837
|
+
return ok
|
|
838
|
+
}
|
|
739
839
|
const { ok, changed } = await apply(manifest, args)
|
|
740
840
|
if (changed) console.log("restart OpenCode sessions to pick up changes.")
|
|
741
841
|
return ok
|