@optitransfer/proofline 0.0.0-stage → 0.2.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +32 -0
- package/CHANGELOG.md +56 -0
- package/CONTRIBUTING.md +29 -0
- package/LICENSE +154 -0
- package/NOTICE +10 -0
- package/PATENTS-NONASSERT.md +1 -0
- package/README.md +285 -2
- package/bin/proofline.mjs +292 -0
- package/docs/00-index.md +43 -0
- package/docs/01-scope.md +70 -0
- package/docs/02-requirements.md +64 -0
- package/docs/03-domain-model.md +43 -0
- package/docs/04-architecture.md +79 -0
- package/docs/05-lifecycle.md +47 -0
- package/docs/06-verification.md +57 -0
- package/docs/07-evidence.md +69 -0
- package/docs/08-adapters.md +61 -0
- package/docs/09-security.md +64 -0
- package/docs/10-conformance.md +59 -0
- package/docs/11-development-pipeline.md +50 -0
- package/docs/12-roadmap.md +104 -0
- package/docs/13-interdependencies.md +53 -0
- package/docs/14-metrics.md +40 -0
- package/docs/adr/0001-product-boundary.md +28 -0
- package/docs/adr/0002-evidence-and-gates.md +22 -0
- package/docs/adr/0003-standalone-first.md +18 -0
- package/docs/receipt.md +25 -0
- package/package.json +60 -4
- package/src/cache.mjs +81 -0
- package/src/checks.mjs +276 -0
- package/src/claims.mjs +60 -0
- package/src/contract.mjs +404 -0
- package/src/engine.mjs +378 -0
- package/src/evidence.mjs +45 -0
- package/src/forbidden.mjs +57 -0
- package/src/guided.mjs +240 -0
- package/src/hook-stop.mjs +105 -0
- package/src/index.mjs +431 -0
- package/src/infer.mjs +173 -0
- package/src/inference.json +20 -0
- package/src/isolate.mjs +123 -0
- package/src/mcp.mjs +70 -0
- package/src/receipt.mjs +17 -0
- package/src/signature.mjs +110 -0
- package/src/util.mjs +92 -0
- package/src/vault.mjs +109 -0
- package/src/verify-receipt.mjs +109 -0
package/src/guided.mjs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Guided flow: setup, task, contract approve, watch. Each refusal names the next command.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import * as C from './contract.mjs';
|
|
7
|
+
import * as E from './engine.mjs';
|
|
8
|
+
import { connectClaudeCode } from './hook-stop.mjs';
|
|
9
|
+
import { init, installHooks, approvalsDir, activeTask } from './index.mjs';
|
|
10
|
+
import { git, sha256 } from './util.mjs';
|
|
11
|
+
|
|
12
|
+
const TASK_SCOPE = 'src/**,bin/**,test/**,docs/**,README.md,CHANGELOG.md,package.json';
|
|
13
|
+
// A few seconds: one failing sample is visible, then the loop ends if the gate is still not PASS.
|
|
14
|
+
const WATCH_BOUND_MS = 6000;
|
|
15
|
+
const WATCH_INTERVAL_MS = 1000;
|
|
16
|
+
|
|
17
|
+
const refuse = (why, next) => new Error(`${why}\nNext step: ${next}`);
|
|
18
|
+
|
|
19
|
+
function pause(ms) {
|
|
20
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** agentkey on PATH. AGENTKEY_HOME is required so the default key directory is never opened. */
|
|
24
|
+
function agentkeyBin(env) {
|
|
25
|
+
for (const dir of String(env.PATH || '').split(path.delimiter)) {
|
|
26
|
+
if (!dir) continue;
|
|
27
|
+
const f = path.join(dir, 'agentkey');
|
|
28
|
+
try {
|
|
29
|
+
const st = fs.statSync(f);
|
|
30
|
+
if (st.isFile() && (st.mode & 0o111)) return f;
|
|
31
|
+
} catch { /* keep looking */ }
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hooksReady(root) {
|
|
37
|
+
const hd = git(['rev-parse', '--git-path', 'hooks'], root);
|
|
38
|
+
if (!hd.ok) return false;
|
|
39
|
+
const dir = path.resolve(root, hd.out);
|
|
40
|
+
for (const name of ['pre-commit', 'pre-push']) {
|
|
41
|
+
try {
|
|
42
|
+
const f = path.join(dir, name);
|
|
43
|
+
const st = fs.statSync(f);
|
|
44
|
+
if (!st.isFile() || (st.mode & 0o111) === 0) return false;
|
|
45
|
+
if (!fs.readFileSync(f, 'utf8').includes('# installed by proofline')) return false;
|
|
46
|
+
} catch { return false; }
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createOwnerKey(env) {
|
|
52
|
+
const home = env.AGENTKEY_HOME;
|
|
53
|
+
if (typeof home !== 'string' || !home.trim()) throw refuse('AGENTKEY_HOME is not set, so no key directory was opened', 'proofline setup');
|
|
54
|
+
const bin = agentkeyBin(env);
|
|
55
|
+
if (!bin) throw refuse('agentkey is not on PATH', 'proofline setup');
|
|
56
|
+
const r = spawnSync(bin, ['init', 'owner'], { encoding: 'utf8', env: { ...env, AGENTKEY_HOME: home } });
|
|
57
|
+
if (r.error || r.status !== 0) throw refuse('agentkey init owner failed', 'proofline setup');
|
|
58
|
+
const key = path.join(home, 'keys', 'owner.key');
|
|
59
|
+
let st = null;
|
|
60
|
+
try { st = fs.lstatSync(key); } catch { st = null; }
|
|
61
|
+
if (!st || !st.isFile() || st.isSymbolicLink()) throw refuse('the owner key was not created', 'proofline setup');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function ownerPaths(root, env) {
|
|
65
|
+
return { pub: path.join(env.AGENTKEY_HOME, 'keys', 'owner.pub'), pin: path.resolve(root, 'guard', 'owner.pub') };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The pinned bytes, or null when there is no pin. A pin that is not a regular file is refused. */
|
|
69
|
+
function readPin(pin) {
|
|
70
|
+
let st;
|
|
71
|
+
try { st = fs.lstatSync(pin); } catch (e) {
|
|
72
|
+
if (e.code === 'ENOENT') return null;
|
|
73
|
+
throw refuse(`${pin} cannot be read`, `ls -l ${pin}`);
|
|
74
|
+
}
|
|
75
|
+
if (!st.isFile()) throw refuse(`${pin} is not a regular file`, `ls -l ${pin}`);
|
|
76
|
+
return fs.readFileSync(pin);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const pinMismatch = (pin, pub) => refuse(`${pin} does not hold the owner key at ${pub}, and a pinned owner key is never replaced`, 'AGENTKEY_HOME=<the directory that holds the pinned key> proofline setup');
|
|
80
|
+
|
|
81
|
+
/** Before anything is written: a pin that is there must hold the key AGENTKEY_HOME already has. */
|
|
82
|
+
function checkOwnerPin(root, env) {
|
|
83
|
+
const { pub, pin } = ownerPaths(root, env);
|
|
84
|
+
const have = readPin(pin);
|
|
85
|
+
if (!have) return;
|
|
86
|
+
let want = null;
|
|
87
|
+
try { want = fs.readFileSync(pub); } catch { want = null; }
|
|
88
|
+
if (!want || !have.equals(want)) throw pinMismatch(pin, pub);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** guard/owner.pub is the only key verifyContractSignature reads. Written once from the key setup created. */
|
|
92
|
+
function pinOwnerKey(root, env) {
|
|
93
|
+
const { pub, pin } = ownerPaths(root, env);
|
|
94
|
+
let want;
|
|
95
|
+
try { want = fs.readFileSync(pub); } catch { throw refuse(`the owner public key ${pub} was not created`, 'proofline setup'); }
|
|
96
|
+
const have = readPin(pin);
|
|
97
|
+
if (have) {
|
|
98
|
+
if (!have.equals(want)) throw pinMismatch(pin, pub);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
fs.mkdirSync(path.dirname(pin), { recursive: true });
|
|
102
|
+
fs.writeFileSync(pin, want, { flag: 'wx', mode: 0o644 });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* init, hooks, the owner key, guard/owner.pub from that key, approvals at .proofline/approvals.
|
|
107
|
+
* Prints three lines. Refuses, without those lines, when a hook is missing.
|
|
108
|
+
* Refuses before init when guard/owner.pub holds a key other than the one in AGENTKEY_HOME.
|
|
109
|
+
*/
|
|
110
|
+
export function setup(root, { agent = 'none', env = process.env } = {}) {
|
|
111
|
+
if (agent !== 'none' && agent !== 'claude-code') throw refuse(`unknown agent ${agent}`, 'proofline setup --agent none');
|
|
112
|
+
if (!git(['rev-parse', '--show-toplevel'], root).ok) throw refuse('not a git repository', 'git init');
|
|
113
|
+
const approvals = path.resolve(root, '.proofline', 'approvals');
|
|
114
|
+
if (typeof env.AGENTKEY_HOME !== 'string' || !env.AGENTKEY_HOME.trim()) throw refuse('AGENTKEY_HOME is not set, so no key directory was opened', 'proofline setup');
|
|
115
|
+
if (!agentkeyBin(env)) throw refuse('agentkey is not on PATH', 'proofline setup');
|
|
116
|
+
checkOwnerPin(root, env);
|
|
117
|
+
try {
|
|
118
|
+
init(root, { hooks: true, approvals });
|
|
119
|
+
installHooks(root);
|
|
120
|
+
} catch {
|
|
121
|
+
throw refuse('a hook is missing', 'proofline setup');
|
|
122
|
+
}
|
|
123
|
+
if (!hooksReady(root)) throw refuse('a hook is missing', 'proofline setup');
|
|
124
|
+
createOwnerKey(env);
|
|
125
|
+
pinOwnerKey(root, env);
|
|
126
|
+
fs.mkdirSync(approvals, { recursive: true });
|
|
127
|
+
if (agent === 'claude-code') {
|
|
128
|
+
const connected = connectClaudeCode();
|
|
129
|
+
if (!connected.ok) throw refuse(connected.reason, 'proofline setup --agent claude-code');
|
|
130
|
+
}
|
|
131
|
+
return `${approvals}\nhooks installed\nproofline task\n`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function freshId(text) {
|
|
135
|
+
const have = new Set((JSON.parse(text).tasks || []).map((t) => t.id));
|
|
136
|
+
let id = 'TASK';
|
|
137
|
+
let n = 2;
|
|
138
|
+
while (have.has(id)) id = `TASK-${n++}`;
|
|
139
|
+
return id;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Scaffold a task from plain words and write the proposal bytes. The contract file stays as it is. */
|
|
143
|
+
export function task(root, words) {
|
|
144
|
+
const title = String(words || '').trim();
|
|
145
|
+
if (!title) throw refuse('name the task in plain words', 'proofline task "<plain words>"');
|
|
146
|
+
const file = C.contractPath(root);
|
|
147
|
+
if (!fs.existsSync(file)) throw refuse('no contract yet', 'proofline setup');
|
|
148
|
+
const before = fs.readFileSync(file, 'utf8');
|
|
149
|
+
let proposal;
|
|
150
|
+
try {
|
|
151
|
+
const spec = C.scaffoldTask({ title, scope: TASK_SCOPE, kind: 'feature', id: freshId(before) });
|
|
152
|
+
proposal = C.appendTaskText(before, [spec]);
|
|
153
|
+
} catch (e) {
|
|
154
|
+
throw refuse(e.message, 'proofline task "<plain words>"');
|
|
155
|
+
}
|
|
156
|
+
const problems = C.validate(JSON.parse(proposal));
|
|
157
|
+
if (problems.length) throw refuse(`proposal is not valid: ${problems.join('; ')}`, 'proofline task "<plain words>"');
|
|
158
|
+
const hex = sha256(proposal);
|
|
159
|
+
const dir = path.join(root, '.proofline', 'proposals');
|
|
160
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
161
|
+
fs.writeFileSync(path.join(dir, hex), proposal);
|
|
162
|
+
return `${hex}\nproofline contract approve ${hex}\n`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function unified(current, next) {
|
|
166
|
+
const a = path.join(os.tmpdir(), `pl-now-${process.pid}`);
|
|
167
|
+
const b = path.join(os.tmpdir(), `pl-new-${process.pid}`);
|
|
168
|
+
fs.writeFileSync(a, current);
|
|
169
|
+
fs.writeFileSync(b, next);
|
|
170
|
+
const r = spawnSync('diff', ['-u', a, b], { encoding: 'utf8' });
|
|
171
|
+
fs.rmSync(a, { force: true });
|
|
172
|
+
fs.rmSync(b, { force: true });
|
|
173
|
+
return r.stdout || '';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readAnswer() {
|
|
177
|
+
process.stderr.write('Apply? [y/N] ');
|
|
178
|
+
if (process.stdin.isTTY) {
|
|
179
|
+
const buf = Buffer.alloc(256);
|
|
180
|
+
let n = 0;
|
|
181
|
+
try { n = fs.readSync(0, buf, 0, buf.length, null); } catch { n = 0; }
|
|
182
|
+
return buf.toString('utf8', 0, n).split(/\r?\n/, 1)[0].trim();
|
|
183
|
+
}
|
|
184
|
+
let raw = '';
|
|
185
|
+
try { raw = fs.readFileSync(0, 'utf8'); } catch { raw = ''; }
|
|
186
|
+
return (raw.split(/\r?\n/, 1)[0] ?? '').trim();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function proposalBytes(root, hash) {
|
|
190
|
+
const dir = path.join(root, '.proofline', 'proposals');
|
|
191
|
+
if (!hash) {
|
|
192
|
+
let names = [];
|
|
193
|
+
try { names = fs.readdirSync(dir).filter((n) => /^[0-9a-f]{64}$/.test(n)); } catch { names = []; }
|
|
194
|
+
if (names.length !== 1) throw refuse('name the proposal hash', 'proofline task "<plain words>"');
|
|
195
|
+
hash = names[0];
|
|
196
|
+
}
|
|
197
|
+
if (!/^[0-9a-f]{64}$/.test(hash)) throw refuse('name the proposal hash', 'proofline contract approve');
|
|
198
|
+
const file = path.join(dir, hash);
|
|
199
|
+
let bytes;
|
|
200
|
+
try { bytes = fs.readFileSync(file); } catch { throw refuse(`no proposal ${hash}`, 'proofline task "<plain words>"'); }
|
|
201
|
+
if (sha256(bytes) !== hash) throw refuse(`proposal bytes do not match ${hash}`, 'proofline task "<plain words>"');
|
|
202
|
+
return { hash, bytes };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Show the diff, then apply only on y. The marker is a regular file named by the proposal sha256. */
|
|
206
|
+
export function approve(root, hash) {
|
|
207
|
+
if (!fs.existsSync(C.contractPath(root))) throw refuse('no contract yet', 'proofline setup');
|
|
208
|
+
const { hash: hex, bytes } = proposalBytes(root, hash);
|
|
209
|
+
const current = fs.readFileSync(C.contractPath(root));
|
|
210
|
+
const diff = unified(current, bytes);
|
|
211
|
+
const answer = readAnswer();
|
|
212
|
+
if (answer !== 'y' && answer !== 'Y') return `${diff}${diff.endsWith('\n') || diff === '' ? '' : '\n'}nothing applied\n`;
|
|
213
|
+
const dir = approvalsDir(root);
|
|
214
|
+
if (!dir) throw refuse('no approvals directory configured', 'proofline setup');
|
|
215
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
216
|
+
const marker = path.join(dir, hex);
|
|
217
|
+
let st = null;
|
|
218
|
+
try { st = fs.lstatSync(marker); } catch { st = null; }
|
|
219
|
+
if (st && (!st.isFile() || st.isSymbolicLink())) throw refuse(`marker ${marker} is not a regular file`, `proofline contract approve ${hex}`);
|
|
220
|
+
if (!st) fs.writeFileSync(marker, '', { flag: 'wx', mode: 0o644 });
|
|
221
|
+
fs.writeFileSync(C.contractPath(root), bytes);
|
|
222
|
+
return `${diff}${diff.endsWith('\n') || diff === '' ? '' : '\n'}applied ${hex}\n`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Print the task's criteria until the gate is PASS, or return non-zero when the bound ends. */
|
|
226
|
+
export function watch(root, taskArg) {
|
|
227
|
+
const id = taskArg || activeTask(root);
|
|
228
|
+
if (!id) throw refuse('no active task', 'proofline next');
|
|
229
|
+
const start = Date.now();
|
|
230
|
+
for (;;) {
|
|
231
|
+
let g;
|
|
232
|
+
try { g = E.gate(root, id); }
|
|
233
|
+
catch (e) { throw refuse(e.message, 'proofline next'); }
|
|
234
|
+
const lines = (g.results || []).map((r) => ` ${String(r.status).toUpperCase()} ${r.id} ${r.statement || ''}`.trimEnd());
|
|
235
|
+
process.stdout.write(`${g.task} ${g.decision}\n${lines.join('\n')}\n`);
|
|
236
|
+
if (g.decision === 'PASS') return 0;
|
|
237
|
+
if (Date.now() - start >= WATCH_BOUND_MS) return 1;
|
|
238
|
+
pause(Math.min(WATCH_INTERVAL_MS, WATCH_BOUND_MS - (Date.now() - start)));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Claude Code Stop hook: one receipt for the commits a session made, and none on the next run.
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { buildReceipt } from './receipt.mjs';
|
|
7
|
+
|
|
8
|
+
export const STOP_COMMAND = 'proofline hook stop';
|
|
9
|
+
|
|
10
|
+
function git(root, args) {
|
|
11
|
+
return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sessionStart(transcriptPath) {
|
|
15
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) return null;
|
|
16
|
+
for (const line of fs.readFileSync(transcriptPath, 'utf8').split('\n')) {
|
|
17
|
+
if (!line.trim()) continue;
|
|
18
|
+
try {
|
|
19
|
+
const row = JSON.parse(line);
|
|
20
|
+
const ts = row.timestamp || row.ts;
|
|
21
|
+
if (typeof ts === 'string' && ts) return ts;
|
|
22
|
+
} catch { /* a later line may carry the time */ }
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function markerFile(root) {
|
|
28
|
+
return path.join(root, '.proofline', 'session');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readMarker(root) {
|
|
32
|
+
try { return JSON.parse(fs.readFileSync(markerFile(root), 'utf8')); }
|
|
33
|
+
catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function commitsSince(root, since) {
|
|
37
|
+
const out = git(root, ['rev-list', `${since}..HEAD`]);
|
|
38
|
+
return out ? out.split('\n').filter(Boolean) : [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function commitsForSession(root, start, sessionId) {
|
|
42
|
+
const found = new Set();
|
|
43
|
+
const startMs = start ? Date.parse(start) : NaN;
|
|
44
|
+
if (!Number.isNaN(startMs)) {
|
|
45
|
+
const out = git(root, ['log', '--format=%H%x09%cI']);
|
|
46
|
+
for (const line of out.split('\n')) {
|
|
47
|
+
const [sha, when] = line.split('\t');
|
|
48
|
+
if (sha && Date.parse(when) >= startMs) found.add(sha);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (sessionId) {
|
|
52
|
+
try {
|
|
53
|
+
const out = git(root, ['log', '-F', '--format=%H', `--grep=Agent-Run: ${sessionId}`]);
|
|
54
|
+
for (const sha of out.split('\n')) if (sha) found.add(sha);
|
|
55
|
+
} catch { /* a git that rejects the flag still keeps the commits matched by time */ }
|
|
56
|
+
}
|
|
57
|
+
return [...found];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function hookStop(root, payload = {}) {
|
|
61
|
+
let head = '';
|
|
62
|
+
try { head = git(root, ['rev-parse', 'HEAD']); }
|
|
63
|
+
catch { return { receipts: [] }; }
|
|
64
|
+
const marker = readMarker(root);
|
|
65
|
+
if (marker?.receipted === head) return { receipts: [] };
|
|
66
|
+
let commits = [];
|
|
67
|
+
try {
|
|
68
|
+
commits = marker?.receipted
|
|
69
|
+
? commitsSince(root, marker.receipted)
|
|
70
|
+
: commitsForSession(root, sessionStart(payload.transcript_path), payload.session_id || '');
|
|
71
|
+
} catch { return { receipts: [] }; }
|
|
72
|
+
if (!commits.length) return { receipts: [] };
|
|
73
|
+
let base = marker?.receipted;
|
|
74
|
+
if (!base) {
|
|
75
|
+
try { base = git(root, ['rev-parse', `${commits[commits.length - 1]}^`]); }
|
|
76
|
+
catch { base = undefined; }
|
|
77
|
+
}
|
|
78
|
+
const receipt = await buildReceipt(root, { base, role: 'claude-code' });
|
|
79
|
+
fs.mkdirSync(path.dirname(markerFile(root)), { recursive: true });
|
|
80
|
+
fs.writeFileSync(markerFile(root), JSON.stringify({ session_id: payload.session_id || '', receipted: head }) + '\n');
|
|
81
|
+
return { receipts: [{ ...receipt, role: 'claude-code' }] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function connectClaudeCode(home = os.homedir()) {
|
|
85
|
+
const file = path.join(home, '.claude', 'settings.json');
|
|
86
|
+
let settings = {};
|
|
87
|
+
if (fs.existsSync(file)) {
|
|
88
|
+
let parsed;
|
|
89
|
+
try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
90
|
+
catch { return { ok: false, file, reason: 'settings.json is not plain JSON, so nothing was changed' }; }
|
|
91
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
92
|
+
return { ok: false, file, reason: 'settings.json is not an object, so nothing was changed' };
|
|
93
|
+
}
|
|
94
|
+
settings = parsed;
|
|
95
|
+
}
|
|
96
|
+
const hooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks) ? settings.hooks : {};
|
|
97
|
+
const stop = Array.isArray(hooks.Stop) ? hooks.Stop.slice() : [];
|
|
98
|
+
if (!stop.some((entry) => JSON.stringify(entry).includes(STOP_COMMAND))) {
|
|
99
|
+
stop.push({ hooks: [{ type: 'command', command: STOP_COMMAND }] });
|
|
100
|
+
}
|
|
101
|
+
settings.hooks = { ...hooks, Stop: stop };
|
|
102
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
103
|
+
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
104
|
+
return { ok: true, file };
|
|
105
|
+
}
|