@ours.network/install 0.16.0 → 0.17.0-nightly.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.
@@ -0,0 +1,374 @@
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
+ cliStartedIt: effects.readJson(join(dir, 'ours-cli-daemon.json')) !== null,
143
+ otherStateDirsWithConfig: effects.knownStateDirs(),
144
+ typedConfirmation: null,
145
+ explicitHarnessSelection: contract.engaged,
146
+ });
147
+
148
+ if (plan.action === 'refuse') {
149
+ effects.out(warn(`ours: ${plan.message}`));
150
+ return EXIT_REFUSED;
151
+ }
152
+
153
+ // 2. Component services and configs. The FILE is kept — it also holds the
154
+ // operator's bot token and settings, which were never ours.
155
+ //
156
+ // THE UNIT OF WORK HERE SPANS STEPS 2 THROUGH 4, and that is what makes it
157
+ // different from the install-side journals. A detached connector's stripped
158
+ // config says "no longer attached to this daemon", and only the daemon's actual
159
+ // removal makes that true. If step 3 or 4 fails, the operator is left with a
160
+ // stopped, detached connector NEXT TO A DAEMON THAT IS STILL THERE — a world the
161
+ // bytes no longer describe.
162
+ const journal = configJournal(effects, { dryRun: args.dryRun });
163
+ const detached = [];
164
+ for (const component of plan.detach) {
165
+ const path = component.key === 'tg' ? tgConfigPath(effects.home, effects.env) : coworkConfigPath(effects.home, effects.env);
166
+ const detach = planComponentDetach(component.key, effects.readJson(path));
167
+ if (detach.behaviourChange) effects.out(warn(`${component.key}: ${detach.behaviourChange}`));
168
+ await perform(effects, args.dryRun, `${component.service.join(' ')}`, () => effects.run(component.service[0], component.service.slice(1)));
169
+ // Recorded whether or not its config changed: the SERVICE was stopped either
170
+ // way, so a rollback owes it a re-apply either way.
171
+ detached.push({ key: component.key, path, service: [component.service[0], 'install-service'] });
172
+ if (detach.removed.length > 0) {
173
+ journal.snapshot(path);
174
+ await perform(effects, args.dryRun, `remove ${detach.removed.join(', ')} from ${path} (file kept)`, () => effects.writeJson(path, `${JSON.stringify(detach.config, null, 2)}\n`));
175
+ }
176
+ }
177
+
178
+ // 3-4. The boot service, then the daemon. Both delegate their refusals.
179
+ try {
180
+ for (const step of plan.daemon) {
181
+ if (step.command === null) {
182
+ effects.out(info(`${step.note} — nothing signalled`));
183
+ continue;
184
+ }
185
+ await perform(effects, args.dryRun, step.command.join(' '), () => effects.run(step.command[0], step.command.slice(1)));
186
+ }
187
+ } catch (error) {
188
+ await rollBackDetach(effects, journal, detached, args);
189
+ throw error;
190
+ }
191
+
192
+ // 5. The harness plugins the installer wrote.
193
+ const pluginOutcome = await runPluginPhase(plan.plugins, { args, effects, selection: contract.harnesses });
194
+
195
+ // 6. State. Last, because it is the only irreversible thing here.
196
+ await runPurgePhase({ dir, purge, args, effects });
197
+
198
+ // 7. Global packages, only when this was the last daemon.
199
+ //
200
+ // Recomputed from what the plugin phase ACTUALLY removed, not from what it
201
+ // found. A harness the operator kept keeps its launcher: removing the package
202
+ // out from under a plugin that is still registered is the same broken
203
+ // half-state, one layer down.
204
+ const packages = planGlobalPackages({
205
+ stateDir: dir,
206
+ otherStateDirsWithConfig: effects.knownStateDirs(),
207
+ pluginPackages: pluginOutcome.packages,
208
+ // The plugin packages are recomputed; the CONNECTOR packages are not. They
209
+ // are decided by what the operator confirmed detaching, which the plugin
210
+ // phase does not touch, so this repeats the plan's own answer rather than
211
+ // inventing a second one — and rather than dropping it, which would quietly
212
+ // undo the connector-package removal one line up the file.
213
+ detachedComponents: plan.detach.map((d) => d.key),
214
+ });
215
+ if (packages.action === 'keep') {
216
+ effects.out(info(`@ours.network/cli kept — ${packages.reason}`));
217
+ } else {
218
+ for (const pkg of packages.packages) {
219
+ await perform(effects, args.dryRun, `npm rm -g ${pkg}`, () => effects.run('npm', ['rm', '-g', pkg]));
220
+ }
221
+ }
222
+ return EXIT_OK;
223
+ }
224
+
225
+ /**
226
+ * Undo a detach when the daemon it was detaching FROM did not go away.
227
+ *
228
+ * THIS ONE NEEDS A COMMAND, NOT JUST BYTES, and that is the whole reason it is a
229
+ * separate function from the install side's rollback. The detach stopped the
230
+ * connector's service before stripping its config, so restoring the bytes under a
231
+ * stopped service is a HALF rollback — and half-states are exactly what this
232
+ * feature exists to eliminate. The config goes back and then `install-service` is
233
+ * re-applied, which is what the nightly uninstaller does
234
+ * (lib/nightly-uninstall.mjs `rollbackConnectorLifecycles`), rather than a second
235
+ * approach invented here.
236
+ *
237
+ * Every failure inside the recovery is REPORTED and none is thrown: the caller is
238
+ * already on a failure path, and a recovery failure must never be what the
239
+ * operator sees instead of the real fault. The original error is what propagates.
240
+ */
241
+ export async function rollBackDetach(effects, journal, detached, args) {
242
+ if (args.dryRun || detached.length === 0) return { restored: [], reapplied: [], failed: [] };
243
+ effects.out(warn('the daemon was not removed, so the connectors are still attached to it — putting them back'));
244
+ const outcome = journal.restoreAll();
245
+ const reapplied = [];
246
+ const failed = [];
247
+ for (const component of detached.slice().reverse()) {
248
+ try {
249
+ await effects.run(component.service[0], component.service.slice(1));
250
+ effects.out(ok(`${component.service.join(' ')} — ${component.key} is attached and running again`));
251
+ reapplied.push(component.key);
252
+ } catch (error) {
253
+ failed.push(component.key);
254
+ 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`));
255
+ }
256
+ }
257
+ reportRollback(effects, outcome, { packagesInstalled: false });
258
+ return { ...outcome, reapplied, failed };
259
+ }
260
+
261
+ /**
262
+ * The harness plugins: the managed config blocks, the ours skills directories,
263
+ * and the plugin launchers on npm.
264
+ *
265
+ * Without this, a v3 uninstall is a capability REGRESSION against the v2 one —
266
+ * it would remove the daemon and leave every harness still advertising ours
267
+ * tools that no longer resolve.
268
+ *
269
+ * Two rules, both inherited rather than invented. A config file is edited only
270
+ * when both our sentinels are found, and an unterminated block is REPORTED and
271
+ * left alone rather than truncated to end-of-file (which is what v2 did, and it
272
+ * would take everything the user wrote after our block with it). And the whole
273
+ * phase is skipped while another daemon is still on this machine, because its
274
+ * harnesses still need these plugins — the same condition that keeps the global
275
+ * packages, decided once.
276
+ */
277
+ export async function runPluginPhase(plugins, { args, effects, selection = null }) {
278
+ effects.out(heading('Harness plugins'));
279
+ if (plugins.action === 'keep') {
280
+ for (const step of plugins.manual) announceManual(step, effects);
281
+ effects.out(info(`harness plugins kept — ${plugins.reason}`));
282
+ return { removed: [], packages: [] };
283
+ }
284
+
285
+ // WHICH harnesses (item 9.5). Asked before the first block is stripped, so a
286
+ // "no" costs nothing and a "yes" is the operator's, not this file's.
287
+ const choice = planHarnessSelection(plugins, { selection, assumeYes: args.assumeYes });
288
+ let chosen = choice.chosen;
289
+ if (choice.mode === 'ask') {
290
+ chosen = [];
291
+ for (const harness of choice.offered) {
292
+ if (await effects.ask(`Remove the ${harness.label}?`, true)) chosen.push(harness.key);
293
+ }
294
+ }
295
+ for (const name of choice.ignored) {
296
+ effects.out(info(`${name} was named for removal but no ${name} plugin is installed here — nothing to do for it`));
297
+ }
298
+ if (choice.mode === 'keep') {
299
+ effects.out(info(`harness plugins kept — ${choice.reason}`));
300
+ effects.out(info(choice.hint));
301
+ return { removed: [], packages: [] };
302
+ }
303
+
304
+ const selected = selectHarnesses(plugins, chosen);
305
+ for (const step of selected.manual) announceManual(step, effects);
306
+ if (selected.harnesses.length === 0) {
307
+ effects.out(info(
308
+ chosen.length === 0
309
+ ? 'no harness plugin selected — none removed'
310
+ : 'no Hermes or Codex plugin files found — nothing of ours to remove',
311
+ ));
312
+ return { removed: chosen, packages: [] };
313
+ }
314
+
315
+ for (const harness of selected.harnesses) {
316
+ for (const block of harness.blocks) {
317
+ const before = effects.readText(block.path);
318
+ if (before === null) continue;
319
+ const stripped = stripManagedBlock(before, block.markers);
320
+ if (stripped.action === 'absent') {
321
+ effects.out(info(`${block.path} carries no ours block — left untouched`));
322
+ continue;
323
+ }
324
+ if (stripped.action === 'refuse') {
325
+ effects.out(warn(`${block.path}: ${stripped.reason}. Remove it by hand.`));
326
+ continue;
327
+ }
328
+ await perform(effects, args.dryRun, `remove the ours managed block from ${block.path} (file kept)`, () => effects.writeText(block.path, stripped.text));
329
+ }
330
+ for (const dir of harness.dirs) {
331
+ if (!effects.exists(dir)) continue;
332
+ await perform(effects, args.dryRun, `remove ${dir}`, () => effects.removeDir(dir));
333
+ }
334
+ for (const file of harness.files) {
335
+ if (!effects.exists(file)) continue;
336
+ await perform(effects, args.dryRun, `remove ${file}`, () => effects.removeFile(file));
337
+ }
338
+ }
339
+ return { removed: chosen, packages: selected.packages };
340
+ }
341
+
342
+ /**
343
+ * Never a dead end, and never a claim: Claude Code's plugin is not ours to
344
+ * remove, so the run says so and prints the two commands that do it.
345
+ */
346
+ function announceManual(step, effects) {
347
+ effects.out(info(`${step.label} — ${step.reason}. Inside Claude Code, run:`));
348
+ for (const command of step.steps) effects.out(info(` ${command}`));
349
+ }
350
+
351
+ /**
352
+ * --purge, gated four ways and asked for by typing the full path.
353
+ *
354
+ * The typed answer is compared by the pure planner, not here, so the comparison
355
+ * cannot drift from the one the tests pin. A wrong or empty answer keeps the
356
+ * state directory — there is no retry loop, because a second chance at deleting
357
+ * identity keys is not a kindness.
358
+ */
359
+ export async function runPurgePhase({ dir, purge, args, effects }) {
360
+ const asked = planStatePurge({ stateDir: dir, purge, assumeYes: args.assumeYes, exists: effects.exists });
361
+ if (asked.action === 'keep') {
362
+ effects.out(info(`state ${dir} kept — ${asked.reason}`));
363
+ if (!purge) effects.out(info(asked.hint));
364
+ return { purged: false };
365
+ }
366
+ const typed = await effects.askLine(asked.prompt);
367
+ const decided = planStatePurge({ stateDir: dir, purge, assumeYes: args.assumeYes, exists: effects.exists, typedConfirmation: typed });
368
+ if (decided.action !== 'purge') {
369
+ effects.out(info(`state ${dir} kept — the typed path did not match`));
370
+ return { purged: false };
371
+ }
372
+ await perform(effects, args.dryRun, `delete ${dir} and everything in it`, () => effects.removeDir(dir));
373
+ return { purged: !args.dryRun };
374
+ }