@raishin/vanguard-frontier-agentic 3.15.0 → 3.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.github/plugin/marketplace.json +1 -1
- package/catalog/AGENTS.md +2 -1
- package/catalog/asset-integrity.json +476 -29
- package/catalog/index.json +3 -2
- package/catalog/mods.json +131 -0
- package/mods/README.md +220 -0
- package/mods/audit-trail/README.md +70 -0
- package/mods/audit-trail/bin/hook.mjs +65 -0
- package/mods/audit-trail/claude-code/UNSUPPORTED.md +11 -0
- package/mods/audit-trail/claude-code/settings.hooks.json +14 -0
- package/mods/audit-trail/codex/hooks.json +14 -0
- package/mods/audit-trail/kiro/UNSUPPORTED.md +7 -0
- package/mods/audit-trail/kiro/audit-trail-post-tool-use-hook.json +14 -0
- package/mods/audit-trail/lib/common.mjs +213 -0
- package/mods/audit-trail/lib/core.mjs +50 -0
- package/mods/audit-trail/lib/runtime.mjs +140 -0
- package/mods/audit-trail/mod.toml +37 -0
- package/mods/blast-radius-guard/.claude-plugin/plugin.json +5 -0
- package/mods/blast-radius-guard/README.md +53 -0
- package/mods/blast-radius-guard/bin/hook.mjs +18 -0
- package/mods/blast-radius-guard/claude-code/settings.hooks.json +14 -0
- package/mods/blast-radius-guard/codex/UNSUPPORTED.md +7 -0
- package/mods/blast-radius-guard/codex/hooks.json +14 -0
- package/mods/blast-radius-guard/hooks/hooks.json +5 -0
- package/mods/blast-radius-guard/hooks/register.test.ts +97 -0
- package/mods/blast-radius-guard/hooks/register.ts +13 -0
- package/mods/blast-radius-guard/kiro/UNSUPPORTED.md +7 -0
- package/mods/blast-radius-guard/kiro/blast-radius-guard-pre-tool-use-hook.json +14 -0
- package/mods/blast-radius-guard/lib/common.mjs +213 -0
- package/mods/blast-radius-guard/lib/core.mjs +66 -0
- package/mods/blast-radius-guard/lib/patterns.mjs +102 -0
- package/mods/blast-radius-guard/lib/runtime.mjs +140 -0
- package/mods/blast-radius-guard/mod.toml +34 -0
- package/mods/blast-radius-guard/patterns.json +101 -0
- package/mods/context-burn/.claude-plugin/plugin.json +5 -0
- package/mods/context-burn/README.md +66 -0
- package/mods/context-burn/bin/hook.mjs +38 -0
- package/mods/context-burn/claude-code/UNSUPPORTED.md +7 -0
- package/mods/context-burn/claude-code/settings.hooks.json +24 -0
- package/mods/context-burn/codex/UNSUPPORTED.md +7 -0
- package/mods/context-burn/codex/hooks.json +24 -0
- package/mods/context-burn/hooks/hooks.json +5 -0
- package/mods/context-burn/hooks/register.test.ts +151 -0
- package/mods/context-burn/hooks/register.ts +35 -0
- package/mods/context-burn/kiro/UNSUPPORTED.md +7 -0
- package/mods/context-burn/kiro/context-burn-post-tool-use-hook.json +14 -0
- package/mods/context-burn/kiro/context-burn-session-start-hook.json +14 -0
- package/mods/context-burn/lib/common.mjs +213 -0
- package/mods/context-burn/lib/core.mjs +79 -0
- package/mods/context-burn/lib/runtime.mjs +140 -0
- package/mods/context-burn/mod.toml +37 -0
- package/mods/live-guard-gate/.claude-plugin/plugin.json +5 -0
- package/mods/live-guard-gate/README.md +43 -0
- package/mods/live-guard-gate/bin/hook.mjs +12 -0
- package/mods/live-guard-gate/claude-code/settings.hooks.json +14 -0
- package/mods/live-guard-gate/codex/UNSUPPORTED.md +7 -0
- package/mods/live-guard-gate/codex/hooks.json +14 -0
- package/mods/live-guard-gate/hooks/hooks.json +5 -0
- package/mods/live-guard-gate/hooks/register.test.ts +59 -0
- package/mods/live-guard-gate/hooks/register.ts +11 -0
- package/mods/live-guard-gate/kiro/UNSUPPORTED.md +7 -0
- package/mods/live-guard-gate/kiro/live-guard-gate-pre-tool-use-hook.json +14 -0
- package/mods/live-guard-gate/lib/common.mjs +213 -0
- package/mods/live-guard-gate/lib/core.mjs +24 -0
- package/mods/live-guard-gate/lib/live-agents.mjs +65 -0
- package/mods/live-guard-gate/lib/patterns.mjs +523 -0
- package/mods/live-guard-gate/lib/runtime.mjs +140 -0
- package/mods/live-guard-gate/mod.toml +34 -0
- package/mods/live-guard-gate/patterns.json +522 -0
- package/mods/secret-redactor/.claude-plugin/plugin.json +5 -0
- package/mods/secret-redactor/README.md +86 -0
- package/mods/secret-redactor/bin/hook.mjs +35 -0
- package/mods/secret-redactor/claude-code/UNSUPPORTED.md +7 -0
- package/mods/secret-redactor/claude-code/settings.hooks.json +24 -0
- package/mods/secret-redactor/codex/UNSUPPORTED.md +11 -0
- package/mods/secret-redactor/codex/hooks.json +14 -0
- package/mods/secret-redactor/hooks/hooks.json +5 -0
- package/mods/secret-redactor/hooks/register.test.ts +150 -0
- package/mods/secret-redactor/hooks/register.ts +19 -0
- package/mods/secret-redactor/kiro/UNSUPPORTED.md +11 -0
- package/mods/secret-redactor/kiro/secret-redactor-pre-tool-use-hook.json +14 -0
- package/mods/secret-redactor/lib/common.mjs +213 -0
- package/mods/secret-redactor/lib/core.mjs +61 -0
- package/mods/secret-redactor/lib/patterns.mjs +75 -0
- package/mods/secret-redactor/lib/runtime.mjs +140 -0
- package/mods/secret-redactor/mod.toml +43 -0
- package/mods/secret-redactor/patterns.json +74 -0
- package/package.json +8 -4
- package/plugins/vanguard-frontier-agentic/.codex-plugin/plugin.json +1 -1
- package/scripts/generate-mods.mjs +784 -0
- package/scripts/mods/common.mjs +213 -0
- package/scripts/mods/runtime.mjs +140 -0
- package/tests/fixtures/README.md +1 -1
- package/tests/fuzz-properties.test.mjs +254 -0
- package/tests/mods.test.mjs +768 -0
- package/tests/validate-asset-integrity.py +1 -0
- package/tests/validate-mods.py +821 -0
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generator: the files marked "G" under mods/<id>/ plus catalog/mods.json.
|
|
4
|
+
*
|
|
5
|
+
* Source of truth is mods/<id>/mod.toml (plus patterns.json for the pattern mods) and the
|
|
6
|
+
* two shared files scripts/mods/common.mjs and scripts/mods/runtime.mjs. Everything else
|
|
7
|
+
* this script writes is derived and must never be hand-edited. Hand-written files in a mod
|
|
8
|
+
* (README.md, bin/, hooks/register.ts, lib/core.mjs, ...) are never touched.
|
|
9
|
+
*
|
|
10
|
+
* mod.toml is read with a strict TOML-subset parser (comments, bare-key tables, basic
|
|
11
|
+
* strings, integers, booleans, arrays of strings). Anything else is an error, so a manifest
|
|
12
|
+
* can never mean one thing to this script and another to Python's tomllib.
|
|
13
|
+
*
|
|
14
|
+
* Deterministic by construction: no clock, no env, no network, no shell, nothing outside
|
|
15
|
+
* the repo. Output is sorted; JSON is 2-space with a trailing newline.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* node scripts/generate-mods.mjs # write generated files, delete stale ones
|
|
19
|
+
* node scripts/generate-mods.mjs --check # verify in sync (CI); writes nothing
|
|
20
|
+
* node scripts/generate-mods.mjs --print-manifests # parsed mod.toml files as JSON
|
|
21
|
+
*
|
|
22
|
+
* Exports: parseToml, validateManifest, loadMods, renderMod, renderAll.
|
|
23
|
+
* loadMods(root) -> { mods, ctx, errors } (the only function that reads the disk)
|
|
24
|
+
* renderAll(mods, ctx) -> Map<repo-relative path, content>, sorted by path
|
|
25
|
+
*/
|
|
26
|
+
import {
|
|
27
|
+
lstatSync,
|
|
28
|
+
mkdirSync,
|
|
29
|
+
readdirSync,
|
|
30
|
+
readFileSync,
|
|
31
|
+
realpathSync,
|
|
32
|
+
rmdirSync,
|
|
33
|
+
unlinkSync,
|
|
34
|
+
writeFileSync,
|
|
35
|
+
} from 'node:fs'
|
|
36
|
+
import path from 'node:path'
|
|
37
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
38
|
+
|
|
39
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
40
|
+
|
|
41
|
+
const HARNESSES = ['claude-code', 'kiro', 'codex']
|
|
42
|
+
const HARNESS_NAMES = { 'claude-code': 'Claude Code', kiro: 'Kiro', codex: 'Codex' }
|
|
43
|
+
const EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart'] // canonical order
|
|
44
|
+
const EV_KEBAB = { PreToolUse: 'pre-tool-use', PostToolUse: 'post-tool-use', SessionStart: 'session-start' }
|
|
45
|
+
const SURFACES = ['native', 'classic']
|
|
46
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
47
|
+
const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
48
|
+
const VERSION_RE = /^\d+\.\d+\.\d+$/
|
|
49
|
+
const CTRL_RE = /[\u0000-\u001f\u007f]/
|
|
50
|
+
const LIVE_AGENT_RE = /-live-.*-guard-agent$/
|
|
51
|
+
const GEN_HEADER = '// Generated by scripts/generate-mods.mjs'
|
|
52
|
+
|
|
53
|
+
// Paths the generator owns inside a mod. Everything else in a mod is hand-written.
|
|
54
|
+
const OWNED_DIRS = ['.claude-plugin', 'claude-code', 'kiro', 'codex'] // every file inside is generated
|
|
55
|
+
const OWNED_FILES = {
|
|
56
|
+
lib: ['common.mjs', 'runtime.mjs', 'patterns.mjs', 'live-agents.mjs'],
|
|
57
|
+
hooks: ['hooks.json'],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0)
|
|
61
|
+
const json = (v) => JSON.stringify(v, null, 2) + '\n'
|
|
62
|
+
const isTable = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
63
|
+
|
|
64
|
+
function aggregate(errors) {
|
|
65
|
+
const e = new Error(errors.join('\n'))
|
|
66
|
+
e.errors = errors
|
|
67
|
+
return e
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------------------
|
|
71
|
+
// TOML subset parser
|
|
72
|
+
// ---------------------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
// table object -> Map(key -> line); key '' is the table's own header line. Lets schema
|
|
75
|
+
// errors point at file:line without changing the plain-object shape parseToml returns.
|
|
76
|
+
const LINES = new WeakMap()
|
|
77
|
+
const ESCAPES = { '"': '"', '\\': '\\', n: '\n', t: '\t', r: '\r' }
|
|
78
|
+
|
|
79
|
+
export function parseToml(text, fileLabel = 'mod.toml') {
|
|
80
|
+
const n = text.length
|
|
81
|
+
const root = {}
|
|
82
|
+
LINES.set(root, new Map())
|
|
83
|
+
const defined = new Set() // explicitly declared [tables], by dotted path
|
|
84
|
+
let cur = root
|
|
85
|
+
let pos = 0
|
|
86
|
+
let line = 1
|
|
87
|
+
|
|
88
|
+
const fail = (msg = 'unsupported TOML', at = line) => {
|
|
89
|
+
throw new Error(`${fileLabel}:${at}: ${msg}`)
|
|
90
|
+
}
|
|
91
|
+
const isWs = (c) => c === ' ' || c === '\t'
|
|
92
|
+
const atNewline = () => text[pos] === '\n' || (text[pos] === '\r' && text[pos + 1] === '\n')
|
|
93
|
+
const eatNewline = () => {
|
|
94
|
+
pos += text[pos] === '\r' ? 2 : 1
|
|
95
|
+
line++
|
|
96
|
+
}
|
|
97
|
+
const skipWs = () => {
|
|
98
|
+
while (pos < n && isWs(text[pos])) pos++
|
|
99
|
+
}
|
|
100
|
+
const skipComment = () => {
|
|
101
|
+
if (text[pos] === '#') while (pos < n && !atNewline()) pos++
|
|
102
|
+
}
|
|
103
|
+
const skipWsNl = () => {
|
|
104
|
+
for (;;) {
|
|
105
|
+
skipWs()
|
|
106
|
+
if (pos < n && atNewline()) eatNewline()
|
|
107
|
+
else if (text[pos] === '#') skipComment()
|
|
108
|
+
else return
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const endOfLine = () => {
|
|
112
|
+
skipWs()
|
|
113
|
+
skipComment()
|
|
114
|
+
if (pos >= n) return
|
|
115
|
+
if (atNewline()) eatNewline()
|
|
116
|
+
else fail()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const BARE = /[A-Za-z0-9_-]+/y
|
|
120
|
+
const bareKey = () => {
|
|
121
|
+
BARE.lastIndex = pos
|
|
122
|
+
const m = BARE.exec(text)
|
|
123
|
+
if (!m) return ''
|
|
124
|
+
pos += m[0].length
|
|
125
|
+
if (FORBIDDEN_KEYS.has(m[0])) fail(`forbidden key ${m[0]}`)
|
|
126
|
+
return m[0]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const readString = () => {
|
|
130
|
+
if (text.startsWith('"""', pos)) fail() // multiline basic string
|
|
131
|
+
pos++
|
|
132
|
+
let out = ''
|
|
133
|
+
for (;;) {
|
|
134
|
+
if (pos >= n) fail()
|
|
135
|
+
const c = text[pos]
|
|
136
|
+
if (c === '"') {
|
|
137
|
+
pos++
|
|
138
|
+
return out
|
|
139
|
+
}
|
|
140
|
+
if (c === '\\') {
|
|
141
|
+
const e = text[pos + 1]
|
|
142
|
+
if (e === 'u') {
|
|
143
|
+
const hex = text.slice(pos + 2, pos + 6)
|
|
144
|
+
if (!/^[0-9A-Fa-f]{4}$/.test(hex)) fail()
|
|
145
|
+
const cp = parseInt(hex, 16)
|
|
146
|
+
if (cp >= 0xd800 && cp <= 0xdfff) fail() // lone surrogate is not a scalar value
|
|
147
|
+
out += String.fromCharCode(cp)
|
|
148
|
+
pos += 6
|
|
149
|
+
} else if (e !== undefined && Object.hasOwn(ESCAPES, e)) {
|
|
150
|
+
out += ESCAPES[e]
|
|
151
|
+
pos += 2
|
|
152
|
+
} else fail()
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
const code = c.charCodeAt(0)
|
|
156
|
+
if (code === 0x0a || code === 0x0d || (code < 0x20 && c !== '\t') || code === 0x7f) fail()
|
|
157
|
+
out += c
|
|
158
|
+
pos++
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const readArray = () => {
|
|
163
|
+
const startLine = line
|
|
164
|
+
pos++
|
|
165
|
+
const items = []
|
|
166
|
+
for (;;) {
|
|
167
|
+
skipWsNl()
|
|
168
|
+
if (pos >= n) fail('unsupported TOML', startLine)
|
|
169
|
+
if (text[pos] === ']') {
|
|
170
|
+
pos++
|
|
171
|
+
return items
|
|
172
|
+
}
|
|
173
|
+
if (text[pos] !== '"') fail() // only strings: no nesting, no mixed arrays, no inline tables
|
|
174
|
+
items.push(readString())
|
|
175
|
+
skipWsNl()
|
|
176
|
+
if (text[pos] === ',') pos++
|
|
177
|
+
else if (text[pos] === ']') {
|
|
178
|
+
pos++
|
|
179
|
+
return items
|
|
180
|
+
} else fail()
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const SCALAR = /(?:-?(?:0|[1-9][0-9]*)|true|false)(?=[ \t\r\n#,\]]|$)/y
|
|
185
|
+
const readValue = () => {
|
|
186
|
+
const c = text[pos]
|
|
187
|
+
if (c === '"') return readString()
|
|
188
|
+
if (c === '[') return readArray()
|
|
189
|
+
SCALAR.lastIndex = pos
|
|
190
|
+
const m = SCALAR.exec(text)
|
|
191
|
+
if (!m) fail() // literal strings, floats, dates, inline tables, ...
|
|
192
|
+
pos += m[0].length
|
|
193
|
+
if (m[0] === 'true') return true
|
|
194
|
+
if (m[0] === 'false') return false
|
|
195
|
+
const v = Number(m[0])
|
|
196
|
+
if (!Number.isSafeInteger(v)) fail()
|
|
197
|
+
return v || 0 // folds -0 into 0
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
while (pos < n) {
|
|
201
|
+
skipWs()
|
|
202
|
+
if (pos >= n) break
|
|
203
|
+
if (atNewline()) {
|
|
204
|
+
eatNewline()
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
if (text[pos] === '#') {
|
|
208
|
+
skipComment()
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
if (text[pos] === '[') {
|
|
212
|
+
if (text[pos + 1] === '[') fail() // array of tables
|
|
213
|
+
const headerLine = line
|
|
214
|
+
pos++
|
|
215
|
+
const segs = []
|
|
216
|
+
for (;;) {
|
|
217
|
+
skipWs()
|
|
218
|
+
const seg = bareKey()
|
|
219
|
+
if (!seg) fail()
|
|
220
|
+
segs.push(seg)
|
|
221
|
+
skipWs()
|
|
222
|
+
if (text[pos] === '.') pos++
|
|
223
|
+
else if (text[pos] === ']') {
|
|
224
|
+
pos++
|
|
225
|
+
break
|
|
226
|
+
} else fail()
|
|
227
|
+
}
|
|
228
|
+
endOfLine()
|
|
229
|
+
cur = root
|
|
230
|
+
for (const seg of segs) {
|
|
231
|
+
if (!Object.hasOwn(cur, seg)) {
|
|
232
|
+
const t = {}
|
|
233
|
+
LINES.set(t, new Map())
|
|
234
|
+
cur[seg] = t
|
|
235
|
+
LINES.get(cur).set(seg, headerLine)
|
|
236
|
+
} else if (!isTable(cur[seg])) fail(`duplicate key ${seg}`, headerLine)
|
|
237
|
+
cur = cur[seg]
|
|
238
|
+
}
|
|
239
|
+
const dotted = segs.join('.')
|
|
240
|
+
if (defined.has(dotted)) fail(`duplicate table [${dotted}]`, headerLine)
|
|
241
|
+
defined.add(dotted)
|
|
242
|
+
LINES.get(cur).set('', headerLine)
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
const keyLine = line
|
|
246
|
+
const key = bareKey() // quoted keys fall through to fail() below
|
|
247
|
+
if (!key) fail()
|
|
248
|
+
skipWs()
|
|
249
|
+
if (text[pos] !== '=') fail() // also rejects dotted keys
|
|
250
|
+
pos++
|
|
251
|
+
skipWs()
|
|
252
|
+
const value = readValue()
|
|
253
|
+
endOfLine()
|
|
254
|
+
if (Object.hasOwn(cur, key)) fail(`duplicate key ${key}`, keyLine)
|
|
255
|
+
cur[key] = value
|
|
256
|
+
LINES.get(cur).set(key, keyLine)
|
|
257
|
+
}
|
|
258
|
+
return root
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------------------
|
|
262
|
+
// mod.toml schema
|
|
263
|
+
// ---------------------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
/** Returns an array of `<label>[:<line>]: <message>` strings; empty when the manifest is valid. */
|
|
266
|
+
export function validateManifest(m, dirName, label) {
|
|
267
|
+
const errs = []
|
|
268
|
+
const err = (table, key, msg) => {
|
|
269
|
+
const l = LINES.get(table)?.get(key)
|
|
270
|
+
errs.push(`${label}${l ? `:${l}` : ''}: ${msg}`)
|
|
271
|
+
}
|
|
272
|
+
const q = (name, k) => (name ? `${name}.${k}` : k)
|
|
273
|
+
const keys = (t, name, required, optional = []) => {
|
|
274
|
+
for (const k of required) if (!Object.hasOwn(t, k)) err(t, '', `missing key ${q(name, k)}`)
|
|
275
|
+
for (const k of Object.keys(t)) {
|
|
276
|
+
if (!required.includes(k) && !optional.includes(k)) err(t, k, `unknown key ${q(name, k)}`)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const sub = (t, k, name) => {
|
|
280
|
+
if (!Object.hasOwn(t, k)) {
|
|
281
|
+
err(t, '', `missing table [${q(name, k)}]`)
|
|
282
|
+
return null
|
|
283
|
+
}
|
|
284
|
+
if (!isTable(t[k])) {
|
|
285
|
+
err(t, k, `${q(name, k)} must be a table`)
|
|
286
|
+
return null
|
|
287
|
+
}
|
|
288
|
+
return t[k]
|
|
289
|
+
}
|
|
290
|
+
const text = (t, k, name) => {
|
|
291
|
+
const v = t[k]
|
|
292
|
+
if (v === undefined) return // keys() already reported it missing
|
|
293
|
+
if (typeof v !== 'string' || !v.trim() || CTRL_RE.test(v)) {
|
|
294
|
+
err(t, k, `${q(name, k)} must be a non-empty single-line string`)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const strArray = (t, k, name, { oneOf, unique = false, nonEmpty = false } = {}) => {
|
|
298
|
+
const v = t[k]
|
|
299
|
+
if (v === undefined) return
|
|
300
|
+
if (!Array.isArray(v) || v.some((s) => typeof s !== 'string' || !s || CTRL_RE.test(s))) {
|
|
301
|
+
err(t, k, `${q(name, k)} must be an array of non-empty single-line strings`)
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
if (nonEmpty && v.length === 0) err(t, k, `${q(name, k)} must not be empty`)
|
|
305
|
+
if (unique && new Set(v).size !== v.length) err(t, k, `${q(name, k)} must not repeat a value`)
|
|
306
|
+
if (oneOf) for (const s of v) if (!oneOf.includes(s)) err(t, k, `${q(name, k)}: unknown value "${s}"`)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!isTable(m)) return [`${label}: manifest must be a table`]
|
|
310
|
+
keys(m, '', ['mod', 'footprint', 'harnesses'])
|
|
311
|
+
|
|
312
|
+
const mod = Object.hasOwn(m, 'mod') ? sub(m, 'mod', '') : null
|
|
313
|
+
if (mod) {
|
|
314
|
+
keys(mod, 'mod', ['id', 'name', 'description', 'version', 'reach_level', 'events'])
|
|
315
|
+
if (typeof mod.id === 'string') {
|
|
316
|
+
if (!ID_RE.test(mod.id)) err(mod, 'id', 'mod.id must be kebab-case (^[a-z0-9]+(-[a-z0-9]+)*$)')
|
|
317
|
+
else if (mod.id !== dirName) err(mod, 'id', `mod.id "${mod.id}" must equal the directory name "${dirName}"`)
|
|
318
|
+
} else if (mod.id !== undefined) err(mod, 'id', 'mod.id must be a string')
|
|
319
|
+
text(mod, 'name', 'mod')
|
|
320
|
+
text(mod, 'description', 'mod')
|
|
321
|
+
if (mod.version !== undefined && !(typeof mod.version === 'string' && VERSION_RE.test(mod.version))) {
|
|
322
|
+
err(mod, 'version', 'mod.version must match ^\\d+\\.\\d+\\.\\d+$')
|
|
323
|
+
}
|
|
324
|
+
if (mod.reach_level !== undefined && !(Number.isInteger(mod.reach_level) && mod.reach_level >= 0 && mod.reach_level <= 3)) {
|
|
325
|
+
err(mod, 'reach_level', 'mod.reach_level must be an integer 0..3')
|
|
326
|
+
}
|
|
327
|
+
strArray(mod, 'events', 'mod', { oneOf: EVENTS, unique: true, nonEmpty: true })
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const fp = Object.hasOwn(m, 'footprint') ? sub(m, 'footprint', '') : null
|
|
331
|
+
if (fp) {
|
|
332
|
+
const names = ['env_reads', 'file_writes', 'spawns', 'egress_hosts']
|
|
333
|
+
keys(fp, 'footprint', names)
|
|
334
|
+
for (const k of names) strArray(fp, k, 'footprint')
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const hs = Object.hasOwn(m, 'harnesses') ? sub(m, 'harnesses', '') : null
|
|
338
|
+
if (hs) {
|
|
339
|
+
keys(hs, 'harnesses', HARNESSES)
|
|
340
|
+
for (const h of HARNESSES) {
|
|
341
|
+
if (!Object.hasOwn(hs, h)) continue
|
|
342
|
+
const t = sub(hs, h, 'harnesses')
|
|
343
|
+
if (!t) continue
|
|
344
|
+
const name = `harnesses.${h}`
|
|
345
|
+
if (typeof t.supported !== 'boolean') {
|
|
346
|
+
err(t, Object.hasOwn(t, 'supported') ? 'supported' : '', `${name}.supported must be a boolean`)
|
|
347
|
+
continue
|
|
348
|
+
}
|
|
349
|
+
if (!t.supported) {
|
|
350
|
+
keys(t, name, ['supported', 'reason'])
|
|
351
|
+
text(t, 'reason', name)
|
|
352
|
+
continue
|
|
353
|
+
}
|
|
354
|
+
const claude = h === 'claude-code'
|
|
355
|
+
keys(t, name, claude ? ['supported', 'events', 'surfaces'] : ['supported', 'events'], claude ? ['unsupported', 'degraded'] : ['requires', 'unsupported', 'degraded'])
|
|
356
|
+
strArray(t, 'events', name, { oneOf: EVENTS, unique: true })
|
|
357
|
+
if (claude) strArray(t, 'surfaces', name, { oneOf: SURFACES, unique: true })
|
|
358
|
+
else strArray(t, 'requires', name)
|
|
359
|
+
const allowed = claude ? [...EVENTS, ...SURFACES] : EVENTS
|
|
360
|
+
for (const k of ['unsupported', 'degraded']) {
|
|
361
|
+
if (!Object.hasOwn(t, k)) continue
|
|
362
|
+
const st = sub(t, k, name)
|
|
363
|
+
if (!st) continue
|
|
364
|
+
for (const key of Object.keys(st)) {
|
|
365
|
+
if (!allowed.includes(key)) err(st, key, `${name}.${k}.${key}: key must be one of ${allowed.join(', ')}`)
|
|
366
|
+
else text(st, key, `${name}.${k}`)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return errs
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------------------------------------
|
|
375
|
+
// Loading (the only disk reads)
|
|
376
|
+
// ---------------------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
// Check components without following symlinks, before any read, mkdir or removal.
|
|
379
|
+
function safePath(root, rel) {
|
|
380
|
+
const base = path.resolve(root)
|
|
381
|
+
const abs = path.resolve(base, rel)
|
|
382
|
+
const relative = path.relative(base, abs)
|
|
383
|
+
if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) {
|
|
384
|
+
throw new Error(`${rel}: escapes the repo`)
|
|
385
|
+
}
|
|
386
|
+
let current = base
|
|
387
|
+
for (const part of relative.split(path.sep).filter(Boolean)) {
|
|
388
|
+
current = path.join(current, part)
|
|
389
|
+
try {
|
|
390
|
+
if (lstatSync(current).isSymbolicLink()) throw new Error(`${rel}: symlink in repo path`)
|
|
391
|
+
} catch (e) {
|
|
392
|
+
if (e.code === 'ENOENT') break
|
|
393
|
+
throw e
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return abs
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function readText(root, rel) {
|
|
400
|
+
const buf = readFileSync(safePath(root, rel))
|
|
401
|
+
const s = buf.toString('utf8')
|
|
402
|
+
if (!Buffer.from(s, 'utf8').equals(buf)) throw new Error('not valid UTF-8, so it cannot be copied byte for byte')
|
|
403
|
+
return s
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function modDirs(root, errors) {
|
|
407
|
+
try {
|
|
408
|
+
return readdirSync(safePath(root, 'mods'), { withFileTypes: true })
|
|
409
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
410
|
+
.map((e) => e.name)
|
|
411
|
+
.sort(cmp)
|
|
412
|
+
} catch (e) {
|
|
413
|
+
errors.push(e.code === 'ENOENT' ? 'mods/: directory is missing' : e.message)
|
|
414
|
+
return []
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Parse (no schema check) every mods/<dir>/mod.toml. */
|
|
419
|
+
function readManifests(root) {
|
|
420
|
+
const errors = []
|
|
421
|
+
const entries = []
|
|
422
|
+
for (const dir of modDirs(root, errors)) {
|
|
423
|
+
const label = `mods/${dir}/mod.toml`
|
|
424
|
+
try {
|
|
425
|
+
entries.push({ dir, label, manifest: parseToml(readText(root, label), label) })
|
|
426
|
+
} catch (e) {
|
|
427
|
+
errors.push(e.code === 'ENOENT' ? `${label}: missing` : e.message.startsWith(label) ? e.message : `${label}: ${e.message}`)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return { entries, errors }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const usesLiveAgents = (patterns) => Array.isArray(patterns) && patterns.some((p) => Array.isArray(p?.agents))
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Read and validate every mod. Mods with errors are left out of `mods`; every error is
|
|
437
|
+
* collected rather than thrown. `ctx` carries the shared inputs renderMod needs:
|
|
438
|
+
* { common, runtime, agentIds } (agentIds is null unless a mod needs the live-agent lists).
|
|
439
|
+
*/
|
|
440
|
+
export function loadMods(root = ROOT) {
|
|
441
|
+
const { entries, errors } = readManifests(root)
|
|
442
|
+
const mods = []
|
|
443
|
+
for (const { dir, label, manifest } of entries) {
|
|
444
|
+
let ok = true
|
|
445
|
+
const report = (msgs) => {
|
|
446
|
+
errors.push(...msgs)
|
|
447
|
+
if (msgs.length) ok = false
|
|
448
|
+
}
|
|
449
|
+
report(validateManifest(manifest, dir, label))
|
|
450
|
+
let patterns = null
|
|
451
|
+
const pLabel = `mods/${dir}/patterns.json`
|
|
452
|
+
try {
|
|
453
|
+
const p = JSON.parse(readText(root, pLabel))
|
|
454
|
+
if (!Array.isArray(p) || p.some((e) => !isTable(e))) throw new Error('must be an array of objects')
|
|
455
|
+
for (const e of p) {
|
|
456
|
+
if (Object.hasOwn(e, 'agents') && !(Array.isArray(e.agents) && e.agents.every((a) => typeof a === 'string'))) {
|
|
457
|
+
throw new Error(`pattern ${e.id}: agents must be an array of strings`)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
patterns = p
|
|
461
|
+
} catch (e) {
|
|
462
|
+
if (e.code !== 'ENOENT') report([`${pLabel}: ${e.message}`])
|
|
463
|
+
}
|
|
464
|
+
if (ok) mods.push({ id: dir, dir: `mods/${dir}`, manifest, patterns })
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const ctx = { common: null, runtime: null, agentIds: null }
|
|
468
|
+
for (const k of ['common', 'runtime']) {
|
|
469
|
+
const rel = `scripts/mods/${k}.mjs`
|
|
470
|
+
try {
|
|
471
|
+
ctx[k] = readText(root, rel)
|
|
472
|
+
} catch (e) {
|
|
473
|
+
errors.push(`${rel}: ${e.code === 'ENOENT' ? 'missing' : e.message}`)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (mods.some((m) => usesLiveAgents(m.patterns))) {
|
|
477
|
+
try {
|
|
478
|
+
const agents = JSON.parse(readText(root, 'catalog/agents.json'))
|
|
479
|
+
if (!Array.isArray(agents)) throw new Error('must be an array')
|
|
480
|
+
ctx.agentIds = agents.map((a) => a?.id).filter((id) => typeof id === 'string')
|
|
481
|
+
} catch (e) {
|
|
482
|
+
errors.push(`catalog/agents.json: ${e.code === 'ENOENT' ? 'missing' : e.message}`)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return { mods, ctx, errors }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---------------------------------------------------------------------------------------
|
|
489
|
+
// Rendering (pure)
|
|
490
|
+
// ---------------------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
const inCanonicalOrder = (events) => EVENTS.filter((e) => events.includes(e))
|
|
493
|
+
|
|
494
|
+
function hookEntry(command) {
|
|
495
|
+
return [{ hooks: [{ type: 'command', command }] }]
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function hooksByEvent(events, command) {
|
|
499
|
+
return { hooks: Object.fromEntries(inCanonicalOrder(events).map((e) => [e, hookEntry(command(e))])) }
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function renderUnsupported(manifest, h) {
|
|
503
|
+
const t = manifest.harnesses[h]
|
|
504
|
+
const unsupported = t.unsupported ?? {}
|
|
505
|
+
const degraded = t.degraded ?? {}
|
|
506
|
+
if (t.supported && !Object.keys(unsupported).length && !Object.keys(degraded).length) return null
|
|
507
|
+
const blocks = [
|
|
508
|
+
`# ${manifest.mod.name} on ${HARNESS_NAMES[h]}`,
|
|
509
|
+
'<!-- Generated by scripts/generate-mods.mjs from mod.toml. Do not edit. -->',
|
|
510
|
+
]
|
|
511
|
+
if (!t.supported) blocks.push(`## Not supported\n\n${t.reason}`)
|
|
512
|
+
for (const [title, table] of [['Unsupported', unsupported], ['Degraded', degraded]]) {
|
|
513
|
+
const ks = Object.keys(table).sort(cmp)
|
|
514
|
+
if (ks.length) blocks.push(`## ${title}\n\n${ks.map((k) => `- \`${k}\`: ${table[k]}`).join('\n')}`)
|
|
515
|
+
}
|
|
516
|
+
return blocks.join('\n\n') + '\n'
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Every generated file of one mod: Map<repo-relative path, content>. */
|
|
520
|
+
export function renderMod(mod, ctx) {
|
|
521
|
+
if (typeof ctx?.common !== 'string' || typeof ctx?.runtime !== 'string') {
|
|
522
|
+
throw new TypeError('renderMod: ctx.common and ctx.runtime must be strings (see loadMods)')
|
|
523
|
+
}
|
|
524
|
+
const { id, manifest: m, patterns } = mod
|
|
525
|
+
const out = new Map()
|
|
526
|
+
const put = (rel, content) => out.set(`mods/${id}/${rel}`, content)
|
|
527
|
+
|
|
528
|
+
put('lib/common.mjs', ctx.common)
|
|
529
|
+
put('lib/runtime.mjs', ctx.runtime)
|
|
530
|
+
|
|
531
|
+
if (patterns) {
|
|
532
|
+
put('lib/patterns.mjs', `${GEN_HEADER} from patterns.json. Do not edit.\nexport default ${JSON.stringify(patterns, null, 2)};\n`)
|
|
533
|
+
}
|
|
534
|
+
if (usesLiveAgents(patterns)) {
|
|
535
|
+
if (!Array.isArray(ctx.agentIds)) throw new TypeError('renderMod: ctx.agentIds is required for a mod with agent-bearing patterns')
|
|
536
|
+
const selected = [...new Set(ctx.agentIds.filter((a) => LIVE_AGENT_RE.test(a)))].sort(cmp)
|
|
537
|
+
const known = new Set(selected)
|
|
538
|
+
const covered = new Set()
|
|
539
|
+
const errors = []
|
|
540
|
+
for (const p of patterns) {
|
|
541
|
+
for (const a of Array.isArray(p.agents) ? p.agents : []) {
|
|
542
|
+
if (known.has(a)) covered.add(a)
|
|
543
|
+
else errors.push(`mods/${id}/patterns.json: pattern ${p.id} references "${a}", which is not a live guard agent in catalog/agents.json`)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (errors.length) throw aggregate(errors)
|
|
547
|
+
const coveredList = [...covered].sort(cmp)
|
|
548
|
+
const uncovered = selected.filter((a) => !covered.has(a))
|
|
549
|
+
put(
|
|
550
|
+
'lib/live-agents.mjs',
|
|
551
|
+
`${GEN_HEADER} from patterns.json and catalog/agents.json. Do not edit.\n` +
|
|
552
|
+
`export const covered = ${JSON.stringify(coveredList, null, 2)};\n` +
|
|
553
|
+
`export const uncovered = ${JSON.stringify(uncovered, null, 2)};\n`,
|
|
554
|
+
)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const cc = m.harnesses['claude-code']
|
|
558
|
+
if (cc.supported && cc.surfaces.includes('native')) {
|
|
559
|
+
put('.claude-plugin/plugin.json', json({ name: id, version: m.mod.version, description: m.mod.description }))
|
|
560
|
+
put('hooks/hooks.json', json({ modules: ['./register.ts'] }))
|
|
561
|
+
}
|
|
562
|
+
if (cc.supported && cc.surfaces.includes('classic')) {
|
|
563
|
+
put(
|
|
564
|
+
'claude-code/settings.hooks.json',
|
|
565
|
+
json(hooksByEvent(cc.events, (e) => `node "$CLAUDE_PROJECT_DIR/.claude/vanguard-mods/${id}/bin/hook.mjs" --harness claude-code --event ${e}`)),
|
|
566
|
+
)
|
|
567
|
+
}
|
|
568
|
+
if (m.harnesses.kiro.supported) {
|
|
569
|
+
for (const e of inCanonicalOrder(m.harnesses.kiro.events)) {
|
|
570
|
+
put(
|
|
571
|
+
`kiro/${id}-${EV_KEBAB[e]}-hook.json`,
|
|
572
|
+
json({
|
|
573
|
+
version: 'v1',
|
|
574
|
+
hooks: [
|
|
575
|
+
{
|
|
576
|
+
name: `${id}:${e}`,
|
|
577
|
+
description: m.mod.description,
|
|
578
|
+
trigger: e,
|
|
579
|
+
action: { type: 'command', command: `node .kiro/vanguard-mods/${id}/bin/hook.mjs --harness kiro --event ${e}` },
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
}),
|
|
583
|
+
)
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (m.harnesses.codex.supported) {
|
|
587
|
+
put('codex/hooks.json', json(hooksByEvent(m.harnesses.codex.events, (e) => `node .codex/vanguard-mods/${id}/bin/hook.mjs --harness codex --event ${e}`)))
|
|
588
|
+
}
|
|
589
|
+
for (const h of HARNESSES) {
|
|
590
|
+
const md = renderUnsupported(m, h)
|
|
591
|
+
if (md) put(`${h}/UNSUPPORTED.md`, md)
|
|
592
|
+
}
|
|
593
|
+
return new Map([...out].sort((a, b) => cmp(a[0], b[0])))
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Every generated file for all mods plus catalog/mods.json, sorted by path; input order is irrelevant. */
|
|
597
|
+
export function renderAll(mods, ctx) {
|
|
598
|
+
const sorted = [...mods].sort((a, b) => cmp(a.id, b.id))
|
|
599
|
+
const files = new Map()
|
|
600
|
+
const errors = []
|
|
601
|
+
for (const mod of sorted) {
|
|
602
|
+
try {
|
|
603
|
+
for (const [k, v] of renderMod(mod, ctx)) files.set(k, v)
|
|
604
|
+
} catch (e) {
|
|
605
|
+
errors.push(...(e.errors ?? [e.message]))
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (errors.length) throw aggregate(errors)
|
|
609
|
+
files.set(
|
|
610
|
+
'catalog/mods.json',
|
|
611
|
+
json(
|
|
612
|
+
sorted.map(({ id, manifest: m }) => ({
|
|
613
|
+
id,
|
|
614
|
+
name: m.mod.name,
|
|
615
|
+
version: m.mod.version,
|
|
616
|
+
description: m.mod.description,
|
|
617
|
+
reach_level: m.mod.reach_level,
|
|
618
|
+
events: [...m.mod.events],
|
|
619
|
+
harnesses: HARNESSES.filter((h) => m.harnesses[h].supported).sort(cmp),
|
|
620
|
+
path: `mods/${id}`,
|
|
621
|
+
footprint: {
|
|
622
|
+
env_reads: [...m.footprint.env_reads],
|
|
623
|
+
file_writes: [...m.footprint.file_writes],
|
|
624
|
+
spawns: [...m.footprint.spawns],
|
|
625
|
+
egress_hosts: [...m.footprint.egress_hosts],
|
|
626
|
+
},
|
|
627
|
+
})),
|
|
628
|
+
),
|
|
629
|
+
)
|
|
630
|
+
return new Map([...files].sort((a, b) => cmp(a[0], b[0])))
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ---------------------------------------------------------------------------------------
|
|
634
|
+
// Disk sync
|
|
635
|
+
// ---------------------------------------------------------------------------------------
|
|
636
|
+
|
|
637
|
+
const exists = (abs) => {
|
|
638
|
+
try {
|
|
639
|
+
lstatSync(abs)
|
|
640
|
+
return true
|
|
641
|
+
} catch {
|
|
642
|
+
return false
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function walkFiles(root, rel, found = []) {
|
|
647
|
+
const abs = safePath(root, rel)
|
|
648
|
+
let entries
|
|
649
|
+
try {
|
|
650
|
+
entries = readdirSync(abs, { withFileTypes: true })
|
|
651
|
+
} catch {
|
|
652
|
+
return found
|
|
653
|
+
}
|
|
654
|
+
for (const e of entries) {
|
|
655
|
+
const child = `${rel}/${e.name}`
|
|
656
|
+
safePath(root, child)
|
|
657
|
+
if (e.isDirectory()) walkFiles(root, child, found)
|
|
658
|
+
else found.push(child) // files, symlinks and anything else count as files
|
|
659
|
+
}
|
|
660
|
+
return found
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Generated paths on disk that the current render no longer produces. */
|
|
664
|
+
function findStale(root, ids, expected) {
|
|
665
|
+
const stale = []
|
|
666
|
+
for (const id of ids) {
|
|
667
|
+
const base = `mods/${id}`
|
|
668
|
+
for (const d of OWNED_DIRS) stale.push(...walkFiles(root, `${base}/${d}`))
|
|
669
|
+
for (const [d, names] of Object.entries(OWNED_FILES)) {
|
|
670
|
+
for (const name of names) if (exists(safePath(root, `${base}/${d}/${name}`))) stale.push(`${base}/${d}/${name}`)
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return stale.filter((rel) => !expected.has(rel)).sort(cmp)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function findDrift(root, expected) {
|
|
677
|
+
const drift = []
|
|
678
|
+
for (const [rel, content] of expected) {
|
|
679
|
+
const abs = safePath(root, rel)
|
|
680
|
+
let actual = null
|
|
681
|
+
try {
|
|
682
|
+
actual = readFileSync(abs)
|
|
683
|
+
} catch {
|
|
684
|
+
// missing or unreadable counts as drift
|
|
685
|
+
}
|
|
686
|
+
if (!actual || !actual.equals(Buffer.from(content, 'utf8'))) drift.push(rel)
|
|
687
|
+
}
|
|
688
|
+
return drift
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function safeWrite(root, realRoot, rel, content) {
|
|
692
|
+
const abs = safePath(root, rel)
|
|
693
|
+
mkdirSync(path.dirname(abs), { recursive: true })
|
|
694
|
+
const dir = realpathSync(path.dirname(abs))
|
|
695
|
+
if (dir !== realRoot && !dir.startsWith(realRoot + path.sep)) throw new Error(`${rel}: resolves outside the repo`)
|
|
696
|
+
if (exists(abs) && lstatSync(abs).isSymbolicLink()) throw new Error(`${rel}: is a symlink; refusing to write through it`)
|
|
697
|
+
writeFileSync(abs, content)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function pruneEmpty(abs) {
|
|
701
|
+
let entries
|
|
702
|
+
try {
|
|
703
|
+
entries = readdirSync(abs, { withFileTypes: true })
|
|
704
|
+
} catch {
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
for (const e of entries) if (e.isDirectory()) pruneEmpty(path.join(abs, e.name))
|
|
708
|
+
if (readdirSync(abs).length === 0) rmdirSync(abs)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ---------------------------------------------------------------------------------------
|
|
712
|
+
// CLI
|
|
713
|
+
// ---------------------------------------------------------------------------------------
|
|
714
|
+
|
|
715
|
+
export function main(argv = process.argv.slice(2), root = ROOT) {
|
|
716
|
+
const modes = ['--check', '--print-manifests']
|
|
717
|
+
if (argv.length > 1 || (argv.length === 1 && !modes.includes(argv[0]))) {
|
|
718
|
+
console.error('Usage: node scripts/generate-mods.mjs [--check | --print-manifests]')
|
|
719
|
+
return 2
|
|
720
|
+
}
|
|
721
|
+
const fail = (errors) => {
|
|
722
|
+
for (const e of errors) console.error(e)
|
|
723
|
+
return 1
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (argv[0] === '--print-manifests') {
|
|
727
|
+
const { entries, errors } = readManifests(root)
|
|
728
|
+
if (errors.length) return fail(errors)
|
|
729
|
+
console.log(JSON.stringify(Object.fromEntries(entries.map((e) => [e.dir, e.manifest])), null, 2))
|
|
730
|
+
return 0
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const { mods, ctx, errors } = loadMods(root)
|
|
734
|
+
let expected = new Map()
|
|
735
|
+
if (ctx.common !== null && ctx.runtime !== null) {
|
|
736
|
+
try {
|
|
737
|
+
expected = renderAll(mods, ctx)
|
|
738
|
+
} catch (e) {
|
|
739
|
+
errors.push(...(e.errors ?? [e.message]))
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (errors.length) return fail(errors)
|
|
743
|
+
|
|
744
|
+
let drift, stale
|
|
745
|
+
try {
|
|
746
|
+
drift = findDrift(root, expected)
|
|
747
|
+
stale = findStale(root, mods.map((m) => m.id), expected)
|
|
748
|
+
} catch (e) {
|
|
749
|
+
return fail([e.message])
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (argv[0] === '--check') {
|
|
753
|
+
const paths = [...new Set([...drift, ...stale])].sort(cmp)
|
|
754
|
+
if (paths.length === 0) {
|
|
755
|
+
console.log('OK: mods in sync')
|
|
756
|
+
return 0
|
|
757
|
+
}
|
|
758
|
+
console.error(`FAIL: mods out of sync (${paths.length} paths). Run: npm run mods:write`)
|
|
759
|
+
for (const p of paths) console.error(` ${p}`)
|
|
760
|
+
return 1
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
try {
|
|
764
|
+
const realRoot = realpathSync(root)
|
|
765
|
+
for (const rel of drift) safeWrite(root, realRoot, rel, expected.get(rel))
|
|
766
|
+
for (const rel of stale) unlinkSync(path.join(root, rel))
|
|
767
|
+
for (const m of mods) for (const d of OWNED_DIRS) pruneEmpty(path.join(root, 'mods', m.id, d))
|
|
768
|
+
} catch (e) {
|
|
769
|
+
return fail([e.message])
|
|
770
|
+
}
|
|
771
|
+
console.log(`Generated ${mods.length} mods for ${HARNESSES.length} harnesses`)
|
|
772
|
+
return 0
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function isEntryPoint() {
|
|
776
|
+
if (!process.argv[1]) return false
|
|
777
|
+
try {
|
|
778
|
+
return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href
|
|
779
|
+
} catch {
|
|
780
|
+
return false
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (isEntryPoint()) process.exitCode = main()
|