@lyupro/codex-bridge 0.6.7 → 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 +148 -0
- package/README.md +10 -6
- package/bin/codex-bridge.mjs +35 -172
- 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 +21 -11
- package/cli/dispatcher-contract.mjs +6 -5
- package/cli/doctor-host-side.mjs +110 -0
- package/cli/doctor-installation.mjs +111 -111
- package/cli/doctor.mjs +35 -7
- package/cli/handback-witness-check.mjs +10 -18
- 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 +36 -21
- 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 +101 -77
- 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/probe-contract.mjs +26 -10
- package/cli/probe-target.mjs +33 -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/session-hosts.mjs +51 -0
- 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 -126
- 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 +33 -1
- package/src/agents/codex-advisor.md +5 -2
- package/src/home/conventions.md +9 -0
- package/src/home/hooks/dispatcher-gate.mjs +15 -2
- 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 +24 -9
- 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 +33 -25
- 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 +60 -0
- package/src/home/lib/host-version.mjs +27 -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/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/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 };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answers whether any recorded Codex run may still be executing, failing closed on anything unreadable.
|
|
3
|
+
*
|
|
4
|
+
* Plan_65 D22 item 5: purge removes the home image a live run executes from. The hook scanner's
|
|
5
|
+
* skipped read errors and fresh-heartbeat requirement cannot prove that image is unused: a hung
|
|
6
|
+
* run still holds it. Purge therefore keeps uncertainty visible and uses the single liveness judge.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { readJsonFileSync } from '../src/home/lib/json-file.mjs';
|
|
11
|
+
import { runLiveness } from '../src/home/lib/meta/run-liveness.mjs';
|
|
12
|
+
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
13
|
+
import { STOP_COMMAND } from '../src/home/lib/stop-contract.mjs';
|
|
14
|
+
|
|
15
|
+
function recordError(unknown, file, error) {
|
|
16
|
+
unknown.push({ path: file, reason: error.code || error.message });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ENOENT is the only proof of absence: a root never created holds no runs, and a project folder
|
|
20
|
+
// pruned between the root listing and its own is gone too. Every other code stays a finding.
|
|
21
|
+
function readEntries(directory, unknown) {
|
|
22
|
+
try {
|
|
23
|
+
return fs.readdirSync(directory, { withFileTypes: true });
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error.code !== 'ENOENT') recordError(unknown, directory, error);
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isDirectory(entry, file, unknown) {
|
|
31
|
+
if (entry.isDirectory()) return true;
|
|
32
|
+
if (!entry.isSymbolicLink()) return false;
|
|
33
|
+
try {
|
|
34
|
+
// Plan_65 D4 forbids deleting through links, not reading their run records.
|
|
35
|
+
return fs.statSync(file).isDirectory();
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code !== 'ENOENT') recordError(unknown, file, error);
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function inspectRun(runDir, identityOptions, blocked, unknown) {
|
|
43
|
+
const statusPath = path.join(runDir, 'status.json');
|
|
44
|
+
let status;
|
|
45
|
+
try {
|
|
46
|
+
status = readJsonFileSync(statusPath);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
// Lifecycle step 10 writes status first; old folders without it are not recorded runs.
|
|
49
|
+
if (error.cause instanceof SyntaxError) {
|
|
50
|
+
unknown.push({ path: statusPath, reason: 'invalid JSON' });
|
|
51
|
+
} else if (error.code !== 'ENOENT') {
|
|
52
|
+
recordError(unknown, statusPath, error);
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!status || typeof status !== 'object' || Array.isArray(status)) {
|
|
57
|
+
unknown.push({ path: statusPath, reason: 'status.json is not a plain object' });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (status.state !== 'running') return;
|
|
61
|
+
try {
|
|
62
|
+
if (runLiveness({ ...identityOptions, runDir, status }).processMayBeAlive === true) {
|
|
63
|
+
blocked.push({
|
|
64
|
+
runDir,
|
|
65
|
+
agent: status.agent,
|
|
66
|
+
slug: status.slug,
|
|
67
|
+
stop: `${STOP_COMMAND} "${runDir}"`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
recordError(unknown, statusPath, error);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function inspectLiveRuns({ root = runsRoot(), ...identityOptions } = {}) {
|
|
76
|
+
const blocked = [];
|
|
77
|
+
const unknown = [];
|
|
78
|
+
for (const project of readEntries(root, unknown)) {
|
|
79
|
+
const projectDir = path.join(root, project.name);
|
|
80
|
+
if (!isDirectory(project, projectDir, unknown)) continue;
|
|
81
|
+
for (const run of readEntries(projectDir, unknown)) {
|
|
82
|
+
const runDir = path.join(projectDir, run.name);
|
|
83
|
+
if (isDirectory(run, runDir, unknown)) inspectRun(runDir, identityOptions, blocked, unknown);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const verdict = blocked.length ? 'blocked' : unknown.length ? 'unknown' : 'clear';
|
|
87
|
+
return { verdict, blocked, unknown };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function liveRunLines(result) {
|
|
91
|
+
if (result.verdict === 'clear') return [];
|
|
92
|
+
return [
|
|
93
|
+
...result.blocked.map(({ runDir, stop }) => `Live run ${path.basename(runDir)}: ${stop}`),
|
|
94
|
+
...result.unknown.map(({ path: file, reason }) =>
|
|
95
|
+
`Could not read ${file} (${reason}): purge cannot prove no run is live.`),
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Holds purge's one set of refusal checks: under a live ticket it asks both consents and mints
|
|
3
|
+
* the executor's authorization; for a dry run it only reports.
|
|
4
|
+
*
|
|
5
|
+
* Plan_65 D12 items 5-6 and D14 require one ordered check inside the lifecycle transaction:
|
|
6
|
+
* live runs, installation inventory, inventory consent, then separate data consent. The executor
|
|
7
|
+
* consumes its result instead of checking again, so permission cannot outlive or change its plan.
|
|
8
|
+
*/
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { assertLiveTicket } from './lifecycle-transaction.mjs';
|
|
11
|
+
import { askPurgeConsent } from './inventory-removal.mjs';
|
|
12
|
+
import { inspectLiveRuns, liveRunLines } from './purge-live-runs.mjs';
|
|
13
|
+
import { buildHomeRemovalPlan } from './home-removal-plan.mjs';
|
|
14
|
+
import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
|
|
15
|
+
|
|
16
|
+
const authorizations = new WeakMap();
|
|
17
|
+
|
|
18
|
+
// D14 item 2: this is THE live-run step, reached only as the default liveRunCheck of
|
|
19
|
+
// runPurgePreflight and diagnosePurge. Plan_74 replaces it alone with the admission gate.
|
|
20
|
+
export function checkLiveRuns() {
|
|
21
|
+
return inspectLiveRuns();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function freezePlan(value) {
|
|
25
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
26
|
+
for (const child of Object.values(value)) freezePlan(child);
|
|
27
|
+
Object.freeze(value);
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const refused = (line) => ({ verdict: 'refused', lines: [line] });
|
|
33
|
+
|
|
34
|
+
export function planRefusals(plan, host) {
|
|
35
|
+
if (plan.homeRoot === 'error') {
|
|
36
|
+
return [`Could not read ${host.brandRoot}: purge cannot inventory the home.`];
|
|
37
|
+
}
|
|
38
|
+
if (plan.recordState === 'corrupt') {
|
|
39
|
+
return [`The installation record of ${host.brandRoot} is unreadable: purge cannot tell who uses this home.`];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ownerKey = normalizeRepoPath(host.root);
|
|
43
|
+
const otherOwners = plan.recordState === 'valid'
|
|
44
|
+
? Object.keys(plan.format2.owners).filter((root) => root !== ownerKey)
|
|
45
|
+
: [];
|
|
46
|
+
return otherOwners.map((root) => `${root} is recorded as using ${host.brandRoot}; uninstall it first.`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function purgeDataFiles(plan) {
|
|
50
|
+
return plan.rows.filter((row) => row.removal === 'purge-only' && row.action === 'remove')
|
|
51
|
+
.map((row) => row.relative).sort();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function diagnosePurge({ host, packageRoot, liveRunCheck = checkLiveRuns }) {
|
|
55
|
+
const liveRuns = await liveRunCheck();
|
|
56
|
+
const plan = await buildHomeRemovalPlan({
|
|
57
|
+
command: 'purge', host, packageRoot, imagePolicy: { remove: true, reason: 'purge' },
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
refusals: [...liveRunLines(liveRuns), ...planRefusals(plan, host)], plan, dataFiles: purgeDataFiles(plan),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function runPurgePreflight({ host, ticket, packageRoot, options = {}, liveRunCheck = checkLiveRuns }) {
|
|
65
|
+
assertLiveTicket(ticket, host);
|
|
66
|
+
const liveRuns = await liveRunCheck();
|
|
67
|
+
if (liveRuns.verdict !== 'clear') return { verdict: 'refused', lines: liveRunLines(liveRuns) };
|
|
68
|
+
|
|
69
|
+
const plan = await buildHomeRemovalPlan({
|
|
70
|
+
command: 'purge', host, packageRoot, imagePolicy: { remove: true, reason: 'purge' },
|
|
71
|
+
});
|
|
72
|
+
const lines = planRefusals(plan, host);
|
|
73
|
+
if (lines.length) return { verdict: 'refused', lines };
|
|
74
|
+
|
|
75
|
+
const ownerKey = normalizeRepoPath(host.root);
|
|
76
|
+
const inventoryConsent = await askPurgeConsent(host, 'inventory', options);
|
|
77
|
+
if (inventoryConsent === 'cancel') return { verdict: 'cancelled' };
|
|
78
|
+
if (inventoryConsent === 'no') {
|
|
79
|
+
return refused(`Purge needs your confirmation that no other host uses ${host.brandRoot}.`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const dataFiles = purgeDataFiles(plan);
|
|
83
|
+
const dataConsent = await askPurgeConsent(host, 'data', options, dataFiles);
|
|
84
|
+
if (dataConsent === 'cancel') return { verdict: 'cancelled' };
|
|
85
|
+
if (dataConsent === 'no') {
|
|
86
|
+
return refused(`Purge needs your consent to delete your data in ${host.brandRoot}.`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
freezePlan(plan);
|
|
90
|
+
const authorization = Object.freeze({});
|
|
91
|
+
authorizations.set(authorization, { home: path.resolve(host.brandRoot), ownerKey, ticket, plan });
|
|
92
|
+
return { verdict: 'authorized', plan, authorization };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function consumePurgeAuthorization(authorization, host, plan) {
|
|
96
|
+
const entry = authorizations.get(authorization);
|
|
97
|
+
if (!entry) throw new Error('purge requires its preflight authorization (Plan_65 D12 item 6)');
|
|
98
|
+
// D14: every attempt consumes the capability, including a stale ticket or wrong plan/home.
|
|
99
|
+
authorizations.delete(authorization);
|
|
100
|
+
assertLiveTicket(entry.ticket, host);
|
|
101
|
+
if (path.resolve(host.brandRoot) !== entry.home) throw new Error('purge authorization belongs to a different home');
|
|
102
|
+
if (entry.plan !== plan) throw new Error('purge authorization was granted for a different plan');
|
|
103
|
+
}
|
package/cli/read.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/** Renders a run's structured Codex transport as a readable event-by-event report. */
|
|
2
2
|
import { readEvents } from '../src/home/lib/meta/events.mjs';
|
|
3
|
+
import { safeSlice } from '../src/home/lib/meta/paths.mjs';
|
|
3
4
|
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
4
5
|
import { resolveRunFolder } from './run-lookup.mjs';
|
|
5
6
|
|
|
6
7
|
const result = (exitCode, output) => ({ exitCode, output });
|
|
7
8
|
const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
8
9
|
|
|
9
|
-
const inline = (value, max = 300) => String(value ?? '').replace(/\s+/g, ' ').trim()
|
|
10
|
+
const inline = (value, max = 300) => safeSlice(String(value ?? '').replace(/\s+/g, ' ').trim(), max);
|
|
10
11
|
|
|
11
12
|
function compactJson(value, max = 300) {
|
|
12
13
|
let text;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removes files listed in an installation record through the home adapter, so uninstall and update
|
|
3
|
+
* cannot remove an unregistered path from the package home.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import { createHomeWriter } from '../src/home/lib/home-write.mjs';
|
|
7
|
+
import { recordTarget } from './install-record.mjs';
|
|
8
|
+
import { claudeBoundary, removeEmptyParents, removeEmptyHomeParents } from './remove-layout.mjs';
|
|
9
|
+
import { inspectSegments } from './link-segments.mjs';
|
|
10
|
+
|
|
11
|
+
export function recordHomeWriter(host, files) {
|
|
12
|
+
const imageMembers = files.filter((entry) => entry.root === 'brand').map((entry) => entry.path);
|
|
13
|
+
return createHomeWriter({ root: host.brandRoot, imageMembers });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function removeRecordedFile(host, writer, entry) {
|
|
17
|
+
const target = recordTarget(host, entry);
|
|
18
|
+
if (entry.root === 'brand') {
|
|
19
|
+
// Plan_65 D4 item 4 covers unlink too: a real file can sit beneath a junction.
|
|
20
|
+
const finding = inspectSegments(target, host.brandRoot);
|
|
21
|
+
if (finding.kind !== 'clear' && finding.kind !== 'missing') return { kept: finding };
|
|
22
|
+
if (finding.kind === 'clear') {
|
|
23
|
+
try {
|
|
24
|
+
await writer.unlink('install-image', target);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
if (err.code !== 'ENOENT') throw err;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// A file already gone still gets its emptied parents taken down: a repeated uninstall after a
|
|
30
|
+
// partial one must finish the folders the first run left. The segments were just checked, so a
|
|
31
|
+
// link the walk finds now is a race; it stops the walk, and the file's own outcome is reported.
|
|
32
|
+
await removeEmptyHomeParents(writer, 'install-image', target, host.brandRoot);
|
|
33
|
+
} else {
|
|
34
|
+
await removeOutside(writer, target);
|
|
35
|
+
await removeEmptyParents(target, claudeBoundary(host, target));
|
|
36
|
+
}
|
|
37
|
+
return { kept: null };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function removeOutside(writer, target) {
|
|
41
|
+
writer.assertOutside(target);
|
|
42
|
+
await fs.rm(target, { force: true });
|
|
43
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns home removal outcomes, or a plan for a dry run, into the lines and exit code the operator sees.
|
|
3
|
+
*
|
|
4
|
+
* Plan_65 D12 item 7 requires actual counts and named keeps: planned removal must not claim
|
|
5
|
+
* that edited image files disappeared, or confuse removing an owner with deleting its record.
|
|
6
|
+
*/
|
|
7
|
+
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
8
|
+
|
|
9
|
+
const unsafeReason = (reason) => reason.startsWith('link') || reason.startsWith('unreadable');
|
|
10
|
+
// An image file kept by content evidence is named; one kept by policy (another owner, an incomplete
|
|
11
|
+
// inventory) is not — that would print ~90 lines the caller explains in one sentence.
|
|
12
|
+
const EVIDENCE_KEEP = new Set(['changed', 'link']);
|
|
13
|
+
const namedKeep = (entry) => unsafeReason(entry.reason) || entry.id === null
|
|
14
|
+
|| (entry.id === 'install-image' && EVIDENCE_KEEP.has(entry.reason));
|
|
15
|
+
const artifactsLine = () => `Run artifacts in ${runsRoot()} are outside uninstall and stay.`;
|
|
16
|
+
|
|
17
|
+
function fileLines(files, host, dryRun) {
|
|
18
|
+
const lines = [];
|
|
19
|
+
const removing = dryRun ? 'remove' : 'removed';
|
|
20
|
+
const verb = dryRun ? 'Would remove' : 'Removed';
|
|
21
|
+
const removed = files.filter((entry) => entry.result === removing);
|
|
22
|
+
const images = removed.filter((entry) => entry.id === 'install-image').length;
|
|
23
|
+
const data = removed.length - images;
|
|
24
|
+
if (images > 0) lines.push(`${verb} ${images} image file(s) from ${host.brandRoot}.`);
|
|
25
|
+
if (data > 0) lines.push(`${verb} ${data} data file(s) from ${host.brandRoot}.`);
|
|
26
|
+
for (const entry of files) {
|
|
27
|
+
if ((entry.result === 'kept' || entry.result === 'blocked') && namedKeep(entry)) {
|
|
28
|
+
lines.push(`${dryRun ? 'Would leave' : 'Left'} brand/${entry.relative} (${entry.reason})`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const retained = files.filter((entry) => entry.result === 'kept' && entry.reason === 'purge-only');
|
|
32
|
+
if (retained.length > 0) {
|
|
33
|
+
const paths = retained.map((entry) => entry.relative).sort().join(', ');
|
|
34
|
+
const location = `in ${host.brandRoot} for --purge: ${paths}`;
|
|
35
|
+
lines.push(`${dryRun ? 'Would keep' : 'Kept'} ${retained.length} operator file(s) ${location}`);
|
|
36
|
+
}
|
|
37
|
+
for (const entry of files) {
|
|
38
|
+
if (entry.result === 'failed') lines.push(`Could not remove brand/${entry.relative}: ${entry.reason}`);
|
|
39
|
+
}
|
|
40
|
+
return lines;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function recordLines(record, host) {
|
|
44
|
+
if (record.result === 'removed') return [`Removed the installation record of ${host.brandRoot}.`];
|
|
45
|
+
if (record.result === 'failed') return [`Could not remove brand/${record.relative}: ${record.reason}`];
|
|
46
|
+
if (record.result !== 'kept') return [];
|
|
47
|
+
if (record.reason === 'this host removed from the record') {
|
|
48
|
+
return [`Removed ${host.root} from the installation record of ${host.brandRoot}; the shared image stays.`];
|
|
49
|
+
}
|
|
50
|
+
if (record.reason === 'this host is still attached') {
|
|
51
|
+
return [`Kept ${host.root} in the installation record because its hooks could not be removed.`];
|
|
52
|
+
}
|
|
53
|
+
return [`Kept the installation record of ${host.brandRoot} (${record.reason}).`];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function outcomeLines(outcomes, { host }) {
|
|
57
|
+
const files = outcomes.filter((entry) => entry.kind === 'file');
|
|
58
|
+
const record = outcomes.find((entry) => entry.kind === 'record');
|
|
59
|
+
const lines = fileLines(files, host, false);
|
|
60
|
+
if (record) lines.push(...recordLines(record, host));
|
|
61
|
+
for (const entry of outcomes.filter((candidate) => candidate.kind === 'directory')) {
|
|
62
|
+
if (entry.relative === '' && entry.result === 'removed') {
|
|
63
|
+
lines.push(`Removed ${host.brandRoot}.`);
|
|
64
|
+
} else if (entry.result === 'kept') {
|
|
65
|
+
lines.push(`Left brand/${entry.relative}/ (${entry.reason})`);
|
|
66
|
+
} else if (entry.result === 'failed') {
|
|
67
|
+
lines.push(`Could not remove brand/${entry.relative}/: ${entry.reason}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
lines.push(artifactsLine());
|
|
71
|
+
return lines;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function outcomeExitCode(outcomes) {
|
|
75
|
+
return outcomes.some((entry) => entry.result === 'blocked' || entry.result === 'failed'
|
|
76
|
+
|| (entry.result === 'kept' && unsafeReason(entry.reason))) ? 1 : 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function planExitCode(plan) {
|
|
80
|
+
return plan.blocked || plan.rows.some((row) => (row.action === 'keep' || row.action === 'blocked')
|
|
81
|
+
&& unsafeReason(row.reason)) ? 1 : 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function planLines(plan, { host, detached = true }) {
|
|
85
|
+
const files = plan.rows.map((row) => ({ ...row, result: row.action === 'keep' ? 'kept' : row.action }));
|
|
86
|
+
const lines = fileLines(files, host, true);
|
|
87
|
+
if (plan.record.dependsOnDetach && detached === false) {
|
|
88
|
+
lines.push(`Would keep ${host.root} in the installation record because its hooks could not be removed.`);
|
|
89
|
+
} else if (plan.record.operation === 'delete') {
|
|
90
|
+
lines.push(`Would remove the installation record of ${host.brandRoot}.`);
|
|
91
|
+
} else if (plan.record.operation === 'remove-current-owner') {
|
|
92
|
+
lines.push(`Would remove ${host.root} from the installation record of ${host.brandRoot}; the shared image stays.`);
|
|
93
|
+
} else if (plan.record.operation === 'retain') {
|
|
94
|
+
lines.push(`Would keep the installation record of ${host.brandRoot} (${plan.record.reason})`);
|
|
95
|
+
}
|
|
96
|
+
lines.push(artifactsLine());
|
|
97
|
+
return lines;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function purgeDryRunLines({ refusals, plan, dataFiles }, { host }) {
|
|
101
|
+
if (plan.homeRoot === 'missing' && refusals.length === 0) {
|
|
102
|
+
return [`Nothing to purge: ${host.brandRoot} does not exist.`, artifactsLine()];
|
|
103
|
+
}
|
|
104
|
+
const lines = [];
|
|
105
|
+
if (refusals.length) lines.push('A real purge would refuse:', ...refusals.map((line) => ` ${line}`));
|
|
106
|
+
const question = `A real purge would ask two questions: whether any other host uses ${host.brandRoot}`;
|
|
107
|
+
lines.push(`${question}, and whether to delete your data in it.`);
|
|
108
|
+
if (dataFiles.length) lines.push(`Your data it would ask about: ${dataFiles.join(', ')}`);
|
|
109
|
+
lines.push('If you confirm both questions:', ...planLines(plan, { host }));
|
|
110
|
+
return lines;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function purgeDryRunExitCode({ refusals, plan }) {
|
|
114
|
+
return refusals.length ? 1 : planExitCode(plan);
|
|
115
|
+
}
|