@lyupro/codex-bridge 0.6.8 → 0.6.9
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 +109 -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 +91 -0
- package/cli/lifecycle-lock.mjs +335 -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 +2 -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/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 +49 -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 +5 -2
- package/src/home/conventions.md +9 -0
- package/src/home/hooks/guard-tries.mjs +6 -4
- package/src/home/hooks/order-gate.mjs +2 -8
- package/src/home/hooks/reply-guard.mjs +2 -7
- 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/config-edit.mjs +25 -10
- 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/meta/advice-status.mjs +2 -2
- package/src/home/lib/meta/advice-verdict.mjs +30 -1
- package/src/home/lib/meta/events.mjs +2 -1
- package/src/home/lib/meta/paths.mjs +24 -24
- package/src/home/lib/meta/run-snapshots.mjs +29 -0
- package/src/home/lib/meta/snapshot-format.mjs +96 -0
- package/src/home/lib/meta/verdict.mjs +14 -16
- package/src/home/lib/run-config.mjs +10 -1
- package/src/home/lib/runner/git-paths.mjs +74 -0
- package/src/home/lib/runner/git-state.mjs +38 -34
- package/src/home/lib/runner/launcher.mjs +2 -2
- package/src/home/lib/runner/preflight.mjs +7 -0
- package/src/home/lib/runner/prompts.mjs +35 -2
- package/src/home/lib/runner/schemas.mjs +4 -1
- package/src/home/lib/runner/scope-check.mjs +29 -20
- package/src/home/lib/runner/worker.mjs +3 -3
package/cli/uninstall.mjs
CHANGED
|
@@ -1,33 +1,27 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Uninstalls one host: its side from an inspection of the package's marks, the shared image only from
|
|
3
|
+
* the record and only once the host is detached, preserving host data and foreign files (Plan_65 D10).
|
|
4
|
+
*/
|
|
4
5
|
import {
|
|
5
|
-
definitionForRecordedHook,
|
|
6
|
-
fileFingerprint,
|
|
7
|
-
installRecordPath,
|
|
8
6
|
legacyInstallRecordPath,
|
|
9
7
|
readInstallRecord,
|
|
10
|
-
recordTarget,
|
|
11
8
|
} from './manifest.mjs';
|
|
9
|
+
import { imageRemoval } from './install-owners.mjs';
|
|
10
|
+
import { asFormat2, readInstallRecordFile } from './install-record.mjs';
|
|
11
|
+
import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
|
|
12
12
|
import { removePermissionRules } from './permissions.mjs';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
} from './
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
removeEmptyParents,
|
|
26
|
-
} from './remove-layout.mjs';
|
|
27
|
-
|
|
28
|
-
function remainingOwnersText(count) {
|
|
29
|
-
return `${count} other owner${count === 1 ? '' : 's'} ${count === 1 ? 'remains' : 'remain'}`;
|
|
30
|
-
}
|
|
13
|
+
import { inspectHost, hasPackageMarks } from './host-inspection.mjs';
|
|
14
|
+
import { removeHostSide } from './host-removal.mjs';
|
|
15
|
+
import { askRemoval, removalHint } from './inventory-removal.mjs';
|
|
16
|
+
import { withSettingsRun } from './settings-merge.mjs';
|
|
17
|
+
import { withLifecycle } from './lifecycle-transaction.mjs';
|
|
18
|
+
import { readRulesRegistry } from './rules-owners.mjs';
|
|
19
|
+
import { rulesDryRunLines, removeRulesForHost, remainingOwnersText } from './uninstall-rules.mjs';
|
|
20
|
+
import { removeEmpty, removeEmptyLayout } from './remove-layout.mjs';
|
|
21
|
+
import { recordHomeWriter, removeOutside } from './record-removal.mjs';
|
|
22
|
+
import { buildHomeRemovalPlan } from './home-removal-plan.mjs';
|
|
23
|
+
import { executeHomePlan } from './home-plan-execute.mjs';
|
|
24
|
+
import { outcomeLines, outcomeExitCode, planLines, planExitCode } from './removal-outcomes.mjs';
|
|
31
25
|
|
|
32
26
|
function permissionOutput(host, removed, dryRun) {
|
|
33
27
|
const verb = dryRun ? 'Would remove' : 'Removed';
|
|
@@ -35,36 +29,116 @@ function permissionOutput(host, removed, dryRun) {
|
|
|
35
29
|
return `${verb} ${removed} permission rule ${plural} from ${host.settingsPath}.`;
|
|
36
30
|
}
|
|
37
31
|
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
32
|
+
// Permission strings live in the same settings.json as the hooks; an unreadable file is left untouched
|
|
33
|
+
// and named rather than rewritten, like the hooks themselves (Plan_65 D10 item 4).
|
|
34
|
+
export async function removePermissions(host, inspection, dryRun) {
|
|
35
|
+
if (inspection.settingsError !== null) {
|
|
36
|
+
return `Left permission rules in ${host.settingsPath} because \
|
|
37
|
+
settings could not be read: ${inspection.settingsError}`;
|
|
38
|
+
}
|
|
39
|
+
const { removed } = await removePermissionRules(host.settingsPath, { dryRun });
|
|
40
|
+
return permissionOutput(host, removed, dryRun);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function stillAttachedLine(host) {
|
|
44
|
+
return `Did not finish uninstalling codex-bridge: ${host.root} still has its hooks; fix \
|
|
45
|
+
${host.settingsPath} and run uninstall again.`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Plan_65 D6: uninstalling one host removed the shared image from under every other host of the
|
|
49
|
+
// home. Only a record at the old per-host path (no home record at all) keeps the old behavior.
|
|
50
|
+
function imageDecision(rawRecord, host) {
|
|
51
|
+
if (rawRecord === null) return { removeImage: true };
|
|
52
|
+
return imageRemoval(asFormat2(rawRecord, host), host);
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
// Plan_65 D12: only a detached last owner permits the plan to remove the shared image.
|
|
56
|
+
function imagePolicyFor(decision, hostSide) {
|
|
57
|
+
if (!decision.removeImage) return { remove: false, reason: decision.reason };
|
|
58
|
+
return hostSide.detached
|
|
59
|
+
? { remove: true, reason: 'last owner' }
|
|
60
|
+
: { remove: false, reason: 'this host is still attached' };
|
|
52
61
|
}
|
|
53
62
|
|
|
54
|
-
function
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
function imageDispositionLine(host, decision, dryRun) {
|
|
64
|
+
if (decision.removeImage) return null;
|
|
65
|
+
const verb = dryRun ? 'Would leave' : 'Left';
|
|
66
|
+
if (decision.reason === 'no-record') {
|
|
67
|
+
return `${verb} the shared image in ${host.brandRoot} because \
|
|
68
|
+
this host has no installation record; its members are unknown.`;
|
|
69
|
+
}
|
|
70
|
+
if (decision.reason === 'other-owners') {
|
|
71
|
+
return `${verb} the shared image in ${host.brandRoot} because ${remainingOwnersText(decision.remaining)}.`;
|
|
72
|
+
}
|
|
73
|
+
return `${verb} the shared image in ${host.brandRoot} because \
|
|
74
|
+
the installation inventory is incomplete: other installations may use this home.`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Plan_65 D9 item 3 as amended by D10 item 4: after the last known owner answered "no", a repeat uninstall
|
|
78
|
+
// of ANY host of the home promised a question and printed "not installed" instead. The branch decides from
|
|
79
|
+
// the raw record and the inspection only — `legacy` is nobody's host view (D10 item 5) — removes the named
|
|
80
|
+
// host's side either way, and removes the image only on "yes" for a detached host.
|
|
81
|
+
async function uninstallOrphan(options, inspection, registry) {
|
|
82
|
+
const { host, dryRun = false } = options;
|
|
83
|
+
const promptOptions = { ...options, candidates: options.candidates ?? registry?.owners ?? [] };
|
|
84
|
+
if (dryRun) {
|
|
85
|
+
const permissionLine = await removePermissions(host, inspection, true);
|
|
86
|
+
const hostSide = await removeHostSide(host, inspection, { owner: null, dryRun: true });
|
|
87
|
+
const imagePolicy = { remove: hostSide.detached,
|
|
88
|
+
reason: hostSide.detached ? 'operator confirmed' : 'this host is still attached' };
|
|
89
|
+
const plan = await buildHomeRemovalPlan({ command: 'uninstall', host, packageRoot: options.packageRoot,
|
|
90
|
+
imagePolicy });
|
|
91
|
+
return {
|
|
92
|
+
exitCode: 1,
|
|
93
|
+
output: [permissionLine, ...hostSide.lines,
|
|
94
|
+
`A real run would ask whether to remove the shared image of ${host.brandRoot}: \
|
|
95
|
+
no host is recorded as using it.`,
|
|
96
|
+
'If the answer is yes:', ...planLines(plan, { host, detached: hostSide.detached })].join('\n'),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const answer = await askRemoval(host, 'orphan', promptOptions);
|
|
101
|
+
if (answer === 'cancel') return { exitCode: 130, output: 'Cancelled; nothing was changed.' };
|
|
102
|
+
|
|
103
|
+
const permissionLine = await removePermissions(host, inspection, false);
|
|
104
|
+
const hostSide = await removeHostSide(host, inspection, { owner: null });
|
|
105
|
+
const imagePolicy = answer === 'remove'
|
|
106
|
+
? { remove: hostSide.detached,
|
|
107
|
+
reason: hostSide.detached ? 'operator confirmed' : 'this host is still attached' }
|
|
108
|
+
: { remove: false, reason: 'no host is recorded as using it' };
|
|
109
|
+
const plan = await buildHomeRemovalPlan({ command: 'uninstall', host, packageRoot: options.packageRoot,
|
|
110
|
+
imagePolicy });
|
|
111
|
+
await removeEmpty(host.commandsDir);
|
|
112
|
+
await removeEmpty(host.agentsDir);
|
|
113
|
+
await removeEmptyLayout(host.legacyAgentsDir);
|
|
114
|
+
await removeEmptyLayout(host.legacyCommandsDir);
|
|
115
|
+
const { outcomes } = await executeHomePlan(host, plan,
|
|
116
|
+
{ detached: hostSide.detached, imageMembers: plan.imageMembers });
|
|
117
|
+
|
|
118
|
+
let imageLine = null;
|
|
119
|
+
let hint = null;
|
|
120
|
+
if (answer === 'remove' && !hostSide.detached) {
|
|
121
|
+
imageLine = `Left the shared image in ${host.brandRoot} because this host's hooks could not be removed.`;
|
|
122
|
+
} else if (answer !== 'remove') {
|
|
123
|
+
imageLine = `Left the shared image in ${host.brandRoot} because no host is recorded as using it \
|
|
124
|
+
and the inventory is incomplete.`;
|
|
125
|
+
hint = removalHint(host);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const hasMarks = hasPackageMarks(inspection);
|
|
129
|
+
const heading = !hostSide.detached
|
|
130
|
+
? stillAttachedLine(host)
|
|
131
|
+
: hasMarks ? 'Uninstalled codex-bridge.'
|
|
132
|
+
: `No codex-bridge files or hooks were found in ${host.root}.`;
|
|
59
133
|
return {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
alternateCommands: [full, short],
|
|
134
|
+
exitCode: answer === 'remove' && hostSide.detached ? outcomeExitCode(outcomes) : 1,
|
|
135
|
+
output: [heading, permissionLine, ...hostSide.lines, imageLine, hint, ...outcomeLines(outcomes, { host })]
|
|
136
|
+
.filter(Boolean).join('\n'),
|
|
64
137
|
};
|
|
65
138
|
}
|
|
66
139
|
|
|
67
|
-
async function uninstallInRun(
|
|
140
|
+
async function uninstallInRun(options = {}) {
|
|
141
|
+
const { host, dryRun = false } = options;
|
|
68
142
|
// Preflight before removing the hook: package removal on a broken registry left the host without its watchdog.
|
|
69
143
|
let registry = null;
|
|
70
144
|
let registryError = null;
|
|
@@ -73,101 +147,108 @@ async function uninstallInRun({ host, dryRun = false } = {}) {
|
|
|
73
147
|
} catch (err) {
|
|
74
148
|
registryError = err;
|
|
75
149
|
}
|
|
76
|
-
|
|
77
|
-
|
|
150
|
+
// Plan_65 D9: the question precedes the first change, so the record is read before the permission rules go;
|
|
151
|
+
// a cancel then leaves the host exactly as it was.
|
|
152
|
+
const rawRecord = await readInstallRecordFile(host);
|
|
153
|
+
const ownerKey = normalizeRepoPath(host.root);
|
|
154
|
+
const format2 = rawRecord === null ? null : asFormat2(rawRecord, host);
|
|
155
|
+
const orphaned = format2 !== null && Object.keys(format2.owners).length === 0;
|
|
156
|
+
const owner = format2?.owners[ownerKey] ?? null;
|
|
157
|
+
// Plan_65 D10: inspect marks and content before permission edits or any other host mutation.
|
|
158
|
+
const inspection = await inspectHost(host, { owner });
|
|
159
|
+
if (orphaned) return uninstallOrphan(options, inspection, registry);
|
|
160
|
+
|
|
78
161
|
const record = await readInstallRecord(host);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
162
|
+
let decision = rawRecord === null
|
|
163
|
+
? (record ? { removeImage: true } : { removeImage: false, reason: 'no-record' })
|
|
164
|
+
: imageDecision(rawRecord, host);
|
|
165
|
+
if (!record && decision.removeImage) decision = { removeImage: false, reason: 'no-record' };
|
|
166
|
+
const lastKnownOwner = decision.reason === 'incomplete-inventory'
|
|
167
|
+
&& Object.hasOwn(format2.owners, ownerKey);
|
|
168
|
+
let lastOwnerHint = null;
|
|
169
|
+
if (lastKnownOwner && !dryRun) {
|
|
170
|
+
const answer = await askRemoval(host, 'last-owner', options);
|
|
171
|
+
if (answer === 'cancel') return { exitCode: 130, output: 'Cancelled; nothing was changed.' };
|
|
172
|
+
if (answer === 'remove') decision = { removeImage: true };
|
|
173
|
+
else lastOwnerHint = removalHint(host);
|
|
82
174
|
}
|
|
175
|
+
const permissionLine = await removePermissions(host, inspection, dryRun);
|
|
176
|
+
const hostMarks = hasPackageMarks(inspection);
|
|
177
|
+
if (!record && !hostMarks) {
|
|
178
|
+
return {
|
|
179
|
+
exitCode: 1,
|
|
180
|
+
output: [permissionLine, 'codex-bridge is not installed.', ...outcomeLines([], { host })].join('\n'),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const writer = record ? recordHomeWriter(host, record.files) : null;
|
|
83
184
|
|
|
84
185
|
if (dryRun) {
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
for (const hook of record.hooks) {
|
|
107
|
-
const definition = definitionForRecordedHook(hook);
|
|
108
|
-
lines.push(`Would remove the ${hook.event} hook ${definition.name} for matcher ${definition.matcher}.`);
|
|
109
|
-
}
|
|
110
|
-
lines.push('Would remove the installation record from the brand root.');
|
|
111
|
-
lines.push(preservation);
|
|
112
|
-
return { exitCode: 0, output: lines.join('\n') };
|
|
186
|
+
const hostSide = await removeHostSide(host, inspection, { owner, dryRun: true });
|
|
187
|
+
const imagePolicy = imagePolicyFor(decision, hostSide);
|
|
188
|
+
const plan = await buildHomeRemovalPlan({ command: 'uninstall', host, packageRoot: options.packageRoot,
|
|
189
|
+
imagePolicy });
|
|
190
|
+
const lines = [permissionLine, ...hostSide.lines];
|
|
191
|
+
const blockedImageLine = decision.removeImage && !hostSide.detached
|
|
192
|
+
? `Would leave the shared image in ${host.brandRoot} because this host's hooks could not be removed.`
|
|
193
|
+
: null;
|
|
194
|
+
const imageLine = blockedImageLine || (!decision.removeImage
|
|
195
|
+
? imageDispositionLine(host, decision, true) : null);
|
|
196
|
+
if (imageLine) lines.push(imageLine);
|
|
197
|
+
if (lastKnownOwner) lines.push(`A real run would ask whether ${host.root} is the last host \
|
|
198
|
+
using ${host.brandRoot}.`);
|
|
199
|
+
lines.push(...await rulesDryRunLines({
|
|
200
|
+
host, record, registry, registryError, detached: hostSide.detached,
|
|
201
|
+
}));
|
|
202
|
+
lines.push(...planLines(plan, { host, detached: hostSide.detached }));
|
|
203
|
+
return {
|
|
204
|
+
exitCode: decision.reason === 'incomplete-inventory' || !hostSide.detached ? 1 : planExitCode(plan),
|
|
205
|
+
output: lines.join('\n'),
|
|
206
|
+
};
|
|
113
207
|
}
|
|
114
208
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (record.rules) {
|
|
130
|
-
if (registryError) {
|
|
131
|
-
rulesOutput.push(`Left ${record.rules.path} because the rules ownership registry is invalid; ownership is unknown.`);
|
|
132
|
-
} else {
|
|
133
|
-
if (ownership?.owners.length) {
|
|
134
|
-
rulesOutput.push(`Left ${record.rules.path} because ${remainingOwnersText(ownership.owners.length)}.`);
|
|
135
|
-
} else {
|
|
136
|
-
const currentFingerprint = await fileFingerprint(record.rules.path);
|
|
137
|
-
if (currentFingerprint === record.rules.fingerprint) {
|
|
138
|
-
await fs.rm(record.rules.path, { force: true });
|
|
139
|
-
} else if (currentFingerprint !== null) {
|
|
140
|
-
rulesOutput.push(`Left ${record.rules.path} because its contents changed after installation.`);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
if (!registry) {
|
|
144
|
-
rulesOutput.push(`Warning: the rules ownership registry was missing; other installations may use ${record.rules.path}.`);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
for (const file of record.files) {
|
|
149
|
-
const target = recordTarget(host, file);
|
|
150
|
-
await fs.rm(target, { force: true });
|
|
151
|
-
const boundary = file.root === 'brand'
|
|
152
|
-
? host.brandRoot
|
|
153
|
-
: claudeBoundary(host, target);
|
|
154
|
-
await removeEmptyParents(target, boundary);
|
|
155
|
-
}
|
|
209
|
+
const hostSide = await removeHostSide(host, inspection, { owner });
|
|
210
|
+
const imagePolicy = imagePolicyFor(decision, hostSide);
|
|
211
|
+
const plan = await buildHomeRemovalPlan({ command: 'uninstall', host, packageRoot: options.packageRoot,
|
|
212
|
+
imagePolicy });
|
|
213
|
+
// Plan_65 D10 item 4: a host still attached keeps everything it shares — its rules ownership, its
|
|
214
|
+
// place in the record, the old per-host record — so a repeat run after the fix finds it whole.
|
|
215
|
+
const detachedRecord = record && hostSide.detached ? record : null;
|
|
216
|
+
const rulesResult = await removeRulesForHost({
|
|
217
|
+
host, record, registry, registryError, detached: hostSide.detached, writer,
|
|
218
|
+
});
|
|
219
|
+
const rulesOutput = rulesResult.lines;
|
|
220
|
+
registryError = rulesResult.registryError;
|
|
221
|
+
const { outcomes } = await executeHomePlan(host, plan,
|
|
222
|
+
{ detached: hostSide.detached, imageMembers: plan.imageMembers });
|
|
156
223
|
await removeEmpty(host.commandsDir);
|
|
157
|
-
|
|
158
|
-
await fs.rm(legacyInstallRecordPath(host), { force: true });
|
|
224
|
+
if (detachedRecord) await removeOutside(writer, legacyInstallRecordPath(host));
|
|
159
225
|
await removeEmpty(host.agentsDir);
|
|
160
226
|
await removeEmptyLayout(host.legacyAgentsDir);
|
|
161
227
|
await removeEmptyLayout(host.legacyCommandsDir);
|
|
162
|
-
|
|
228
|
+
const blockedImageLine = decision.removeImage && !hostSide.detached
|
|
229
|
+
? `Left the shared image in ${host.brandRoot} because this host's hooks could not be removed.`
|
|
230
|
+
: null;
|
|
231
|
+
const imageLine = blockedImageLine || (!decision.removeImage
|
|
232
|
+
? imageDispositionLine(host, decision, false) : null);
|
|
233
|
+
const heading = hostSide.detached
|
|
234
|
+
? 'Uninstalled codex-bridge.'
|
|
235
|
+
: stillAttachedLine(host);
|
|
163
236
|
return {
|
|
164
|
-
exitCode:
|
|
165
|
-
output: [
|
|
237
|
+
exitCode: decision.reason === 'incomplete-inventory' || !hostSide.detached ? 1 : outcomeExitCode(outcomes),
|
|
238
|
+
output: [heading, permissionLine, ...hostSide.lines, ...rulesOutput, imageLine, lastOwnerHint,
|
|
239
|
+
...outcomeLines(outcomes, { host })].filter(Boolean).join('\n'),
|
|
166
240
|
};
|
|
167
241
|
}
|
|
168
242
|
|
|
169
243
|
export async function uninstall(options = {}) {
|
|
170
244
|
const host = options?.host;
|
|
171
245
|
if (!host?.settingsPath) return uninstallInRun(options);
|
|
172
|
-
|
|
246
|
+
// A dry run only reads; like install's, it must not wait on or create anything.
|
|
247
|
+
if (options.dryRun === true) return withSettingsRun(host.settingsPath, () => uninstallInRun(options));
|
|
248
|
+
return withLifecycle(
|
|
249
|
+
host,
|
|
250
|
+
'uninstall',
|
|
251
|
+
() => withSettingsRun(host.settingsPath, () => uninstallInRun(options)),
|
|
252
|
+
{ ticket: options.lifecycleTicket, waitMs: options.lifecycleWaitMs },
|
|
253
|
+
);
|
|
173
254
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses and renders `unlock --lifecycle [--clear]` (Plan_65 D11): the busy refusal of install, update
|
|
3
|
+
* and uninstall sends the operator here, so the command has to name the holder and clear a crash's lock.
|
|
4
|
+
*/
|
|
5
|
+
import { resolveBrandHome } from '../src/home/lib/brand-home.mjs';
|
|
6
|
+
import { withCommandHelp } from './command-help.mjs';
|
|
7
|
+
import { inspectLifecycleLock } from './lifecycle-lock-inspect.mjs';
|
|
8
|
+
import { clearLifecycleLock } from './lifecycle-lock-clear.mjs';
|
|
9
|
+
|
|
10
|
+
const heldLine = (holder) => (
|
|
11
|
+
`held by ${holder.command} for ${holder.hostRoot}, pid ${holder.pid}, since ${holder.acquiredAt}.`
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
function renderClear(observation) {
|
|
15
|
+
const { homeRoot, strategy, holder, lockPath, gatePath, gateLeft } = observation;
|
|
16
|
+
const lines = [`Lifecycle lock of ${homeRoot} (${strategy}), clearing:`];
|
|
17
|
+
let exitCode = 0;
|
|
18
|
+
switch (observation.outcome) {
|
|
19
|
+
case 'cleared':
|
|
20
|
+
lines.push(`Removed the lock left by ${holder.command} for ${holder.hostRoot}, pid ${holder.pid} (dead).`);
|
|
21
|
+
lines.push('The next install, update or uninstall may take it.');
|
|
22
|
+
break;
|
|
23
|
+
case 'free':
|
|
24
|
+
lines.push('Nothing to clear; the lock is free.');
|
|
25
|
+
break;
|
|
26
|
+
case 'kernel-managed':
|
|
27
|
+
lines.push('Kernel-managed lock; nothing to clear.');
|
|
28
|
+
lines.push('Run codex-bridge unlock --lifecycle to see its holder.');
|
|
29
|
+
break;
|
|
30
|
+
case 'refused':
|
|
31
|
+
lines.push(`Refused: ${observation.reason}. Nothing was removed.`);
|
|
32
|
+
if (holder) lines.push(heldLine(holder));
|
|
33
|
+
lines.push(`If you are sure no install, update or uninstall is running, delete ${lockPath} yourself.`);
|
|
34
|
+
exitCode = 1;
|
|
35
|
+
break;
|
|
36
|
+
case 'gate-busy':
|
|
37
|
+
lines.push(`Another clear is in progress or crashed: ${gatePath}.`);
|
|
38
|
+
lines.push('If none is running, delete that file yourself.');
|
|
39
|
+
exitCode = 1;
|
|
40
|
+
break;
|
|
41
|
+
case 'no-home':
|
|
42
|
+
lines.push(`codex-bridge unlock --lifecycle: no package home at ${homeRoot}; nothing to inspect.`);
|
|
43
|
+
exitCode = 1;
|
|
44
|
+
break;
|
|
45
|
+
default:
|
|
46
|
+
throw new Error(`Unknown lifecycle lock clear outcome: ${observation.outcome}`);
|
|
47
|
+
}
|
|
48
|
+
if (gateLeft) {
|
|
49
|
+
lines.push(`Could not remove the clear gate: ${gateLeft}.`);
|
|
50
|
+
lines.push('If no clear is running, delete that file yourself.');
|
|
51
|
+
exitCode = 1;
|
|
52
|
+
}
|
|
53
|
+
return { exitCode, output: lines.join('\n') };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function unlockLifecycle(argv, options = {}) {
|
|
57
|
+
const clearing = argv.length === 2 && argv[0] === '--lifecycle' && argv[1] === '--clear';
|
|
58
|
+
if (!clearing && (argv.length !== 1 || argv[0] !== '--lifecycle')) {
|
|
59
|
+
// Name the first argument past the longest accepted prefix, not always the second one.
|
|
60
|
+
const accepted = argv[0] === '--lifecycle' ? (argv[1] === '--clear' ? 2 : 1) : 0;
|
|
61
|
+
const argument = argv[accepted];
|
|
62
|
+
return {
|
|
63
|
+
exitCode: 2,
|
|
64
|
+
output: withCommandHelp('unlock', `codex-bridge unlock --lifecycle: unexpected argument "${argument}".`),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const homeRoot = options.homeRoot ?? resolveBrandHome().root;
|
|
68
|
+
if (clearing) return renderClear(await clearLifecycleLock(homeRoot, options.clear ?? {}));
|
|
69
|
+
const observation = await inspectLifecycleLock(homeRoot, options.inspect ?? {});
|
|
70
|
+
const { strategy, observedAt, holder, liveness } = observation;
|
|
71
|
+
const lines = [`Lifecycle lock of ${homeRoot} (${strategy}), observed ${observedAt}:`];
|
|
72
|
+
let exitCode = 0;
|
|
73
|
+
switch (observation.state) {
|
|
74
|
+
case 'free':
|
|
75
|
+
lines.push('free — no install, update or uninstall holds it.');
|
|
76
|
+
break;
|
|
77
|
+
case 'held':
|
|
78
|
+
if (holder === null) {
|
|
79
|
+
lines.push('held; the holder did not identify itself.');
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
lines.push(heldLine(holder));
|
|
83
|
+
if (strategy === 'file') {
|
|
84
|
+
lines.push(`Holder process: ${liveness}.`);
|
|
85
|
+
if (liveness === 'dead') {
|
|
86
|
+
lines.push('The file was left by a crash. Clear it with codex-bridge unlock --lifecycle --clear');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
case 'unverified':
|
|
91
|
+
lines.push(`could not be verified: ${observation.reason}. Nothing was changed.`);
|
|
92
|
+
exitCode = 1;
|
|
93
|
+
break;
|
|
94
|
+
case 'no-home':
|
|
95
|
+
lines.push(`codex-bridge unlock --lifecycle: no package home at ${homeRoot}; nothing to inspect.`);
|
|
96
|
+
exitCode = 1;
|
|
97
|
+
break;
|
|
98
|
+
default:
|
|
99
|
+
throw new Error(`Unknown lifecycle lock state: ${observation.state}`);
|
|
100
|
+
}
|
|
101
|
+
return { exitCode, output: lines.join('\n') };
|
|
102
|
+
}
|
package/cli/unlock.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
/** Closes dead-pid running records without deleting any run artifacts. */
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { withCommandHelp } from './command-help.mjs';
|
|
5
6
|
import { formatSilence, heartbeatAge, HEARTBEAT_STALE_MS } from '../src/home/lib/heartbeat.mjs';
|
|
6
7
|
import { runLiveness } from '../src/home/lib/meta/run-liveness.mjs';
|
|
7
8
|
import { markAbandoned } from '../src/home/lib/meta/run-state.mjs';
|
|
@@ -14,6 +15,7 @@ import { resolveProjectRunsDir } from '../src/home/lib/runner/project-dir.mjs';
|
|
|
14
15
|
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
15
16
|
import { readJson } from '../src/home/lib/write-meta.mjs';
|
|
16
17
|
import { renderTable } from './table.mjs';
|
|
18
|
+
import { unlockLifecycle } from './unlock-lifecycle.mjs';
|
|
17
19
|
|
|
18
20
|
const CLOSED_COLUMNS = [
|
|
19
21
|
{ key: 'project', label: 'project' },
|
|
@@ -38,20 +40,20 @@ function parseArgs(argv) {
|
|
|
38
40
|
let all = false;
|
|
39
41
|
for (const arg of argv) {
|
|
40
42
|
if (arg === '--all') {
|
|
41
|
-
if (all) return { error: 'codex-bridge unlock accepts --all only once.' };
|
|
43
|
+
if (all) return { error: withCommandHelp('unlock', 'codex-bridge unlock accepts --all only once.') };
|
|
42
44
|
all = true;
|
|
43
45
|
continue;
|
|
44
46
|
}
|
|
45
47
|
if (typeof arg !== 'string' || arg.startsWith('-')) {
|
|
46
|
-
return { error: `codex-bridge unlock: unknown option "${arg}".` };
|
|
48
|
+
return { error: withCommandHelp('unlock', `codex-bridge unlock: unknown option "${arg}".`) };
|
|
47
49
|
}
|
|
48
50
|
positional.push(arg);
|
|
49
51
|
}
|
|
50
52
|
if (all && positional.length) {
|
|
51
|
-
return { error: 'codex-bridge unlock accepts either --all or one project name.' };
|
|
53
|
+
return { error: withCommandHelp('unlock', 'codex-bridge unlock accepts either --all or one project name.') };
|
|
52
54
|
}
|
|
53
55
|
if (positional.length > 1) {
|
|
54
|
-
return { error: 'codex-bridge unlock accepts at most one project name.' };
|
|
56
|
+
return { error: withCommandHelp('unlock', 'codex-bridge unlock accepts at most one project name.') };
|
|
55
57
|
}
|
|
56
58
|
const project = positional[0] || null;
|
|
57
59
|
if (project && (path.isAbsolute(project) || path.dirname(project) !== '.')) {
|
|
@@ -228,6 +230,7 @@ function renderReport(plan, records, closed, failures, options) {
|
|
|
228
230
|
}
|
|
229
231
|
|
|
230
232
|
export function unlock(argv = [], options = {}) {
|
|
233
|
+
if (argv[0] === '--lifecycle') return unlockLifecycle(argv, options);
|
|
231
234
|
const plan = unlockPlan(argv, options);
|
|
232
235
|
if (plan.error) return result(2, plan.error);
|
|
233
236
|
const records = plan.projects.flatMap((project) => inspectRuns(project, options));
|