@djordje-stojanovic/sigmaskills 0.2.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/CHANGELOG.md +77 -0
- package/LICENSE +21 -0
- package/README.md +342 -0
- package/bin/sigmaskills.js +7 -0
- package/manifest.json +28 -0
- package/package.json +42 -0
- package/registry/agent-hosts.json +2404 -0
- package/registry/schema.json +110 -0
- package/registry/skill-baselines.json +4 -0
- package/registry/source.json +6 -0
- package/sigmabrief/SKILL.md +56 -0
- package/sigmabrief/agents/openai.yaml +12 -0
- package/sigmabrief/references/brief-method.md +73 -0
- package/sigmabrief/references/prompt-contract.md +174 -0
- package/sigmaperformance/SKILL.md +118 -0
- package/sigmaperformance/agents/openai.yaml +12 -0
- package/sigmaperformance/references/audit-method.md +112 -0
- package/sigmaperformance/references/calibration.md +45 -0
- package/sigmaperformance/references/report-contract.md +103 -0
- package/sigmareview/SKILL.md +133 -0
- package/sigmareview/agents/openai.yaml +12 -0
- package/sigmareview/references/report-contract.md +217 -0
- package/sigmareview/references/review-method.md +233 -0
- package/sigmawrite/SKILL.md +45 -0
- package/sigmawrite/agents/openai.yaml +12 -0
- package/src/adoption.js +370 -0
- package/src/backup.js +398 -0
- package/src/catalog.js +211 -0
- package/src/cli.js +657 -0
- package/src/customization.js +344 -0
- package/src/destinations.js +491 -0
- package/src/interactive.js +959 -0
- package/src/links.js +157 -0
- package/src/plan.js +429 -0
- package/src/prepack.js +10 -0
- package/src/project-lock.js +169 -0
- package/src/purge.js +477 -0
- package/src/registry/automation-ci.js +411 -0
- package/src/registry/automation.js +554 -0
- package/src/registry/diff.js +149 -0
- package/src/registry/normalize.js +67 -0
- package/src/registry/parse.js +184 -0
- package/src/registry/sync.js +230 -0
- package/src/registry/validate.js +223 -0
- package/src/release-ci.js +12 -0
- package/src/release.js +837 -0
- package/src/restore.js +518 -0
- package/src/revision.js +82 -0
- package/src/state.js +480 -0
- package/src/status.js +469 -0
- package/src/transaction.js +636 -0
- package/src/uninstall.js +647 -0
- package/src/update.js +815 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* normalize.js — turn parsed upstream host records into the canonical,
|
|
5
|
+
* versioned, attributed registry snapshot consumed by the installer.
|
|
6
|
+
*
|
|
7
|
+
* Only declarative data survives normalization. Detection function bodies
|
|
8
|
+
* are reduced to env-var metadata; destination formulas are kept as safe
|
|
9
|
+
* declarative records (kind: literal | join | conditional | none). Nothing
|
|
10
|
+
* is evaluated. Hosts are sorted bytewise by id so the emitted JSON is
|
|
11
|
+
* reproducible regardless of host locale or ICU settings.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Bytewise (code-point) id comparator, locale-independent. Guarantees the
|
|
16
|
+
* snapshot ordering is identical on every machine and Node build.
|
|
17
|
+
*/
|
|
18
|
+
export function compareIds(a, b) {
|
|
19
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Normalize a parsed host record into a canonical registry entry.
|
|
24
|
+
* Adds attribution (upstream file + line) and a stable normalized shape.
|
|
25
|
+
*/
|
|
26
|
+
export function normalizeHost(host, source) {
|
|
27
|
+
return {
|
|
28
|
+
id: host.id,
|
|
29
|
+
name: host.name,
|
|
30
|
+
displayName: host.displayName,
|
|
31
|
+
universal: host.universal,
|
|
32
|
+
universalPrompt: host.universalPrompt,
|
|
33
|
+
destinations: {
|
|
34
|
+
project: host.project ?? { kind: 'none', raw: undefined },
|
|
35
|
+
global: host.global ?? { kind: 'none', raw: undefined },
|
|
36
|
+
},
|
|
37
|
+
aliases: host.aliases ?? [],
|
|
38
|
+
platforms: host.platforms ?? [],
|
|
39
|
+
detection: {
|
|
40
|
+
envVars: host.envVars ?? [],
|
|
41
|
+
},
|
|
42
|
+
attribution: {
|
|
43
|
+
upstreamFile: source.upstreamFile ?? source.file,
|
|
44
|
+
upstreamLine: host.line,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the canonical snapshot container.
|
|
51
|
+
* @param {Array} hosts normalized host records
|
|
52
|
+
* @param {object} source pin record { repository, pinnedRevision, upstreamFile, fetchedAt? }
|
|
53
|
+
*/
|
|
54
|
+
export function buildSnapshot(hosts, source) {
|
|
55
|
+
const sorted = [...hosts].sort((a, b) => compareIds(a.id, b.id));
|
|
56
|
+
const generatedFrom = {
|
|
57
|
+
repository: source.repository,
|
|
58
|
+
pinnedRevision: source.pinnedRevision ?? source.revision,
|
|
59
|
+
upstreamFile: source.upstreamFile ?? source.file,
|
|
60
|
+
};
|
|
61
|
+
if (source.fetchedAt) generatedFrom.fetchedAt = source.fetchedAt;
|
|
62
|
+
return {
|
|
63
|
+
schemaVersion: 1,
|
|
64
|
+
generatedFrom,
|
|
65
|
+
hosts: sorted,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* parse.js — extract declarative Agent Host data from upstream registry text.
|
|
5
|
+
*
|
|
6
|
+
* Sigma never executes upstream TypeScript, detector, or path-building code.
|
|
7
|
+
* This parser reads upstream `agents.ts` as untrusted text and extracts only
|
|
8
|
+
* literal, declarative fields: host ids, display names, project/global
|
|
9
|
+
* destination formulas, aliases, platform declarations, and env-var
|
|
10
|
+
* detection metadata. Conditional helpers are extracted as declarative
|
|
11
|
+
* case lists (probe + formula), never evaluated.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Env-var names referenced in a text fragment (metadata, never evaluated). */
|
|
15
|
+
export function extractEnvVars(text) {
|
|
16
|
+
const names = new Set();
|
|
17
|
+
const re = /(?:env|process\.env)\.([A-Z][A-Z0-9_]*)/g;
|
|
18
|
+
let m;
|
|
19
|
+
while ((m = re.exec(text)) !== null) names.add(m[1]);
|
|
20
|
+
return [...names].sort();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Capture the value of a single-line field like ` skillsDir: '...',`. */
|
|
24
|
+
function field(body, name) {
|
|
25
|
+
const re = new RegExp('^\\s*' + name + '\\s*:\\s*([^\\n]+)', 'm');
|
|
26
|
+
const m = re.exec(body);
|
|
27
|
+
if (!m) return null;
|
|
28
|
+
let value = m[1].trim();
|
|
29
|
+
value = value.replace(/,$/, '').trim();
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Unquote a single-quoted upstream literal. Returns null when malformed. */
|
|
34
|
+
function quoted(value) {
|
|
35
|
+
const m = /^'((?:[^'\\]|\\.)*)'$/.exec(value.trim());
|
|
36
|
+
return m ? m[1] : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const JOIN_RE = /^join\(([A-Za-z0-9_.]+)((?:,\s*'((?:[^'\\]|\\.)*)')*)\)$/;
|
|
40
|
+
|
|
41
|
+
/** Parse a destination formula. Forms: join(...) | '<path>' | undefined | <fn>(). */
|
|
42
|
+
export function parseDirFormula(value) {
|
|
43
|
+
const raw = value.trim();
|
|
44
|
+
if (!raw) return null;
|
|
45
|
+
if (raw === 'undefined') return { kind: 'none', raw };
|
|
46
|
+
|
|
47
|
+
const quotedPath = quoted(raw);
|
|
48
|
+
if (quotedPath !== null) return { kind: 'literal', path: quotedPath, raw };
|
|
49
|
+
|
|
50
|
+
const join = JOIN_RE.exec(raw);
|
|
51
|
+
if (join) {
|
|
52
|
+
const segmentsRe = /'((?:[^'\\]|\\.)*)'/g;
|
|
53
|
+
const segments = [];
|
|
54
|
+
let sm;
|
|
55
|
+
while ((sm = segmentsRe.exec(raw)) !== null) segments.push(sm[1]);
|
|
56
|
+
return { kind: 'join', base: join[1], segments, raw };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const fnRe = /^([A-Za-z0-9_]+)\(\)$/;
|
|
60
|
+
const fn = fnRe.exec(raw);
|
|
61
|
+
if (fn) return { kind: 'function', name: fn[1], raw };
|
|
62
|
+
|
|
63
|
+
return { kind: 'unknown', raw };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const PROBE_RE = /(?:existsSync|pathExists)\(join\(([A-Za-z0-9_.]+)\s*,\s*'([^']*)'\)\)/g;
|
|
67
|
+
const RETURN_RE = /return\s+(join\([^;]*\)|[^;\n]+);/g;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Declaratively extract a conditional helper `function <name>(...) { ... }`.
|
|
71
|
+
* Returns { kind: 'conditional', base, cases: [{ probe, formula }] } where
|
|
72
|
+
* cases are (probe, return-join) pairs and the final return is the default.
|
|
73
|
+
* Guards and returns are parsed as text; nothing is evaluated.
|
|
74
|
+
*/
|
|
75
|
+
export function parseConditionalHelper(sourceText, name) {
|
|
76
|
+
const anchor = 'function ' + name;
|
|
77
|
+
let idx = sourceText.indexOf(anchor);
|
|
78
|
+
if (idx === -1) return null;
|
|
79
|
+
let p = sourceText.indexOf('(', idx + anchor.length);
|
|
80
|
+
if (p === -1) return null;
|
|
81
|
+
let depth = 1;
|
|
82
|
+
let q = p + 1;
|
|
83
|
+
for (; q < sourceText.length; q++) {
|
|
84
|
+
const ch = sourceText[q];
|
|
85
|
+
if (ch === '(') depth++;
|
|
86
|
+
else if (ch === ')') { depth--; if (depth === 0) break; }
|
|
87
|
+
}
|
|
88
|
+
if (depth !== 0) return null;
|
|
89
|
+
let brace = sourceText.indexOf('{', q);
|
|
90
|
+
if (brace === -1) return null;
|
|
91
|
+
const bodyStart = brace + 1;
|
|
92
|
+
let braceDepth = 1;
|
|
93
|
+
let i = bodyStart;
|
|
94
|
+
for (; i < sourceText.length; i++) {
|
|
95
|
+
const ch = sourceText[i];
|
|
96
|
+
if (ch === '{') braceDepth++;
|
|
97
|
+
else if (ch === '}') {
|
|
98
|
+
braceDepth--;
|
|
99
|
+
if (braceDepth === 0) break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const body = sourceText.slice(bodyStart, i);
|
|
103
|
+
|
|
104
|
+
const probes = [];
|
|
105
|
+
let pm;
|
|
106
|
+
while ((pm = PROBE_RE.exec(body)) !== null) {
|
|
107
|
+
probes.push({ index: pm.index, end: pm.index + pm[0].length, probe: pm[2] });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const cases = [];
|
|
111
|
+
let rm;
|
|
112
|
+
while ((rm = RETURN_RE.exec(body)) !== null) {
|
|
113
|
+
let probe = null;
|
|
114
|
+
for (const p of probes) {
|
|
115
|
+
if (p.end > rm.index) break;
|
|
116
|
+
const between = body.slice(p.end, rm.index);
|
|
117
|
+
if (!between.includes('}')) probe = p.probe;
|
|
118
|
+
}
|
|
119
|
+
const formula = parseDirFormula(rm[1]);
|
|
120
|
+
cases.push({ probe, formula });
|
|
121
|
+
}
|
|
122
|
+
if (cases.length === 0) return null;
|
|
123
|
+
const first = cases.find((c) => c.formula && c.formula.base);
|
|
124
|
+
const base = first ? first.formula.base : null;
|
|
125
|
+
return { kind: 'conditional', base, cases };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Boolean value of an upstream literal boolean field. */
|
|
129
|
+
function bool(value) {
|
|
130
|
+
if (value === null) return null;
|
|
131
|
+
if (value === 'true') return true;
|
|
132
|
+
if (value === 'false') return false;
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Parse one host entry body into a declarative record. */
|
|
137
|
+
function parseHostBody(id, body, line, sourceText) {
|
|
138
|
+
const name = quoted(field(body, 'name')) ?? id;
|
|
139
|
+
const displayName = quoted(field(body, 'displayName')) ?? name;
|
|
140
|
+
const project = parseDirFormula(field(body, 'skillsDir'));
|
|
141
|
+
const globalRaw = field(body, 'globalSkillsDir');
|
|
142
|
+
let global = parseDirFormula(globalRaw);
|
|
143
|
+
if (global && global.kind === 'function') {
|
|
144
|
+
const conditional = parseConditionalHelper(sourceText, global.name);
|
|
145
|
+
if (conditional) global = conditional;
|
|
146
|
+
}
|
|
147
|
+
const aliasesRaw = field(body, 'aliases');
|
|
148
|
+
const aliases = aliasesRaw ? aliasesRaw.replace(/^\[|\]$/g, '').split(',').map((s) => s.trim()) : [];
|
|
149
|
+
const platformsRaw = field(body, 'platforms');
|
|
150
|
+
const platforms = platformsRaw ? platformsRaw.replace(/^\[|\]$/g, '').split(',').map((s) => s.trim()) : [];
|
|
151
|
+
const showInUniversalList = bool(field(body, 'showInUniversalList'));
|
|
152
|
+
const showInUniversalPrompt = bool(field(body, 'showInUniversalPrompt'));
|
|
153
|
+
const envVars = extractEnvVars(body);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
id,
|
|
157
|
+
name,
|
|
158
|
+
displayName,
|
|
159
|
+
project,
|
|
160
|
+
global,
|
|
161
|
+
aliases,
|
|
162
|
+
platforms,
|
|
163
|
+
universal: showInUniversalList !== false,
|
|
164
|
+
universalPrompt: showInUniversalPrompt !== false,
|
|
165
|
+
envVars,
|
|
166
|
+
line,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Parse upstream agents.ts text into declarative host records.
|
|
172
|
+
*/
|
|
173
|
+
export function parseAgents(agentsText) {
|
|
174
|
+
const hosts = [];
|
|
175
|
+
const entryRe = /^ '?([A-Za-z0-9_-]+)'?: \{([\s\S]*?)\n \},$/gm;
|
|
176
|
+
let m;
|
|
177
|
+
while ((m = entryRe.exec(agentsText)) !== null) {
|
|
178
|
+
const id = m[1];
|
|
179
|
+
const body = m[2];
|
|
180
|
+
const line = 1 + agentsText.slice(0, m.index).split('\n').length;
|
|
181
|
+
hosts.push(parseHostBody(id, body, line, agentsText));
|
|
182
|
+
}
|
|
183
|
+
return hosts;
|
|
184
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* sync.js — deterministic registry synchronization command.
|
|
5
|
+
*
|
|
6
|
+
* node src/registry/sync.js [--fetch] [--dry-run] [--allow-review]
|
|
7
|
+
*
|
|
8
|
+
* Reads upstream `agents.ts` (the pinned revision recorded in
|
|
9
|
+
* `registry/source.json`), verifies its content hash against the recorded
|
|
10
|
+
* pin, parses only declarative fields, normalizes into the canonical
|
|
11
|
+
* attributed snapshot, validates, and emits a normalized semantic diff
|
|
12
|
+
* against the existing snapshot. Unless --dry-run, writes the snapshot only
|
|
13
|
+
* when there are no review-classified changes, or when --allow-review is
|
|
14
|
+
* passed. `--fetch` downloads the pinned revision from the upstream
|
|
15
|
+
* repository and saves it as the local pinned fixture before syncing.
|
|
16
|
+
*
|
|
17
|
+
* The default pin is the real immutable upstream commit recorded in
|
|
18
|
+
* `registry/source.json`; a pseudo-pin is rejected so a release can never
|
|
19
|
+
* masquerade as synchronized from upstream.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import crypto from 'node:crypto';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { parseAgents } from './parse.js';
|
|
27
|
+
import { normalizeHost, buildSnapshot } from './normalize.js';
|
|
28
|
+
import { validateSnapshot } from './validate.js';
|
|
29
|
+
import { diffSnapshots } from './diff.js';
|
|
30
|
+
|
|
31
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
33
|
+
const REGISTRY_DIR = path.join(ROOT, 'registry');
|
|
34
|
+
const FIXTURE_PATH = path.join(ROOT, 'test', 'fixtures', 'vercel-skills', 'src', 'agents.ts');
|
|
35
|
+
const PIN_PATH = path.join(REGISTRY_DIR, 'source.json');
|
|
36
|
+
const SNAPSHOT_PATH = path.join(REGISTRY_DIR, 'agent-hosts.json');
|
|
37
|
+
|
|
38
|
+
const REVISION_PATTERN = /^[0-9a-f]{40}$/;
|
|
39
|
+
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
40
|
+
|
|
41
|
+
function sha256(text) {
|
|
42
|
+
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function canonicalSourceText(text) {
|
|
46
|
+
return String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseArgs(argv) {
|
|
50
|
+
const args = { fetch: false, dryRun: false, allowReview: false, source: null };
|
|
51
|
+
for (let i = 0; i < argv.length; i++) {
|
|
52
|
+
const arg = argv[i];
|
|
53
|
+
if (arg === '--dry-run') args.dryRun = true;
|
|
54
|
+
else if (arg === '--allow-review') args.allowReview = true;
|
|
55
|
+
else if (arg === '--fetch') args.fetch = true;
|
|
56
|
+
else if (arg === '--source' && i + 1 < argv.length) args.source = argv[++i];
|
|
57
|
+
}
|
|
58
|
+
return args;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readJson(file) {
|
|
62
|
+
if (!fs.existsSync(file)) return null;
|
|
63
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function writeJson(file, value) {
|
|
67
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
68
|
+
fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', 'utf8');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Load the pinned source record and reject pseudo-pins.
|
|
73
|
+
* @returns {object} pin { repository, pinnedRevision, upstreamFile, contentSha256 }
|
|
74
|
+
*/
|
|
75
|
+
export function loadPin(pinPath = PIN_PATH) {
|
|
76
|
+
const pin = readJson(pinPath);
|
|
77
|
+
if (!pin || !pin.pinnedRevision || !REVISION_PATTERN.test(pin.pinnedRevision)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`registry/source.json must pin an immutable 40-hex upstream revision; got ${JSON.stringify(pin && pin.pinnedRevision)}`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (typeof pin.repository !== 'string' || pin.repository.length === 0) {
|
|
83
|
+
throw new Error('registry/source.json must record the upstream repository URL');
|
|
84
|
+
}
|
|
85
|
+
if (!pin.upstreamFile || !pin.contentSha256 || !SHA256_PATTERN.test(pin.contentSha256)) {
|
|
86
|
+
throw new Error('registry/source.json must record the upstream file path and contentSha256');
|
|
87
|
+
}
|
|
88
|
+
return pin;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Fetch the pinned upstream file from the repository at the pinned revision
|
|
93
|
+
* and save it as the local pinned fixture. Verifies the content hash.
|
|
94
|
+
* @param {object} pin loaded pin record
|
|
95
|
+
* @param {string} [fixturePath] where to save the fetched content
|
|
96
|
+
*/
|
|
97
|
+
export async function fetchPinnedSource(pin, fixturePath = FIXTURE_PATH) {
|
|
98
|
+
const url = `https://raw.githubusercontent.com/${pin.repository}/${pin.pinnedRevision}/${pin.upstreamFile}`;
|
|
99
|
+
let res;
|
|
100
|
+
try {
|
|
101
|
+
res = await fetch(url);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
throw new Error(`failed to fetch ${url}: ${err.message}`);
|
|
104
|
+
}
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
throw new Error(`failed to fetch ${url}: HTTP ${res.status}`);
|
|
107
|
+
}
|
|
108
|
+
const text = canonicalSourceText(await res.text());
|
|
109
|
+
const digest = sha256(text);
|
|
110
|
+
if (digest !== pin.contentSha256) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`fetched content hash ${digest} does not match pinned contentSha256 ${pin.contentSha256} for ${pin.pinnedRevision}`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
fs.mkdirSync(path.dirname(fixturePath), { recursive: true });
|
|
116
|
+
fs.writeFileSync(fixturePath, text, 'utf8');
|
|
117
|
+
return text;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Synchronize the registry from the pinned upstream source.
|
|
122
|
+
*
|
|
123
|
+
* @param {object} args { fetch, dryRun, allowReview, source? }
|
|
124
|
+
* @param {object} [options] { pin?, previous?, fixturePath? }
|
|
125
|
+
* @returns {object} { ok, snapshot, validation, diff, shouldWrite, errors? }
|
|
126
|
+
*/
|
|
127
|
+
export function syncRegistry(args, options = {}) {
|
|
128
|
+
const pin = options.pin ?? loadPin();
|
|
129
|
+
const fixturePath = options.fixturePath ?? FIXTURE_PATH;
|
|
130
|
+
const sourcePath = args.source ?? fixturePath;
|
|
131
|
+
|
|
132
|
+
let sourceText;
|
|
133
|
+
if (args.fetch) {
|
|
134
|
+
sourceText = fetchPinnedSourceSync(pin, fixturePath);
|
|
135
|
+
} else {
|
|
136
|
+
sourceText = canonicalSourceText(fs.readFileSync(sourcePath, 'utf8'));
|
|
137
|
+
const digest = sha256(sourceText);
|
|
138
|
+
if (digest !== pin.contentSha256) {
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
errors: [
|
|
142
|
+
`source content hash ${digest} does not match pinned contentSha256 ${pin.contentSha256}` +
|
|
143
|
+
` (source ${sourcePath}); re-fetch with --fetch or update registry/source.json`,
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const parsed = parseAgents(sourceText);
|
|
150
|
+
const hosts = parsed.map((h) => normalizeHost(h, pin));
|
|
151
|
+
const snapshot = buildSnapshot(hosts, pin);
|
|
152
|
+
|
|
153
|
+
const validation = validateSnapshot(snapshot);
|
|
154
|
+
if (!validation.valid) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
errors: [...validation.errors, ...Object.values(validation.hostErrors)],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const previous = options.previous ?? readJson(SNAPSHOT_PATH);
|
|
162
|
+
const diff = previous ? diffSnapshots(previous, snapshot) : null;
|
|
163
|
+
const reviewCount = diff ? diff.summary.review : 0;
|
|
164
|
+
|
|
165
|
+
const shouldWrite = !args.dryRun && (args.allowReview || reviewCount === 0);
|
|
166
|
+
|
|
167
|
+
if (shouldWrite) {
|
|
168
|
+
writeJson(SNAPSHOT_PATH, snapshot);
|
|
169
|
+
writeJson(PIN_PATH, pin);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
ok: true,
|
|
174
|
+
snapshot,
|
|
175
|
+
validation,
|
|
176
|
+
diff,
|
|
177
|
+
shouldWrite,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Small sync wrapper so syncRegistry stays pure for tests.
|
|
182
|
+
function fetchPinnedSourceSync(pin, fixturePath) {
|
|
183
|
+
const url = `https://raw.githubusercontent.com/${pin.repository}/${pin.pinnedRevision}/${pin.upstreamFile}`;
|
|
184
|
+
throw new Error(`sync --fetch requires async fetch; use runSync() instead (${url})`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Async entrypoint used by the CLI. Fetches when requested, then syncs.
|
|
189
|
+
* @param {object} args CLI args
|
|
190
|
+
* @returns {Promise<object>} result of syncRegistry
|
|
191
|
+
*/
|
|
192
|
+
export async function runSync(args, options = {}) {
|
|
193
|
+
const pin = options.pin ?? loadPin();
|
|
194
|
+
if (args.fetch) {
|
|
195
|
+
await fetchPinnedSource(pin, options.fixturePath ?? FIXTURE_PATH);
|
|
196
|
+
}
|
|
197
|
+
return syncRegistry({ ...args, fetch: false }, options);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function main() {
|
|
201
|
+
const args = parseArgs(process.argv.slice(2));
|
|
202
|
+
runSync(args).then((result) => {
|
|
203
|
+
if (!result.ok) {
|
|
204
|
+
for (const err of result.errors) console.error('validation error:', err);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
console.log(`sync: parsed ${result.snapshot.hosts.length} hosts from pinned revision ${loadPin().pinnedRevision}`);
|
|
208
|
+
if (result.diff) {
|
|
209
|
+
console.log('diff summary:', JSON.stringify(result.diff.summary));
|
|
210
|
+
for (const change of result.diff.changes) {
|
|
211
|
+
console.log(` ${change.authority.padEnd(6)} ${change.id} ${change.kind}` +
|
|
212
|
+
(change.fields ? ' [' + change.fields.join(', ') + ']' : ''));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (result.shouldWrite) {
|
|
216
|
+
console.log('wrote registry/agent-hosts.json + registry/source.json');
|
|
217
|
+
} else if (args.dryRun) {
|
|
218
|
+
console.log('dry run: no files written');
|
|
219
|
+
} else {
|
|
220
|
+
console.log('review-classified changes present; registry not written (use --allow-review to force)');
|
|
221
|
+
}
|
|
222
|
+
}).catch((err) => {
|
|
223
|
+
console.error('error:', err.message);
|
|
224
|
+
process.exit(1);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (process.argv[1] && path.basename(process.argv[1]) === 'sync.js') {
|
|
229
|
+
main();
|
|
230
|
+
}
|