codex-model-router 1.1.0 → 2.0.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/MAINTAINERS.md +92 -0
- package/README.md +84 -95
- package/SECURITY.md +21 -0
- package/lib/manifest.js +207 -0
- package/lib/router-core.js +882 -240
- package/lib/router.js +1 -210
- package/lib/toml.js +11 -15
- package/package.json +16 -5
package/lib/router-core.js
CHANGED
|
@@ -1,138 +1,103 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
access,
|
|
4
|
+
lstat,
|
|
4
5
|
mkdir,
|
|
5
6
|
open,
|
|
6
7
|
readFile,
|
|
8
|
+
realpath,
|
|
7
9
|
rename,
|
|
8
10
|
rm,
|
|
9
11
|
rmdir,
|
|
10
|
-
stat
|
|
12
|
+
stat,
|
|
13
|
+
writeFile
|
|
11
14
|
} from "node:fs/promises";
|
|
12
|
-
import { homedir } from "node:os";
|
|
15
|
+
import { homedir, hostname } from "node:os";
|
|
13
16
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
14
|
-
import { applyEdits, insertionEdit,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
""
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
relative: ["agents", "sol.toml"],
|
|
39
|
-
content: `name = "sol"
|
|
40
|
-
description = "Read-only reviewer for security-sensitive or high-regression-risk logic."
|
|
41
|
-
model = "gpt-5.6-sol"
|
|
42
|
-
model_reasoning_effort = "medium"
|
|
43
|
-
sandbox_mode = "read-only"
|
|
44
|
-
developer_instructions = """
|
|
45
|
-
Review only. Focus on security, authentication, permissions, secrets, destructive actions, financial logic, SQL writes, concurrency, complex state, and regression risk.
|
|
46
|
-
Report concrete findings to Terra; do not apply fixes.
|
|
47
|
-
"""
|
|
48
|
-
`
|
|
49
|
-
},
|
|
50
|
-
skill: {
|
|
51
|
-
content: `---
|
|
52
|
-
name: model-router
|
|
53
|
-
description: Route Codex work between Terra, Luna, and Sol with the fewest required agents.
|
|
54
|
-
---
|
|
55
|
-
|
|
56
|
-
Terra handles ordinary questions, coding, debugging, fixes, testing, and implementation. Never create a Terra subagent.
|
|
57
|
-
Use Luna only for deterministic low-risk repeated edits, bulk patterns, read-heavy searches, formatting, counting, extraction, or summaries; prefer Luna when the same clear operation repeats at least three times.
|
|
58
|
-
Use Sol only as a read-only reviewer for security, authentication, authorization, permissions, secrets, destructive actions, financial logic, SQL writes, concurrency, complex state changes, high-regression-risk logic, or an explicit review. Terra applies fixes.
|
|
59
|
-
Do not spawn a subagent for a simple question. Use the minimum number of agents and run Luna with Sol only when both independent tasks are required.
|
|
60
|
-
`
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
function hash(content) {
|
|
65
|
-
return createHash("sha256").update(content).digest("hex");
|
|
17
|
+
import { applyEdits, insertionEdit, removalEdit, scanToml } from "./toml.js";
|
|
18
|
+
import {
|
|
19
|
+
AGENT_EXPECTATIONS,
|
|
20
|
+
AGENT_NAMES,
|
|
21
|
+
DEFAULTS,
|
|
22
|
+
LEGACY_TEMPLATES,
|
|
23
|
+
MANAGED_FILE_NAMES,
|
|
24
|
+
SKILL_EXPECTATIONS,
|
|
25
|
+
TEMPLATES,
|
|
26
|
+
VERSION
|
|
27
|
+
} from "./manifest.js";
|
|
28
|
+
|
|
29
|
+
export { VERSION };
|
|
30
|
+
|
|
31
|
+
const STATE_VERSION = 4;
|
|
32
|
+
const LOCK_FILE = "model-router.lock";
|
|
33
|
+
const JOURNAL_FILE = "model-router-transaction.json";
|
|
34
|
+
const TRANSACTION_DIR = "model-router-transaction-data";
|
|
35
|
+
const TEST_CRASH_ENV = "CODEX_MODEL_ROUTER_TEST_CRASH_AFTER";
|
|
36
|
+
const TEST_HOLD_LOCK_ENV = "CODEX_MODEL_ROUTER_TEST_HOLD_LOCK_MS";
|
|
37
|
+
const TESTING_ENV = "CODEX_MODEL_ROUTER_TESTING";
|
|
38
|
+
|
|
39
|
+
function digest(value) {
|
|
40
|
+
return createHash("sha256").update(value).digest("hex");
|
|
66
41
|
}
|
|
67
42
|
|
|
68
43
|
function quoteToml(value) {
|
|
69
44
|
return JSON.stringify(value);
|
|
70
45
|
}
|
|
71
46
|
|
|
72
|
-
function
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
: join(projectRoot, ".agents", "skills");
|
|
81
|
-
return {
|
|
82
|
-
scope: global ? "global" : "project",
|
|
83
|
-
projectRoot,
|
|
84
|
-
userHome,
|
|
85
|
-
codexHome,
|
|
86
|
-
skillsHome,
|
|
87
|
-
skillsRoot: dirname(skillsHome),
|
|
88
|
-
config: join(codexHome, "config.toml"),
|
|
89
|
-
state: join(codexHome, "model-router-state.json"),
|
|
90
|
-
backup: join(codexHome, "config.toml.codex-model-router.bak"),
|
|
91
|
-
luna: join(codexHome, ...TEMPLATES.luna.relative),
|
|
92
|
-
sol: join(codexHome, ...TEMPLATES.sol.relative),
|
|
93
|
-
skill: join(skillsHome, "model-router", "SKILL.md")
|
|
94
|
-
};
|
|
47
|
+
function normalizeForCompare(path) {
|
|
48
|
+
const value = resolve(path);
|
|
49
|
+
return process.platform === "win32" ? value.toLowerCase() : value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isWithin(path, root) {
|
|
53
|
+
const value = relative(resolve(root), resolve(path));
|
|
54
|
+
return value === "" || (!value.startsWith(`..${sep}`) && value !== ".." && !isAbsolute(value));
|
|
95
55
|
}
|
|
96
56
|
|
|
97
57
|
async function pathExists(path) {
|
|
98
|
-
try {
|
|
58
|
+
try {
|
|
59
|
+
await access(path);
|
|
60
|
+
return true;
|
|
61
|
+
} catch (error) {
|
|
99
62
|
if (error?.code === "ENOENT") return false;
|
|
100
63
|
throw error;
|
|
101
64
|
}
|
|
102
65
|
}
|
|
103
66
|
|
|
104
|
-
async function
|
|
67
|
+
async function readBytes(path) {
|
|
105
68
|
try {
|
|
106
|
-
|
|
107
|
-
const text = bytes.toString("utf8");
|
|
108
|
-
if (!Buffer.from(text, "utf8").equals(bytes)) {
|
|
109
|
-
const error = new Error(`${path} is not valid UTF-8`);
|
|
110
|
-
error.code = "INVALID_UTF8";
|
|
111
|
-
throw error;
|
|
112
|
-
}
|
|
113
|
-
return { exists: true, text };
|
|
69
|
+
return { exists: true, bytes: await readFile(path) };
|
|
114
70
|
} catch (error) {
|
|
115
|
-
if (error?.code === "ENOENT") return { exists: false,
|
|
71
|
+
if (error?.code === "ENOENT") return { exists: false, bytes: null };
|
|
116
72
|
throw error;
|
|
117
73
|
}
|
|
118
74
|
}
|
|
119
75
|
|
|
120
|
-
async function
|
|
121
|
-
|
|
122
|
-
|
|
76
|
+
async function readText(path) {
|
|
77
|
+
const file = await readBytes(path);
|
|
78
|
+
if (!file.exists) return { exists: false, text: undefined };
|
|
79
|
+
const text = file.bytes.toString("utf8");
|
|
80
|
+
if (!Buffer.from(text, "utf8").equals(file.bytes)) {
|
|
81
|
+
const error = new Error(`${path} is not valid UTF-8`);
|
|
82
|
+
error.code = "INVALID_UTF8";
|
|
123
83
|
throw error;
|
|
124
84
|
}
|
|
85
|
+
return { exists: true, text };
|
|
125
86
|
}
|
|
126
87
|
|
|
127
|
-
async function
|
|
88
|
+
async function atomicWriteBytes(path, bytes) {
|
|
128
89
|
await mkdir(dirname(path), { recursive: true });
|
|
129
90
|
let mode = 0o600;
|
|
130
|
-
try {
|
|
91
|
+
try {
|
|
92
|
+
mode = (await stat(path)).mode;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error?.code !== "ENOENT") throw error;
|
|
95
|
+
}
|
|
131
96
|
const temporary = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
132
97
|
let handle;
|
|
133
98
|
try {
|
|
134
99
|
handle = await open(temporary, "wx", mode);
|
|
135
|
-
await handle.writeFile(
|
|
100
|
+
await handle.writeFile(bytes);
|
|
136
101
|
await handle.sync();
|
|
137
102
|
await handle.close();
|
|
138
103
|
handle = undefined;
|
|
@@ -144,22 +109,178 @@ async function atomicWrite(path, content) {
|
|
|
144
109
|
}
|
|
145
110
|
}
|
|
146
111
|
|
|
147
|
-
function
|
|
148
|
-
|
|
149
|
-
|
|
112
|
+
async function atomicWrite(path, content) {
|
|
113
|
+
return atomicWriteBytes(path, Buffer.from(content, "utf8"));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function directoryExists(path) {
|
|
117
|
+
try {
|
|
118
|
+
return (await stat(path)).isDirectory();
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (error?.code === "ENOENT") return false;
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function nearestExistingAncestor(path) {
|
|
126
|
+
let current = resolve(path);
|
|
127
|
+
while (true) {
|
|
128
|
+
try {
|
|
129
|
+
await lstat(current);
|
|
130
|
+
return current;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error?.code !== "ENOENT") throw error;
|
|
133
|
+
const parent = dirname(current);
|
|
134
|
+
if (parent === current) throw new Error(`no existing ancestor for ${path}`);
|
|
135
|
+
current = parent;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function canonicalizeRoot(path) {
|
|
141
|
+
const absolute = resolve(path);
|
|
142
|
+
const ancestor = await nearestExistingAncestor(absolute);
|
|
143
|
+
const physicalAncestor = await realpath(ancestor);
|
|
144
|
+
return resolve(physicalAncestor, relative(ancestor, absolute));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function canonicalizeScope(scope) {
|
|
148
|
+
return {
|
|
149
|
+
...scope,
|
|
150
|
+
cwd: await canonicalizeRoot(scope.cwd),
|
|
151
|
+
home: await canonicalizeRoot(scope.home ?? homedir())
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function assertNoIndirection(target, root, anchor, label) {
|
|
156
|
+
const absoluteTarget = resolve(target);
|
|
157
|
+
const absoluteRoot = resolve(root);
|
|
158
|
+
const absoluteAnchor = resolve(anchor);
|
|
159
|
+
if (!isWithin(absoluteTarget, absoluteRoot)) {
|
|
160
|
+
throw new Error(`unsafe path for ${label}: ${absoluteTarget} is outside ${absoluteRoot}`);
|
|
161
|
+
}
|
|
162
|
+
if (!isWithin(absoluteTarget, absoluteAnchor)) {
|
|
163
|
+
throw new Error(`unsafe path for ${label}: ${absoluteTarget} is outside anchor ${absoluteAnchor}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const rel = relative(absoluteAnchor, absoluteTarget);
|
|
167
|
+
const components = rel ? rel.split(sep) : [];
|
|
168
|
+
const physicalAnchor = await realpath(absoluteAnchor);
|
|
169
|
+
let current = absoluteAnchor;
|
|
170
|
+
const inspect = [current];
|
|
171
|
+
for (const component of components) {
|
|
172
|
+
current = join(current, component);
|
|
173
|
+
inspect.push(current);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (let index = 0; index < inspect.length; index += 1) {
|
|
177
|
+
const candidate = inspect[index];
|
|
178
|
+
let info;
|
|
179
|
+
try {
|
|
180
|
+
info = await lstat(candidate);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error?.code === "ENOENT") break;
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
if (info.isSymbolicLink()) {
|
|
186
|
+
throw new Error(`unsafe path component for ${label}: ${candidate} is a symbolic link or junction`);
|
|
187
|
+
}
|
|
188
|
+
if (index < inspect.length - 1 && !info.isDirectory()) {
|
|
189
|
+
throw new Error(`unsafe path component for ${label}: ${candidate} is not a directory`);
|
|
190
|
+
}
|
|
191
|
+
const physical = await realpath(candidate);
|
|
192
|
+
const expectedPhysical = resolve(physicalAnchor, relative(absoluteAnchor, candidate));
|
|
193
|
+
if (normalizeForCompare(physical) !== normalizeForCompare(expectedPhysical)) {
|
|
194
|
+
throw new Error(`unsafe path component for ${label}: ${candidate} resolves to ${physical}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function expectedPaths({ cwd, home, global, env }) {
|
|
200
|
+
const userHome = resolve(home ?? homedir());
|
|
201
|
+
const projectRoot = resolve(cwd);
|
|
202
|
+
const codexHome = global
|
|
203
|
+
? resolve(env?.CODEX_HOME || join(userHome, ".codex"))
|
|
204
|
+
: join(projectRoot, ".codex");
|
|
205
|
+
const skillsHome = global
|
|
206
|
+
? join(userHome, ".agents", "skills")
|
|
207
|
+
: join(projectRoot, ".agents", "skills");
|
|
208
|
+
const skillsRoot = dirname(skillsHome);
|
|
209
|
+
return {
|
|
210
|
+
scope: global ? "global" : "project",
|
|
211
|
+
projectRoot,
|
|
212
|
+
userHome,
|
|
213
|
+
codexHome,
|
|
214
|
+
skillsHome,
|
|
215
|
+
skillsRoot,
|
|
216
|
+
config: join(codexHome, "config.toml"),
|
|
217
|
+
state: join(codexHome, "model-router-state.json"),
|
|
218
|
+
backup: join(codexHome, "config.toml.codex-model-router.bak"),
|
|
219
|
+
terra: join(codexHome, ...TEMPLATES.terra.relative),
|
|
220
|
+
luna: join(codexHome, ...TEMPLATES.luna.relative),
|
|
221
|
+
sol: join(codexHome, ...TEMPLATES.sol.relative),
|
|
222
|
+
skill: join(skillsHome, ...TEMPLATES.skill.relative),
|
|
223
|
+
planning: join(skillsHome, ...TEMPLATES.planning.relative),
|
|
224
|
+
lock: join(codexHome, LOCK_FILE),
|
|
225
|
+
journal: join(codexHome, JOURNAL_FILE),
|
|
226
|
+
transactionData: join(codexHome, TRANSACTION_DIR)
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function safetyAnchors(location) {
|
|
231
|
+
const codexAnchor = location.scope === "project"
|
|
232
|
+
? location.projectRoot
|
|
233
|
+
: await nearestExistingAncestor(location.codexHome);
|
|
234
|
+
const skillsAnchor = location.scope === "project"
|
|
235
|
+
? location.projectRoot
|
|
236
|
+
: location.userHome;
|
|
237
|
+
return { codexAnchor, skillsAnchor };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function validateLocationSafety(location) {
|
|
241
|
+
const { codexAnchor, skillsAnchor } = await safetyAnchors(location);
|
|
242
|
+
const codexTargets = [
|
|
243
|
+
location.codexHome,
|
|
244
|
+
location.config,
|
|
245
|
+
location.state,
|
|
246
|
+
location.backup,
|
|
247
|
+
location.terra,
|
|
248
|
+
location.luna,
|
|
249
|
+
location.sol,
|
|
250
|
+
location.lock,
|
|
251
|
+
location.journal,
|
|
252
|
+
location.transactionData
|
|
253
|
+
];
|
|
254
|
+
for (const target of codexTargets) {
|
|
255
|
+
await assertNoIndirection(target, location.codexHome, codexAnchor, target);
|
|
256
|
+
}
|
|
257
|
+
const skillsTargets = [location.skillsRoot, location.skillsHome, location.skill, location.planning];
|
|
258
|
+
for (const target of skillsTargets) {
|
|
259
|
+
await assertNoIndirection(target, location.skillsRoot, skillsAnchor, target);
|
|
260
|
+
}
|
|
150
261
|
}
|
|
151
262
|
|
|
152
263
|
function assertExactPath(actual, expected, label) {
|
|
153
|
-
if (
|
|
264
|
+
if (normalizeForCompare(actual) !== normalizeForCompare(expected)) {
|
|
265
|
+
throw new Error(`unsafe state path for ${label}`);
|
|
266
|
+
}
|
|
154
267
|
}
|
|
155
268
|
|
|
156
269
|
function freshState(location) {
|
|
157
270
|
return {
|
|
158
|
-
version:
|
|
271
|
+
version: STATE_VERSION,
|
|
159
272
|
packageVersion: VERSION,
|
|
160
273
|
scope: location.scope,
|
|
161
|
-
roots: {
|
|
162
|
-
|
|
274
|
+
roots: {
|
|
275
|
+
codex: location.codexHome,
|
|
276
|
+
skills: location.skillsHome,
|
|
277
|
+
skillsRoot: location.skillsRoot
|
|
278
|
+
},
|
|
279
|
+
config: {
|
|
280
|
+
path: location.config,
|
|
281
|
+
createdFile: false,
|
|
282
|
+
values: {}
|
|
283
|
+
},
|
|
163
284
|
files: {},
|
|
164
285
|
backup: null,
|
|
165
286
|
createdDirs: []
|
|
@@ -170,16 +291,17 @@ function normalizeLegacyState(raw, location) {
|
|
|
170
291
|
const state = freshState(location);
|
|
171
292
|
if (!raw?.config || !raw?.files) throw new Error("invalid legacy state structure");
|
|
172
293
|
assertExactPath(raw.config.path, location.config, "config");
|
|
173
|
-
state.config.values = {};
|
|
174
294
|
for (const [key, item] of Object.entries(raw.config.values || {})) {
|
|
175
|
-
if (!Object.hasOwn(DEFAULTS, key) || item?.installed !== DEFAULTS[key])
|
|
295
|
+
if (!Object.hasOwn(DEFAULTS, key) || item?.installed !== DEFAULTS[key]) {
|
|
296
|
+
throw new Error(`invalid legacy state value: ${key}`);
|
|
297
|
+
}
|
|
176
298
|
state.config.values[key] = {
|
|
177
299
|
installed: item.installed,
|
|
178
300
|
previousRaw: item.previous == null ? null : quoteToml(String(item.previous)),
|
|
179
301
|
source: item.previous == null ? "inserted" : "replaced"
|
|
180
302
|
};
|
|
181
303
|
}
|
|
182
|
-
for (const name of
|
|
304
|
+
for (const name of MANAGED_FILE_NAMES) {
|
|
183
305
|
const item = raw.files[name];
|
|
184
306
|
if (!item) continue;
|
|
185
307
|
assertExactPath(item.path, location[name], name);
|
|
@@ -194,18 +316,26 @@ function normalizeLegacyState(raw, location) {
|
|
|
194
316
|
|
|
195
317
|
function validateState(raw, location) {
|
|
196
318
|
if (!raw || typeof raw !== "object") throw new Error("state is not an object");
|
|
197
|
-
|
|
198
|
-
if (
|
|
319
|
+
let state;
|
|
320
|
+
if (!raw.version) state = normalizeLegacyState(raw, location);
|
|
321
|
+
else if ([2, 3, STATE_VERSION].includes(raw.version)) state = structuredClone(raw);
|
|
322
|
+
else throw new Error(`unsupported state version: ${raw.version}`);
|
|
323
|
+
|
|
324
|
+
state.version = STATE_VERSION;
|
|
199
325
|
if (state.scope !== location.scope) throw new Error("state scope does not match this command");
|
|
200
326
|
assertExactPath(state.roots?.codex, location.codexHome, "Codex root");
|
|
201
327
|
assertExactPath(state.roots?.skills, location.skillsHome, "skills root");
|
|
202
328
|
assertExactPath(state.roots?.skillsRoot ?? dirname(state.roots?.skills || ""), location.skillsRoot, "skills parent root");
|
|
203
329
|
assertExactPath(state.config?.path, location.config, "config");
|
|
204
|
-
if (typeof state.config.createdFile !== "boolean" || typeof state.config.values !== "object")
|
|
330
|
+
if (typeof state.config.createdFile !== "boolean" || typeof state.config.values !== "object") {
|
|
331
|
+
throw new Error("invalid config state");
|
|
332
|
+
}
|
|
205
333
|
for (const [key, item] of Object.entries(state.config.values)) {
|
|
206
334
|
if (!Object.hasOwn(DEFAULTS, key)) throw new Error(`unknown managed config key: ${key}`);
|
|
207
335
|
if (item?.installed !== DEFAULTS[key]) throw new Error(`invalid installed value for ${key}`);
|
|
208
|
-
if (!(item.previousRaw === null || typeof item.previousRaw === "string"))
|
|
336
|
+
if (!(item.previousRaw === null || typeof item.previousRaw === "string")) {
|
|
337
|
+
throw new Error(`invalid previous value for ${key}`);
|
|
338
|
+
}
|
|
209
339
|
}
|
|
210
340
|
state.files ||= {};
|
|
211
341
|
for (const [name, item] of Object.entries(state.files)) {
|
|
@@ -231,7 +361,11 @@ async function loadState(location) {
|
|
|
231
361
|
const file = await readText(location.state);
|
|
232
362
|
if (!file.exists) return { exists: false, state: freshState(location) };
|
|
233
363
|
let raw;
|
|
234
|
-
try {
|
|
364
|
+
try {
|
|
365
|
+
raw = JSON.parse(file.text);
|
|
366
|
+
} catch {
|
|
367
|
+
throw new Error("state file is not valid JSON");
|
|
368
|
+
}
|
|
235
369
|
return { exists: true, state: validateState(raw, location) };
|
|
236
370
|
}
|
|
237
371
|
|
|
@@ -256,7 +390,7 @@ function addMessage(plan, status, label, detail) {
|
|
|
256
390
|
}
|
|
257
391
|
|
|
258
392
|
function addWrite(plan, path, content, status, label) {
|
|
259
|
-
plan.operations.push({ kind: "write", path, content });
|
|
393
|
+
plan.operations.push({ kind: "write", path, content: Buffer.from(content, "utf8") });
|
|
260
394
|
addMessage(plan, status, label);
|
|
261
395
|
}
|
|
262
396
|
|
|
@@ -282,14 +416,18 @@ function stateJson(state) {
|
|
|
282
416
|
async function missingDirectories(location) {
|
|
283
417
|
const candidates = [
|
|
284
418
|
location.codexHome,
|
|
419
|
+
dirname(location.terra),
|
|
285
420
|
dirname(location.luna),
|
|
286
421
|
location.skillsRoot,
|
|
287
422
|
location.skillsHome,
|
|
288
|
-
dirname(location.skill)
|
|
423
|
+
dirname(location.skill),
|
|
424
|
+
dirname(location.planning)
|
|
289
425
|
];
|
|
290
426
|
const unique = [...new Set(candidates.map((value) => resolve(value)))].sort((a, b) => a.length - b.length);
|
|
291
427
|
const missing = [];
|
|
292
|
-
for (const directory of unique)
|
|
428
|
+
for (const directory of unique) {
|
|
429
|
+
if (!(await directoryExists(directory))) missing.push(directory);
|
|
430
|
+
}
|
|
293
431
|
return missing;
|
|
294
432
|
}
|
|
295
433
|
|
|
@@ -299,33 +437,57 @@ function patchInstallConfig(original, state, setDefault) {
|
|
|
299
437
|
const additions = [];
|
|
300
438
|
const nextValues = structuredClone(state.config.values || {});
|
|
301
439
|
|
|
440
|
+
if (!setDefault) {
|
|
441
|
+
for (const [key, item] of Object.entries(nextValues)) {
|
|
442
|
+
const assignment = scan.targets.get(key);
|
|
443
|
+
if (!assignment) {
|
|
444
|
+
delete nextValues[key];
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (assignment.parsedValue.kind !== "string") {
|
|
448
|
+
throw new Error(`${key} must be a TOML string`);
|
|
449
|
+
}
|
|
450
|
+
if (semanticValue(assignment) === item.installed) {
|
|
451
|
+
if (item.previousRaw === null) edits.push(removalEdit(assignment));
|
|
452
|
+
else edits.push({ start: assignment.valueStart, end: assignment.valueEnd, text: item.previousRaw });
|
|
453
|
+
}
|
|
454
|
+
delete nextValues[key];
|
|
455
|
+
}
|
|
456
|
+
return {
|
|
457
|
+
content: edits.length ? applyEdits(original, edits) : original,
|
|
458
|
+
values: nextValues,
|
|
459
|
+
changed: edits.length > 0
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
302
463
|
for (const [key, installed] of Object.entries(DEFAULTS)) {
|
|
303
464
|
const assignment = scan.targets.get(key);
|
|
304
465
|
const tracked = nextValues[key];
|
|
305
|
-
if (assignment && assignment.parsedValue.kind !== "string")
|
|
466
|
+
if (assignment && assignment.parsedValue.kind !== "string") {
|
|
467
|
+
throw new Error(`${key} must be a TOML string`);
|
|
468
|
+
}
|
|
306
469
|
const current = semanticValue(assignment);
|
|
307
|
-
|
|
308
470
|
if (!assignment) {
|
|
309
471
|
additions.push(`${key} = ${quoteToml(installed)}`);
|
|
310
472
|
nextValues[key] = tracked || { installed, previousRaw: null, source: "inserted" };
|
|
311
473
|
continue;
|
|
312
474
|
}
|
|
313
|
-
|
|
314
|
-
if (!setDefault) {
|
|
315
|
-
if (tracked && current !== installed) {
|
|
316
|
-
// Keep ownership metadata so doctor can identify the later user edit.
|
|
317
|
-
}
|
|
318
|
-
continue;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
475
|
if (current === installed) continue;
|
|
322
476
|
edits.push({ start: assignment.valueStart, end: assignment.valueEnd, text: quoteToml(installed) });
|
|
323
|
-
nextValues[key] = tracked || {
|
|
477
|
+
nextValues[key] = tracked || {
|
|
478
|
+
installed,
|
|
479
|
+
previousRaw: assignment.rawValue,
|
|
480
|
+
source: "replaced"
|
|
481
|
+
};
|
|
324
482
|
}
|
|
325
483
|
|
|
326
484
|
const insert = insertionEdit(original, scan, additions);
|
|
327
485
|
if (insert) edits.push(insert);
|
|
328
|
-
return {
|
|
486
|
+
return {
|
|
487
|
+
content: edits.length ? applyEdits(original, edits) : original,
|
|
488
|
+
values: nextValues,
|
|
489
|
+
changed: edits.length > 0
|
|
490
|
+
};
|
|
329
491
|
}
|
|
330
492
|
|
|
331
493
|
function patchUninstallConfig(original, stateValues) {
|
|
@@ -342,20 +504,33 @@ function patchUninstallConfig(original, stateValues) {
|
|
|
342
504
|
if (item.previousRaw === null) edits.push(removalEdit(assignment));
|
|
343
505
|
else edits.push({ start: assignment.valueStart, end: assignment.valueEnd, text: item.previousRaw });
|
|
344
506
|
}
|
|
345
|
-
return {
|
|
507
|
+
return {
|
|
508
|
+
content: edits.length ? applyEdits(original, edits) : original,
|
|
509
|
+
changed: edits.length > 0,
|
|
510
|
+
preserved
|
|
511
|
+
};
|
|
346
512
|
}
|
|
347
513
|
|
|
348
514
|
async function planInstall(scope, flags) {
|
|
349
515
|
const location = expectedPaths(scope);
|
|
350
516
|
const plan = { command: "install", location, operations: [], messages: [], failed: false };
|
|
351
517
|
let loaded;
|
|
352
|
-
try {
|
|
518
|
+
try {
|
|
519
|
+
loaded = await loadState(location);
|
|
520
|
+
} catch (error) {
|
|
521
|
+
failPlan(plan, error.message);
|
|
522
|
+
return plan;
|
|
523
|
+
}
|
|
353
524
|
const state = loaded.state;
|
|
354
525
|
const config = await readText(location.config);
|
|
355
526
|
const original = config.exists ? config.text : "";
|
|
356
527
|
let patched;
|
|
357
|
-
try {
|
|
358
|
-
|
|
528
|
+
try {
|
|
529
|
+
patched = patchInstallConfig(original, state, flags.setDefault);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
failPlan(plan, error.message);
|
|
532
|
+
return plan;
|
|
533
|
+
}
|
|
359
534
|
|
|
360
535
|
const createdDirs = await missingDirectories(location);
|
|
361
536
|
plan.createdDirs = createdDirs;
|
|
@@ -364,34 +539,69 @@ async function planInstall(scope, flags) {
|
|
|
364
539
|
state.config.values = patched.values;
|
|
365
540
|
|
|
366
541
|
if (patched.changed) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
542
|
+
const empty = patched.content.replace(/^\uFEFF/, "").trim() === "";
|
|
543
|
+
if (!flags.setDefault && state.config.createdFile && empty) {
|
|
544
|
+
addDelete(plan, location.config, "config.toml");
|
|
545
|
+
state.config.createdFile = false;
|
|
546
|
+
} else {
|
|
547
|
+
if (flags.setDefault && !state.backup && config.exists) {
|
|
548
|
+
const backup = await readText(location.backup);
|
|
549
|
+
if (backup.exists) {
|
|
550
|
+
failPlan(plan, `untracked backup already exists: ${location.backup}`);
|
|
551
|
+
return plan;
|
|
552
|
+
}
|
|
553
|
+
state.backup = { path: location.backup, hash: digest(Buffer.from(original, "utf8")) };
|
|
554
|
+
addWrite(plan, location.backup, original, "create", "config backup");
|
|
372
555
|
}
|
|
373
|
-
|
|
374
|
-
addWrite(plan, location.backup, original, "create", "config backup");
|
|
556
|
+
addWrite(plan, location.config, patched.content, config.exists ? "update" : "create", "config.toml");
|
|
375
557
|
}
|
|
376
|
-
addWrite(plan, location.config, patched.content, config.exists ? "update" : "create", "config.toml");
|
|
377
558
|
} else {
|
|
378
|
-
addMessage(
|
|
559
|
+
addMessage(
|
|
560
|
+
plan,
|
|
561
|
+
"preserve",
|
|
562
|
+
"config.toml",
|
|
563
|
+
flags.setDefault ? "already matches or contains protected values" : "primary model preserved"
|
|
564
|
+
);
|
|
379
565
|
}
|
|
380
566
|
|
|
381
|
-
|
|
567
|
+
if (!flags.setDefault && Object.keys(state.config.values).length === 0 && state.backup) {
|
|
568
|
+
const backup = await readText(location.backup);
|
|
569
|
+
if (!backup.exists) addMessage(plan, "skip", "config backup", "already missing");
|
|
570
|
+
else if (digest(Buffer.from(backup.text, "utf8")) === state.backup.hash) {
|
|
571
|
+
addDelete(plan, location.backup, "config backup");
|
|
572
|
+
} else {
|
|
573
|
+
addMessage(plan, "preserve", "config backup", "user-modified");
|
|
574
|
+
}
|
|
575
|
+
state.backup = null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
for (const name of MANAGED_FILE_NAMES) {
|
|
382
579
|
const current = await readText(location[name]);
|
|
383
580
|
const tracked = state.files[name];
|
|
384
581
|
const template = TEMPLATES[name].content;
|
|
582
|
+
|
|
385
583
|
if (!current.exists) {
|
|
386
|
-
state.files[name] = { path: location[name], hash:
|
|
584
|
+
state.files[name] = { path: location[name], hash: digest(Buffer.from(template, "utf8")) };
|
|
387
585
|
addWrite(plan, location[name], template, "create", name);
|
|
388
|
-
|
|
389
|
-
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const currentHash = digest(Buffer.from(current.text, "utf8"));
|
|
590
|
+
if (tracked && currentHash === tracked.hash) {
|
|
591
|
+
if (current.text === template) {
|
|
592
|
+
addMessage(plan, "skip", name, "already managed");
|
|
593
|
+
} else if ((LEGACY_TEMPLATES[name] || []).includes(current.text)) {
|
|
594
|
+
state.files[name].hash = digest(Buffer.from(template, "utf8"));
|
|
595
|
+
addWrite(plan, location[name], template, "update", `${name} migration`);
|
|
596
|
+
} else {
|
|
597
|
+
addMessage(plan, "preserve", name, "managed content is not a recognized package template");
|
|
598
|
+
}
|
|
390
599
|
} else {
|
|
391
600
|
addMessage(plan, "preserve", name, tracked ? "user-modified" : "pre-existing");
|
|
392
601
|
}
|
|
393
602
|
}
|
|
394
603
|
|
|
604
|
+
state.version = STATE_VERSION;
|
|
395
605
|
state.packageVersion = VERSION;
|
|
396
606
|
const existingState = await readText(location.state);
|
|
397
607
|
addWrite(plan, location.state, stateJson(state), existingState.exists ? "update" : "create", "state");
|
|
@@ -402,17 +612,27 @@ async function planUninstall(scope) {
|
|
|
402
612
|
const location = expectedPaths(scope);
|
|
403
613
|
const plan = { command: "uninstall", location, operations: [], messages: [], failed: false };
|
|
404
614
|
let loaded;
|
|
405
|
-
try {
|
|
615
|
+
try {
|
|
616
|
+
loaded = await loadState(location);
|
|
617
|
+
} catch (error) {
|
|
618
|
+
failPlan(plan, error.message);
|
|
619
|
+
return plan;
|
|
620
|
+
}
|
|
406
621
|
if (!loaded.exists) {
|
|
407
622
|
addMessage(plan, "skip", "state", "not installed");
|
|
408
623
|
return plan;
|
|
409
624
|
}
|
|
410
625
|
const state = loaded.state;
|
|
411
626
|
const config = await readText(location.config);
|
|
627
|
+
|
|
412
628
|
if (Object.keys(state.config.values || {}).length && config.exists) {
|
|
413
629
|
let patched;
|
|
414
|
-
try {
|
|
415
|
-
|
|
630
|
+
try {
|
|
631
|
+
patched = patchUninstallConfig(config.text, state.config.values);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
failPlan(plan, error.message);
|
|
634
|
+
return plan;
|
|
635
|
+
}
|
|
416
636
|
for (const key of patched.preserved) addMessage(plan, "preserve", key, "user-modified");
|
|
417
637
|
if (patched.changed) {
|
|
418
638
|
const empty = patched.content.replace(/^\uFEFF/, "").trim() === "";
|
|
@@ -426,73 +646,397 @@ async function planUninstall(scope) {
|
|
|
426
646
|
}
|
|
427
647
|
state.config.values = {};
|
|
428
648
|
|
|
429
|
-
for (const name of
|
|
649
|
+
for (const name of MANAGED_FILE_NAMES) {
|
|
430
650
|
const tracked = state.files[name];
|
|
431
651
|
if (!tracked) continue;
|
|
432
652
|
const current = await readText(location[name]);
|
|
433
|
-
if (!current.exists)
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
addDelete(plan, location[name], name);
|
|
437
|
-
} else {
|
|
438
|
-
addMessage(plan, "preserve", name, "user-modified");
|
|
439
|
-
}
|
|
653
|
+
if (!current.exists) addMessage(plan, "skip", name, "already missing");
|
|
654
|
+
else if (digest(Buffer.from(current.text, "utf8")) === tracked.hash) addDelete(plan, location[name], name);
|
|
655
|
+
else addMessage(plan, "preserve", name, "user-modified");
|
|
440
656
|
delete state.files[name];
|
|
441
657
|
}
|
|
442
658
|
|
|
443
659
|
if (state.backup) {
|
|
444
660
|
const backup = await readText(location.backup);
|
|
445
661
|
if (!backup.exists) addMessage(plan, "skip", "config backup", "already missing");
|
|
446
|
-
else if (
|
|
447
|
-
|
|
662
|
+
else if (digest(Buffer.from(backup.text, "utf8")) === state.backup.hash) {
|
|
663
|
+
addDelete(plan, location.backup, "config backup");
|
|
664
|
+
} else {
|
|
665
|
+
addMessage(plan, "preserve", "config backup", "user-modified");
|
|
666
|
+
}
|
|
448
667
|
state.backup = null;
|
|
449
668
|
}
|
|
450
669
|
|
|
451
670
|
addDelete(plan, location.state, "state");
|
|
452
|
-
for (const directory of [...state.createdDirs].sort((a, b) => b.length - a.length))
|
|
453
|
-
|
|
671
|
+
for (const directory of [...state.createdDirs].sort((a, b) => b.length - a.length)) {
|
|
672
|
+
addRemoveDirectory(plan, directory);
|
|
673
|
+
}
|
|
454
674
|
return plan;
|
|
455
675
|
}
|
|
456
676
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
677
|
+
function signatureFromBytes(file) {
|
|
678
|
+
return file.exists
|
|
679
|
+
? { exists: true, hash: digest(file.bytes) }
|
|
680
|
+
: { exists: false, hash: null };
|
|
460
681
|
}
|
|
461
682
|
|
|
462
|
-
async function
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
683
|
+
async function signature(path) {
|
|
684
|
+
return signatureFromBytes(await readBytes(path));
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function sameSignature(left, right) {
|
|
688
|
+
return Boolean(left?.exists) === Boolean(right?.exists) &&
|
|
689
|
+
(!left?.exists || left.hash === right.hash);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function inspectLock(location) {
|
|
693
|
+
const lock = await readText(location.lock);
|
|
694
|
+
if (!lock.exists) return { status: "none" };
|
|
695
|
+
let metadata;
|
|
696
|
+
try {
|
|
697
|
+
metadata = JSON.parse(lock.text);
|
|
698
|
+
} catch {
|
|
699
|
+
return { status: "corrupt", detail: "lock file is not valid JSON" };
|
|
700
|
+
}
|
|
701
|
+
if (!Number.isInteger(metadata.pid) || metadata.pid <= 0 || typeof metadata.hostname !== "string") {
|
|
702
|
+
return { status: "corrupt", detail: "lock metadata is invalid" };
|
|
703
|
+
}
|
|
704
|
+
if (metadata.hostname !== hostname()) {
|
|
705
|
+
return { status: "active", metadata, detail: "lock belongs to another host" };
|
|
706
|
+
}
|
|
707
|
+
let alive = true;
|
|
467
708
|
try {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
709
|
+
process.kill(metadata.pid, 0);
|
|
710
|
+
} catch (error) {
|
|
711
|
+
if (["ESRCH", "EINVAL"].includes(error?.code)) alive = false;
|
|
712
|
+
else if (error?.code !== "EPERM") throw error;
|
|
713
|
+
}
|
|
714
|
+
return alive
|
|
715
|
+
? { status: "active", metadata }
|
|
716
|
+
: { status: "stale", metadata };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function acquireLock(location, command, output, env) {
|
|
720
|
+
let createdRoot = false;
|
|
721
|
+
if (!(await directoryExists(location.codexHome))) {
|
|
722
|
+
await mkdir(location.codexHome, { recursive: true });
|
|
723
|
+
createdRoot = true;
|
|
724
|
+
}
|
|
725
|
+
await validateLocationSafety(location);
|
|
726
|
+
|
|
727
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
728
|
+
const token = randomUUID();
|
|
729
|
+
let handle;
|
|
730
|
+
try {
|
|
731
|
+
handle = await open(location.lock, "wx", 0o600);
|
|
732
|
+
const metadata = {
|
|
733
|
+
version: 1,
|
|
734
|
+
token,
|
|
735
|
+
pid: process.pid,
|
|
736
|
+
hostname: hostname(),
|
|
737
|
+
command,
|
|
738
|
+
scope: location.scope,
|
|
739
|
+
startedAt: new Date().toISOString()
|
|
740
|
+
};
|
|
741
|
+
await handle.writeFile(`${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
|
742
|
+
await handle.sync();
|
|
743
|
+
await handle.close();
|
|
744
|
+
handle = undefined;
|
|
745
|
+
const hold = Number.parseInt(env?.[TEST_HOLD_LOCK_ENV] || "", 10);
|
|
746
|
+
if (env?.[TESTING_ENV] === "1" && Number.isFinite(hold) && hold > 0) {
|
|
747
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, hold));
|
|
748
|
+
}
|
|
749
|
+
return { token, createdRoot, command };
|
|
750
|
+
} catch (error) {
|
|
751
|
+
try { await handle?.close(); } catch {}
|
|
752
|
+
if (error?.code !== "EEXIST") throw error;
|
|
753
|
+
const lock = await inspectLock(location);
|
|
754
|
+
if (lock.status === "stale") {
|
|
755
|
+
const before = await readText(location.lock);
|
|
756
|
+
const again = await readText(location.lock);
|
|
757
|
+
if (before.exists && again.exists && before.text === again.text) {
|
|
758
|
+
await rm(location.lock, { force: true });
|
|
759
|
+
output?.(`recover: stale lock (${lock.metadata.command || "unknown"} pid ${lock.metadata.pid})`);
|
|
760
|
+
continue;
|
|
477
761
|
}
|
|
478
762
|
}
|
|
479
|
-
|
|
763
|
+
const detail = lock.metadata
|
|
764
|
+
? `${lock.metadata.command || "operation"} pid ${lock.metadata.pid} since ${lock.metadata.startedAt || "unknown"}`
|
|
765
|
+
: lock.detail || "unknown lock owner";
|
|
766
|
+
throw new Error(`scope is locked by ${detail}`);
|
|
480
767
|
}
|
|
481
|
-
|
|
768
|
+
}
|
|
769
|
+
throw new Error("unable to acquire scope lock");
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function releaseLock(location, lock) {
|
|
773
|
+
if (!lock) return;
|
|
774
|
+
try {
|
|
775
|
+
await validateLocationSafety(location);
|
|
776
|
+
const current = await readText(location.lock);
|
|
777
|
+
if (!current.exists) return;
|
|
778
|
+
const metadata = JSON.parse(current.text);
|
|
779
|
+
if (metadata.token === lock.token) await rm(location.lock, { force: true });
|
|
780
|
+
} catch {}
|
|
781
|
+
if (lock.createdRoot || lock.command === "uninstall") {
|
|
782
|
+
try { await rmdir(location.codexHome); } catch {}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function operationAfterSignature(operation) {
|
|
787
|
+
if (operation.kind === "write") {
|
|
788
|
+
return { exists: true, hash: digest(operation.content) };
|
|
789
|
+
}
|
|
790
|
+
if (operation.kind === "delete") {
|
|
791
|
+
return { exists: false, hash: null };
|
|
792
|
+
}
|
|
793
|
+
throw new Error(`unknown operation kind: ${operation.kind}`);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async function validateJournal(raw, location) {
|
|
797
|
+
if (!raw || typeof raw !== "object" || raw.version !== 1) {
|
|
798
|
+
throw new Error("unsupported transaction journal");
|
|
799
|
+
}
|
|
800
|
+
if (raw.scope !== location.scope || typeof raw.id !== "string" || !Array.isArray(raw.operations)) {
|
|
801
|
+
throw new Error("invalid transaction journal structure");
|
|
802
|
+
}
|
|
803
|
+
if (!["preparing", "ready", "applying"].includes(raw.status)) {
|
|
804
|
+
throw new Error("invalid transaction status");
|
|
805
|
+
}
|
|
806
|
+
for (let index = 0; index < raw.operations.length; index += 1) {
|
|
807
|
+
const operation = raw.operations[index];
|
|
808
|
+
if (!["write", "delete"].includes(operation.kind)) {
|
|
809
|
+
throw new Error(`invalid transaction operation ${index}`);
|
|
810
|
+
}
|
|
811
|
+
if (typeof operation.path !== "string" || !isAbsolute(operation.path)) {
|
|
812
|
+
throw new Error(`invalid transaction path ${index}`);
|
|
813
|
+
}
|
|
814
|
+
const expected = isWithin(operation.path, location.skillsRoot)
|
|
815
|
+
? location.skillsRoot
|
|
816
|
+
: location.codexHome;
|
|
817
|
+
if (!isWithin(operation.path, expected)) throw new Error(`unsafe transaction path: ${operation.path}`);
|
|
818
|
+
if (!operation.before || typeof operation.before.exists !== "boolean") {
|
|
819
|
+
throw new Error(`invalid transaction snapshot ${index}`);
|
|
820
|
+
}
|
|
821
|
+
if (operation.before.exists && !/^[a-f0-9]{64}$/.test(operation.before.hash || "")) {
|
|
822
|
+
throw new Error(`invalid transaction hash ${index}`);
|
|
823
|
+
}
|
|
824
|
+
if (operation.before.exists && !operation.snapshot) {
|
|
825
|
+
throw new Error(`missing transaction snapshot ${index}`);
|
|
826
|
+
}
|
|
827
|
+
if (operation.snapshot) {
|
|
828
|
+
if (!/^[0-9]{4}\.snapshot$/.test(operation.snapshot)) {
|
|
829
|
+
throw new Error(`invalid transaction snapshot name ${index}`);
|
|
830
|
+
}
|
|
831
|
+
const snapshotRoot = join(location.transactionData, raw.id);
|
|
832
|
+
const snapshot = join(snapshotRoot, operation.snapshot);
|
|
833
|
+
if (!isWithin(snapshot, snapshotRoot)) throw new Error(`unsafe transaction snapshot ${index}`);
|
|
834
|
+
await assertNoIndirection(snapshot, snapshotRoot, location.codexHome, `transaction snapshot ${index}`);
|
|
835
|
+
const snapshotFile = await readBytes(snapshot);
|
|
836
|
+
if (!snapshotFile.exists || digest(snapshotFile.bytes) !== operation.before.hash) {
|
|
837
|
+
throw new Error(`corrupt transaction snapshot ${index}`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (!operation.after || typeof operation.after.exists !== "boolean") {
|
|
841
|
+
throw new Error(`invalid transaction target ${index}`);
|
|
842
|
+
}
|
|
843
|
+
if (operation.after.exists && !/^[a-f0-9]{64}$/.test(operation.after.hash || "")) {
|
|
844
|
+
throw new Error(`invalid transaction target hash ${index}`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return raw;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
async function readJournal(location) {
|
|
851
|
+
const file = await readText(location.journal);
|
|
852
|
+
if (!file.exists) return { exists: false };
|
|
853
|
+
let raw;
|
|
854
|
+
try {
|
|
855
|
+
raw = JSON.parse(file.text);
|
|
856
|
+
} catch {
|
|
857
|
+
return { exists: true, classification: "corrupt", detail: "journal is not valid JSON" };
|
|
858
|
+
}
|
|
859
|
+
try {
|
|
860
|
+
const journal = await validateJournal(raw, location);
|
|
861
|
+
return { exists: true, journal };
|
|
482
862
|
} catch (error) {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
863
|
+
return { exists: true, classification: "corrupt", detail: error.message };
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
async function classifyJournal(location) {
|
|
868
|
+
const loaded = await readJournal(location);
|
|
869
|
+
if (!loaded.exists || loaded.classification === "corrupt") return loaded;
|
|
870
|
+
const journal = loaded.journal;
|
|
871
|
+
if (journal.status === "preparing") {
|
|
872
|
+
return {
|
|
873
|
+
exists: true,
|
|
874
|
+
classification: "unfinished",
|
|
875
|
+
detail: "preparation was interrupted before managed writes",
|
|
876
|
+
journal
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
const states = [];
|
|
880
|
+
for (const operation of journal.operations) {
|
|
881
|
+
const current = await signature(operation.path);
|
|
882
|
+
if (sameSignature(current, operation.before)) states.push("before");
|
|
883
|
+
else if (sameSignature(current, operation.after)) states.push("after");
|
|
884
|
+
else states.push("conflict");
|
|
885
|
+
}
|
|
886
|
+
const conflictIndex = states.indexOf("conflict");
|
|
887
|
+
if (conflictIndex >= 0) {
|
|
888
|
+
return {
|
|
889
|
+
exists: true,
|
|
890
|
+
classification: "conflicting",
|
|
891
|
+
detail: `managed path changed after interruption: ${journal.operations[conflictIndex].path}`,
|
|
892
|
+
journal,
|
|
893
|
+
states
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
return {
|
|
897
|
+
exists: true,
|
|
898
|
+
classification: "recoverable",
|
|
899
|
+
detail: `${states.filter((state) => state === "after").length} applied operation(s) can be rolled back`,
|
|
900
|
+
journal,
|
|
901
|
+
states
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async function cleanupTransaction(location, journal) {
|
|
906
|
+
if (journal?.id) await rm(join(location.transactionData, journal.id), { recursive: true, force: true });
|
|
907
|
+
await rm(location.journal, { force: true });
|
|
908
|
+
try { await rmdir(location.transactionData); } catch {}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
async function recoverTransaction(location, output) {
|
|
912
|
+
const classified = await classifyJournal(location);
|
|
913
|
+
if (!classified.exists) return;
|
|
914
|
+
if (classified.classification === "corrupt") {
|
|
915
|
+
throw new Error(`corrupt transaction: ${classified.detail}`);
|
|
916
|
+
}
|
|
917
|
+
if (classified.classification === "conflicting") {
|
|
918
|
+
throw new Error(`conflicting transaction: ${classified.detail}`);
|
|
919
|
+
}
|
|
920
|
+
const journal = classified.journal;
|
|
921
|
+
if (classified.classification === "unfinished") {
|
|
922
|
+
await cleanupTransaction(location, journal);
|
|
923
|
+
output?.("recover: unfinished transaction (no managed writes were applied)");
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
for (let index = journal.operations.length - 1; index >= 0; index -= 1) {
|
|
928
|
+
await validateLocationSafety(location);
|
|
929
|
+
const operation = journal.operations[index];
|
|
930
|
+
const current = await signature(operation.path);
|
|
931
|
+
if (sameSignature(current, operation.before)) continue;
|
|
932
|
+
if (!sameSignature(current, operation.after)) {
|
|
933
|
+
throw new Error(`conflicting transaction during recovery: ${operation.path}`);
|
|
934
|
+
}
|
|
935
|
+
if (!operation.before.exists) {
|
|
936
|
+
await rm(operation.path, { force: true });
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
const snapshot = join(location.transactionData, journal.id, operation.snapshot);
|
|
940
|
+
const bytes = await readFile(snapshot);
|
|
941
|
+
if (digest(bytes) !== operation.before.hash) {
|
|
942
|
+
throw new Error(`corrupt transaction snapshot: ${operation.path}`);
|
|
943
|
+
}
|
|
944
|
+
await atomicWriteBytes(operation.path, bytes);
|
|
945
|
+
}
|
|
946
|
+
await cleanupTransaction(location, journal);
|
|
947
|
+
output?.("recover: interrupted transaction rolled back");
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
async function prepareJournal(plan) {
|
|
951
|
+
const id = randomUUID();
|
|
952
|
+
const dataRoot = join(plan.location.transactionData, id);
|
|
953
|
+
const operations = [];
|
|
954
|
+
const journal = {
|
|
955
|
+
version: 1,
|
|
956
|
+
id,
|
|
957
|
+
scope: plan.location.scope,
|
|
958
|
+
command: plan.command,
|
|
959
|
+
createdAt: new Date().toISOString(),
|
|
960
|
+
status: "preparing",
|
|
961
|
+
recovery: "rollback",
|
|
962
|
+
operations
|
|
963
|
+
};
|
|
964
|
+
await atomicWrite(plan.location.journal, `${JSON.stringify(journal, null, 2)}\n`);
|
|
965
|
+
await mkdir(dataRoot, { recursive: true });
|
|
966
|
+
|
|
967
|
+
const transactionalOperations = plan.operations.filter((operation) => operation.kind !== "rmdir");
|
|
968
|
+
for (let index = 0; index < transactionalOperations.length; index += 1) {
|
|
969
|
+
const operation = transactionalOperations[index];
|
|
970
|
+
let before;
|
|
971
|
+
let snapshot = null;
|
|
972
|
+
const current = await readBytes(operation.path);
|
|
973
|
+
before = signatureFromBytes(current);
|
|
974
|
+
if (current.exists) {
|
|
975
|
+
snapshot = `${String(index).padStart(4, "0")}.snapshot`;
|
|
976
|
+
const snapshotPath = join(dataRoot, snapshot);
|
|
977
|
+
await writeFile(snapshotPath, current.bytes, { mode: 0o600, flag: "wx" });
|
|
978
|
+
}
|
|
979
|
+
operations.push({
|
|
980
|
+
kind: operation.kind,
|
|
981
|
+
path: operation.path,
|
|
982
|
+
label: operation.label || "",
|
|
983
|
+
before,
|
|
984
|
+
after: operationAfterSignature(operation),
|
|
985
|
+
snapshot,
|
|
986
|
+
done: false
|
|
987
|
+
});
|
|
988
|
+
await atomicWrite(plan.location.journal, `${JSON.stringify(journal, null, 2)}\n`);
|
|
989
|
+
}
|
|
990
|
+
journal.status = "ready";
|
|
991
|
+
await atomicWrite(plan.location.journal, `${JSON.stringify(journal, null, 2)}\n`);
|
|
992
|
+
return journal;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
async function applyOperation(operation) {
|
|
996
|
+
if (operation.kind === "write") await atomicWriteBytes(operation.path, operation.content);
|
|
997
|
+
else if (operation.kind === "delete") await rm(operation.path, { force: true });
|
|
998
|
+
else if (operation.kind === "rmdir") {
|
|
999
|
+
try {
|
|
1000
|
+
await rmdir(operation.path);
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error?.code)) throw error;
|
|
1003
|
+
}
|
|
1004
|
+
} else {
|
|
1005
|
+
throw new Error(`unknown operation kind: ${operation.kind}`);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
async function executePlan(plan, env) {
|
|
1010
|
+
if (plan.failed) return { ok: false };
|
|
1011
|
+
if (!plan.operations.length) return { ok: true };
|
|
1012
|
+
let journal;
|
|
1013
|
+
try {
|
|
1014
|
+
const transactionalOperations = plan.operations.filter((operation) => operation.kind !== "rmdir");
|
|
1015
|
+
const directoryOperations = plan.operations.filter((operation) => operation.kind === "rmdir");
|
|
1016
|
+
if (transactionalOperations.length) {
|
|
1017
|
+
journal = await prepareJournal(plan);
|
|
1018
|
+
journal.status = "applying";
|
|
1019
|
+
await atomicWrite(plan.location.journal, `${JSON.stringify(journal, null, 2)}\n`);
|
|
1020
|
+
for (let index = 0; index < transactionalOperations.length; index += 1) {
|
|
1021
|
+
await validateLocationSafety(plan.location);
|
|
1022
|
+
await applyOperation(transactionalOperations[index]);
|
|
1023
|
+
journal.operations[index].done = true;
|
|
1024
|
+
await atomicWrite(plan.location.journal, `${JSON.stringify(journal, null, 2)}\n`);
|
|
1025
|
+
const crashAfter = Number.parseInt(env?.[TEST_CRASH_ENV] || "", 10);
|
|
1026
|
+
if (env?.[TESTING_ENV] === "1" && crashAfter === index + 1) process.exit(91);
|
|
487
1027
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
1028
|
+
await cleanupTransaction(plan.location, journal);
|
|
1029
|
+
}
|
|
1030
|
+
for (const operation of directoryOperations) {
|
|
1031
|
+
await validateLocationSafety(plan.location);
|
|
1032
|
+
await applyOperation(operation);
|
|
493
1033
|
}
|
|
494
|
-
|
|
495
|
-
|
|
1034
|
+
return { ok: true };
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
try {
|
|
1037
|
+
await recoverTransaction(plan.location);
|
|
1038
|
+
} catch (recoveryError) {
|
|
1039
|
+
return { ok: false, error: new Error(`${error.message}; recovery failed: ${recoveryError.message}`) };
|
|
496
1040
|
}
|
|
497
1041
|
return { ok: false, error };
|
|
498
1042
|
}
|
|
@@ -510,8 +1054,7 @@ function printPlan(plan, output, dryRun) {
|
|
|
510
1054
|
}
|
|
511
1055
|
|
|
512
1056
|
function getAgentValues(content) {
|
|
513
|
-
|
|
514
|
-
return rootAssignments(scan);
|
|
1057
|
+
return rootAssignments(scanToml(content));
|
|
515
1058
|
}
|
|
516
1059
|
|
|
517
1060
|
function getString(assignments, key) {
|
|
@@ -519,16 +1062,15 @@ function getString(assignments, key) {
|
|
|
519
1062
|
return assignment?.parsedValue?.kind === "string" ? assignment.parsedValue.value : null;
|
|
520
1063
|
}
|
|
521
1064
|
|
|
522
|
-
function validateSkill(content) {
|
|
1065
|
+
function validateSkill(content, expectation) {
|
|
523
1066
|
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
524
1067
|
if (!match) return "invalid front matter";
|
|
525
1068
|
const front = match[1];
|
|
526
1069
|
const body = match[2];
|
|
527
|
-
if (
|
|
1070
|
+
if (!front.split(/\r?\n/).some((line) => line.trim() === `name: ${expectation.name}`)) return "incorrect skill name";
|
|
528
1071
|
if (!/^description:\s*\S.+$/m.test(front)) return "missing skill description";
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
return null;
|
|
1072
|
+
const missing = expectation.required.find((term) => !body.toLowerCase().includes(term.toLowerCase()));
|
|
1073
|
+
return missing ? `missing required rule: ${missing}` : null;
|
|
532
1074
|
}
|
|
533
1075
|
|
|
534
1076
|
function doctorStatus(messages, status, label, detail) {
|
|
@@ -540,9 +1082,47 @@ async function doctor(scope) {
|
|
|
540
1082
|
const location = expectedPaths(scope);
|
|
541
1083
|
const messages = [];
|
|
542
1084
|
let healthy = true;
|
|
1085
|
+
try {
|
|
1086
|
+
await validateLocationSafety(location);
|
|
1087
|
+
} catch (error) {
|
|
1088
|
+
doctorStatus(messages, "unsafe-state", "path", error.message);
|
|
1089
|
+
return { healthy: false, messages };
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const lock = await inspectLock(location);
|
|
1093
|
+
if (lock.status === "active") {
|
|
1094
|
+
healthy = doctorStatus(
|
|
1095
|
+
messages,
|
|
1096
|
+
"locked",
|
|
1097
|
+
"scope",
|
|
1098
|
+
`${lock.metadata.command || "operation"} pid ${lock.metadata.pid}`
|
|
1099
|
+
) && healthy;
|
|
1100
|
+
} else if (lock.status === "stale") {
|
|
1101
|
+
healthy = doctorStatus(messages, "stale-lock", "scope", `pid ${lock.metadata.pid}`) && healthy;
|
|
1102
|
+
} else if (lock.status === "corrupt") {
|
|
1103
|
+
healthy = doctorStatus(messages, "unsafe-state", "lock", lock.detail) && healthy;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const transaction = await classifyJournal(location);
|
|
1107
|
+
if (transaction.exists) {
|
|
1108
|
+
const mapping = {
|
|
1109
|
+
unfinished: "unfinished",
|
|
1110
|
+
recoverable: "recoverable",
|
|
1111
|
+
conflicting: "conflicting",
|
|
1112
|
+
corrupt: "corrupt"
|
|
1113
|
+
};
|
|
1114
|
+
healthy = doctorStatus(
|
|
1115
|
+
messages,
|
|
1116
|
+
mapping[transaction.classification] || "invalid",
|
|
1117
|
+
"transaction",
|
|
1118
|
+
transaction.detail
|
|
1119
|
+
) && healthy;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
543
1122
|
let loaded;
|
|
544
|
-
try {
|
|
545
|
-
|
|
1123
|
+
try {
|
|
1124
|
+
loaded = await loadState(location);
|
|
1125
|
+
} catch (error) {
|
|
546
1126
|
doctorStatus(messages, "unsafe-state", "state", error.message);
|
|
547
1127
|
return { healthy: false, messages };
|
|
548
1128
|
}
|
|
@@ -554,20 +1134,22 @@ async function doctor(scope) {
|
|
|
554
1134
|
doctorStatus(messages, "healthy", "state", `schema v${state.version}`);
|
|
555
1135
|
|
|
556
1136
|
const config = await readText(location.config);
|
|
557
|
-
|
|
558
|
-
|
|
1137
|
+
const managedConfigKeys = Object.keys(state.config.values || {});
|
|
1138
|
+
if (!managedConfigKeys.length) {
|
|
1139
|
+
doctorStatus(messages, "healthy", "config.toml", config.exists ? "primary model preserved" : "free mode");
|
|
1140
|
+
} else if (!config.exists) {
|
|
1141
|
+
healthy = doctorStatus(messages, "missing", "config.toml") && healthy;
|
|
559
1142
|
} else {
|
|
560
1143
|
try {
|
|
561
1144
|
const scan = scanToml(config.text);
|
|
562
|
-
for (const
|
|
1145
|
+
for (const key of managedConfigKeys) {
|
|
1146
|
+
const expected = DEFAULTS[key];
|
|
563
1147
|
const assignment = scan.targets.get(key);
|
|
564
|
-
if (!assignment)
|
|
565
|
-
|
|
566
|
-
} else if (assignment.parsedValue.kind !== "string") {
|
|
1148
|
+
if (!assignment) healthy = doctorStatus(messages, "missing", key) && healthy;
|
|
1149
|
+
else if (assignment.parsedValue.kind !== "string") {
|
|
567
1150
|
healthy = doctorStatus(messages, "invalid", key, "must be a TOML string") && healthy;
|
|
568
1151
|
} else if (assignment.parsedValue.value !== expected) {
|
|
569
|
-
|
|
570
|
-
healthy = doctorStatus(messages, status, key, assignment.parsedValue.value ?? assignment.rawValue) && healthy;
|
|
1152
|
+
healthy = doctorStatus(messages, "user-modified", key, assignment.parsedValue.value ?? assignment.rawValue) && healthy;
|
|
571
1153
|
} else {
|
|
572
1154
|
doctorStatus(messages, "healthy", key, expected);
|
|
573
1155
|
}
|
|
@@ -577,7 +1159,7 @@ async function doctor(scope) {
|
|
|
577
1159
|
}
|
|
578
1160
|
}
|
|
579
1161
|
|
|
580
|
-
for (const name of
|
|
1162
|
+
for (const name of AGENT_NAMES) {
|
|
581
1163
|
const current = await readText(location[name]);
|
|
582
1164
|
if (!current.exists) {
|
|
583
1165
|
healthy = doctorStatus(messages, "missing", name) && healthy;
|
|
@@ -585,11 +1167,9 @@ async function doctor(scope) {
|
|
|
585
1167
|
}
|
|
586
1168
|
try {
|
|
587
1169
|
const values = getAgentValues(current.text);
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
const invalid = Object.entries(expected).find(([key, value]) => getString(values, key) !== value);
|
|
592
|
-
if (state.files[name] && hash(current.text) !== state.files[name].hash) {
|
|
1170
|
+
const invalid = Object.entries(AGENT_EXPECTATIONS[name])
|
|
1171
|
+
.find(([key, value]) => getString(values, key) !== value);
|
|
1172
|
+
if (state.files[name] && digest(Buffer.from(current.text, "utf8")) !== state.files[name].hash) {
|
|
593
1173
|
const detail = invalid ? `${invalid[0]} is no longer ${invalid[1]}` : "managed file hash changed";
|
|
594
1174
|
healthy = doctorStatus(messages, "user-modified", name, detail) && healthy;
|
|
595
1175
|
} else if (invalid) {
|
|
@@ -602,28 +1182,42 @@ async function doctor(scope) {
|
|
|
602
1182
|
}
|
|
603
1183
|
}
|
|
604
1184
|
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
1185
|
+
for (const [name, expectation] of Object.entries(SKILL_EXPECTATIONS)) {
|
|
1186
|
+
const current = await readText(location[name]);
|
|
1187
|
+
if (!current.exists) {
|
|
1188
|
+
healthy = doctorStatus(messages, "missing", name) && healthy;
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
const invalid = validateSkill(current.text, expectation);
|
|
1192
|
+
if (state.files[name] && digest(Buffer.from(current.text, "utf8")) !== state.files[name].hash) {
|
|
1193
|
+
healthy = doctorStatus(messages, "user-modified", name, invalid || "managed file hash changed") && healthy;
|
|
1194
|
+
} else if (invalid) {
|
|
1195
|
+
healthy = doctorStatus(messages, "invalid", name, invalid) && healthy;
|
|
1196
|
+
} else {
|
|
1197
|
+
doctorStatus(messages, "healthy", name);
|
|
1198
|
+
}
|
|
613
1199
|
}
|
|
614
1200
|
|
|
615
1201
|
if (state.backup) {
|
|
616
1202
|
const backup = await readText(location.backup);
|
|
617
1203
|
if (!backup.exists) healthy = doctorStatus(messages, "missing", "config backup") && healthy;
|
|
618
|
-
else if (
|
|
619
|
-
|
|
1204
|
+
else if (digest(Buffer.from(backup.text, "utf8")) !== state.backup.hash) {
|
|
1205
|
+
healthy = doctorStatus(messages, "user-modified", "config backup") && healthy;
|
|
1206
|
+
} else {
|
|
1207
|
+
doctorStatus(messages, "healthy", "config backup");
|
|
1208
|
+
}
|
|
620
1209
|
}
|
|
621
|
-
|
|
622
1210
|
return { healthy, messages };
|
|
623
1211
|
}
|
|
624
1212
|
|
|
625
1213
|
function usage() {
|
|
626
|
-
return `codex-model-router ${VERSION}
|
|
1214
|
+
return `codex-model-router ${VERSION}
|
|
1215
|
+
|
|
1216
|
+
Usage:
|
|
1217
|
+
codex-model-router install [--global] [--set-default] [--dry-run]
|
|
1218
|
+
codex-model-router uninstall [--global] [--dry-run]
|
|
1219
|
+
codex-model-router doctor [--global]
|
|
1220
|
+
codex-model-router --version`;
|
|
627
1221
|
}
|
|
628
1222
|
|
|
629
1223
|
function parseArgs(argv) {
|
|
@@ -641,37 +1235,85 @@ function parseArgs(argv) {
|
|
|
641
1235
|
return { command, flags };
|
|
642
1236
|
}
|
|
643
1237
|
|
|
1238
|
+
async function reportNonMutatingBlockers(location, output) {
|
|
1239
|
+
const lock = await inspectLock(location);
|
|
1240
|
+
if (lock.status !== "none") {
|
|
1241
|
+
const detail = lock.metadata
|
|
1242
|
+
? `${lock.metadata.command || "operation"} pid ${lock.metadata.pid}`
|
|
1243
|
+
: lock.detail;
|
|
1244
|
+
const status = lock.status === "stale"
|
|
1245
|
+
? "stale-lock"
|
|
1246
|
+
: lock.status === "corrupt" ? "unsafe-state" : "locked";
|
|
1247
|
+
output(`${status}: scope (${detail})`);
|
|
1248
|
+
return false;
|
|
1249
|
+
}
|
|
1250
|
+
const transaction = await classifyJournal(location);
|
|
1251
|
+
if (transaction.exists) {
|
|
1252
|
+
output(`${transaction.classification}: transaction (${transaction.detail})`);
|
|
1253
|
+
return false;
|
|
1254
|
+
}
|
|
1255
|
+
return true;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
644
1258
|
export async function run(argv, options = {}) {
|
|
645
1259
|
const output = options.output ?? console.log;
|
|
646
1260
|
const parsed = parseArgs(argv);
|
|
647
|
-
if (parsed.version) {
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
1261
|
+
if (parsed.version) {
|
|
1262
|
+
output(VERSION);
|
|
1263
|
+
return 0;
|
|
1264
|
+
}
|
|
1265
|
+
if (parsed.help) {
|
|
1266
|
+
output(usage());
|
|
1267
|
+
return 0;
|
|
1268
|
+
}
|
|
1269
|
+
if (parsed.error) {
|
|
1270
|
+
output(`${parsed.error}\n\n${usage()}`);
|
|
1271
|
+
return 1;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
try {
|
|
1275
|
+
const scope = await canonicalizeScope({
|
|
651
1276
|
cwd: options.cwd ?? process.cwd(),
|
|
652
1277
|
home: options.home,
|
|
653
1278
|
env: options.env ?? process.env,
|
|
654
1279
|
global: parsed.flags.global
|
|
655
|
-
};
|
|
656
|
-
|
|
657
|
-
|
|
1280
|
+
});
|
|
1281
|
+
const location = expectedPaths(scope);
|
|
1282
|
+
await validateLocationSafety(location);
|
|
658
1283
|
if (parsed.command === "doctor") {
|
|
659
1284
|
const result = await doctor(scope);
|
|
660
1285
|
for (const message of result.messages) output(formatMessage(message, false));
|
|
661
1286
|
return result.healthy ? 0 : 1;
|
|
662
1287
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
1288
|
+
|
|
1289
|
+
if (parsed.flags.dryRun) {
|
|
1290
|
+
if (!(await reportNonMutatingBlockers(location, output))) return 1;
|
|
1291
|
+
const plan = parsed.command === "install"
|
|
1292
|
+
? await planInstall(scope, parsed.flags)
|
|
1293
|
+
: await planUninstall(scope);
|
|
1294
|
+
printPlan(plan, output, true);
|
|
1295
|
+
return plan.failed ? 1 : 0;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
let lock;
|
|
1299
|
+
try {
|
|
1300
|
+
lock = await acquireLock(location, parsed.command, output, scope.env);
|
|
1301
|
+
await validateLocationSafety(location);
|
|
1302
|
+
await recoverTransaction(location, output);
|
|
1303
|
+
const plan = parsed.command === "install"
|
|
1304
|
+
? await planInstall(scope, parsed.flags)
|
|
1305
|
+
: await planUninstall(scope);
|
|
1306
|
+
printPlan(plan, output, false);
|
|
1307
|
+
if (plan.failed) return 1;
|
|
1308
|
+
const result = await executePlan(plan, scope.env);
|
|
1309
|
+
if (!result.ok) {
|
|
1310
|
+
output(`fail: execution (${result.error?.message || "unknown error"})`);
|
|
1311
|
+
return 1;
|
|
1312
|
+
}
|
|
1313
|
+
return 0;
|
|
1314
|
+
} finally {
|
|
1315
|
+
await releaseLock(location, lock);
|
|
673
1316
|
}
|
|
674
|
-
return 0;
|
|
675
1317
|
} catch (error) {
|
|
676
1318
|
output(`fail: ${error.message}`);
|
|
677
1319
|
return 1;
|