@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,735 @@
1
+ // ours-uninstall v3 — removing ONE daemon, and the non-interactive contract.
2
+ //
3
+ // Spec: installer-spec-v3 §§8-9. Pure, like every other stage: the caller
4
+ // injects the file reads and this returns a plan.
5
+ //
6
+ // ours-uninstall [--state-dir PATH] [--purge] [--dry-run]
7
+ //
8
+ // `--state-dir` selects WHICH daemon to remove and defaults to ~/.ours. The
9
+ // uninstaller only ever touches that daemon and the components pointing at it.
10
+ //
11
+ // THE BIAS OF THIS WHOLE FILE IS TOWARD KEEPING THINGS. State is kept by default,
12
+ // a component's config file is kept even when its daemon keys are removed, and
13
+ // global packages are kept while any other daemon still needs them. The one
14
+ // destructive operation, --purge, is separately gated (§8 step 5) because
15
+ // it deletes identity keys, and no other step here is irreversible.
16
+
17
+ import { join, resolve } from 'node:path';
18
+ import { unitNameForStateDir } from './plan.mjs';
19
+ import { tgConfigPath, coworkConfigPath } from './components.mjs';
20
+ import { canonHarnesses } from './logic.mjs';
21
+
22
+ /**
23
+ * Does this directory even look like an ours state directory?
24
+ *
25
+ * WHY THIS EXISTS. The owner removed the "created by an installer run" gate —
26
+ * purge means purge, on any state directory. That was a deliberate ruling and
27
+ * this does not reintroduce it: this asks "is this a state directory at all",
28
+ * not "is it ours". With provenance gone, the typed path would otherwise be the
29
+ * only thing between `ours-uninstall --state-dir ~ --purge` and a deleted home
30
+ * directory, and a typed path is no protection against a path typed exactly as
31
+ * intended but meant differently.
32
+ *
33
+ * A directory qualifies if it carries any of the artefacts only a daemon writes.
34
+ * Absent all of them, purge refuses and says why — the operator can still delete
35
+ * the directory themselves, which is the right place for that decision.
36
+ */
37
+ export const STATE_DIR_EVIDENCE = ['config.json', 'daemon-token', 'ours-cli-daemon.json', 'root.json'];
38
+ export function looksLikeStateDir(stateDir, exists) {
39
+ return STATE_DIR_EVIDENCE.some((name) => exists(join(resolve(stateDir), name)));
40
+ }
41
+
42
+ /**
43
+ * A component config file, read so that ABSENT and CORRUPT are different answers.
44
+ *
45
+ * WHY THIS IS NOT `readJson`. `effects.readJson` swallows every failure into
46
+ * `null` (lib/effects.mjs), which is the right shape for the installer — an
47
+ * unreadable daemon config there means "nothing recorded", and the run proceeds
48
+ * to write one. On the UNINSTALL side that same `null` is a fail-open:
49
+ * `componentsPointingHere` reads it as "no connector points at this daemon",
50
+ * step 1's refusal never fires, and the daemon is removed out from under a
51
+ * connector that may still be using it. The nightly uninstaller refuses here
52
+ * (`lib/nightly-uninstall.mjs:22,244`) and has a test pinning that it does.
53
+ *
54
+ * So this takes `readText` — already on the effects contract — and does the parse
55
+ * itself, which keeps the whole decision pure and testable:
56
+ *
57
+ * absent — no file (readText returned null). Nothing to point anywhere.
58
+ * ok — a JSON object.
59
+ * corrupt — the file exists and is not a readable JSON object. An EXISTING file
60
+ * we cannot parse is the case that must stop the run: we cannot prove
61
+ * it does not name this daemon, and "cannot prove" is not "does not".
62
+ *
63
+ * An empty existing file is CORRUPT, not absent — same as the nightly reader,
64
+ * which parses whatever `existsSync` says is there.
65
+ *
66
+ * THIS DECIDES NOTHING ABOUT CONTENTS. `componentsPointingHere` still reads its
67
+ * values through `readJson`, unchanged — deliberately, so this addition cannot
68
+ * alter which components are found. The only new outcome is `corrupt`, and the
69
+ * only thing that consumes it is a refusal.
70
+ */
71
+ export function inspectComponentConfig(path, { readText } = {}) {
72
+ if (typeof readText !== 'function') return { state: 'unknown', reason: 'no text reader injected' };
73
+ const text = readText(path);
74
+ if (text === null || text === undefined) return { state: 'absent' };
75
+ let parsed;
76
+ try {
77
+ parsed = JSON.parse(String(text));
78
+ } catch (error) {
79
+ return { state: 'corrupt', reason: error instanceof Error ? error.message : String(error) };
80
+ }
81
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
82
+ return { state: 'corrupt', reason: 'expected a JSON object' };
83
+ }
84
+ return { state: 'ok' };
85
+ }
86
+
87
+ /**
88
+ * §8 step 1 — refuse if a component still points at this daemon.
89
+ *
90
+ * Read the connector's and cowork's config files; if either names this daemon's
91
+ * endpoint or its state directory, list them and stop. Exit 2, nothing removed.
92
+ * The operator either repoints them or confirms their removal in the same run.
93
+ *
94
+ * Matching on EITHER the endpoint or the state directory is deliberate: a
95
+ * half-written pair should still be caught, and the whole point of the pair is
96
+ * that neither half alone is trustworthy.
97
+ */
98
+ export function componentsPointingHere({ home, env = {}, endpoint, stateDir, readJson }) {
99
+ const dir = resolve(stateDir);
100
+ const found = [];
101
+ const tg = readJson(tgConfigPath(home, env));
102
+ if (tg && (tg.daemonUrl === endpoint || (typeof tg.daemonStateDir === 'string' && resolve(tg.daemonStateDir) === dir))) {
103
+ found.push({ key: 'tg', config: tgConfigPath(home, env), endpoint: tg.daemonUrl ?? null, stateDir: tg.daemonStateDir ?? null });
104
+ }
105
+ const cowork = readJson(coworkConfigPath(home, env));
106
+ const block = cowork && typeof cowork.daemon === 'object' && cowork.daemon !== null ? cowork.daemon : null;
107
+ if (block && (block.endpoint === endpoint || (typeof block.stateDir === 'string' && resolve(block.stateDir) === dir))) {
108
+ found.push({ key: 'cowork', config: coworkConfigPath(home, env), endpoint: block.endpoint ?? null, stateDir: block.stateDir ?? null });
109
+ }
110
+ return found;
111
+ }
112
+
113
+ /**
114
+ * The other half of step 1, and the reason it is a SEPARATE question.
115
+ *
116
+ * `componentsPointingHere` answers "does a component point here?". This answers
117
+ * "is there a component config I cannot read at all?" — and the two must not be
118
+ * collapsed, because they have opposite resolutions. A component that points here
119
+ * can be confirmed for removal in the same run; a config that will not parse
120
+ * cannot be confirmed away by anybody, because nothing about its contents is
121
+ * known. The operator has to fix or move the file first.
122
+ *
123
+ * Returns [] when no reader capable of telling absent from corrupt was injected.
124
+ * That is not a silent pass: planUninstall reports the limit in the plan itself.
125
+ */
126
+ export function unreadableComponentConfigs({ home, env = {}, readText }) {
127
+ if (typeof readText !== 'function') return [];
128
+ const out = [];
129
+ for (const [key, path] of [['tg', tgConfigPath(home, env)], ['cowork', coworkConfigPath(home, env)]]) {
130
+ const read = inspectComponentConfig(path, { readText });
131
+ if (read.state === 'corrupt') out.push({ key, config: path, reason: read.reason });
132
+ }
133
+ return out;
134
+ }
135
+
136
+ /**
137
+ * Strip a component's daemon keys while KEEPING the file. It also holds the
138
+ * operator's bot token and settings, and those are not ours to delete.
139
+ *
140
+ * For cowork, removing the `daemon` block returns it to embedded mode. That is a
141
+ * real behaviour change, not a cleanup, so the plan carries `behaviourChange` for
142
+ * the screen to state BEFORE it happens.
143
+ */
144
+ export function planComponentDetach(key, existing) {
145
+ const base = existing && typeof existing === 'object' && !Array.isArray(existing) ? { ...existing } : {};
146
+ if (key === 'tg') {
147
+ const removed = ['daemonUrl', 'daemonStateDir'].filter((k) => k in base);
148
+ for (const k of removed) delete base[k];
149
+ return { key, removed, config: base, keepsFile: true, behaviourChange: null };
150
+ }
151
+ const had = 'daemon' in base;
152
+ delete base.daemon;
153
+ return {
154
+ key,
155
+ removed: had ? ['daemon'] : [],
156
+ config: base,
157
+ keepsFile: true,
158
+ behaviourChange: had ? 'cowork returns to embedded mode' : null,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * §8 steps 3-4 — the boot service, then the daemon itself.
164
+ *
165
+ * Both delegate their refusals rather than reimplementing them: `ours daemon
166
+ * uninstall-service` refuses to remove a unit not marked as CLI-managed, and
167
+ * `ours daemon stop` refuses to signal a daemon it did not start. In the second
168
+ * case the screen names the external launcher and the run CONTINUES — a daemon
169
+ * someone else supervises is not a failure of this uninstall.
170
+ */
171
+ export function planDaemonRemoval({ stateDir, cliStartedIt }) {
172
+ const dir = resolve(stateDir);
173
+ const unit = unitNameForStateDir(dir);
174
+ return [
175
+ {
176
+ id: 'service',
177
+ unit: unit.ok ? unit.unit : null,
178
+ command: ['ours', 'daemon', 'uninstall-service', '--yes', '--state-dir', dir],
179
+ note: 'refuses a unit not marked as CLI-managed',
180
+ },
181
+ cliStartedIt
182
+ ? { id: 'stop', command: ['ours', 'daemon', 'stop', '--config', join(dir, 'config.json')] }
183
+ : { id: 'stop-external', command: null, continues: true, note: 'this daemon was not started by the CLI; naming its launcher and continuing' },
184
+ ];
185
+ }
186
+
187
+ /**
188
+ * §8 step 5 — state. Kept unless every gate opens.
189
+ *
190
+ * --purge given — never the default; deleting identity keys is opt-in.
191
+ * interactive — an unattended run never deletes state (§9).
192
+ * looks like a state directory — see looksLikeStateDir.
193
+ * typed confirmation — the full path, not a y/N. The owner removed the
194
+ * provenance condition, not the deliberateness, and with
195
+ * provenance gone this is the last thing standing between
196
+ * a mistyped command and someone's identity keys.
197
+ *
198
+ * Returns the exact directory, never a glob or a parent, and only when every gate
199
+ * is satisfied.
200
+ */
201
+ export function planStatePurge({ stateDir, purge = false, assumeYes = false, exists = () => true, typedConfirmation = null }) {
202
+ const dir = resolve(stateDir);
203
+ const keep = (reason) => ({ action: 'keep', stateDir: dir, reason, hint: 're-run with --purge to delete identities and history' });
204
+ if (!purge) return keep('state is kept by default');
205
+ if (assumeYes) return keep('state is never deleted non-interactively');
206
+ if (!looksLikeStateDir(dir, exists)) {
207
+ return keep(`${dir} does not look like an ours state directory (none of ${STATE_DIR_EVIDENCE.join(', ')}); refusing to delete it`);
208
+ }
209
+ if (typedConfirmation !== dir) {
210
+ return {
211
+ action: 'confirm-typed',
212
+ stateDir: dir,
213
+ expected: dir,
214
+ prompt: `This permanently deletes ${dir} and everything in it, including the identity keys and message history of every identity there. Those keys exist nowhere else and no peer can give them back.\nType the full path to confirm:`,
215
+ };
216
+ }
217
+ return { action: 'purge', stateDir: dir, paths: [dir] };
218
+ }
219
+
220
+ /**
221
+ * §8 step 6 — global packages are shared. Remove them only when no OTHER state
222
+ * directory on this machine still has a daemon config; otherwise keep them and
223
+ * say which daemon still needs them.
224
+ */
225
+ export const CONNECTOR_PACKAGES = { tg: '@ours.network/tg-connector', cowork: '@ours.network/cowork' };
226
+
227
+ export function planGlobalPackages({ stateDir, otherStateDirsWithConfig = [], pluginPackages = [], detachedComponents = [] }) {
228
+ const dir = resolve(stateDir);
229
+ const others = otherStateDirsWithConfig.map((d) => resolve(d)).filter((d) => d !== dir);
230
+ if (others.length > 0) {
231
+ return { action: 'keep', packages: [], stillNeededBy: others, reason: `still used by the daemon at ${others[0]}` };
232
+ }
233
+ // The harness-plugin launchers follow the SAME rule, not a second one: they
234
+ // speak to a daemon through ours-mcp, so a second daemon still on the machine
235
+ // still needs them, and the one condition above already decides that.
236
+ // A connector's package goes only when BOTH conditions hold: the operator
237
+ // confirmed removing its attachment in THIS run, and this was the last daemon —
238
+ // the same condition already decided once above.
239
+ //
240
+ // WHY NOT NIGHTLY'S THREE-WAY CHOICE. The nightly uninstaller asks per connector
241
+ // for detach / uninstall / reassign:<profile> and removes the package only on
242
+ // `uninstall`. v3 has no such lifecycle question and inventing one here would be
243
+ // deciding scope in a patch. What v3 DOES have is the operator's explicit yes to
244
+ // "remove its attachment too", which is a narrower thing than nightly's
245
+ // `uninstall` — so this stays behind the last-daemon condition rather than
246
+ // standing on the confirmation alone. A connector detached while another daemon
247
+ // survives keeps its package, because that daemon may still be using it.
248
+ const connectors = detachedComponents
249
+ .map((key) => CONNECTOR_PACKAGES[key])
250
+ .filter(Boolean);
251
+ return {
252
+ action: 'remove',
253
+ packages: ['@ours.network/cli', '@ours.network/mcp', ...pluginPackages, ...connectors],
254
+ stillNeededBy: [],
255
+ };
256
+ }
257
+
258
+ // -----------------------------------------------------------------------------
259
+ // §8 — the harness plugins the installer wrote
260
+ // -----------------------------------------------------------------------------
261
+
262
+ /**
263
+ * The sentinels the plugin installers stamp around everything they write. They
264
+ * are the ONLY thing that makes a block removable: without them we would be
265
+ * editing a config file on a guess.
266
+ */
267
+ export const YAML_BLOCK = { start: '# >>> ours.network plugin (managed block)', end: '# <<< ours.network plugin' };
268
+ export const MD_BLOCK = { start: '<!-- >>> ours.network plugin (managed block) -->', end: '<!-- <<< ours.network plugin -->' };
269
+
270
+ /**
271
+ * Remove OUR block from a config file, or refuse.
272
+ *
273
+ * SAME IDENTIFICATION DISCIPLINE AS THE SYSTEMD UNIT, and for the same reason.
274
+ * The installer refuses to overwrite a unit it cannot positively identify rather
275
+ * than guessing; this refuses to edit a config it cannot positively identify.
276
+ * Three outcomes, and the third is the one that matters:
277
+ *
278
+ * absent — no start sentinel. The file is not ours to edit; left untouched.
279
+ * strip — both sentinels present. Exactly the delimited span is removed.
280
+ * refuse — a start sentinel with NO end. v2 deleted to end-of-file here,
281
+ * which would take everything the user added after our block with
282
+ * it. An unterminated block is damage we cannot bound, so it is
283
+ * reported and left alone.
284
+ */
285
+ export function stripManagedBlock(text, markers) {
286
+ if (typeof text !== 'string' || !text.includes(markers.start)) {
287
+ return { action: 'absent', reason: 'no ours managed block in this file' };
288
+ }
289
+ const lines = text.split('\n');
290
+ const out = [];
291
+ let inside = false;
292
+ let closed = false;
293
+ for (const line of lines) {
294
+ if (!inside && line.includes(markers.start)) { inside = true; continue; }
295
+ if (inside) {
296
+ if (line.includes(markers.end)) { inside = false; closed = true; }
297
+ continue;
298
+ }
299
+ out.push(line);
300
+ }
301
+ if (!closed) {
302
+ return {
303
+ action: 'refuse',
304
+ reason: 'the ours managed block has no closing marker; refusing to guess where it ends',
305
+ };
306
+ }
307
+ return { action: 'strip', text: out.join('\n') };
308
+ }
309
+
310
+ /**
311
+ * The three harnesses this uninstaller knows, in the order they are offered.
312
+ * Same names and same order as the nightly picker and as `canonHarnesses`, so a
313
+ * selection written for one is a selection for the other.
314
+ */
315
+ export const HARNESS_ORDER = ['claude-code', 'codex', 'hermes'];
316
+
317
+ /**
318
+ * What the harness-plugin half of an uninstall removes.
319
+ *
320
+ * `lastDaemon` is the same condition planGlobalPackages decides on, passed in
321
+ * rather than recomputed: while another daemon is still on this machine its
322
+ * harnesses still need their plugins, so nothing here is touched at all.
323
+ *
324
+ * `explicitSelection` OVERRIDES that keep, and only that. The keep is a guess
325
+ * made on the operator's behalf — "another daemon is here, so you probably still
326
+ * want these". When the operator has named the harnesses themselves (the picker,
327
+ * or OURS_UNINSTALL), the guess has been answered and must not outrank the
328
+ * answer. Nothing else about the plan changes: WHICH of the discovered harnesses
329
+ * are then acted on is decided by the caller, not here, so this stays the single
330
+ * description of what exists on disk.
331
+ *
332
+ * Claude Code is deliberately manual-only. Its plugin lives in the in-app
333
+ * marketplace, and there is no file on disk we own — so this prints the two
334
+ * commands and claims nothing, which is the same never-dead-end contract the
335
+ * installer applies to a harness it cannot drive.
336
+ *
337
+ * Every path is EXACT — the precise file or directory the plugin installer
338
+ * writes. Nothing here is a glob, and nothing walks a tree looking for matches.
339
+ */
340
+ export function planPluginRemoval({ home, env = {}, exists = () => false, lastDaemon = true, explicitSelection = false } = {}) {
341
+ const hermesDir = env.HERMES_DIR || join(home, '.hermes');
342
+ const codexDir = env.CODEX_DIR || join(home, '.codex');
343
+ const skillsDir = env.SKILLS_DIR || join(home, '.agents', 'skills');
344
+
345
+ const manual = {
346
+ key: 'claude-code',
347
+ label: 'Claude Code plugin',
348
+ action: 'manual',
349
+ reason: "its plugin lives in Claude Code's in-app marketplace, so nothing on disk is ours to remove",
350
+ steps: ['/plugin uninstall ours', '/plugin marketplace remove adapt-toolkit/ours-claude-marketplace'],
351
+ };
352
+ if (!lastDaemon && !explicitSelection) {
353
+ return {
354
+ action: 'keep',
355
+ reason: 'another daemon on this machine still uses these plugins',
356
+ harnesses: [],
357
+ manual: [manual],
358
+ packages: [],
359
+ };
360
+ }
361
+
362
+ const harnesses = [];
363
+ const hermesConfig = join(hermesDir, 'config.yaml');
364
+ if (exists(hermesConfig) || exists(hermesDir)) {
365
+ harnesses.push({
366
+ key: 'hermes',
367
+ label: 'Hermes plugin',
368
+ blocks: [{ path: hermesConfig, markers: YAML_BLOCK }],
369
+ dirs: [
370
+ join(hermesDir, 'skills', 'communication', 'ours'),
371
+ join(hermesDir, 'skills', 'communication', 'writing-agent-bios'),
372
+ ],
373
+ files: [join(hermesDir, 'ours-connector.env'), join(hermesDir, 'ours-connector.log')],
374
+ pkg: '@ours.network/hermes',
375
+ });
376
+ }
377
+ if (exists(join(codexDir, 'config.toml')) || exists(codexDir)) {
378
+ harnesses.push({
379
+ key: 'codex',
380
+ label: 'Codex plugin',
381
+ blocks: [
382
+ { path: join(codexDir, 'config.toml'), markers: YAML_BLOCK },
383
+ { path: join(codexDir, 'AGENTS.md'), markers: MD_BLOCK },
384
+ ],
385
+ dirs: [join(skillsDir, 'ours'), join(skillsDir, 'writing-agent-bios')],
386
+ files: [],
387
+ pkg: '@ours.network/codex',
388
+ });
389
+ }
390
+ return {
391
+ action: 'remove',
392
+ harnesses,
393
+ manual: [manual],
394
+ packages: harnesses.map((h) => h.pkg),
395
+ };
396
+ }
397
+
398
+ /**
399
+ * §8 — WHICH harnesses to detach (inventory item 9.5).
400
+ *
401
+ * The nightly uninstaller let the operator choose: a `checkboxSelect` picker on a
402
+ * terminal, `OURS_UNINSTALL` without one, and NOTHING removed when it had
403
+ * neither. v3 had no choice at all — it removed every plugin artefact it found
404
+ * whenever this was the last daemon. A user who wanted to detach one harness had
405
+ * no way to say so, and an unattended run removed plugins that leaving
406
+ * `OURS_UNINSTALL` unset used to protect.
407
+ *
408
+ * This decides the three cases; the orchestrator only does the asking, so the
409
+ * rule and the terminal I/O stay apart.
410
+ *
411
+ * explicit — a selection was given (OURS_UNINSTALL, or the answers to the
412
+ * per-harness questions). Exactly those, intersected with what is
413
+ * actually there. A name for a harness that is not installed is
414
+ * reported as `ignored`, not silently dropped.
415
+ * keep — unattended with no selection. Nothing is removed, and the caller
416
+ * says how to select. This is the conservative side: an uninstall
417
+ * nobody is watching does not decide on its own that a harness
418
+ * should lose its plugin.
419
+ * ask — a terminal and no selection. One question per harness, DEFAULTING
420
+ * TO YES, because the daemon these plugins talk to is going away
421
+ * and a plugin left behind advertises tools that no longer resolve.
422
+ * An operator who just presses Enter gets exactly what v3 does
423
+ * today; the only new thing is that they can now say no.
424
+ *
425
+ * A checkbox picker would match nightly's chrome more closely, but the effects
426
+ * contract this uninstaller runs on has `ask`, not `checkboxSelect` — and the
427
+ * question here is which harnesses, not which widget.
428
+ */
429
+ export function planHarnessSelection(plugins, { selection = null, assumeYes = false } = {}) {
430
+ const offered = [...(plugins.manual ?? []), ...(plugins.harnesses ?? [])]
431
+ .map((h) => ({ key: h.key, label: h.label }))
432
+ .sort((a, b) => HARNESS_ORDER.indexOf(a.key) - HARNESS_ORDER.indexOf(b.key));
433
+ if (selection !== null) {
434
+ const keys = offered.map((o) => o.key);
435
+ return {
436
+ mode: 'explicit',
437
+ offered,
438
+ chosen: keys.filter((k) => selection.includes(k)),
439
+ ignored: selection.filter((k) => !keys.includes(k)),
440
+ };
441
+ }
442
+ if (assumeYes) {
443
+ return {
444
+ mode: 'keep',
445
+ offered,
446
+ chosen: [],
447
+ ignored: [],
448
+ reason: 'no OURS_UNINSTALL was set and there is nobody to ask',
449
+ hint: `set OURS_UNINSTALL="${HARNESS_ORDER.join(' ')}" (or a subset, or "all") to remove harness plugins unattended`,
450
+ };
451
+ }
452
+ return { mode: 'ask', offered, chosen: null, ignored: [] };
453
+ }
454
+
455
+ /**
456
+ * Narrow a plugin plan to the chosen harnesses. Pure, so the orchestrator never
457
+ * decides what "chosen" means to a plan — it only supplies the answers.
458
+ *
459
+ * `packages` follows the harnesses it narrows to: a plugin package belongs to
460
+ * the harness that was removed, so a harness that was kept keeps its package.
461
+ */
462
+ export function selectHarnesses(plugins, chosen) {
463
+ const keep = (h) => chosen.includes(h.key);
464
+ const harnesses = (plugins.harnesses ?? []).filter(keep);
465
+ return {
466
+ ...plugins,
467
+ harnesses,
468
+ manual: (plugins.manual ?? []).filter(keep),
469
+ packages: harnesses.map((h) => h.pkg),
470
+ };
471
+ }
472
+
473
+ /**
474
+ * The whole §8 order, refusing at step 1 rather than starting and stopping
475
+ * half-way.
476
+ */
477
+ export function planUninstall({ home, env = {}, endpoint, stateDir, purge = false, assumeYes = false, confirmedComponents = [], readJson, readText, exists = () => true, cliStartedIt = true, otherStateDirsWithConfig = [], typedConfirmation = null, explicitHarnessSelection = false }) {
478
+ const dir = resolve(stateDir);
479
+ const lastDaemon = otherStateDirsWithConfig.map((d) => resolve(d)).filter((d) => d !== dir).length === 0;
480
+ const plugins = planPluginRemoval({ home, env, exists, lastDaemon, explicitSelection: explicitHarnessSelection });
481
+
482
+ // BEFORE the pointing question, and not resolvable by confirming anything: a
483
+ // component config that will not parse cannot be proven not to name this
484
+ // daemon. Fail closed, name the file, remove nothing. This mirrors the nightly
485
+ // uninstaller's refusal (lib/nightly-uninstall.mjs:244) rather than inventing a
486
+ // second wording for the same event.
487
+ const unreadable = unreadableComponentConfigs({ home, env, readText });
488
+ if (unreadable.length > 0) {
489
+ return {
490
+ action: 'refuse',
491
+ exitCode: 2,
492
+ reason: 'component-config-unreadable',
493
+ components: unreadable,
494
+ removed: [],
495
+ message: `${unreadable.map((c) => `${c.config} is corrupt or unsafe to inspect${c.reason ? `: ${c.reason}` : ''}`).join('; ')}. Refusing to uninstall: a component config that cannot be read cannot be shown not to point at this daemon. Repair or move it, then re-run. Nothing was removed.`,
496
+ };
497
+ }
498
+
499
+ const pointing = componentsPointingHere({ home, env, endpoint, stateDir: dir, readJson });
500
+ const unconfirmed = pointing.filter((p) => !confirmedComponents.includes(p.key));
501
+ if (unconfirmed.length > 0) {
502
+ return {
503
+ action: 'refuse',
504
+ exitCode: 2,
505
+ reason: 'component-still-points-here',
506
+ components: unconfirmed,
507
+ removed: [],
508
+ message: `${unconfirmed.map((p) => p.key).join(' and ')} still point at this daemon. Repoint them, or confirm their removal in the same run. Nothing was removed.`,
509
+ };
510
+ }
511
+ return {
512
+ action: 'uninstall',
513
+ stateDir: dir,
514
+ detach: pointing.map((p) => ({ key: p.key, service: [`ours-${p.key === 'tg' ? 'tg-connector' : 'cowork'}`, 'uninstall-service'] })),
515
+ daemon: planDaemonRemoval({ stateDir: dir, cliStartedIt }),
516
+ state: planStatePurge({ stateDir: dir, purge, assumeYes, exists, typedConfirmation }),
517
+ plugins,
518
+ packages: planGlobalPackages({
519
+ stateDir: dir,
520
+ otherStateDirsWithConfig,
521
+ pluginPackages: plugins.packages,
522
+ // Only the components the operator explicitly confirmed removing — never
523
+ // every component that merely happens to point here.
524
+ detachedComponents: pointing.map((p) => p.key).filter((key) => confirmedComponents.includes(key)),
525
+ }),
526
+ };
527
+ }
528
+
529
+ // -----------------------------------------------------------------------------
530
+ // §9 — the non-interactive contract
531
+ // -----------------------------------------------------------------------------
532
+
533
+ /**
534
+ * What each question answers to under OURS_ASSUME_YES.
535
+ *
536
+ * The `false` entries are the point of the table: assume-yes never turns a
537
+ * component on that was off, never MOVES one that already exists, never removes
538
+ * a harness's plugin, and never deletes state. It suppresses questions; it does
539
+ * not consent on the operator's behalf to anything irreversible or to anything
540
+ * that changes where an existing component is pointing.
541
+ *
542
+ * `plugins: false` is the one that answers a question this uninstaller used to
543
+ * answer the other way. A harness plugin is not this daemon's to give away
544
+ * unasked, and the operator has a way to ask for it by name — OURS_UNINSTALL —
545
+ * which is precisely the interface an unattended run should have to go through.
546
+ */
547
+ export const NON_INTERACTIVE_ANSWERS = {
548
+ daemon: true,
549
+ mcp: true,
550
+ tg: false,
551
+ cowork: false,
552
+ repointExistingConnector: false,
553
+ plugins: false,
554
+ purge: false,
555
+ };
556
+
557
+ /**
558
+ * OURS_ASSUME_YES suppresses questions; it NEVER suppresses a refusal. Every
559
+ * refusal in this specification applies unchanged in non-interactive mode and
560
+ * exits 2 without writing anything.
561
+ */
562
+ export function refusalSurvivesAssumeYes(refusal) {
563
+ return refusal && refusal.action === 'refuse' ? { ...refusal, exitCode: 2 } : refusal;
564
+ }
565
+
566
+ // -----------------------------------------------------------------------------
567
+ // §9 — the OURS_UNINSTALL_* contract (inventory item 10.9)
568
+ // -----------------------------------------------------------------------------
569
+
570
+ /**
571
+ * The seven variables. THIS IS A DOCUMENTED PUBLIC INTERFACE, not an internal
572
+ * detail: all seven are in packages/installer/README.md, two of them are in
573
+ * uninstall.sh's header as well, and somebody's unattended uninstall script is
574
+ * written against them. v3 honoured none of them and said nothing, which is the
575
+ * worst of the three available behaviours — a documented variable that quietly
576
+ * does nothing is worse than one that errors, because the operator believes it
577
+ * worked.
578
+ *
579
+ * Each is in exactly one of two categories, and which one is not a matter of
580
+ * taste. A variable is HONOURED when v3 can deliver what it documents. A
581
+ * variable is REFUSED — exit 2, nothing removed, naming the variable and naming
582
+ * the replacement — when what it documents depends on something v3 does not
583
+ * have. Nothing is silently ignored and nothing is silently obeyed.
584
+ *
585
+ * HONOURED
586
+ * OURS_UNINSTALL which harness plugins to remove. Parsed by the same
587
+ * canonHarnesses the nightly picker used, so names,
588
+ * numbers, "all" and "none" all still mean what they
589
+ * meant. Feeds item 9.5's selection.
590
+ * OURS_UNINSTALL_DAEMON whether the daemon goes. See below — this is the one
591
+ * that matters most.
592
+ * OURS_UNINSTALL_TELEGRAM
593
+ * OURS_UNINSTALL_ROOMS at the value `detach`, which is exactly the
594
+ * confirmation §8 step 1 asks a human for and which an
595
+ * unattended run otherwise cannot give, so today the
596
+ * run refuses instead of detaching.
597
+ *
598
+ * REFUSED
599
+ * OURS_UNINSTALL_DATA=yes v3 never deletes state without a human
600
+ * present (§9), and that rule protects private
601
+ * keys that exist nowhere else.
602
+ * OURS_UNINSTALL_PROFILE names an entry in the daemon registry. v3
603
+ * has no registry; the selector is --state-dir.
604
+ * OURS_UNINSTALL_FORGET_PROFILE asks to forget a daemon as metadata while it
605
+ * keeps running. v3 has no metadata to forget.
606
+ * OURS_UNINSTALL_TELEGRAM/_ROOMS at `uninstall` (v3 detaches, it does not
607
+ * remove connector packages) or
608
+ * `reassign:<profile-id>` (again the registry).
609
+ *
610
+ * WHY THE WHOLE CONTRACT ENGAGES OR NONE OF IT DOES. v2 gated on
611
+ * `OURS_UNINSTALL != null || OURS_UNINSTALL_DATA || OURS_UNINSTALL_DAEMON` and
612
+ * then asked nothing at all. That gate is kept exactly: with none of the seven
613
+ * set, `ours-uninstall` behaves precisely as it does today, so the blast radius
614
+ * of this whole change is "an operator who set one of the documented variables".
615
+ *
616
+ * AND THE ESCALATION THIS CLOSES. Before this, `OURS_UNINSTALL="hermes"` — a
617
+ * script that asked for ONE HARNESS PLUGIN to be removed — reached v3, which
618
+ * read none of it, and removed THE DAEMON: its service, its process, and the
619
+ * global packages. Honouring OURS_UNINSTALL_DAEMON is what makes a request to
620
+ * detach a plugin stay a request to detach a plugin.
621
+ */
622
+ export const UNINSTALL_ENV_VARS = [
623
+ 'OURS_UNINSTALL',
624
+ 'OURS_UNINSTALL_PROFILE',
625
+ 'OURS_UNINSTALL_FORGET_PROFILE',
626
+ 'OURS_UNINSTALL_DAEMON',
627
+ 'OURS_UNINSTALL_DATA',
628
+ 'OURS_UNINSTALL_TELEGRAM',
629
+ 'OURS_UNINSTALL_ROOMS',
630
+ ];
631
+
632
+ /**
633
+ * Is the contract engaged at all? PRESENCE, not truth — `OURS_UNINSTALL=""` is a
634
+ * deliberate "no harnesses", exactly as v2's `!= null` read it, and is not the
635
+ * same as never having set it.
636
+ */
637
+ export function uninstallEnvEngaged(env = {}) {
638
+ return UNINSTALL_ENV_VARS.some((name) => env[name] !== undefined && env[name] !== null);
639
+ }
640
+
641
+ const CONNECTOR_VARS = [
642
+ { name: 'OURS_UNINSTALL_TELEGRAM', key: 'tg', pkg: '@ours.network/tg-connector' },
643
+ { name: 'OURS_UNINSTALL_ROOMS', key: 'cowork', pkg: '@ours.network/cowork' },
644
+ ];
645
+
646
+ function connectorRefusal(name, value, pkg) {
647
+ if (value === 'uninstall') {
648
+ return `${name}=uninstall asks for the connector's global package to be removed as well; this uninstaller detaches a connector, it does not uninstall one. Use ${name}=detach, then 'npm rm -g ${pkg}' yourself.`;
649
+ }
650
+ if (value.startsWith('reassign:')) {
651
+ return `${name}=${value} asks for the connector to be pointed at another daemon by profile id. This uninstaller has no profile registry, so there is no id it could resolve. Point the connector at the daemon you want first — 'ours-install --state-dir <path>' — and then re-run this uninstall.`;
652
+ }
653
+ return `${name}=${value} is not a value this uninstaller understands. The only supported value is 'detach'.`;
654
+ }
655
+
656
+ /**
657
+ * Read the contract, or refuse it. Pure: the caller passes the environment in.
658
+ *
659
+ * A refusal is returned rather than thrown, in the same shape planUninstall uses
660
+ * for its own refusals, so the orchestrator has one thing to print and one exit
661
+ * code to return — and so a test can assert the whole set of refusals at once
662
+ * instead of catching them one at a time.
663
+ */
664
+ export function parseUninstallEnv(env = {}) {
665
+ const engaged = uninstallEnvEngaged(env);
666
+ const value = (name) => (typeof env[name] === 'string' ? env[name].trim() : '');
667
+ const refusals = [];
668
+
669
+ if (value('OURS_UNINSTALL_DATA') === 'yes') {
670
+ refusals.push({
671
+ variable: 'OURS_UNINSTALL_DATA',
672
+ value: 'yes',
673
+ message: "OURS_UNINSTALL_DATA=yes asks this uninstaller to delete a state directory with nobody present. It will not: that directory holds identity private keys that exist nowhere else and that no peer can give back, and state is never deleted in an unattended run. To delete it, run 'ours-uninstall --state-dir <dir> --purge' from a terminal and type the full path when it asks.",
674
+ });
675
+ }
676
+ const profile = value('OURS_UNINSTALL_PROFILE');
677
+ if (profile) {
678
+ refusals.push({
679
+ variable: 'OURS_UNINSTALL_PROFILE',
680
+ value: profile,
681
+ message: `OURS_UNINSTALL_PROFILE=${profile} names an entry in the daemon profile registry, which this uninstaller does not have — there is no profile to select. Choose the daemon by its state directory instead: 'ours-uninstall --state-dir <path>'.`,
682
+ });
683
+ }
684
+ if (value('OURS_UNINSTALL_FORGET_PROFILE') === 'yes') {
685
+ refusals.push({
686
+ variable: 'OURS_UNINSTALL_FORGET_PROFILE',
687
+ value: 'yes',
688
+ message: 'OURS_UNINSTALL_FORGET_PROFILE=yes asks for a daemon to be forgotten as metadata while it keeps running. This uninstaller keeps no registry of daemons, so there is nothing to forget and nothing it could do that would match that request — a daemon it is not pointed at is already left entirely alone.',
689
+ });
690
+ }
691
+
692
+ const components = {};
693
+ for (const connector of CONNECTOR_VARS) {
694
+ const raw = value(connector.name);
695
+ if (!raw) continue;
696
+ if (raw === 'detach') { components[connector.key] = 'detach'; continue; }
697
+ refusals.push({
698
+ variable: connector.name,
699
+ value: raw,
700
+ message: connectorRefusal(connector.name, raw, connector.pkg),
701
+ });
702
+ }
703
+
704
+ const canon = engaged ? canonHarnesses(env.OURS_UNINSTALL ?? '') : null;
705
+ // A token we cannot map is a request we cannot deliver. The nightly picker
706
+ // reported these and carried on, which under an unattended run means
707
+ // OURS_UNINSTALL="hermez" removes nothing and says so into a log nobody reads.
708
+ // Same rule as the rest of this contract: name it, refuse, remove nothing.
709
+ for (const token of canon?.unknown ?? []) {
710
+ refusals.push({
711
+ variable: 'OURS_UNINSTALL',
712
+ value: token,
713
+ message: `OURS_UNINSTALL names "${token}", which is not a harness this uninstaller knows. Use ${HARNESS_ORDER.join(', ')}, or "all", or "none".`,
714
+ });
715
+ }
716
+
717
+ const contract = {
718
+ engaged,
719
+ // Not engaged means "decide this the way you always did": null selection, and
720
+ // the daemon removal that IS this command.
721
+ harnesses: canon ? canon.names : null,
722
+ unknownHarnessTokens: canon ? canon.unknown : [],
723
+ daemon: engaged ? value('OURS_UNINSTALL_DAEMON') === 'yes' : true,
724
+ confirmedComponents: Object.keys(components),
725
+ };
726
+ if (refusals.length === 0) return contract;
727
+ return {
728
+ ...contract,
729
+ action: 'refuse',
730
+ exitCode: 2,
731
+ reason: 'uninstall-env-unsupported',
732
+ refusals,
733
+ message: `${refusals.map((r) => r.message).join('\n')}\nNothing was removed.`,
734
+ };
735
+ }