@ours.network/install 0.17.0 → 0.18.0-nightly.1

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,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
+ }