@lyupro/codex-bridge 0.3.1 → 0.5.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 +92 -0
- package/README.md +88 -84
- package/bin/codex-bridge.mjs +38 -11
- package/cli/copy.mjs +7 -6
- package/cli/doctor.mjs +73 -12
- package/cli/hook-targets.mjs +39 -0
- package/cli/hook.mjs +34 -0
- package/cli/hosts.mjs +28 -4
- package/cli/install-record.mjs +287 -0
- package/cli/install.mjs +73 -39
- package/cli/invoked-directly.mjs +37 -0
- package/cli/manifest.mjs +90 -164
- package/cli/permissions.mjs +179 -0
- package/cli/projects.mjs +20 -5
- package/cli/prune-args.mjs +1 -1
- package/cli/remove-layout.mjs +59 -0
- package/cli/rules-owners.mjs +6 -8
- package/cli/runs-inventory.mjs +2 -1
- package/cli/settings-merge.mjs +182 -50
- package/cli/stop.mjs +33 -20
- package/cli/uninstall.mjs +64 -36
- package/cli/unlock.mjs +239 -0
- package/cli/update.mjs +109 -48
- package/package.json +2 -2
- package/scripts/check-file-size.mjs +8 -10
- package/scripts/isolated-roots.mjs +13 -0
- package/scripts/run-tests.mjs +14 -4
- package/src/agents/codex-build.md +17 -13
- package/src/agents/codex-review.md +15 -9
- package/src/agents/codex-scout.md +16 -11
- package/src/commands/env.md +4 -4
- package/src/commands/usage.md +8 -6
- package/src/heartbeat.mjs +1 -1
- package/src/hook-definitions.mjs +17 -4
- package/src/hooks/guard-tries.mjs +59 -0
- package/src/hooks/live-runs.mjs +31 -20
- package/src/hooks/order-gate.mjs +7 -2
- package/src/hooks/reply-guard.mjs +4 -60
- package/src/hooks/stop-guard.mjs +71 -0
- package/src/json-file.mjs +30 -0
- package/src/meta/chain.mjs +8 -6
- package/src/meta/paths.mjs +5 -9
- package/src/meta/pre-start.mjs +30 -0
- package/src/meta/run-state.mjs +31 -16
- package/src/process-identity.mjs +140 -0
- package/src/required-inputs.mjs +42 -0
- package/src/run-config.mjs +4 -8
- package/src/runner/args.mjs +26 -3
- package/src/runner/attach.mjs +32 -19
- package/src/runner/codex-cmd.mjs +1 -1
- package/src/runner/continuation.mjs +34 -3
- package/src/runner/launcher.mjs +54 -28
- package/src/runner/project-dir.mjs +4 -3
- package/src/runner/scope-check.mjs +159 -0
- package/src/runner/worker.mjs +13 -5
- package/src/stop-contract.mjs +15 -0
- package/src/write-meta.mjs +12 -1
- package/cli/sweep.mjs +0 -144
package/cli/runs-inventory.mjs
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { liveRuns } from '../src/hooks/live-runs.mjs';
|
|
5
|
+
import { readJsonFileSync } from '../src/json-file.mjs';
|
|
5
6
|
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
6
7
|
|
|
7
8
|
const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
8
9
|
|
|
9
10
|
function readRecord(file) {
|
|
10
11
|
try {
|
|
11
|
-
const value =
|
|
12
|
+
const value = readJsonFileSync(file);
|
|
12
13
|
return record(value) ? value : null;
|
|
13
14
|
} catch {
|
|
14
15
|
return null;
|
package/cli/settings-merge.mjs
CHANGED
|
@@ -1,14 +1,87 @@
|
|
|
1
1
|
/** Merges and removes named codex-bridge hooks without disturbing host settings. */
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
5
|
import fs from 'node:fs/promises';
|
|
3
6
|
import path from 'node:path';
|
|
4
7
|
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { readJsonFileWithRaw } from '../src/json-file.mjs';
|
|
5
9
|
|
|
6
10
|
const LEGACY_SPEC = { event: 'SubagentStop', matcher: '*' };
|
|
11
|
+
const settingsRuns = new AsyncLocalStorage();
|
|
7
12
|
|
|
8
13
|
export function commandFor(guardPath) {
|
|
9
14
|
return `node "${path.resolve(guardPath)}"`;
|
|
10
15
|
}
|
|
11
16
|
|
|
17
|
+
export function shortCommandFor(name) {
|
|
18
|
+
return `codex-bridge hook ${name}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pathValue(env) {
|
|
22
|
+
const key = Object.keys(env).find((entry) => entry.toLowerCase() === 'path');
|
|
23
|
+
return key ? env[key] : '';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function commandReachable(name = 'codex-bridge', env = process.env) {
|
|
27
|
+
const extensions = process.platform === 'win32'
|
|
28
|
+
? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';')
|
|
29
|
+
: [''];
|
|
30
|
+
return pathValue(env).split(path.delimiter).filter(Boolean).some((directory) =>
|
|
31
|
+
extensions.some((extension) => {
|
|
32
|
+
const candidate = path.join(directory, `${name}${extension}`);
|
|
33
|
+
try {
|
|
34
|
+
return existsSync(candidate) && statSync(candidate).isFile();
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The version the command on PATH reports, or null when there is none or it will not say.
|
|
43
|
+
*
|
|
44
|
+
* Existence used to be the only question, and on 2026-08-11 that cost the operator every guard on
|
|
45
|
+
* the machine: an install from the clone wrote `codex-bridge hook <name>` while the command on
|
|
46
|
+
* PATH was the previous release, which has no `hook` subcommand. Every guard then failed before it
|
|
47
|
+
* could decide anything, and the host refused Bash, PowerShell, file edits and agent launches.
|
|
48
|
+
*/
|
|
49
|
+
export function reachableCommandVersion(name = 'codex-bridge', env = process.env) {
|
|
50
|
+
if (!commandReachable(name, env)) return null;
|
|
51
|
+
const result = spawnSync(name, ['--version'], {
|
|
52
|
+
encoding: 'utf8',
|
|
53
|
+
env,
|
|
54
|
+
// The launcher npm writes on Windows is a .cmd shim, which spawn cannot execute without a
|
|
55
|
+
// shell. The name is a constant of this module, never operator input.
|
|
56
|
+
shell: process.platform === 'win32',
|
|
57
|
+
});
|
|
58
|
+
if (result.error || result.status !== 0) return null;
|
|
59
|
+
return String(result.stdout || '').trim().split(/\r?\n/).pop()?.trim() || null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function hookRegistration(name, target, env = process.env, packageVersion = null) {
|
|
63
|
+
// Requiring the versions to match is stricter than asking whether `hook` exists, deliberately:
|
|
64
|
+
// a command of another version would run code this install has never seen, which is the hazard
|
|
65
|
+
// README describes when a global install and a clone coexist. Anything short of a match falls
|
|
66
|
+
// back to the copy this install placed itself.
|
|
67
|
+
const reachable = reachableCommandVersion('codex-bridge', env);
|
|
68
|
+
if (reachable && packageVersion && reachable === packageVersion) {
|
|
69
|
+
return {
|
|
70
|
+
command: shortCommandFor(name),
|
|
71
|
+
form: 'short',
|
|
72
|
+
reason: `codex-bridge ${reachable} on PATH matches this package; the global command will execute`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const reason = reachable
|
|
76
|
+
? `codex-bridge on PATH reports ${reachable}, not ${packageVersion}; the installed copy will execute`
|
|
77
|
+
: 'codex-bridge is not reachable from PATH; the installed copy will execute';
|
|
78
|
+
return { command: commandFor(target), form: 'path', reason };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function commandForm(command) {
|
|
82
|
+
return command.startsWith('codex-bridge hook ') ? 'short' : 'path';
|
|
83
|
+
}
|
|
84
|
+
|
|
12
85
|
function normalizeSpec(specOrPath) {
|
|
13
86
|
if (typeof specOrPath === 'string') {
|
|
14
87
|
return { ...LEGACY_SPEC, command: commandFor(specOrPath) };
|
|
@@ -23,21 +96,19 @@ function normalizeSpec(specOrPath) {
|
|
|
23
96
|
event: specOrPath.event,
|
|
24
97
|
matcher: specOrPath.matcher,
|
|
25
98
|
command: specOrPath.command,
|
|
99
|
+
alternateCommands: Array.isArray(specOrPath.alternateCommands)
|
|
100
|
+
? specOrPath.alternateCommands.filter((command) => typeof command === 'string')
|
|
101
|
+
: [],
|
|
26
102
|
};
|
|
27
103
|
}
|
|
28
104
|
|
|
29
|
-
async function readSettings(settingsPath) {
|
|
105
|
+
export async function readSettings(settingsPath) {
|
|
30
106
|
try {
|
|
31
|
-
const raw = await
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
35
|
-
throw new Error('top level must be an object');
|
|
36
|
-
}
|
|
37
|
-
return { exists: true, raw, settings: parsed };
|
|
38
|
-
} catch (err) {
|
|
39
|
-
throw new Error(`cannot parse ${settingsPath}: ${err.message}`);
|
|
107
|
+
const { raw, value: settings } = await readJsonFileWithRaw(settingsPath);
|
|
108
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
|
109
|
+
throw new Error(`cannot parse ${settingsPath}: top level must be an object`);
|
|
40
110
|
}
|
|
111
|
+
return { exists: true, raw, settings };
|
|
41
112
|
} catch (err) {
|
|
42
113
|
if (err.code === 'ENOENT') return { exists: false, raw: null, settings: {} };
|
|
43
114
|
throw err;
|
|
@@ -57,8 +128,10 @@ function groupHooks(group) {
|
|
|
57
128
|
return Array.isArray(group?.hooks) ? group.hooks : [];
|
|
58
129
|
}
|
|
59
130
|
|
|
60
|
-
const hasOwnCommand = (group,
|
|
61
|
-
|
|
131
|
+
const hasOwnCommand = (group, commands) => {
|
|
132
|
+
const expected = Array.isArray(commands) ? commands : [commands];
|
|
133
|
+
return groupHooks(group).some((hook) => hook?.type === 'command' && expected.includes(hook.command));
|
|
134
|
+
};
|
|
62
135
|
|
|
63
136
|
/**
|
|
64
137
|
* Presence is decided by the command, not by the matcher it currently sits under.
|
|
@@ -73,9 +146,17 @@ const hasOwnCommand = (group, command) =>
|
|
|
73
146
|
export async function inspectHook(settingsPath, specOrPath) {
|
|
74
147
|
const state = await readSettings(settingsPath);
|
|
75
148
|
const spec = normalizeSpec(specOrPath);
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
149
|
+
const commands = [spec.command, ...spec.alternateCommands];
|
|
150
|
+
let matchedCommand;
|
|
151
|
+
for (const group of groups(state.settings, spec.event)) {
|
|
152
|
+
const hook = groupHooks(group).find((entry) =>
|
|
153
|
+
entry?.type === 'command' && commands.includes(entry.command));
|
|
154
|
+
if (hook) {
|
|
155
|
+
matchedCommand = hook.command;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { ...state, ...spec, present: Boolean(matchedCommand), matchedCommand };
|
|
79
160
|
}
|
|
80
161
|
|
|
81
162
|
function backupName(settingsPath) {
|
|
@@ -83,9 +164,38 @@ function backupName(settingsPath) {
|
|
|
83
164
|
return `${settingsPath}.codex-bridge-backup-${stamp}-${randomUUID()}`;
|
|
84
165
|
}
|
|
85
166
|
|
|
167
|
+
function runKey(settingsPath) {
|
|
168
|
+
return path.resolve(settingsPath);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A command gets one in-memory backup scope. AsyncLocalStorage keeps concurrent commands separate
|
|
173
|
+
* in one process, while nested install calls reuse their caller's scope (Plan_24).
|
|
174
|
+
*/
|
|
175
|
+
export async function withSettingsRun(settingsPath, action) {
|
|
176
|
+
if (typeof action !== 'function') throw new TypeError('settings run action must be a function');
|
|
177
|
+
const key = runKey(settingsPath);
|
|
178
|
+
const active = settingsRuns.getStore();
|
|
179
|
+
if (active?.key === key) return action();
|
|
180
|
+
return settingsRuns.run({ key, backupTaken: false }, action);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function withMutationRun(settingsPath, action) {
|
|
184
|
+
const active = settingsRuns.getStore();
|
|
185
|
+
return active?.key === runKey(settingsPath)
|
|
186
|
+
? action()
|
|
187
|
+
: withSettingsRun(settingsPath, action);
|
|
188
|
+
}
|
|
189
|
+
|
|
86
190
|
async function atomicWrite(settingsPath, settings, previous) {
|
|
87
191
|
await fs.mkdir(path.dirname(settingsPath), { recursive: true });
|
|
88
|
-
|
|
192
|
+
const active = settingsRuns.getStore();
|
|
193
|
+
const ownsScope = active?.key === runKey(settingsPath);
|
|
194
|
+
const shouldBackup = !ownsScope || !active.backupTaken;
|
|
195
|
+
if (ownsScope) active.backupTaken = true;
|
|
196
|
+
if (shouldBackup && previous.exists) {
|
|
197
|
+
await fs.writeFile(backupName(settingsPath), previous.raw, { flag: 'wx' });
|
|
198
|
+
}
|
|
89
199
|
const temporary = `${settingsPath}.${randomUUID()}.tmp`;
|
|
90
200
|
try {
|
|
91
201
|
await fs.writeFile(temporary, `${JSON.stringify(settings, null, 2)}\n`, { flag: 'wx' });
|
|
@@ -96,42 +206,64 @@ async function atomicWrite(settingsPath, settings, previous) {
|
|
|
96
206
|
}
|
|
97
207
|
}
|
|
98
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Runs a settings mutation through the hook writer's backup and atomic-write boundary.
|
|
211
|
+
* Plan_22 requires permissions to preserve the same recovery guarantee after the settings merge
|
|
212
|
+
* incident: a second writer must not grow its own backup or serialization path.
|
|
213
|
+
*/
|
|
214
|
+
export async function updateSettings(settingsPath, edit) {
|
|
215
|
+
if (typeof edit !== 'function') throw new TypeError('settings edit must be a function');
|
|
216
|
+
return withMutationRun(settingsPath, async () => {
|
|
217
|
+
const previous = await readSettings(settingsPath);
|
|
218
|
+
const settings = structuredClone(previous.settings);
|
|
219
|
+
const result = await edit(settings);
|
|
220
|
+
if (!result?.changed) return result || { changed: false };
|
|
221
|
+
await atomicWrite(settingsPath, settings, previous);
|
|
222
|
+
return result;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
99
226
|
export async function mergeHook(settingsPath, specOrPath, inspected) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
group
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
227
|
+
return withMutationRun(settingsPath, async () => {
|
|
228
|
+
const spec = normalizeSpec(specOrPath);
|
|
229
|
+
const state = inspected || await inspectHook(settingsPath, spec);
|
|
230
|
+
if (state.present) return { changed: false, createdGroup: false };
|
|
231
|
+
const settings = structuredClone(state.settings);
|
|
232
|
+
settings.hooks ??= {};
|
|
233
|
+
if (!Array.isArray(settings.hooks[spec.event])) settings.hooks[spec.event] = [];
|
|
234
|
+
let group = settings.hooks[spec.event].find((entry) => entry?.matcher === spec.matcher);
|
|
235
|
+
const createdGroup = !group;
|
|
236
|
+
if (!group) {
|
|
237
|
+
group = { matcher: spec.matcher, hooks: [] };
|
|
238
|
+
settings.hooks[spec.event].push(group);
|
|
239
|
+
}
|
|
240
|
+
if (!Array.isArray(group.hooks)) group.hooks = [];
|
|
241
|
+
group.hooks.push(ownHook(spec.command));
|
|
242
|
+
await atomicWrite(settingsPath, settings, state);
|
|
243
|
+
return { changed: true, createdGroup };
|
|
244
|
+
});
|
|
116
245
|
}
|
|
117
246
|
|
|
118
247
|
export async function removeHook(settingsPath, specOrPath, { createdGroup = false } = {}) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
!(
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
248
|
+
return withMutationRun(settingsPath, async () => {
|
|
249
|
+
const spec = normalizeSpec(specOrPath);
|
|
250
|
+
const state = await inspectHook(settingsPath, spec);
|
|
251
|
+
if (!state.present) return { changed: false };
|
|
252
|
+
const settings = structuredClone(state.settings);
|
|
253
|
+
const eventGroups = groups(settings, spec.event);
|
|
254
|
+
// Removal follows the same rule as the lookup above: our command, whatever matcher it is
|
|
255
|
+
// filed under. A foreign hook is left alone because its command is not ours, not because it
|
|
256
|
+
// sits in another group.
|
|
257
|
+
for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
|
|
258
|
+
const group = eventGroups[index];
|
|
259
|
+
const commands = [spec.command, ...spec.alternateCommands];
|
|
260
|
+
if (!hasOwnCommand(group, commands)) continue;
|
|
261
|
+
if (!Array.isArray(group?.hooks)) continue;
|
|
262
|
+
group.hooks = group.hooks.filter((hook) =>
|
|
263
|
+
!(hook?.type === 'command' && commands.includes(hook.command)));
|
|
264
|
+
if (createdGroup && group.hooks.length === 0) eventGroups.splice(index, 1);
|
|
265
|
+
}
|
|
266
|
+
await atomicWrite(settingsPath, settings, state);
|
|
267
|
+
return { changed: true };
|
|
268
|
+
});
|
|
137
269
|
}
|
package/cli/stop.mjs
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
/** Stops a live run and records the same abandoned FAIL artifacts as the normal
|
|
1
|
+
/** Stops a live run and records the same abandoned FAIL artifacts as the normal unlock. */
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { stopCodex } from '../src/runner/codex-cmd.mjs';
|
|
4
4
|
import { git, worktreeSnapshot } from '../src/runner/git-state.mjs';
|
|
5
|
+
import {
|
|
6
|
+
IDENTITY_ALIVE,
|
|
7
|
+
IDENTITY_FOREIGN,
|
|
8
|
+
IDENTITY_UNVERIFIED,
|
|
9
|
+
processAlive,
|
|
10
|
+
processIdentity,
|
|
11
|
+
} from '../src/process-identity.mjs';
|
|
5
12
|
import { markAbandoned, readJson } from '../src/write-meta.mjs';
|
|
6
13
|
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
7
14
|
import { resolveRunFolder } from './run-lookup.mjs';
|
|
@@ -32,27 +39,22 @@ function processHandle(pid) {
|
|
|
32
39
|
};
|
|
33
40
|
}
|
|
34
41
|
|
|
35
|
-
function alive(pid) {
|
|
36
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
37
|
-
try {
|
|
38
|
-
process.kill(pid, 0);
|
|
39
|
-
return true;
|
|
40
|
-
} catch (err) {
|
|
41
|
-
return err.code === 'EPERM';
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
42
|
async function waitForExit(pid) {
|
|
46
43
|
const deadline = Date.now() + EXIT_WAIT_MS;
|
|
47
|
-
while (
|
|
48
|
-
return !
|
|
44
|
+
while (processAlive(pid) && Date.now() < deadline) await sleep(EXIT_POLL_MS);
|
|
45
|
+
return !processAlive(pid);
|
|
49
46
|
}
|
|
50
47
|
|
|
51
48
|
function result(exitCode, output) {
|
|
52
49
|
return { exitCode, output };
|
|
53
50
|
}
|
|
54
51
|
|
|
55
|
-
export async function stop({
|
|
52
|
+
export async function stop({
|
|
53
|
+
run,
|
|
54
|
+
cwd = process.cwd(),
|
|
55
|
+
runsRootPath = runsRoot(),
|
|
56
|
+
commandRunner,
|
|
57
|
+
} = {}) {
|
|
56
58
|
const lookup = resolveRunFolder({ command: 'stop', run, cwd, runsRootPath });
|
|
57
59
|
if (lookup.error) return result(1, lookup.error);
|
|
58
60
|
const { runDir } = lookup;
|
|
@@ -70,13 +72,24 @@ export async function stop({ run, cwd = process.cwd(), runsRootPath = runsRoot()
|
|
|
70
72
|
return result(1, `Run ${runDir} has no valid recorded pid; it was not changed.`);
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
const identity = processIdentity({ runDir, status, commandRunner });
|
|
76
|
+
if (identity === IDENTITY_UNVERIFIED || identity === IDENTITY_FOREIGN) {
|
|
77
|
+
return result(
|
|
78
|
+
1,
|
|
79
|
+
`Process identity could not be confirmed for run ${runDir} (${identity}); no signal was sent. ` +
|
|
80
|
+
'"codex-bridge unlock" closes the record without killing a process.',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (identity === IDENTITY_ALIVE) {
|
|
85
|
+
try {
|
|
86
|
+
stopCodex(processHandle(status.pid));
|
|
87
|
+
if (!(await waitForExit(status.pid))) {
|
|
88
|
+
return result(1, `Run ${runDir} did not stop within ${EXIT_WAIT_MS}ms; it was not closed.`);
|
|
89
|
+
}
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return result(1, `Could not stop run ${runDir}: ${err.message}; it was not closed.`);
|
|
77
92
|
}
|
|
78
|
-
} catch (err) {
|
|
79
|
-
return result(1, `Could not stop run ${runDir}: ${err.message}; it was not closed.`);
|
|
80
93
|
}
|
|
81
94
|
|
|
82
95
|
// The snapshot is what turns the verdict from a bare label into a list of what the stopped run
|
package/cli/uninstall.mjs
CHANGED
|
@@ -1,40 +1,56 @@
|
|
|
1
|
-
/** Uninstalls only recorded
|
|
1
|
+
/** Uninstalls only recorded files from both roots while preserving host data and foreign files. */
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import {
|
|
5
|
+
definitionForRecordedHook,
|
|
5
6
|
fileFingerprint,
|
|
6
|
-
HOOK_DEFINITIONS,
|
|
7
7
|
installRecordPath,
|
|
8
|
+
legacyInstallRecordPath,
|
|
8
9
|
readInstallRecord,
|
|
10
|
+
recordTarget,
|
|
9
11
|
} from './manifest.mjs';
|
|
10
|
-
import {
|
|
12
|
+
import { removePermissionRules } from './permissions.mjs';
|
|
13
|
+
import {
|
|
14
|
+
commandFor,
|
|
15
|
+
removeHook,
|
|
16
|
+
withSettingsRun,
|
|
17
|
+
} from './settings-merge.mjs';
|
|
11
18
|
import { readRulesRegistry, removeRulesOwner, remainingRulesOwners } from './rules-owners.mjs';
|
|
19
|
+
import {
|
|
20
|
+
claudeBoundary,
|
|
21
|
+
removeEmpty,
|
|
22
|
+
removeEmptyLayout,
|
|
23
|
+
removeEmptyParents,
|
|
24
|
+
} from './remove-layout.mjs';
|
|
12
25
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if ((await fs.readdir(directory)).length === 0) await fs.rmdir(directory);
|
|
16
|
-
} catch (err) {
|
|
17
|
-
if (err.code !== 'ENOENT') throw err;
|
|
18
|
-
}
|
|
26
|
+
function remainingOwnersText(count) {
|
|
27
|
+
return `${count} other owner${count === 1 ? '' : 's'} ${count === 1 ? 'remains' : 'remain'}`;
|
|
19
28
|
}
|
|
20
29
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
current = path.dirname(current);
|
|
26
|
-
}
|
|
30
|
+
function permissionOutput(host, removed, dryRun) {
|
|
31
|
+
const verb = dryRun ? 'Would remove' : 'Removed';
|
|
32
|
+
const plural = removed === 1 ? 'string' : 'strings';
|
|
33
|
+
return `${verb} ${removed} permission rule ${plural} from ${host.settingsPath}.`;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
function
|
|
30
|
-
return `${
|
|
36
|
+
function displayFile(file) {
|
|
37
|
+
return `${file.root}/${file.path}`;
|
|
31
38
|
}
|
|
32
39
|
|
|
33
|
-
function
|
|
34
|
-
|
|
40
|
+
function hookRemovalSpec(host, hook) {
|
|
41
|
+
const definition = definitionForRecordedHook(hook);
|
|
42
|
+
const target = recordTarget(host, hook);
|
|
43
|
+
const full = commandFor(target);
|
|
44
|
+
const short = `codex-bridge hook ${definition.name}`;
|
|
45
|
+
return {
|
|
46
|
+
event: hook.event,
|
|
47
|
+
matcher: definition.matcher,
|
|
48
|
+
command: hook.command || full,
|
|
49
|
+
alternateCommands: [full, short],
|
|
50
|
+
};
|
|
35
51
|
}
|
|
36
52
|
|
|
37
|
-
|
|
53
|
+
async function uninstallInRun({ host, dryRun = false } = {}) {
|
|
38
54
|
// Preflight before removing the hook: package removal on a broken registry left the host without its watchdog.
|
|
39
55
|
let registry = null;
|
|
40
56
|
let registryError = null;
|
|
@@ -43,13 +59,17 @@ export async function uninstall({ host, dryRun = false } = {}) {
|
|
|
43
59
|
} catch (err) {
|
|
44
60
|
registryError = err;
|
|
45
61
|
}
|
|
62
|
+
const permissionResult = await removePermissionRules(host.settingsPath, { dryRun });
|
|
63
|
+
const permissionLine = permissionOutput(host, permissionResult.removed, dryRun);
|
|
46
64
|
const record = await readInstallRecord(host);
|
|
47
65
|
const preservation = `Run artifacts in ${path.join(host.root, 'codex-runs')} and the run `
|
|
48
|
-
+ `configuration in ${
|
|
49
|
-
if (!record)
|
|
66
|
+
+ `configuration in ${host.brandConfigPath} are preserved.`;
|
|
67
|
+
if (!record) {
|
|
68
|
+
return { exitCode: 1, output: `${permissionLine}\ncodex-bridge is not installed.\n${preservation}` };
|
|
69
|
+
}
|
|
50
70
|
|
|
51
71
|
if (dryRun) {
|
|
52
|
-
const lines = record.files.map((file) => `Would remove ${file}`);
|
|
72
|
+
const lines = [permissionLine, ...record.files.map((file) => `Would remove ${displayFile(file)}`)];
|
|
53
73
|
if (record.rules) {
|
|
54
74
|
if (registryError) {
|
|
55
75
|
lines.push(`Would leave ${record.rules.path} because the rules ownership registry is invalid; ownership is unknown.`);
|
|
@@ -71,22 +91,18 @@ export async function uninstall({ host, dryRun = false } = {}) {
|
|
|
71
91
|
}
|
|
72
92
|
}
|
|
73
93
|
for (const hook of record.hooks) {
|
|
74
|
-
const definition =
|
|
94
|
+
const definition = definitionForRecordedHook(hook);
|
|
75
95
|
lines.push(`Would remove the ${hook.event} hook for matcher ${definition.matcher}.`);
|
|
76
96
|
}
|
|
77
|
-
lines.push('Would remove the installation record.');
|
|
97
|
+
lines.push('Would remove the installation record from the brand root.');
|
|
78
98
|
lines.push(preservation);
|
|
79
99
|
return { exitCode: 0, output: lines.join('\n') };
|
|
80
100
|
}
|
|
81
101
|
|
|
82
102
|
for (const hook of record.hooks) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
event: hook.event,
|
|
87
|
-
matcher: definition.matcher,
|
|
88
|
-
command: commandFor(target),
|
|
89
|
-
}, { createdGroup: hook.createdGroup === true });
|
|
103
|
+
await removeHook(host.settingsPath, hookRemovalSpec(host, hook), {
|
|
104
|
+
createdGroup: hook.createdGroup === true,
|
|
105
|
+
});
|
|
90
106
|
}
|
|
91
107
|
let ownership = null;
|
|
92
108
|
if (!registryError) {
|
|
@@ -116,17 +132,29 @@ export async function uninstall({ host, dryRun = false } = {}) {
|
|
|
116
132
|
}
|
|
117
133
|
}
|
|
118
134
|
}
|
|
119
|
-
for (const
|
|
120
|
-
const target =
|
|
135
|
+
for (const file of record.files) {
|
|
136
|
+
const target = recordTarget(host, file);
|
|
121
137
|
await fs.rm(target, { force: true });
|
|
122
|
-
const boundary =
|
|
138
|
+
const boundary = file.root === 'brand'
|
|
139
|
+
? host.brandRoot
|
|
140
|
+
: claudeBoundary(host, target);
|
|
123
141
|
await removeEmptyParents(target, boundary);
|
|
124
142
|
}
|
|
125
143
|
await removeEmpty(host.commandsDir);
|
|
126
144
|
await fs.rm(installRecordPath(host), { force: true });
|
|
145
|
+
await fs.rm(legacyInstallRecordPath(host), { force: true });
|
|
127
146
|
await removeEmpty(host.agentsDir);
|
|
147
|
+
await removeEmptyLayout(host.legacyAgentsDir);
|
|
148
|
+
await removeEmptyLayout(host.legacyCommandsDir);
|
|
149
|
+
await removeEmpty(host.brandRoot);
|
|
128
150
|
return {
|
|
129
151
|
exitCode: 0,
|
|
130
|
-
output: [
|
|
152
|
+
output: ['Uninstalled codex-bridge.', permissionLine, ...rulesOutput, preservation].join('\n'),
|
|
131
153
|
};
|
|
132
154
|
}
|
|
155
|
+
|
|
156
|
+
export async function uninstall(options = {}) {
|
|
157
|
+
const host = options?.host;
|
|
158
|
+
if (!host?.settingsPath) return uninstallInRun(options);
|
|
159
|
+
return withSettingsRun(host.settingsPath, () => uninstallInRun(options));
|
|
160
|
+
}
|