@ours.network/install 0.17.0 → 0.18.0-nightly.2
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/README.md +72 -120
- package/install.mjs +23 -790
- package/lib/components.mjs +361 -0
- package/lib/detect.mjs +169 -0
- package/lib/effects.mjs +349 -0
- package/lib/extras.mjs +351 -0
- package/lib/journal.mjs +158 -0
- package/lib/logic.mjs +351 -25
- package/lib/orchestrate-uninstall.mjs +379 -0
- package/lib/orchestrate.mjs +984 -0
- package/lib/plan.mjs +270 -0
- package/lib/rerun.mjs +119 -0
- package/lib/target.mjs +390 -0
- package/lib/ui.mjs +15 -0
- package/lib/uninstall.mjs +736 -0
- package/lib/usage.mjs +48 -0
- package/package.json +2 -2
- package/uninstall.mjs +23 -194
- package/uninstall.sh +13 -4
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// ours-uninstall v3 — the orchestrator.
|
|
2
|
+
//
|
|
3
|
+
// ours-uninstall [--state-dir PATH] [--purge] [--dry-run]
|
|
4
|
+
//
|
|
5
|
+
// Same shape as lib/orchestrate.mjs: every side effect arrives through one
|
|
6
|
+
// injected `effects` object, every DECISION comes from lib/uninstall.mjs, which
|
|
7
|
+
// is pure and separately tested.
|
|
8
|
+
//
|
|
9
|
+
// This file removes things, so two properties matter more here than anywhere
|
|
10
|
+
// else in the installer:
|
|
11
|
+
//
|
|
12
|
+
// NOTHING IS REMOVED AFTER A REFUSAL. Step 1 refuses before the first
|
|
13
|
+
// mutation, so a run that stops leaves the daemon exactly as it was rather
|
|
14
|
+
// than half-dismantled.
|
|
15
|
+
//
|
|
16
|
+
// THE DESTRUCTIVE STEP IS LAST AND SEPARATELY GATED. --purge runs after
|
|
17
|
+
// everything else has succeeded, needs four gates open, and is the only step
|
|
18
|
+
// here that cannot be undone by re-running the installer.
|
|
19
|
+
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { parseInstallArgs, InstallUsageError } from './target.mjs';
|
|
22
|
+
import { planUninstall, planComponentDetach, planStatePurge, stripManagedBlock, planHarnessSelection, selectHarnesses, planGlobalPackages, planPluginRemoval, parseUninstallEnv } from './uninstall.mjs';
|
|
23
|
+
import { tgConfigPath, coworkConfigPath } from './components.mjs';
|
|
24
|
+
import { configJournal, reportRollback } from './journal.mjs';
|
|
25
|
+
import { UNINSTALL_USAGE } from './usage.mjs';
|
|
26
|
+
import { ok, info, warn, heading } from './ui.mjs';
|
|
27
|
+
|
|
28
|
+
export const EXIT_OK = 0;
|
|
29
|
+
export const EXIT_REFUSED = 2;
|
|
30
|
+
|
|
31
|
+
async function perform(effects, dryRun, label, thunk) {
|
|
32
|
+
if (dryRun) {
|
|
33
|
+
effects.out(info(`[dry-run] would: ${label}`));
|
|
34
|
+
return { performed: false };
|
|
35
|
+
}
|
|
36
|
+
await thunk();
|
|
37
|
+
effects.out(ok(label));
|
|
38
|
+
return { performed: true };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Which components are being removed alongside this daemon. Asked once, before
|
|
43
|
+
* anything is touched, so the §8 step 1 refusal can be resolved in the same run
|
|
44
|
+
* rather than sending the operator away and back.
|
|
45
|
+
*
|
|
46
|
+
* Non-interactively the answer is NO — assume-yes never consents to removing
|
|
47
|
+
* something on the operator's behalf, and step 1 then refuses, which is the
|
|
48
|
+
* correct outcome for an unattended run pointed at a daemon still in use.
|
|
49
|
+
*/
|
|
50
|
+
export async function confirmComponentRemoval(pointing, { assumeYes, effects }) {
|
|
51
|
+
if (pointing.length === 0 || assumeYes) return [];
|
|
52
|
+
const confirmed = [];
|
|
53
|
+
for (const component of pointing) {
|
|
54
|
+
const answer = await effects.ask(
|
|
55
|
+
`${component.key} points at this daemon (${component.config}). Remove its attachment too?`,
|
|
56
|
+
false,
|
|
57
|
+
);
|
|
58
|
+
if (answer) confirmed.push(component.key);
|
|
59
|
+
}
|
|
60
|
+
return confirmed;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function runUninstall(argv, effects) {
|
|
64
|
+
let args;
|
|
65
|
+
try {
|
|
66
|
+
args = parseInstallArgs(argv.filter((a) => a !== '--purge'), effects.env, { home: effects.home });
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error instanceof InstallUsageError) {
|
|
69
|
+
effects.out(warn(`ours: ${error.message}`));
|
|
70
|
+
return EXIT_REFUSED;
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
if (args.help) { effects.out(UNINSTALL_USAGE); return EXIT_OK; }
|
|
75
|
+
if (args.version) { effects.out(`ours-uninstall v${effects.version ?? '?'}`); return EXIT_OK; }
|
|
76
|
+
const purge = argv.includes('--purge');
|
|
77
|
+
const dir = args.stateDir;
|
|
78
|
+
const config = effects.readJson(join(dir, 'config.json'));
|
|
79
|
+
const endpoint = `http://127.0.0.1:${typeof config?.port === 'number' ? config.port : 3050}`;
|
|
80
|
+
|
|
81
|
+
effects.out(heading(`ours-uninstall --state-dir ${dir}`));
|
|
82
|
+
if (args.dryRun) effects.out(info('dry-run: nothing will be removed or stopped'));
|
|
83
|
+
|
|
84
|
+
// §9 — the documented OURS_UNINSTALL_* contract (item 10.9), read BEFORE any
|
|
85
|
+
// file is opened. A variable this uninstaller cannot deliver stops the run
|
|
86
|
+
// here, naming itself and naming the replacement, rather than being silently
|
|
87
|
+
// ignored while the operator's script reports success.
|
|
88
|
+
const contract = parseUninstallEnv(effects.env);
|
|
89
|
+
if (contract.action === 'refuse') {
|
|
90
|
+
effects.out(warn(`ours: ${contract.message}`));
|
|
91
|
+
return EXIT_REFUSED;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// OURS_UNINSTALL_DAEMON decides whether this is an uninstall of the daemon at
|
|
95
|
+
// all. Before this gate, OURS_UNINSTALL="hermes" — a script asking for one
|
|
96
|
+
// harness plugin — tore down the daemon, its service and the global packages,
|
|
97
|
+
// because none of the seven variables was read. A request to detach a plugin
|
|
98
|
+
// stays a request to detach a plugin.
|
|
99
|
+
if (!contract.daemon) {
|
|
100
|
+
effects.out(info(`the daemon at ${dir} is KEPT — OURS_UNINSTALL is set and OURS_UNINSTALL_DAEMON is not "yes"`));
|
|
101
|
+
const plugins = planPluginRemoval({ home: effects.home, env: effects.env, exists: effects.exists, lastDaemon: false, explicitSelection: true });
|
|
102
|
+
const outcome = await runPluginPhase(plugins, { args, effects, selection: contract.harnesses });
|
|
103
|
+
// Only the launchers of the harnesses that actually went. @ours.network/cli
|
|
104
|
+
// and /mcp belong to the daemon, and the daemon is staying.
|
|
105
|
+
for (const pkg of outcome.packages) {
|
|
106
|
+
await perform(effects, args.dryRun, `npm rm -g ${pkg}`, () => effects.run('npm', ['rm', '-g', pkg]));
|
|
107
|
+
}
|
|
108
|
+
return EXIT_OK;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Ask first, mutate second. The question is about resolving the step-1
|
|
112
|
+
// refusal, so it has to come before the plan that would refuse.
|
|
113
|
+
// `readText` is passed alongside `readJson` so the planner can tell an ABSENT
|
|
114
|
+
// component config from a CORRUPT one. Without it, effects.readJson's null
|
|
115
|
+
// stands for both, and a file that will not parse reads as "no connector points
|
|
116
|
+
// here" — which removes the daemon out from under a live connector.
|
|
117
|
+
const probe = planUninstall({ home: effects.home, env: effects.env, endpoint, stateDir: dir, readJson: effects.readJson, readText: effects.readText });
|
|
118
|
+
if (probe.action === 'refuse' && probe.reason === 'component-config-unreadable') {
|
|
119
|
+
effects.out(warn(`ours: ${probe.message}`));
|
|
120
|
+
return EXIT_REFUSED;
|
|
121
|
+
}
|
|
122
|
+
const pointing = probe.action === 'refuse' ? probe.components : [];
|
|
123
|
+
// With the contract engaged there is nobody to ask, and OURS_UNINSTALL_TELEGRAM
|
|
124
|
+
// / _ROOMS at `detach` is exactly the answer the question wants. Without it, an
|
|
125
|
+
// unattended run against a daemon a connector points at refuses — which is the
|
|
126
|
+
// right outcome, and the reason honouring `detach` is worth doing.
|
|
127
|
+
const confirmedComponents = contract.engaged
|
|
128
|
+
? contract.confirmedComponents
|
|
129
|
+
: await confirmComponentRemoval(pointing, { assumeYes: args.assumeYes, effects });
|
|
130
|
+
|
|
131
|
+
const plan = planUninstall({
|
|
132
|
+
home: effects.home,
|
|
133
|
+
env: effects.env,
|
|
134
|
+
endpoint,
|
|
135
|
+
stateDir: dir,
|
|
136
|
+
purge,
|
|
137
|
+
assumeYes: args.assumeYes,
|
|
138
|
+
confirmedComponents,
|
|
139
|
+
readJson: effects.readJson,
|
|
140
|
+
readText: effects.readText,
|
|
141
|
+
exists: effects.exists,
|
|
142
|
+
// Accept the current CLI record and the legacy ours-mcp pid file during migration.
|
|
143
|
+
cliStartedIt: effects.readJson(join(dir, 'ours-cli-daemon.json')) !== null
|
|
144
|
+
|| effects.readText(join(dir, 'daemon.pid')) !== null,
|
|
145
|
+
otherStateDirsWithConfig: effects.knownStateDirs(),
|
|
146
|
+
typedConfirmation: null,
|
|
147
|
+
explicitHarnessSelection: contract.engaged,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (plan.action === 'refuse') {
|
|
151
|
+
effects.out(warn(`ours: ${plan.message}`));
|
|
152
|
+
return EXIT_REFUSED;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 2. Component services and configs. The FILE is kept — it also holds the
|
|
156
|
+
// operator's bot token and settings, which were never ours.
|
|
157
|
+
//
|
|
158
|
+
// THE UNIT OF WORK HERE SPANS STEPS 2 THROUGH 4, and that is what makes it
|
|
159
|
+
// different from the install-side journals. A detached connector's stripped
|
|
160
|
+
// config says "no longer attached to this daemon", and only the daemon's actual
|
|
161
|
+
// removal makes that true. If step 3 or 4 fails, the operator is left with a
|
|
162
|
+
// stopped, detached connector NEXT TO A DAEMON THAT IS STILL THERE — a world the
|
|
163
|
+
// bytes no longer describe.
|
|
164
|
+
const journal = configJournal(effects, { dryRun: args.dryRun });
|
|
165
|
+
const detached = [];
|
|
166
|
+
for (const component of plan.detach) {
|
|
167
|
+
const path = component.key === 'tg' ? tgConfigPath(effects.home, effects.env) : coworkConfigPath(effects.home, effects.env);
|
|
168
|
+
const detach = planComponentDetach(component.key, effects.readJson(path));
|
|
169
|
+
if (detach.behaviourChange) effects.out(warn(`${component.key}: ${detach.behaviourChange}`));
|
|
170
|
+
await perform(effects, args.dryRun, `${component.service.join(' ')}`, () => effects.run(component.service[0], component.service.slice(1)));
|
|
171
|
+
// Recorded whether or not its config changed: the SERVICE was stopped either
|
|
172
|
+
// way, so a rollback owes it a re-apply either way.
|
|
173
|
+
detached.push({ key: component.key, path, service: [component.service[0], 'install-service'] });
|
|
174
|
+
if (detach.removed.length > 0) {
|
|
175
|
+
journal.snapshot(path);
|
|
176
|
+
await perform(effects, args.dryRun, `remove ${detach.removed.join(', ')} from ${path} (file kept)`, () => effects.writeJson(path, `${JSON.stringify(detach.config, null, 2)}\n`));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 3-4. The boot service, then the daemon. Both delegate their refusals.
|
|
181
|
+
try {
|
|
182
|
+
for (const step of plan.daemon) {
|
|
183
|
+
if (step.command === null) {
|
|
184
|
+
effects.out(info(`${step.note} — nothing signalled`));
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
await perform(effects, args.dryRun, step.command.join(' '), () => effects.run(step.command[0], step.command.slice(1)));
|
|
188
|
+
}
|
|
189
|
+
} catch (error) {
|
|
190
|
+
await rollBackDetach(effects, journal, detached, args);
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 5. The harness plugins the installer wrote.
|
|
195
|
+
const pluginOutcome = await runPluginPhase(plan.plugins, { args, effects, selection: contract.harnesses });
|
|
196
|
+
|
|
197
|
+
// 6. State. Last, because it is the only irreversible thing here.
|
|
198
|
+
await runPurgePhase({ dir, purge, args, effects });
|
|
199
|
+
|
|
200
|
+
// 7. Global packages, only when this was the last daemon.
|
|
201
|
+
//
|
|
202
|
+
// Recomputed from what the plugin phase ACTUALLY removed, not from what it
|
|
203
|
+
// found. A harness the operator kept keeps its launcher: removing the package
|
|
204
|
+
// out from under a plugin that is still registered is the same broken
|
|
205
|
+
// half-state, one layer down.
|
|
206
|
+
const packages = planGlobalPackages({
|
|
207
|
+
stateDir: dir,
|
|
208
|
+
otherStateDirsWithConfig: effects.knownStateDirs(),
|
|
209
|
+
pluginPackages: pluginOutcome.packages,
|
|
210
|
+
// The plugin packages are recomputed; the CONNECTOR packages are not. They
|
|
211
|
+
// are decided by what the operator confirmed detaching, which the plugin
|
|
212
|
+
// phase does not touch, so this repeats the plan's own answer rather than
|
|
213
|
+
// inventing a second one — and rather than dropping it, which would quietly
|
|
214
|
+
// undo the connector-package removal one line up the file.
|
|
215
|
+
detachedComponents: plan.detach.map((d) => d.key),
|
|
216
|
+
});
|
|
217
|
+
if (packages.action === 'keep') {
|
|
218
|
+
effects.out(info(`@ours.network/cli kept — ${packages.reason}`));
|
|
219
|
+
} else {
|
|
220
|
+
for (const pkg of packages.packages) {
|
|
221
|
+
await perform(effects, args.dryRun, `npm rm -g ${pkg}`, () => effects.run('npm', ['rm', '-g', pkg]));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return EXIT_OK;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Undo a detach when the daemon it was detaching FROM did not go away.
|
|
229
|
+
*
|
|
230
|
+
* THIS ONE NEEDS A COMMAND, NOT JUST BYTES, and that is the whole reason it is a
|
|
231
|
+
* separate function from the install side's rollback. The detach stopped the
|
|
232
|
+
* connector's service before stripping its config, so restoring the bytes under a
|
|
233
|
+
* stopped service is a HALF rollback — and half-states are exactly what this
|
|
234
|
+
* feature exists to eliminate. The config goes back and then `install-service` is
|
|
235
|
+
* re-applied, which is what the nightly uninstaller does
|
|
236
|
+
* (lib/nightly-uninstall.mjs `rollbackConnectorLifecycles`), rather than a second
|
|
237
|
+
* approach invented here.
|
|
238
|
+
*
|
|
239
|
+
* Every failure inside the recovery is REPORTED and none is thrown: the caller is
|
|
240
|
+
* already on a failure path, and a recovery failure must never be what the
|
|
241
|
+
* operator sees instead of the real fault. The original error is what propagates.
|
|
242
|
+
*/
|
|
243
|
+
export async function rollBackDetach(effects, journal, detached, args) {
|
|
244
|
+
if (args.dryRun || detached.length === 0) return { restored: [], reapplied: [], failed: [] };
|
|
245
|
+
effects.out(warn('the daemon was not removed, so the connectors are still attached to it — putting them back'));
|
|
246
|
+
const outcome = journal.restoreAll();
|
|
247
|
+
const reapplied = [];
|
|
248
|
+
const failed = [];
|
|
249
|
+
for (const component of detached.slice().reverse()) {
|
|
250
|
+
try {
|
|
251
|
+
await effects.run(component.service[0], component.service.slice(1));
|
|
252
|
+
effects.out(ok(`${component.service.join(' ')} — ${component.key} is attached and running again`));
|
|
253
|
+
reapplied.push(component.key);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
failed.push(component.key);
|
|
256
|
+
effects.out(warn(`could NOT re-apply ${component.key}'s service: ${error instanceof Error ? error.message : String(error)} — its config is back but the service is down; run '${component.service.join(' ')}' yourself`));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
reportRollback(effects, outcome, { packagesInstalled: false });
|
|
260
|
+
return { ...outcome, reapplied, failed };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The harness plugins: the managed config blocks, the ours skills directories,
|
|
265
|
+
* and the plugin launchers on npm.
|
|
266
|
+
*
|
|
267
|
+
* Without this, a v3 uninstall is a capability REGRESSION against the v2 one —
|
|
268
|
+
* it would remove the daemon and leave every harness still advertising ours
|
|
269
|
+
* tools that no longer resolve.
|
|
270
|
+
*
|
|
271
|
+
* Two rules, both inherited rather than invented. A config file is edited only
|
|
272
|
+
* when both our sentinels are found, and an unterminated block is REPORTED and
|
|
273
|
+
* left alone rather than truncated to end-of-file (which is what v2 did, and it
|
|
274
|
+
* would take everything the user wrote after our block with it). And the whole
|
|
275
|
+
* phase is skipped while another daemon is still on this machine, because its
|
|
276
|
+
* harnesses still need these plugins — the same condition that keeps the global
|
|
277
|
+
* packages, decided once.
|
|
278
|
+
*/
|
|
279
|
+
export async function runPluginPhase(plugins, { args, effects, selection = null }) {
|
|
280
|
+
effects.out(heading('Harness plugins'));
|
|
281
|
+
if (plugins.action === 'keep') {
|
|
282
|
+
for (const step of plugins.manual) announceManual(step, effects);
|
|
283
|
+
effects.out(info(`harness plugins kept — ${plugins.reason}`));
|
|
284
|
+
return { removed: [], packages: [] };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// WHICH harnesses (item 9.5). Asked before the first block is stripped, so a
|
|
288
|
+
// "no" costs nothing and a "yes" is the operator's, not this file's.
|
|
289
|
+
const choice = planHarnessSelection(plugins, { selection, assumeYes: args.assumeYes });
|
|
290
|
+
let chosen = choice.chosen;
|
|
291
|
+
if (choice.mode === 'ask') {
|
|
292
|
+
chosen = [];
|
|
293
|
+
for (const harness of choice.offered) {
|
|
294
|
+
if (await effects.ask(`Remove the ${harness.label}?`, true)) chosen.push(harness.key);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
for (const name of choice.ignored) {
|
|
298
|
+
effects.out(info(`${name} was named for removal but no ${name} plugin is installed here — nothing to do for it`));
|
|
299
|
+
}
|
|
300
|
+
if (choice.mode === 'keep') {
|
|
301
|
+
effects.out(info(`harness plugins kept — ${choice.reason}`));
|
|
302
|
+
effects.out(info(choice.hint));
|
|
303
|
+
return { removed: [], packages: [] };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const selected = selectHarnesses(plugins, chosen);
|
|
307
|
+
for (const step of selected.manual) announceManual(step, effects);
|
|
308
|
+
if (selected.harnesses.length === 0) {
|
|
309
|
+
effects.out(info(
|
|
310
|
+
chosen.length === 0
|
|
311
|
+
? 'no harness plugin selected — none removed'
|
|
312
|
+
: 'no Hermes or Codex plugin files found — nothing of ours to remove',
|
|
313
|
+
));
|
|
314
|
+
return { removed: chosen, packages: [] };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for (const harness of selected.harnesses) {
|
|
318
|
+
for (const block of harness.blocks) {
|
|
319
|
+
const before = effects.readText(block.path);
|
|
320
|
+
if (before === null) continue;
|
|
321
|
+
const stripped = stripManagedBlock(before, block.markers);
|
|
322
|
+
if (stripped.action === 'absent') {
|
|
323
|
+
effects.out(info(`${block.path} carries no ours block — left untouched`));
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (stripped.action === 'refuse') {
|
|
327
|
+
effects.out(warn(`${block.path}: ${stripped.reason}. Remove it by hand.`));
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
// Deliberately NOT journalled: this write IS the state its bytes describe, so
|
|
331
|
+
// nothing behind it can fail and leave them untrue. Adding one would undo a
|
|
332
|
+
// completed removal.
|
|
333
|
+
await perform(effects, args.dryRun, `remove the ours managed block from ${block.path} (file kept)`, () => effects.writeText(block.path, stripped.text));
|
|
334
|
+
}
|
|
335
|
+
for (const dir of harness.dirs) {
|
|
336
|
+
if (!effects.exists(dir)) continue;
|
|
337
|
+
await perform(effects, args.dryRun, `remove ${dir}`, () => effects.removeDir(dir));
|
|
338
|
+
}
|
|
339
|
+
for (const file of harness.files) {
|
|
340
|
+
if (!effects.exists(file)) continue;
|
|
341
|
+
await perform(effects, args.dryRun, `remove ${file}`, () => effects.removeFile(file));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return { removed: chosen, packages: selected.packages };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Never a dead end, and never a claim: Claude Code's plugin is not ours to
|
|
349
|
+
* remove, so the run says so and prints the two commands that do it.
|
|
350
|
+
*/
|
|
351
|
+
function announceManual(step, effects) {
|
|
352
|
+
effects.out(info(`${step.label} — ${step.reason}. Inside Claude Code, run:`));
|
|
353
|
+
for (const command of step.steps) effects.out(info(` ${command}`));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* --purge, gated four ways and asked for by typing the full path.
|
|
358
|
+
*
|
|
359
|
+
* The typed answer is compared by the pure planner, not here, so the comparison
|
|
360
|
+
* cannot drift from the one the tests pin. A wrong or empty answer keeps the
|
|
361
|
+
* state directory — there is no retry loop, because a second chance at deleting
|
|
362
|
+
* identity keys is not a kindness.
|
|
363
|
+
*/
|
|
364
|
+
export async function runPurgePhase({ dir, purge, args, effects }) {
|
|
365
|
+
const asked = planStatePurge({ stateDir: dir, purge, assumeYes: args.assumeYes, exists: effects.exists });
|
|
366
|
+
if (asked.action === 'keep') {
|
|
367
|
+
effects.out(info(`state ${dir} kept — ${asked.reason}`));
|
|
368
|
+
if (!purge) effects.out(info(asked.hint));
|
|
369
|
+
return { purged: false };
|
|
370
|
+
}
|
|
371
|
+
const typed = await effects.askLine(asked.prompt);
|
|
372
|
+
const decided = planStatePurge({ stateDir: dir, purge, assumeYes: args.assumeYes, exists: effects.exists, typedConfirmation: typed });
|
|
373
|
+
if (decided.action !== 'purge') {
|
|
374
|
+
effects.out(info(`state ${dir} kept — the typed path did not match`));
|
|
375
|
+
return { purged: false };
|
|
376
|
+
}
|
|
377
|
+
await perform(effects, args.dryRun, `delete ${dir} and everything in it`, () => effects.removeDir(dir));
|
|
378
|
+
return { purged: !args.dryRun };
|
|
379
|
+
}
|