@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
package/cli/model-set.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { ROLES, validateRunConfig } from '../src/home/lib/config-validate.mjs';
|
|
|
5
5
|
import { readJsonFileSync } from '../src/home/lib/json-file.mjs';
|
|
6
6
|
import { editRunConfig } from '../src/home/lib/config-edit.mjs';
|
|
7
7
|
import { fetchCatalogue, parseCatalogue } from './model-catalogue.mjs';
|
|
8
|
+
import { withCommandHelp } from './command-help.mjs';
|
|
8
9
|
|
|
9
10
|
const failure = (exitCode, message) => ({ exitCode, output: `codex-bridge model: ${message}` });
|
|
10
11
|
const refusal = (message) => Object.assign(new Error(message), { exitCode: 2 });
|
|
@@ -17,14 +18,14 @@ function parseChanges(args) {
|
|
|
17
18
|
let value = argument;
|
|
18
19
|
if (argument.startsWith('-')) {
|
|
19
20
|
if (argument !== '--model' && argument !== '--effort') {
|
|
20
|
-
throw new Error(`unexpected argument "${argument}".`);
|
|
21
|
+
throw new Error(withCommandHelp('model set', `unexpected argument "${argument}".`));
|
|
21
22
|
}
|
|
22
23
|
field = argument.slice(2);
|
|
23
24
|
value = args[++index];
|
|
24
25
|
if (value === undefined || value.startsWith('-')) throw new Error(`missing value for ${argument}.`);
|
|
25
26
|
} else {
|
|
26
27
|
field = ['model', 'effort'].find((key) => changes[key] === undefined);
|
|
27
|
-
if (!field) throw new Error(`unexpected argument "${argument}".`);
|
|
28
|
+
if (!field) throw new Error(withCommandHelp('model set', `unexpected argument "${argument}".`));
|
|
28
29
|
}
|
|
29
30
|
if (changes[field] !== undefined) throw new Error(`${field} was supplied more than once.`);
|
|
30
31
|
if (!value || /\s/.test(value)) {
|
|
@@ -65,7 +66,7 @@ export async function editModelProfile(action, argv, options) {
|
|
|
65
66
|
}
|
|
66
67
|
let changes;
|
|
67
68
|
try {
|
|
68
|
-
if (action === 'unset' && args.length) throw new Error(`unexpected argument "${args[0]}".`);
|
|
69
|
+
if (action === 'unset' && args.length) throw new Error(withCommandHelp('model unset', `unexpected argument "${args[0]}".`));
|
|
69
70
|
if (action === 'set') changes = parseChanges(args);
|
|
70
71
|
} catch (error) {
|
|
71
72
|
return failure(2, error.message);
|
package/cli/model-speed.mjs
CHANGED
|
@@ -5,12 +5,19 @@ import { ROLES, validateRunConfig } from '../src/home/lib/config-validate.mjs';
|
|
|
5
5
|
import { editRunConfig } from '../src/home/lib/config-edit.mjs';
|
|
6
6
|
import { fetchCatalogue, parseCatalogue } from './model-catalogue.mjs';
|
|
7
7
|
import { describe, readProfiles } from './model-set.mjs';
|
|
8
|
+
import { withCommandHelp } from './command-help.mjs';
|
|
8
9
|
|
|
9
10
|
const failure = (exitCode, message) => ({ exitCode, output: `codex-bridge model: ${message}` });
|
|
10
11
|
const refusal = (message) => Object.assign(new Error(message), { exitCode: 2 });
|
|
12
|
+
const unexpected = (argument) => {
|
|
13
|
+
const result = failure(2, `unexpected argument "${argument}".`);
|
|
14
|
+
return { ...result, output: withCommandHelp('model speed', result.output) };
|
|
15
|
+
};
|
|
11
16
|
|
|
12
17
|
export async function editModelSpeed(argv, options) {
|
|
13
18
|
const [role, speed, confirmation, ...extra] = argv;
|
|
19
|
+
const dashArgument = argv.find((arg) => typeof arg === 'string' && arg.startsWith('-'));
|
|
20
|
+
if (dashArgument !== undefined) return unexpected(dashArgument);
|
|
14
21
|
if (!ROLES.includes(role)) {
|
|
15
22
|
return failure(2, `${role ? `unknown role "${role}"` : 'role is required'}. Allowed roles: ${ROLES.join(', ')}.`);
|
|
16
23
|
}
|
|
@@ -18,9 +25,9 @@ export async function editModelSpeed(argv, options) {
|
|
|
18
25
|
return failure(2, 'speed requires a tier identifier: a non-empty single word with no whitespace, or unset.');
|
|
19
26
|
}
|
|
20
27
|
if (confirmation !== undefined && (speed === 'unset' || confirmation !== 'confirm')) {
|
|
21
|
-
return
|
|
28
|
+
return unexpected(confirmation);
|
|
22
29
|
}
|
|
23
|
-
if (extra.length) return
|
|
30
|
+
if (extra.length) return unexpected(extra[0]);
|
|
24
31
|
|
|
25
32
|
try {
|
|
26
33
|
const configPath = path.resolve(options.configPath ?? CONFIG_PATH);
|
package/cli/model.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { editModelProfile } from './model-set.mjs';
|
|
|
8
8
|
import { editModelSpeed } from './model-speed.mjs';
|
|
9
9
|
import { readCodexUserTier } from './codex-user-config.mjs';
|
|
10
10
|
import { renderTable } from './table.mjs';
|
|
11
|
+
import { withCommandHelp } from './command-help.mjs';
|
|
11
12
|
|
|
12
13
|
const PROFILE_COLUMNS = ['role', 'model', 'effort', 'speed', 'source']
|
|
13
14
|
.map((key) => ({ key, header: key, fixed: true }));
|
|
@@ -43,10 +44,10 @@ export async function model(argv = [], options = {}) {
|
|
|
43
44
|
}
|
|
44
45
|
if (action === 'speed') return editModelSpeed(optionArgs, options);
|
|
45
46
|
if (action && action !== 'list') {
|
|
46
|
-
return { exitCode: 2, output: `codex-bridge model: unknown action "${action}". Use model, model list, model set, model unset or model speed.` };
|
|
47
|
+
return { exitCode: 2, output: withCommandHelp('model', `codex-bridge model: unknown action "${action}". Use model, model list, model set, model unset or model speed.`) };
|
|
47
48
|
}
|
|
48
49
|
if (optionArgs.length) {
|
|
49
|
-
return { exitCode: 2, output: `codex-bridge model: unexpected argument "${optionArgs[0]}".` };
|
|
50
|
+
return { exitCode: 2, output: withCommandHelp('model', `codex-bridge model: unexpected argument "${optionArgs[0]}".`) };
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
try {
|
|
@@ -75,7 +76,8 @@ export async function model(argv = [], options = {}) {
|
|
|
75
76
|
return {
|
|
76
77
|
exitCode: 0,
|
|
77
78
|
output: `${renderTable(PROFILE_COLUMNS, rows, options.terminalWidth)}\n\nConfig file: ${configPath}\n`
|
|
78
|
-
+ 'Machine-wide: shared by every project on this machine, not per-project
|
|
79
|
+
+ 'Machine-wide: shared by every project on this machine, not per-project.\n'
|
|
80
|
+
+ 'Set: codex-bridge model set <role> <model> [effort] · catalogue: codex-bridge model list',
|
|
79
81
|
};
|
|
80
82
|
} catch (error) {
|
|
81
83
|
const subject = action === 'list' ? 'live catalogue unavailable; refusing to list models' : 'cannot read profile';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds one owner's installation-record row from what is on disk after its files and hooks were written.
|
|
3
|
+
* Install and the owner sync (Plan_65 D7) both advance a row; two copies of this construction would let
|
|
4
|
+
* a synced owner carry fingerprints or `createdGroup` flags that its own install would never write.
|
|
5
|
+
*/
|
|
6
|
+
import { fileFingerprint } from './manifest.mjs';
|
|
7
|
+
import { INSTALL_METHOD_COPY, INSTALL_METHOD_KEY } from './install-record.mjs';
|
|
8
|
+
|
|
9
|
+
export async function ownerRecord({ plan, rule, targets, hookResults, prior, currentPackage }) {
|
|
10
|
+
const fingerprints = {};
|
|
11
|
+
for (const item of plan) {
|
|
12
|
+
fingerprints[item.root] ??= {};
|
|
13
|
+
fingerprints[item.root][item.relativeToRoot] = await fileFingerprint(item.target);
|
|
14
|
+
}
|
|
15
|
+
const hooks = targets.map(({ definition, relative, registration }, index) => {
|
|
16
|
+
const recorded = prior?.hooks?.find((hook) => hook.event === definition.event
|
|
17
|
+
&& hook.root === 'brand' && hook.path === relative);
|
|
18
|
+
// A group we created stays ours after a later run merely joins it; losing the flag would make
|
|
19
|
+
// uninstall leave behind an empty group that only this package ever put there.
|
|
20
|
+
const createdGroup = hookResults[index].createdGroup || recorded?.createdGroup === true;
|
|
21
|
+
const command = registration.command;
|
|
22
|
+
return {
|
|
23
|
+
event: definition.event,
|
|
24
|
+
root: 'brand',
|
|
25
|
+
path: relative,
|
|
26
|
+
command,
|
|
27
|
+
form: command.startsWith('codex-bridge hook ') ? 'short' : 'path',
|
|
28
|
+
...(createdGroup ? { createdGroup: true } : {}),
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
...currentPackage,
|
|
33
|
+
installedAt: new Date().toISOString(),
|
|
34
|
+
[INSTALL_METHOD_KEY]: INSTALL_METHOD_COPY,
|
|
35
|
+
files: plan.map((item) => ({ root: item.root, path: item.relativeToRoot })),
|
|
36
|
+
fingerprints,
|
|
37
|
+
rules: { path: rule.target, fingerprint: await fileFingerprint(rule.target) },
|
|
38
|
+
hooks,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies other owners' host updates under the initiator's lifecycle lock (Plan_65 D7, advice A6).
|
|
3
|
+
* Replacing the shared image must bring eligible owners' files and hooks along, without extending
|
|
4
|
+
* the initiator's force to their edits, permissions, seeds, or Codex rules registry. An owner row
|
|
5
|
+
* advances only after its whole host update verifies; failures must not stop the remaining owners.
|
|
6
|
+
* In-sync rows are stamped with the image fingerprint too (D11): clones of one version ship different
|
|
7
|
+
* images (A6), so only the stamp tells doctor which image a host was last verified against.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
|
|
11
|
+
import { buildInstallPlan, packageInfo, rulesPlan } from './manifest.mjs';
|
|
12
|
+
import { copyPlannedFile, planHomeWriter } from './copy.mjs';
|
|
13
|
+
import { readInstallRecordFile, writeInstallRecord } from './install-record.mjs';
|
|
14
|
+
import { imageFingerprint, ownerView } from './install-owners.mjs';
|
|
15
|
+
import { ownerRecord } from './owner-record.mjs';
|
|
16
|
+
import { ownerHost, ownerSyncLines, planOwner, planOwnerSync } from './owner-sync.mjs';
|
|
17
|
+
import { mergeHook } from './settings-merge.mjs';
|
|
18
|
+
|
|
19
|
+
async function applyOwner(owner, initiator, { packageRoot, env }) {
|
|
20
|
+
const host = ownerHost(owner, initiator);
|
|
21
|
+
// D7: a root lost after planning must not be recreated by the copy or settings writers.
|
|
22
|
+
if (!(await fs.stat(host.root)).isDirectory()) throw new Error('owner root is not a directory');
|
|
23
|
+
const record = await readInstallRecordFile(host);
|
|
24
|
+
const prior = ownerView(record, host);
|
|
25
|
+
if (!prior) throw new Error('owner is not recorded');
|
|
26
|
+
const plan = await buildInstallPlan(host, packageRoot);
|
|
27
|
+
const { writer } = planHomeWriter(host.brandRoot, plan);
|
|
28
|
+
let files = 0;
|
|
29
|
+
for (const { item, state } of owner.files) {
|
|
30
|
+
if (state !== 'replace' && state !== 'create') continue;
|
|
31
|
+
await copyPlannedFile(item, host.brandRoot, { writer });
|
|
32
|
+
files += 1;
|
|
33
|
+
}
|
|
34
|
+
const hookResults = [];
|
|
35
|
+
let hooks = 0;
|
|
36
|
+
for (const { item, state } of owner.hooks) {
|
|
37
|
+
if (state === 'register') {
|
|
38
|
+
hookResults.push(await mergeHook(host.settingsPath, item.spec));
|
|
39
|
+
hooks += 1;
|
|
40
|
+
} else {
|
|
41
|
+
hookResults.push({ createdGroup: false });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const verified = await planOwner(record, record.owners[normalizeRepoPath(host.root)], initiator,
|
|
45
|
+
{ packageRoot, env });
|
|
46
|
+
// A bare status word made the operator line read "Left <root> behind: conflict", naming no cause.
|
|
47
|
+
if (verified.status !== 'in-sync') throw new Error(`verification after the update found ${verified.status}`);
|
|
48
|
+
// D7: resolving an explicit root must not relabel a recorded user/project owner as host.
|
|
49
|
+
await writeInstallRecord({ ...host, scope: owner.scope }, await ownerRecord({
|
|
50
|
+
plan,
|
|
51
|
+
rule: rulesPlan(host, packageRoot),
|
|
52
|
+
targets: owner.hooks.map(({ item }) => item),
|
|
53
|
+
hookResults,
|
|
54
|
+
prior,
|
|
55
|
+
currentPackage: await packageInfo(packageRoot),
|
|
56
|
+
}), {});
|
|
57
|
+
return { root: owner.root, status: 'updated', files, hooks };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function applyOwnerSync(plan, initiator, { packageRoot, env } = {}) {
|
|
61
|
+
const record = await readInstallRecordFile(initiator);
|
|
62
|
+
const fingerprint = imageFingerprint(record.image);
|
|
63
|
+
const owners = [];
|
|
64
|
+
for (const owner of plan.owners) {
|
|
65
|
+
if (owner.status === 'in-sync'
|
|
66
|
+
// An image without fingerprints yields null; a row can then never carry a stamp to match.
|
|
67
|
+
&& (record.owners[normalizeRepoPath(owner.root)]?.imageFingerprint ?? null) === fingerprint) {
|
|
68
|
+
owners.push({ root: owner.root, status: 'in-sync' });
|
|
69
|
+
} else if (owner.status !== 'eligible' && owner.status !== 'in-sync') {
|
|
70
|
+
owners.push(owner);
|
|
71
|
+
} else {
|
|
72
|
+
try {
|
|
73
|
+
const applied = await applyOwner(owner, initiator, { packageRoot, env });
|
|
74
|
+
owners.push(owner.status === 'in-sync' && applied.files === 0 && applied.hooks === 0
|
|
75
|
+
? { root: owner.root, status: 'in-sync' } : applied);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
owners.push({ root: owner.root, status: 'failed', reason: error.message });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { owners, complete: owners.every(({ status }) => status === 'in-sync' || status === 'updated') };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One entrance for every command that replaced the image: install and update both call it, so neither
|
|
86
|
+
* can sync the other owners differently. A dry run only plans; it never counts as incomplete.
|
|
87
|
+
*/
|
|
88
|
+
export async function syncOtherOwners(initiator, { packageRoot, env, dryRun = false } = {}) {
|
|
89
|
+
const record = await readInstallRecordFile(initiator);
|
|
90
|
+
if (record?.format !== 2) return { complete: true, lines: [] };
|
|
91
|
+
const plan = await planOwnerSync(record, initiator, { packageRoot, env });
|
|
92
|
+
if (dryRun) return { complete: true, lines: ownerSyncLines(plan) };
|
|
93
|
+
// Plan_65 D7: the initiator's force never authorizes another owner's edits.
|
|
94
|
+
const result = await applyOwnerSync(plan, initiator, { packageRoot, env });
|
|
95
|
+
return { complete: result.complete, lines: ownerApplyLines(result) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function ownerApplyLines(result) {
|
|
99
|
+
return result.owners.flatMap((owner) => {
|
|
100
|
+
if (owner.status === 'updated') {
|
|
101
|
+
return [`Updated ${owner.files} file(s) and ${owner.hooks} hook(s) of ${owner.root}.`];
|
|
102
|
+
}
|
|
103
|
+
if (owner.status === 'failed') {
|
|
104
|
+
return [`Left ${owner.root} behind: ${owner.reason}; run codex-bridge update --host "${owner.root}".`];
|
|
105
|
+
}
|
|
106
|
+
return ownerSyncLines({ owners: [owner] });
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plans other owners' host updates without writes or locks (Plan_65 D7, advice A6).
|
|
3
|
+
* Replacing the shared image leaves their agents, commands, and guards behind. Only an owner's
|
|
4
|
+
* recorded, unedited files may catch up; the initiator's force never authorizes another owner's
|
|
5
|
+
* conflicts, permissions, seeds, or unrelated settings. B13e2 applies and verifies this plan.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
|
|
10
|
+
import { resolveHost } from './hosts.mjs';
|
|
11
|
+
import { buildInstallPlan, fileFingerprint } from './manifest.mjs';
|
|
12
|
+
import { targetMatches } from './copy.mjs';
|
|
13
|
+
import { fingerprintFor, recordFileKey } from './install-record.mjs';
|
|
14
|
+
import { ownerView, validateFormat2 } from './install-owners.mjs';
|
|
15
|
+
import { hookTargets } from './hook-targets.mjs';
|
|
16
|
+
import { inspectHook } from './settings-merge.mjs';
|
|
17
|
+
|
|
18
|
+
async function fileState(item, host, view) {
|
|
19
|
+
let target;
|
|
20
|
+
try {
|
|
21
|
+
target = await fs.lstat(item.target);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error.code !== 'ENOENT') throw error;
|
|
24
|
+
}
|
|
25
|
+
// D7: lstat must precede content reads, including for dangling links and Windows junctions.
|
|
26
|
+
if (target?.isSymbolicLink()) return { item, state: 'conflict', reason: 'link' };
|
|
27
|
+
if (!target) {
|
|
28
|
+
const recorded = view.files.some((file) => recordFileKey(file) === recordFileKey(item));
|
|
29
|
+
return recorded
|
|
30
|
+
? { item, state: 'conflict', reason: 'missing' }
|
|
31
|
+
: { item, state: 'create' };
|
|
32
|
+
}
|
|
33
|
+
if (target.isFile()) {
|
|
34
|
+
if (await targetMatches(item, host.brandRoot)) return { item, state: 'current' };
|
|
35
|
+
const recorded = fingerprintFor(view, item);
|
|
36
|
+
if (recorded !== undefined && await fileFingerprint(item.target) === recorded) {
|
|
37
|
+
return { item, state: 'replace' };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { item, state: 'conflict', reason: 'changed' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The owner's host seen from the initiator: same home and Codex home, the owner's own root and scope. */
|
|
44
|
+
export function ownerHost(owner, initiator) {
|
|
45
|
+
return resolveHost({
|
|
46
|
+
host: owner.root,
|
|
47
|
+
scope: owner.scope,
|
|
48
|
+
brandRoot: initiator.brandRoot,
|
|
49
|
+
// A resolved host has no codexHome field; it keeps the Codex home only as `<home>/rules`.
|
|
50
|
+
codexHome: path.dirname(initiator.codexRulesDir),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function planOwner(record2, owner, initiator, { packageRoot, env }) {
|
|
55
|
+
const entry = { root: owner.root, scope: owner.scope, version: owner.version, files: [], hooks: [] };
|
|
56
|
+
const host = ownerHost(owner, initiator);
|
|
57
|
+
try {
|
|
58
|
+
if (!(await fs.stat(host.root)).isDirectory()) {
|
|
59
|
+
return { ...entry, status: 'unreachable', reason: 'ENOTDIR' };
|
|
60
|
+
}
|
|
61
|
+
await fs.readdir(host.root);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return { ...entry, status: 'unreachable', reason: error.code === 'ENOENT' ? 'absent' : error.code };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const view = ownerView(record2, host);
|
|
67
|
+
const items = (await buildInstallPlan(host, packageRoot)).filter((item) => item.root === 'claude');
|
|
68
|
+
try {
|
|
69
|
+
for (const item of items) entry.files.push(await fileState(item, host, view));
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { ...entry, status: 'unreachable', reason: error.code ?? error.message };
|
|
72
|
+
}
|
|
73
|
+
const targets = hookTargets(host, env);
|
|
74
|
+
try {
|
|
75
|
+
for (const item of targets) {
|
|
76
|
+
const inspected = await inspectHook(host.settingsPath, item.spec);
|
|
77
|
+
entry.hooks.push({ item, state: inspected.current ? 'current' : 'register' });
|
|
78
|
+
}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return { ...entry, status: 'unreachable', reason: `settings unreadable: ${error.message}` };
|
|
81
|
+
}
|
|
82
|
+
const conflict = entry.files.some(({ state }) => state === 'conflict');
|
|
83
|
+
const pending = entry.files.some(({ state }) => state === 'replace' || state === 'create')
|
|
84
|
+
|| entry.hooks.some(({ state }) => state === 'register');
|
|
85
|
+
return { ...entry, status: conflict ? 'conflict' : pending ? 'eligible' : 'in-sync' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function planOwnerSync(record2, initiator, { packageRoot, env } = {}) {
|
|
89
|
+
validateFormat2(record2);
|
|
90
|
+
const key = normalizeRepoPath(initiator.root);
|
|
91
|
+
const others = Object.values(record2.owners)
|
|
92
|
+
.filter((owner) => normalizeRepoPath(owner.root) !== key)
|
|
93
|
+
.sort((a, b) => a.root.localeCompare(b.root));
|
|
94
|
+
const owners = [];
|
|
95
|
+
for (const owner of others) owners.push(await planOwner(record2, owner, initiator, { packageRoot, env }));
|
|
96
|
+
return { owners };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function ownerSyncLines(plan) {
|
|
100
|
+
const lines = [];
|
|
101
|
+
for (const owner of plan.owners) {
|
|
102
|
+
if (owner.status === 'eligible') {
|
|
103
|
+
const files = owner.files.filter(({ state }) => state === 'replace' || state === 'create').length;
|
|
104
|
+
const hooks = owner.hooks.filter(({ state }) => state === 'register').length;
|
|
105
|
+
lines.push(`Would update ${files} file(s) and ${hooks} hook(s) of ${owner.root}.`);
|
|
106
|
+
} else if (owner.status === 'conflict') {
|
|
107
|
+
const conflicts = owner.files.filter(({ state }) => state === 'conflict')
|
|
108
|
+
.map(({ item, reason }) => `${item.relativeToRoot} (${reason})`).join(', ');
|
|
109
|
+
lines.push(`Left ${owner.root} behind: ${conflicts}; run codex-bridge update --host "${owner.root}" after resolving it.`);
|
|
110
|
+
} else if (owner.status === 'unreachable') {
|
|
111
|
+
lines.push(`Left ${owner.root} behind: ${owner.reason}; run codex-bridge update --host "${owner.root}" when it is available.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return lines;
|
|
115
|
+
}
|
package/cli/projects.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Implements the projects command. */
|
|
2
|
+
import { withCommandHelp } from './command-help.mjs';
|
|
2
3
|
import { listProjectRuns, listProjects } from './runs-inventory.mjs';
|
|
3
4
|
import { renderTable } from './table.mjs';
|
|
4
5
|
import { allLiveRuns } from '../src/home/hooks/live-runs.mjs';
|
|
@@ -69,11 +70,13 @@ function parseArgs(args) {
|
|
|
69
70
|
json = true;
|
|
70
71
|
continue;
|
|
71
72
|
}
|
|
72
|
-
if (arg.startsWith('-'))
|
|
73
|
+
if (arg.startsWith('-')) {
|
|
74
|
+
return { error: withCommandHelp('projects', `codex-bridge projects: unknown option "${arg}"`) };
|
|
75
|
+
}
|
|
73
76
|
positional.push(arg);
|
|
74
77
|
}
|
|
75
78
|
if (positional.length > 1) {
|
|
76
|
-
return { error: 'codex-bridge projects accepts at most one project name.' };
|
|
79
|
+
return { error: withCommandHelp('projects', 'codex-bridge projects accepts at most one project name.') };
|
|
77
80
|
}
|
|
78
81
|
return { json, projectName: positional[0] || null };
|
|
79
82
|
}
|
package/cli/prune-args.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Parses prune scopes and refuses combinations that could widen destructive reach. */
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { withCommandHelp } from './command-help.mjs';
|
|
3
4
|
|
|
4
5
|
const DURATION = /^(\d+)([dh])$/;
|
|
5
6
|
const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
@@ -8,6 +9,10 @@ function error(message) {
|
|
|
8
9
|
return { error: `codex-bridge prune: ${message}` };
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
function usageError(message) {
|
|
13
|
+
return { error: withCommandHelp('prune', error(message).error) };
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
function validCalendarDate(value) {
|
|
12
17
|
const match = DATE.exec(value);
|
|
13
18
|
if (!match) return false;
|
|
@@ -87,18 +92,18 @@ export function parsePruneArgs(argv = []) {
|
|
|
87
92
|
olderThanExplicit = true;
|
|
88
93
|
continue;
|
|
89
94
|
}
|
|
90
|
-
if (arg.startsWith('-')) return
|
|
95
|
+
if (arg.startsWith('-')) return usageError(`unknown option "${arg}"`);
|
|
91
96
|
positional.push(arg);
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
if (allProjects && purge) {
|
|
95
|
-
return
|
|
100
|
+
return usageError('--all-projects cannot be combined with --purge; all-projects is gentle only');
|
|
96
101
|
}
|
|
97
102
|
if (allProjects && positional.length) {
|
|
98
|
-
return
|
|
103
|
+
return usageError('--all-projects does not accept a project or run name');
|
|
99
104
|
}
|
|
100
105
|
if (!allProjects && (positional.length < 1 || positional.length > 2)) {
|
|
101
|
-
return
|
|
106
|
+
return usageError('expected <project>, optional <run>, or --all-projects');
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
const projectName = positional[0] || null;
|
package/cli/prune.mjs
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/** Confirms, executes, and reports the destructive actions in a prune plan. */
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
-
import
|
|
4
|
-
import readline from 'node:readline/promises';
|
|
5
|
-
import { normalizePath } from '../src/home/hooks/live-runs.mjs';
|
|
3
|
+
import { inspectSegments } from './link-segments.mjs';
|
|
6
4
|
import { parsePruneArgs } from './prune-args.mjs';
|
|
7
5
|
import { prunePlan } from './prune-plan.mjs';
|
|
8
6
|
import { recursiveSize } from './runs-inventory.mjs';
|
|
7
|
+
import { askYesNo, isInteractive } from './terminal-question.mjs';
|
|
9
8
|
|
|
10
9
|
function formatBytes(bytes) {
|
|
11
10
|
if (!Number.isFinite(bytes)) return 'unknown';
|
|
@@ -102,64 +101,6 @@ function result(exitCode, text) {
|
|
|
102
101
|
return { exitCode, output: text };
|
|
103
102
|
}
|
|
104
103
|
|
|
105
|
-
// One spelling only. A guard that answers to several option names is a guard with several ways
|
|
106
|
-
// to be switched off by accident.
|
|
107
|
-
function tty(options) {
|
|
108
|
-
if (options.isTTY !== undefined) return Boolean(options.isTTY);
|
|
109
|
-
return Boolean((options.stdin || process.stdin).isTTY);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
async function defaultPrompt(question, options = {}) {
|
|
113
|
-
const input = options.stdin || process.stdin;
|
|
114
|
-
const outputStream = options.promptOutput || process.stderr;
|
|
115
|
-
const interfaceHandle = readline.createInterface({ input, output: outputStream });
|
|
116
|
-
try {
|
|
117
|
-
const answer = await interfaceHandle.question(`${question} [y/N] `);
|
|
118
|
-
return /^(y|yes)$/i.test(answer.trim());
|
|
119
|
-
} finally {
|
|
120
|
-
interfaceHandle.close();
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function accepted(answer) {
|
|
125
|
-
return answer === true || /^(y|yes)$/i.test(String(answer ?? '').trim());
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// The package compares paths through one function, and this guard is the last thing standing
|
|
129
|
-
// between a bug and rm -r. Raw string comparison is case-sensitive: a runs root spelled
|
|
130
|
-
// `C:\Users\...` against a target resolved as `c:\users\...` would put every target "outside the
|
|
131
|
-
// root", and the deletion would fail with a reason that names the wrong problem.
|
|
132
|
-
function targetInsideRoot(target, root) {
|
|
133
|
-
const normalizedRoot = normalizePath(root);
|
|
134
|
-
const normalizedTarget = normalizePath(target);
|
|
135
|
-
if (!normalizedRoot || !normalizedTarget) return false;
|
|
136
|
-
return normalizedTarget !== normalizedRoot && normalizedTarget.startsWith(`${normalizedRoot}/`);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Refuses a target reached through a link. Independent review found that lexical containment alone
|
|
141
|
-
* is satisfiable: replace `<root>/<project>` with a junction to an external directory and a target
|
|
142
|
-
* that still spells out as inside the root deletes what is outside it. The package deliberately
|
|
143
|
-
* does not resolve paths (`realpath` returns `\\?\` and UNC forms on Windows and creates a new
|
|
144
|
-
* class of mismatch), so the check is the cheap half instead: every segment from the root down to
|
|
145
|
-
* the target must be a real directory, verified with lstat right before deletion.
|
|
146
|
-
*/
|
|
147
|
-
function linkedSegment(target, root) {
|
|
148
|
-
const normalizedRoot = normalizePath(root);
|
|
149
|
-
let current = path.resolve(target);
|
|
150
|
-
while (normalizePath(current) !== normalizedRoot) {
|
|
151
|
-
try {
|
|
152
|
-
if (fs.lstatSync(current).isSymbolicLink()) return current;
|
|
153
|
-
} catch {
|
|
154
|
-
return null; // Already gone: rmSync will report it honestly.
|
|
155
|
-
}
|
|
156
|
-
const parent = path.dirname(current);
|
|
157
|
-
if (parent === current) return null;
|
|
158
|
-
current = parent;
|
|
159
|
-
}
|
|
160
|
-
return null;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
104
|
function currentTargetBytes(target, strategy) {
|
|
164
105
|
if (strategy === 'purge') {
|
|
165
106
|
// The inventory's recursive measurement is the same accounting used by projects and keeps
|
|
@@ -174,18 +115,21 @@ function currentTargetBytes(target, strategy) {
|
|
|
174
115
|
}
|
|
175
116
|
|
|
176
117
|
async function confirmAll(plan, args, options) {
|
|
177
|
-
const prompt = options.prompt || defaultPrompt;
|
|
178
118
|
for (const action of plan.actions) {
|
|
179
119
|
const question = action.strategy === 'purge'
|
|
180
120
|
? `Delete folder ${action.path}?`
|
|
181
121
|
: `Delete archived transport from ${action.project}/${action.run}?\n ${action.targets.join('\n ')}`;
|
|
182
|
-
let
|
|
122
|
+
let outcome;
|
|
183
123
|
try {
|
|
184
|
-
|
|
124
|
+
const prompt = options.prompt;
|
|
125
|
+
outcome = await askYesNo(question, {
|
|
126
|
+
...options,
|
|
127
|
+
prompt: prompt ? (text) => prompt(text, action, options) : undefined,
|
|
128
|
+
});
|
|
185
129
|
} catch (err) {
|
|
186
130
|
return { error: `codex-bridge prune: confirmation failed: ${err.message}` };
|
|
187
131
|
}
|
|
188
|
-
if (
|
|
132
|
+
if (outcome !== 'yes') return { declined: true };
|
|
189
133
|
}
|
|
190
134
|
return { declined: false };
|
|
191
135
|
}
|
|
@@ -196,21 +140,30 @@ function bytesAfterRemoval(values) {
|
|
|
196
140
|
: null;
|
|
197
141
|
}
|
|
198
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Uses the shared top-down check before deletion: the old leaf-up walk inspected descendants under
|
|
145
|
+
* a link, and its catch treated every lstat error as a clear path.
|
|
146
|
+
*/
|
|
199
147
|
async function execute(plan, options) {
|
|
200
148
|
const removed = [];
|
|
201
149
|
const sizes = [];
|
|
202
150
|
const failed = [];
|
|
203
151
|
for (const action of plan.actions) {
|
|
204
152
|
for (const target of action.targets) {
|
|
205
|
-
|
|
153
|
+
const inspection = inspectSegments(target, plan.root);
|
|
154
|
+
if (inspection.kind === 'outside') {
|
|
206
155
|
failed.push({ target, error: 'target is outside the configured runs root' });
|
|
207
156
|
continue;
|
|
208
157
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
failed.push({ target, error: `refusing to delete through a link: ${linked}` });
|
|
158
|
+
if (inspection.kind === 'link') {
|
|
159
|
+
failed.push({ target, error: `refusing to delete through a link: ${inspection.at}` });
|
|
212
160
|
continue;
|
|
213
161
|
}
|
|
162
|
+
if (inspection.kind === 'error') {
|
|
163
|
+
failed.push({ target, error: `cannot inspect ${inspection.at} (${inspection.code}); nothing deleted` });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
// `missing` goes on: rmSync reports the vanished target honestly, as it always did.
|
|
214
167
|
const bytes = currentTargetBytes(target, action.strategy);
|
|
215
168
|
try {
|
|
216
169
|
fs.rmSync(target, { recursive: action.strategy === 'purge', force: false });
|
|
@@ -253,7 +206,7 @@ export async function prune(argv = [], options = {}) {
|
|
|
253
206
|
const payload = { status: 'completed', ...basePayload(plan), removed: [], bytesFreed: 0, failed: [] };
|
|
254
207
|
return result(0, output(payload, args.json));
|
|
255
208
|
}
|
|
256
|
-
if (!
|
|
209
|
+
if (!isInteractive(options)) {
|
|
257
210
|
const message = 'No TTY: refusing deletion because deletion is an operator action; no files were changed.';
|
|
258
211
|
const payload = { status: 'refused', ...basePayload(plan), error: message, removed: [], bytesFreed: 0 };
|
|
259
212
|
return result(1, output(payload, args.json));
|
|
@@ -274,5 +227,3 @@ export async function prune(argv = [], options = {}) {
|
|
|
274
227
|
? output(executed.payload, true)
|
|
275
228
|
: reportLines(executed.payload, executed.payload.status));
|
|
276
229
|
}
|
|
277
|
-
|
|
278
|
-
export { defaultPrompt };
|