@lyupro/codex-bridge 0.6.8 → 0.6.10
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 +160 -0
- package/README.md +6 -4
- package/bin/codex-bridge.mjs +35 -177
- package/cli/command-help.mjs +49 -0
- package/cli/command-registry.mjs +284 -0
- package/cli/copy.mjs +42 -5
- package/cli/dispatcher-contract-record.mjs +3 -2
- package/cli/doctor-host-side.mjs +110 -0
- package/cli/doctor.mjs +17 -1
- package/cli/home-inspection.mjs +122 -0
- package/cli/home-plan-execute.mjs +140 -0
- package/cli/home-removal-plan.mjs +61 -0
- package/cli/hook-recognizer.mjs +78 -0
- package/cli/host-contract.mjs +11 -5
- package/cli/host-inspection.mjs +156 -0
- package/cli/host-removal.mjs +91 -0
- package/cli/image-evidence.mjs +44 -0
- package/cli/install-owners.mjs +244 -0
- package/cli/install-record.mjs +71 -9
- package/cli/install.mjs +88 -75
- package/cli/inventory-removal.mjs +84 -0
- package/cli/inventory-transition.mjs +66 -0
- package/cli/lifecycle-lock-clear.mjs +104 -0
- package/cli/lifecycle-lock-inspect.mjs +92 -0
- package/cli/lifecycle-lock.mjs +222 -0
- package/cli/lifecycle-transaction.mjs +57 -0
- package/cli/link-segments.mjs +45 -0
- package/cli/model-set.mjs +4 -3
- package/cli/model-speed.mjs +9 -2
- package/cli/model.mjs +5 -3
- package/cli/owner-record.mjs +40 -0
- package/cli/owner-sync-apply.mjs +108 -0
- package/cli/owner-sync.mjs +115 -0
- package/cli/projects.mjs +5 -2
- package/cli/prune-args.mjs +9 -4
- package/cli/prune.mjs +23 -72
- package/cli/purge-live-runs.mjs +98 -0
- package/cli/purge-preflight.mjs +103 -0
- package/cli/read.mjs +22 -1
- package/cli/record-removal.mjs +43 -0
- package/cli/removal-outcomes.mjs +115 -0
- package/cli/removal-plan.mjs +187 -0
- package/cli/remove-layout.mjs +40 -1
- package/cli/rules-owners.mjs +4 -2
- package/cli/settings-merge.mjs +3 -29
- package/cli/terminal-question.mjs +41 -0
- package/cli/uninstall-purge.mjs +139 -0
- package/cli/uninstall-rules.mjs +72 -0
- package/cli/uninstall.mjs +208 -127
- package/cli/unlock-lifecycle.mjs +102 -0
- package/cli/unlock.mjs +7 -4
- package/cli/update.mjs +102 -27
- package/package.json +4 -3
- package/scripts/home-write-audit.mjs +286 -0
- package/scripts/home-write-inventory.mjs +59 -0
- package/scripts/home-write-lexer.mjs +182 -0
- package/scripts/isolated-roots.mjs +6 -1
- package/scripts/open-work/check.mjs +180 -0
- package/scripts/open-work/live-thresholds.mjs +155 -0
- package/scripts/open-work/register.mjs +56 -0
- package/scripts/root-guard/root-entries.mjs +15 -0
- package/scripts/run-tests.mjs +26 -1
- package/src/agents/codex-advisor.md +23 -10
- package/src/agents/codex-build.md +24 -14
- package/src/agents/codex-review.md +17 -10
- package/src/agents/codex-scout.md +22 -12
- package/src/home/conventions.md +9 -0
- package/src/home/hooks/guard-tries.mjs +6 -4
- package/src/home/hooks/order-gate.mjs +27 -19
- package/src/home/hooks/reply-guard.mjs +2 -7
- package/src/home/hooks/reply-parser.mjs +1 -1
- package/src/home/hooks/worktree-witness.mjs +6 -6
- package/src/home/lib/agents.mjs +1 -1
- package/src/home/lib/atomic-json.mjs +24 -6
- package/src/home/lib/command-path.mjs +32 -0
- package/src/home/lib/config-edit.mjs +25 -10
- package/src/home/lib/dispatcher-command.mjs +4 -2
- package/src/home/lib/dispatcher-state.mjs +25 -10
- package/src/home/lib/file-lock.mjs +41 -10
- package/src/home/lib/handback-witness.mjs +7 -4
- package/src/home/lib/home-registry.mjs +211 -0
- package/src/home/lib/home-write.mjs +189 -0
- package/src/home/lib/hook-diagnostics.mjs +20 -0
- package/src/home/lib/host-observations.mjs +7 -4
- package/src/home/lib/kernel-lock.mjs +171 -0
- package/src/home/lib/meta/advice-status.mjs +2 -2
- package/src/home/lib/meta/advice-verdict.mjs +30 -1
- package/src/home/lib/meta/chain.mjs +19 -8
- package/src/home/lib/meta/context-only.mjs +43 -0
- package/src/home/lib/meta/events.mjs +16 -3
- package/src/home/lib/meta/launch-rows.mjs +23 -0
- package/src/home/lib/meta/paths.mjs +24 -24
- package/src/home/lib/meta/reply.mjs +37 -3
- package/src/home/lib/meta/run-snapshots.mjs +29 -0
- package/src/home/lib/meta/run-state.mjs +3 -0
- package/src/home/lib/meta/snapshot-format.mjs +96 -0
- package/src/home/lib/meta/verdict.mjs +30 -24
- package/src/home/lib/required-inputs.mjs +48 -8
- package/src/home/lib/run-codex.mjs +6 -3
- package/src/home/lib/run-config.mjs +10 -1
- package/src/home/lib/runner/advise-carry.mjs +2 -1
- package/src/home/lib/runner/args.mjs +16 -5
- package/src/home/lib/runner/attach.mjs +21 -14
- package/src/home/lib/runner/build-evidence.mjs +29 -0
- package/src/home/lib/runner/codex-availability.mjs +50 -0
- package/src/home/lib/runner/codex-cmd.mjs +2 -19
- package/src/home/lib/runner/continuation.mjs +60 -8
- package/src/home/lib/runner/exit-codes.mjs +13 -0
- package/src/home/lib/runner/flag-baseline.mjs +105 -0
- package/src/home/lib/runner/flag-scan.mjs +165 -0
- package/src/home/lib/runner/git-paths.mjs +74 -0
- package/src/home/lib/runner/git-state.mjs +37 -66
- package/src/home/lib/runner/launcher.mjs +233 -273
- package/src/home/lib/runner/order-claim.mjs +84 -0
- package/src/home/lib/runner/order-owner.mjs +18 -0
- package/src/home/lib/runner/pass-gate.mjs +109 -0
- package/src/home/lib/runner/preflight.mjs +22 -18
- package/src/home/lib/runner/prompts.mjs +42 -4
- package/src/home/lib/runner/question-kind.mjs +41 -0
- package/src/home/lib/runner/run-env.mjs +2 -1
- package/src/home/lib/runner/sandbox-probe.mjs +1 -1
- package/src/home/lib/runner/schemas.mjs +4 -1
- package/src/home/lib/runner/scope-check.mjs +55 -28
- package/src/home/lib/runner/task-input.mjs +11 -2
- package/src/home/lib/runner/worker-admission.mjs +43 -0
- package/src/home/lib/runner/worker.mjs +19 -18
- package/src/home/lib/task-header.mjs +127 -0
- package/src/home/lib/write-meta.mjs +17 -5
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lists commands and how each is invoked (Plan_71 D1, advice A1: registry-dispatch).
|
|
3
|
+
* model -h refused help and global help omitted its actions (2026-09-29) because
|
|
4
|
+
* dispatch and usage had separate sources. One registry keeps invocation and help together.
|
|
5
|
+
*/
|
|
6
|
+
import { diagnose, renderDoctor } from './doctor.mjs';
|
|
7
|
+
import { probeContract } from './probe-contract.mjs';
|
|
8
|
+
import { resolveProbeTarget } from './probe-target.mjs';
|
|
9
|
+
import { brandStateDir } from '../src/home/lib/brand-home.mjs';
|
|
10
|
+
import { hook } from './hook.mjs';
|
|
11
|
+
import { resolveHost } from './hosts.mjs';
|
|
12
|
+
import { install } from './install.mjs';
|
|
13
|
+
import { model } from './model.mjs';
|
|
14
|
+
import { projects } from './projects.mjs';
|
|
15
|
+
import { read } from './read.mjs';
|
|
16
|
+
import { runCodex } from './run-launcher.mjs';
|
|
17
|
+
import { permissions } from './permissions.mjs';
|
|
18
|
+
import { prune } from './prune.mjs';
|
|
19
|
+
import { stop } from './stop.mjs';
|
|
20
|
+
import { unlock } from './unlock.mjs';
|
|
21
|
+
import { uninstall } from './uninstall.mjs';
|
|
22
|
+
import { purge } from './uninstall-purge.mjs';
|
|
23
|
+
import { update } from './update.mjs';
|
|
24
|
+
|
|
25
|
+
export function commandOptions(command, argv) {
|
|
26
|
+
const options = {};
|
|
27
|
+
const booleanFlags = command === 'install' || command === 'update' ? new Set(['--dry-run', '--force'])
|
|
28
|
+
: command === 'uninstall' ? new Set(['--dry-run', '--purge'])
|
|
29
|
+
: command === 'doctor' ? new Set(['--probe-contract']) : new Set();
|
|
30
|
+
const flagNames = new Map([
|
|
31
|
+
['--dry-run', 'dryRun'],
|
|
32
|
+
['--purge', 'purge'],
|
|
33
|
+
['--force', 'force'],
|
|
34
|
+
['--probe-contract', 'probeContract'],
|
|
35
|
+
]);
|
|
36
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
37
|
+
const arg = argv[index];
|
|
38
|
+
if (booleanFlags.has(arg)) {
|
|
39
|
+
options[flagNames.get(arg)] = true;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (arg !== '--scope' && arg !== '--host' && !(command === 'doctor' && arg === '--probe-executable')) throw new Error(`unknown ${command} option "${arg}"`);
|
|
43
|
+
const value = argv[index + 1];
|
|
44
|
+
if (!value || value.startsWith('-')) throw new Error(`${arg} requires a value`);
|
|
45
|
+
options[arg === '--scope' ? 'scope' : arg === '--host' ? 'host' : 'probeExecutable'] = value;
|
|
46
|
+
index += 1;
|
|
47
|
+
}
|
|
48
|
+
return options;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const COMMANDS = [
|
|
52
|
+
{
|
|
53
|
+
name: 'install',
|
|
54
|
+
summary: 'Install codex-bridge into the selected Claude Code host',
|
|
55
|
+
usage: ['codex-bridge install [--scope user|project] [--host <path>] [--dry-run] [--force]'],
|
|
56
|
+
section: 'public',
|
|
57
|
+
async handler(argv, io) {
|
|
58
|
+
const options = commandOptions('install', argv);
|
|
59
|
+
const host = resolveHost(options);
|
|
60
|
+
const result = await install({ host, dryRun: options.dryRun, force: options.force });
|
|
61
|
+
io.log(result.output);
|
|
62
|
+
return result.exitCode;
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'update',
|
|
67
|
+
summary: 'Update a recorded codex-bridge installation',
|
|
68
|
+
usage: ['codex-bridge update [--scope user|project] [--host <path>] [--dry-run] [--force]'],
|
|
69
|
+
section: 'public',
|
|
70
|
+
async handler(argv, io) {
|
|
71
|
+
const options = commandOptions('update', argv);
|
|
72
|
+
const host = resolveHost(options);
|
|
73
|
+
const result = await update({ host, dryRun: options.dryRun, force: options.force });
|
|
74
|
+
io.log(result.output);
|
|
75
|
+
return result.exitCode;
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'permissions',
|
|
80
|
+
summary: 'Show, add, or remove optional shell permission rules',
|
|
81
|
+
usage: ['codex-bridge permissions [add|remove] [--scope user|project] [--host <path>]'],
|
|
82
|
+
section: 'public',
|
|
83
|
+
actions: {
|
|
84
|
+
add: {
|
|
85
|
+
summary: 'Add optional shell permission rules',
|
|
86
|
+
usage: ['codex-bridge permissions add [--scope user|project] [--host <path>]'],
|
|
87
|
+
},
|
|
88
|
+
remove: {
|
|
89
|
+
summary: 'Remove optional shell permission rules',
|
|
90
|
+
usage: ['codex-bridge permissions remove [--scope user|project] [--host <path>]'],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
async handler(argv, io) {
|
|
94
|
+
let action;
|
|
95
|
+
let optionArgs = argv;
|
|
96
|
+
if (argv[0] && !argv[0].startsWith('-')) {
|
|
97
|
+
action = argv[0];
|
|
98
|
+
optionArgs = argv.slice(1);
|
|
99
|
+
}
|
|
100
|
+
if (action && !['add', 'remove'].includes(action)) {
|
|
101
|
+
throw new Error(`unknown permissions action "${action}"`);
|
|
102
|
+
}
|
|
103
|
+
const options = commandOptions('permissions', optionArgs);
|
|
104
|
+
const host = resolveHost(options);
|
|
105
|
+
const result = await permissions({ host, action });
|
|
106
|
+
io.log(result.output);
|
|
107
|
+
return result.exitCode;
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'uninstall',
|
|
112
|
+
summary: 'Remove installed files while preserving run artifacts',
|
|
113
|
+
usage: ['codex-bridge uninstall [--scope user|project] [--host <path>] [--dry-run] [--purge]'],
|
|
114
|
+
section: 'public',
|
|
115
|
+
async handler(argv, io) {
|
|
116
|
+
const options = commandOptions('uninstall', argv);
|
|
117
|
+
const host = resolveHost(options);
|
|
118
|
+
const result = options.purge
|
|
119
|
+
? await purge({ host, dryRun: options.dryRun })
|
|
120
|
+
: await uninstall({ host, dryRun: options.dryRun });
|
|
121
|
+
io.log(result.output);
|
|
122
|
+
return result.exitCode;
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: 'doctor',
|
|
127
|
+
summary: 'Diagnose the selected Claude Code host (--probe-contract measures on a live host)',
|
|
128
|
+
usage: ['codex-bridge doctor [--scope user|project] [--host <path>] [--probe-contract] [--probe-executable <path>]'],
|
|
129
|
+
section: 'public',
|
|
130
|
+
async handler(argv, io) {
|
|
131
|
+
const options = commandOptions('doctor', argv);
|
|
132
|
+
if (options.probeExecutable && !options.probeContract) throw new Error('--probe-executable requires --probe-contract');
|
|
133
|
+
const host = resolveHost(options);
|
|
134
|
+
// The probe runs BEFORE the diagnosis so the `hostContract` line below carries the verdict just
|
|
135
|
+
// measured. Printing the diagnosis first and the measurement after would answer one question
|
|
136
|
+
// twice in one output, with the older answer on top (Plan_52 D25).
|
|
137
|
+
let probe = null;
|
|
138
|
+
if (options.probeContract) {
|
|
139
|
+
const target = resolveProbeTarget({ stateDir: brandStateDir(host.brandRoot), executable: options.probeExecutable });
|
|
140
|
+
if (!target.error) io.log(`probe: target ${target.version} at ${target.executable} (${target.source})`);
|
|
141
|
+
probe = await probeContract({ host, target });
|
|
142
|
+
io.log(`probe: ${probe.message}`);
|
|
143
|
+
for (const [name, verdict] of Object.entries(probe.dispatcher ?? {})) {
|
|
144
|
+
io.log(`probe: ${name} ${verdict.result} — ${verdict.detail}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const result = await diagnose({ host });
|
|
148
|
+
io.log(renderDoctor(result));
|
|
149
|
+
// An inconclusive probe wrote nothing and measured nothing; exiting 0 would let a failed
|
|
150
|
+
// measurement pass silently in a script (Plan_52 D26).
|
|
151
|
+
return probe && probe.state === 'inconclusive' ? 2 : result.exitCode;
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: 'run',
|
|
156
|
+
summary: 'Start or attach to a delegated Codex run',
|
|
157
|
+
usage: ['codex-bridge run <runner options> --task-file <path>'],
|
|
158
|
+
section: 'public',
|
|
159
|
+
async handler(argv, io) {
|
|
160
|
+
return runCodex(argv);
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'model',
|
|
165
|
+
summary: 'Show, set or list the models delegated roles run on',
|
|
166
|
+
usage: ['codex-bridge model'],
|
|
167
|
+
section: 'public',
|
|
168
|
+
actions: {
|
|
169
|
+
list: {
|
|
170
|
+
summary: 'List the live model catalogue',
|
|
171
|
+
usage: ['codex-bridge model list'],
|
|
172
|
+
},
|
|
173
|
+
set: {
|
|
174
|
+
summary: 'Set the model and optional effort for a role',
|
|
175
|
+
usage: [
|
|
176
|
+
'codex-bridge model set <role> <model> [effort]',
|
|
177
|
+
'codex-bridge model set <role> --model <model> [--effort <effort>]',
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
unset: {
|
|
181
|
+
summary: 'Unset the model override for a role',
|
|
182
|
+
usage: ['codex-bridge model unset <role>'],
|
|
183
|
+
},
|
|
184
|
+
speed: {
|
|
185
|
+
summary: 'Preview, pin (confirm) or unpin the accelerated speed tier of a role',
|
|
186
|
+
usage: [
|
|
187
|
+
'codex-bridge model speed <role> <tier>',
|
|
188
|
+
'codex-bridge model speed <role> <tier> confirm',
|
|
189
|
+
'codex-bridge model speed <role> unset',
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
async handler(argv, io) {
|
|
194
|
+
const result = await model(argv);
|
|
195
|
+
io.log(result.output);
|
|
196
|
+
return result.exitCode;
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'projects',
|
|
201
|
+
summary: 'List projects or runs from the run store',
|
|
202
|
+
usage: ['codex-bridge projects [<name>] [--json]'],
|
|
203
|
+
section: 'public',
|
|
204
|
+
async handler(argv, io) {
|
|
205
|
+
const result = projects(argv);
|
|
206
|
+
io.log(result.output);
|
|
207
|
+
return result.exitCode;
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: 'prune',
|
|
212
|
+
summary: 'Remove archived transport, or purge selected run folders',
|
|
213
|
+
usage: [
|
|
214
|
+
'codex-bridge prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]',
|
|
215
|
+
'codex-bridge prune --all-projects [--older-than <age>] [-f] [--json]',
|
|
216
|
+
],
|
|
217
|
+
section: 'public',
|
|
218
|
+
async handler(argv, io) {
|
|
219
|
+
const result = await prune(argv);
|
|
220
|
+
io.log(result.output);
|
|
221
|
+
return result.exitCode;
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
name: 'unlock',
|
|
226
|
+
summary: 'Close running records whose runner is gone, or show the lifecycle lock',
|
|
227
|
+
usage: ['codex-bridge unlock [<project>|--all]', 'codex-bridge unlock --lifecycle [--clear]'],
|
|
228
|
+
section: 'public',
|
|
229
|
+
async handler(argv, io) {
|
|
230
|
+
const result = await unlock(argv);
|
|
231
|
+
io.log(result.output);
|
|
232
|
+
return result.exitCode;
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
name: 'read',
|
|
237
|
+
summary: "Render a run's structured event stream",
|
|
238
|
+
usage: ['codex-bridge read <run>'],
|
|
239
|
+
section: 'public',
|
|
240
|
+
async handler(argv, io) {
|
|
241
|
+
if (argv.length !== 1) {
|
|
242
|
+
io.error('codex-bridge read requires exactly one run folder (full path or bare name).');
|
|
243
|
+
return 2;
|
|
244
|
+
}
|
|
245
|
+
const result = read({ run: argv[0] });
|
|
246
|
+
io.log(result.output);
|
|
247
|
+
return result.exitCode;
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: 'stop',
|
|
252
|
+
summary: 'Stop a running Codex run and record FAIL',
|
|
253
|
+
usage: ['codex-bridge stop <run>'],
|
|
254
|
+
section: 'public',
|
|
255
|
+
async handler(argv, io) {
|
|
256
|
+
if (argv.length !== 1) {
|
|
257
|
+
io.error('codex-bridge stop requires exactly one run folder (full path or bare name).');
|
|
258
|
+
return 2;
|
|
259
|
+
}
|
|
260
|
+
const result = await stop({ run: argv[0] });
|
|
261
|
+
io.log(result.output);
|
|
262
|
+
return result.exitCode;
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
name: 'hook',
|
|
267
|
+
summary: 'Dispatch a registered guard with stdin unchanged',
|
|
268
|
+
usage: ['codex-bridge hook <name>'],
|
|
269
|
+
section: 'machine',
|
|
270
|
+
async handler(argv, io) {
|
|
271
|
+
return hook(argv, io);
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
name: 'sweep',
|
|
276
|
+
summary: 'Renamed to codex-bridge unlock',
|
|
277
|
+
usage: [],
|
|
278
|
+
section: 'renamed',
|
|
279
|
+
async handler(argv, io) {
|
|
280
|
+
io.error('codex-bridge sweep was renamed to codex-bridge unlock; use the new command.');
|
|
281
|
+
return 2;
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
];
|
package/cli/copy.mjs
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { classifyHomePath } from '../src/home/lib/home-registry.mjs';
|
|
6
|
+
import { createHomeWriter } from '../src/home/lib/home-write.mjs';
|
|
5
7
|
import { replacePlaceholders } from './manifest.mjs';
|
|
6
8
|
|
|
7
9
|
export async function plannedContent(item, installationRoot) {
|
|
@@ -25,15 +27,50 @@ export async function targetMatches(item, installationRoot) {
|
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Plan_65 D3: the install image is one registry entry whose exact members come from the plan, and
|
|
32
|
+
* seeds keep their own ids. One place decides which id a planned item writes under, so the
|
|
33
|
+
* installer and every fixture that copies a plan cannot disagree about it.
|
|
34
|
+
*/
|
|
35
|
+
export function planHomeWriter(brandRoot, plan) {
|
|
36
|
+
const imageMembers = plan.filter((item) => item.root === 'brand').map((item) => item.relativeToRoot);
|
|
37
|
+
const writer = createHomeWriter({ root: brandRoot, imageMembers });
|
|
38
|
+
const idFor = (item) => {
|
|
39
|
+
if (item.root !== 'brand') return undefined;
|
|
40
|
+
const id = classifyHomePath(item.relativeToRoot, { imageMembers })?.id;
|
|
41
|
+
if (!id) throw new Error(`brand install plan item is not a registered home artifact: ${item.relativeToRoot}`);
|
|
42
|
+
return id;
|
|
43
|
+
};
|
|
44
|
+
return { writer, idFor };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function copyPlannedFile(item, installationRoot, options = {}) {
|
|
48
|
+
const { writer, id } = options ?? {};
|
|
49
|
+
if (!writer) throw new Error('copyPlannedFile requires a home writer');
|
|
50
|
+
if (id !== undefined) await writer.assertArtifact(id, item.target);
|
|
51
|
+
else await writer.assertOutside(item.target);
|
|
52
|
+
|
|
29
53
|
const content = await plannedContent(item, installationRoot);
|
|
30
|
-
|
|
54
|
+
const operations = id === undefined
|
|
55
|
+
? {
|
|
56
|
+
mkdir: (...args) => fs.mkdir(...args),
|
|
57
|
+
writeFile: (...args) => fs.writeFile(...args),
|
|
58
|
+
rename: (...args) => fs.rename(...args),
|
|
59
|
+
cleanup: (temporary) => fs.rm(temporary, { force: true }),
|
|
60
|
+
}
|
|
61
|
+
: {
|
|
62
|
+
mkdir: (...args) => writer.mkdir(id, ...args),
|
|
63
|
+
writeFile: (...args) => writer.writeFile(id, ...args),
|
|
64
|
+
rename: (...args) => writer.rename(id, ...args),
|
|
65
|
+
cleanup: (temporary) => writer.unlink(id, temporary),
|
|
66
|
+
};
|
|
67
|
+
await operations.mkdir(path.dirname(item.target), { recursive: true });
|
|
31
68
|
const temporary = path.join(path.dirname(item.target), `.${path.basename(item.target)}.${randomUUID()}.tmp`);
|
|
32
69
|
try {
|
|
33
|
-
await
|
|
34
|
-
await
|
|
70
|
+
await operations.writeFile(temporary, content, { flag: 'wx' });
|
|
71
|
+
await operations.rename(temporary, item.target);
|
|
35
72
|
} catch (err) {
|
|
36
|
-
await
|
|
73
|
+
await Promise.resolve().then(() => operations.cleanup(temporary)).catch(() => {});
|
|
37
74
|
throw err;
|
|
38
75
|
}
|
|
39
76
|
}
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import {
|
|
9
|
+
import { writeHomeJsonAtomic } from '../src/home/lib/atomic-json.mjs';
|
|
10
|
+
import { stateDirWriter } from '../src/home/lib/home-write.mjs';
|
|
10
11
|
import { parseJsonText } from '../src/home/lib/json-file.mjs';
|
|
11
12
|
import { DISPATCHER_CONTRACTS } from './dispatcher-contract.mjs';
|
|
12
13
|
|
|
@@ -46,6 +47,6 @@ export function writeDispatcherContract({ stateDir, version, verdicts, now = new
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
const record = { hosts: { ...(previous?.hosts ?? {}), [version]: { contracts } } };
|
|
49
|
-
|
|
50
|
+
writeHomeJsonAtomic(stateDirWriter(stateDir), 'dispatcher-contract', file, record);
|
|
50
51
|
return record;
|
|
51
52
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reports host marks separately from home ownership (Plan_65 D10 item 6): WHAT lies in a host
|
|
3
|
+
* comes from its marks, while WHO uses the home comes only from its owner rows. A single
|
|
4
|
+
* "installation: not installed" line hid a host that plainly carried our agents and hooks, and it
|
|
5
|
+
* never said which hosts the home record names or whether its inventory is complete.
|
|
6
|
+
* Owners in sync keeps lagging hosts visible because install/update named them only once (Plan_65 D11 item 3).
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
|
|
11
|
+
import { check } from './doctor-format.mjs';
|
|
12
|
+
import { hasPackageMarks } from './host-inspection.mjs';
|
|
13
|
+
import { imageFingerprint, isFormat2 } from './install-owners.mjs';
|
|
14
|
+
|
|
15
|
+
// Format 1 is not migrated here: asFormat2 would record this host as the owner, and doctor only reads.
|
|
16
|
+
export function ownerEntry(rawRecord, host) {
|
|
17
|
+
if (rawRecord === null || !isFormat2(rawRecord)) return null;
|
|
18
|
+
return rawRecord.owners[normalizeRepoPath(host.root)] ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function hostSideCheck(host, inspection) {
|
|
22
|
+
if (inspection.settingsError) {
|
|
23
|
+
return check('host files', 'fail', `settings could not be read: ${inspection.settingsError}`);
|
|
24
|
+
}
|
|
25
|
+
if (!hasPackageMarks(inspection)) {
|
|
26
|
+
return check('host files', 'warn', `no codex-bridge files or hooks in ${host.root}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const kept = inspection.files.filter((file) => file.disposition === 'keep'
|
|
30
|
+
&& ['changed', 'unknown'].includes(file.reason));
|
|
31
|
+
const counts = `${inspection.files.length} package file(s) and ${inspection.hooks.length} own hook(s) in ${host.root}`;
|
|
32
|
+
const keptText = kept.map((file) => `${file.relativeToHost} (${file.reason})`).join(', ');
|
|
33
|
+
return kept.length
|
|
34
|
+
? check('host files', 'warn', `${counts}; kept by uninstall: ${keptText}`)
|
|
35
|
+
: check('host files', 'ok', counts);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function homeImageExists(host) {
|
|
39
|
+
try {
|
|
40
|
+
await fs.access(path.join(host.brandRoot, 'lib'));
|
|
41
|
+
return true;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code === 'ENOENT') return false;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function homeOwnersCheck(host, rawRecord, inspection) {
|
|
49
|
+
if (rawRecord === null) {
|
|
50
|
+
return await homeImageExists(host)
|
|
51
|
+
? check('home owners', 'warn', `no installation record in ${host.brandRoot}; the image has no record`)
|
|
52
|
+
: check('home owners', 'ok', `no installation record in ${host.brandRoot}`);
|
|
53
|
+
}
|
|
54
|
+
if (!isFormat2(rawRecord)) {
|
|
55
|
+
return check(
|
|
56
|
+
'home owners',
|
|
57
|
+
'warn',
|
|
58
|
+
'old record format; the hosts using this home are unknown until the next install or update',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const roots = Object.values(rawRecord.owners).map((owner) => owner.root);
|
|
63
|
+
const inventoryComplete = rawRecord.inventory === 'complete' && rawRecord.legacy === undefined;
|
|
64
|
+
const hostRecorded = Object.hasOwn(rawRecord.owners, normalizeRepoPath(host.root));
|
|
65
|
+
const unrecordedMarks = hasPackageMarks(inspection) && !hostRecorded;
|
|
66
|
+
const parts = [
|
|
67
|
+
`recorded: ${roots.length ? roots.join(', ') : 'none'}`,
|
|
68
|
+
inventoryComplete ? 'inventory complete' : 'inventory incomplete: an old record did not name every host',
|
|
69
|
+
...(unrecordedMarks
|
|
70
|
+
? [`${host.root} has package files or hooks but is not recorded: run codex-bridge install --host "${host.root}"`]
|
|
71
|
+
: []),
|
|
72
|
+
];
|
|
73
|
+
return check('home owners', inventoryComplete && !unrecordedMarks ? 'ok' : 'warn', parts.join('; '));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function ownersInSyncCheck(rawRecord) {
|
|
77
|
+
if (rawRecord === null || !isFormat2(rawRecord)) {
|
|
78
|
+
return check('owners in sync', 'ok', 'no format-2 record; nothing to compare');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const owners = Object.values(rawRecord.owners);
|
|
82
|
+
const inventoryComplete = rawRecord.inventory === 'complete' && rawRecord.legacy === undefined;
|
|
83
|
+
if (!owners.length) {
|
|
84
|
+
return check('owners in sync', 'warn',
|
|
85
|
+
`no recorded owners${inventoryComplete ? '' : '; inventory incomplete'}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const fingerprint = imageFingerprint(rawRecord.image);
|
|
89
|
+
const parts = [];
|
|
90
|
+
const unstamped = [];
|
|
91
|
+
for (const owner of owners) {
|
|
92
|
+
if (owner.imageFingerprint != null) {
|
|
93
|
+
if (owner.imageFingerprint === fingerprint) continue;
|
|
94
|
+
} else if (owner.version === rawRecord.image.version) {
|
|
95
|
+
unstamped.push(owner.root);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// The same version on both sides is the clone case the stamp exists for; say so instead of "0.6.9, image 0.6.9".
|
|
99
|
+
const why = owner.version === rawRecord.image.version
|
|
100
|
+
? `verified against an earlier image of ${owner.version}`
|
|
101
|
+
: `recorded ${owner.version}, image ${rawRecord.image.version}`;
|
|
102
|
+
parts.push(`${owner.root} (${why}): run codex-bridge update --host "${owner.root}"`);
|
|
103
|
+
}
|
|
104
|
+
if (unstamped.length) parts.push(`not yet verified by image fingerprint: ${unstamped.join(', ')}`);
|
|
105
|
+
if (!inventoryComplete) parts.push('inventory incomplete');
|
|
106
|
+
const warning = parts.length > 0;
|
|
107
|
+
if (!warning) parts.push(`all ${owners.length} owner(s) verified against the current image`);
|
|
108
|
+
return check('owners in sync', warning ? 'warn' : 'ok',
|
|
109
|
+
`${parts.join('; ')} (recorded verification; host files and reachability not inspected)`);
|
|
110
|
+
}
|
package/cli/doctor.mjs
CHANGED
|
@@ -4,9 +4,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
5
|
import { packageSource } from './package-source.mjs';
|
|
6
6
|
import { readInstallRecord, packageInfo } from './manifest.mjs';
|
|
7
|
-
import { recordTarget } from './install-record.mjs';
|
|
7
|
+
import { readInstallRecordFile, recordTarget } from './install-record.mjs';
|
|
8
8
|
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
9
9
|
import { check, renderDoctor } from './doctor-format.mjs';
|
|
10
|
+
import { homeOwnersCheck, hostSideCheck, ownerEntry, ownersInSyncCheck } from './doctor-host-side.mjs';
|
|
11
|
+
import { inspectHost } from './host-inspection.mjs';
|
|
10
12
|
import {
|
|
11
13
|
agentsCheck,
|
|
12
14
|
conventionsCheck,
|
|
@@ -108,6 +110,20 @@ export async function diagnose({
|
|
|
108
110
|
checks.push(check('installation', 'fail', `broken record: ${err.message}`));
|
|
109
111
|
}
|
|
110
112
|
|
|
113
|
+
let rawRecord = null;
|
|
114
|
+
let rawRecordError = null;
|
|
115
|
+
try {
|
|
116
|
+
rawRecord = await readInstallRecordFile(host);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
rawRecordError = err;
|
|
119
|
+
}
|
|
120
|
+
// The owner row goes into the inspection so "kept by uninstall" names exactly what uninstall would keep.
|
|
121
|
+
const inspection = await inspectHost(host, { owner: ownerEntry(rawRecord, host) });
|
|
122
|
+
checks.push(hostSideCheck(host, inspection));
|
|
123
|
+
checks.push(rawRecordError
|
|
124
|
+
? check('home owners', 'fail', `broken record: ${rawRecordError.message}`)
|
|
125
|
+
: await homeOwnersCheck(host, rawRecord, inspection));
|
|
126
|
+
if (rawRecordError === null) checks.push(ownersInSyncCheck(rawRecord));
|
|
111
127
|
const missingFiles = [];
|
|
112
128
|
if (record) {
|
|
113
129
|
for (const file of record.files) {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lists what lies in the package home, classified by the home registry, without crossing a link.
|
|
3
|
+
*
|
|
4
|
+
* Plan_65 D4 trusts the root as written: a relocated home or CODEX_BRIDGE_HOME may legitimately
|
|
5
|
+
* reach it through a link. Below that root, links and junctions stay and are named, never read
|
|
6
|
+
* through or removed. Walking ancestors first prevents even an lstat below a discovered link.
|
|
7
|
+
* Only ENOENT means gone; every other read failure must stay visible to a later removal planner.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
HOME_ARTIFACTS,
|
|
13
|
+
HOME_DIRECTORIES,
|
|
14
|
+
classifyHomePath,
|
|
15
|
+
homeArtifact,
|
|
16
|
+
} from '../src/home/lib/home-registry.mjs';
|
|
17
|
+
|
|
18
|
+
function knownDirectories(imageMembers) {
|
|
19
|
+
const directories = new Set(HOME_DIRECTORIES);
|
|
20
|
+
const addAncestors = (relative) => {
|
|
21
|
+
let parent = path.posix.dirname(relative);
|
|
22
|
+
while (parent !== '.' && parent !== '/') {
|
|
23
|
+
directories.add(parent);
|
|
24
|
+
parent = path.posix.dirname(parent);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
for (const artifact of HOME_ARTIFACTS) {
|
|
28
|
+
if (Array.isArray(artifact.primary)) {
|
|
29
|
+
for (const relative of artifact.primary) addAncestors(relative);
|
|
30
|
+
} else {
|
|
31
|
+
directories.add(artifact.primary.dir);
|
|
32
|
+
addAncestors(artifact.primary.dir);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
for (const relative of imageMembers) addAncestors(relative);
|
|
36
|
+
return directories;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const byRelative = (left, right) => (
|
|
40
|
+
left.relative < right.relative ? -1 : left.relative > right.relative ? 1 : 0
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
export function inspectHome(brandRoot, {
|
|
44
|
+
imageMembers = [],
|
|
45
|
+
lstat = fs.lstatSync,
|
|
46
|
+
readdir = fs.readdirSync,
|
|
47
|
+
} = {}) {
|
|
48
|
+
const result = {
|
|
49
|
+
root: 'present',
|
|
50
|
+
rootCode: null,
|
|
51
|
+
files: [],
|
|
52
|
+
unknown: [],
|
|
53
|
+
links: [],
|
|
54
|
+
errors: [],
|
|
55
|
+
directories: [],
|
|
56
|
+
};
|
|
57
|
+
try {
|
|
58
|
+
// D4 deliberately follows the root itself, unlike every entry below it.
|
|
59
|
+
if (!fs.statSync(brandRoot).isDirectory()) {
|
|
60
|
+
result.root = 'error';
|
|
61
|
+
result.rootCode = 'ENOTDIR';
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
result.root = error.code === 'ENOENT' ? 'missing' : 'error';
|
|
66
|
+
result.rootCode = error.code === 'ENOENT' ? null : error.code;
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const known = knownDirectories(imageMembers);
|
|
71
|
+
const recordError = (relative, error) => {
|
|
72
|
+
if (error.code !== 'ENOENT') result.errors.push({ relative, code: error.code });
|
|
73
|
+
};
|
|
74
|
+
const walk = (relative) => {
|
|
75
|
+
const absolute = relative ? path.join(brandRoot, ...relative.split('/')) : brandRoot;
|
|
76
|
+
let names;
|
|
77
|
+
try {
|
|
78
|
+
names = readdir(absolute);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
recordError(relative, error);
|
|
81
|
+
return error.code !== 'ENOENT';
|
|
82
|
+
}
|
|
83
|
+
for (const name of names) {
|
|
84
|
+
const child = relative ? `${relative}/${name}` : name;
|
|
85
|
+
let stats;
|
|
86
|
+
try {
|
|
87
|
+
stats = lstat(path.join(absolute, name));
|
|
88
|
+
} catch (error) {
|
|
89
|
+
recordError(child, error);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (stats.isSymbolicLink()) {
|
|
93
|
+
result.links.push({ relative: child });
|
|
94
|
+
} else if (stats.isFile()) {
|
|
95
|
+
const match = classifyHomePath(child, { imageMembers });
|
|
96
|
+
if (match) {
|
|
97
|
+
result.files.push({ relative: child, ...match, removal: homeArtifact(match.id).removal });
|
|
98
|
+
} else {
|
|
99
|
+
result.unknown.push({ relative: child, kind: 'file' });
|
|
100
|
+
}
|
|
101
|
+
} else if (stats.isDirectory()) {
|
|
102
|
+
// An undeclared folder is unknown as a whole: its contents cannot be ours, and an
|
|
103
|
+
// operator's large folder (a backup, a checkout) must not be walked to say so.
|
|
104
|
+
if (known.has(child)) {
|
|
105
|
+
if (walk(child)) result.directories.push({ relative: child });
|
|
106
|
+
} else {
|
|
107
|
+
result.unknown.push({ relative: child, kind: 'directory' });
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
result.unknown.push({ relative: child, kind: 'other' });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
};
|
|
115
|
+
walk('');
|
|
116
|
+
for (const list of [result.files, result.unknown, result.links, result.errors]) list.sort(byRelative);
|
|
117
|
+
// Deepest first: the order in which a later empty-folder removal can take them down.
|
|
118
|
+
result.directories.sort((left, right) => (
|
|
119
|
+
right.relative.split('/').length - left.relative.split('/').length || byRelative(left, right)
|
|
120
|
+
));
|
|
121
|
+
return result;
|
|
122
|
+
}
|