@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.
@@ -0,0 +1,736 @@
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
+ const service = {
175
+ id: 'service',
176
+ unit: unit.ok ? unit.unit : null,
177
+ command: ['ours', 'daemon', 'uninstall-service', '--yes', '--state-dir', dir, '--config', join(dir, 'config.json')],
178
+ note: 'removes the unit managed by the ours CLI',
179
+ };
180
+ return [
181
+ service,
182
+ cliStartedIt
183
+ ? { id: 'stop', command: ['ours', 'daemon', 'stop', '--state-dir', dir, '--config', join(dir, 'config.json')] }
184
+ : { id: 'stop-external', command: null, continues: true, note: 'this daemon was not started by the CLI; naming its launcher and continuing' },
185
+ ];
186
+ }
187
+
188
+ /**
189
+ * §8 step 5 — state. Kept unless every gate opens.
190
+ *
191
+ * --purge given — never the default; deleting identity keys is opt-in.
192
+ * interactive — an unattended run never deletes state (§9).
193
+ * looks like a state directory — see looksLikeStateDir.
194
+ * typed confirmation — the full path, not a y/N. The owner removed the
195
+ * provenance condition, not the deliberateness, and with
196
+ * provenance gone this is the last thing standing between
197
+ * a mistyped command and someone's identity keys.
198
+ *
199
+ * Returns the exact directory, never a glob or a parent, and only when every gate
200
+ * is satisfied.
201
+ */
202
+ export function planStatePurge({ stateDir, purge = false, assumeYes = false, exists = () => true, typedConfirmation = null }) {
203
+ const dir = resolve(stateDir);
204
+ const keep = (reason) => ({ action: 'keep', stateDir: dir, reason, hint: 're-run with --purge to delete identities and history' });
205
+ if (!purge) return keep('state is kept by default');
206
+ if (assumeYes) return keep('state is never deleted non-interactively');
207
+ if (!looksLikeStateDir(dir, exists)) {
208
+ return keep(`${dir} does not look like an ours state directory (none of ${STATE_DIR_EVIDENCE.join(', ')}); refusing to delete it`);
209
+ }
210
+ if (typedConfirmation !== dir) {
211
+ return {
212
+ action: 'confirm-typed',
213
+ stateDir: dir,
214
+ expected: dir,
215
+ 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:`,
216
+ };
217
+ }
218
+ return { action: 'purge', stateDir: dir, paths: [dir] };
219
+ }
220
+
221
+ /**
222
+ * §8 step 6 — global packages are shared. Remove them only when no OTHER state
223
+ * directory on this machine still has a daemon config; otherwise keep them and
224
+ * say which daemon still needs them.
225
+ */
226
+ export const CONNECTOR_PACKAGES = { tg: '@ours.network/tg-connector', cowork: '@ours.network/cowork' };
227
+
228
+ export function planGlobalPackages({ stateDir, otherStateDirsWithConfig = [], pluginPackages = [], detachedComponents = [] }) {
229
+ const dir = resolve(stateDir);
230
+ const others = otherStateDirsWithConfig.map((d) => resolve(d)).filter((d) => d !== dir);
231
+ if (others.length > 0) {
232
+ return { action: 'keep', packages: [], stillNeededBy: others, reason: `still used by the daemon at ${others[0]}` };
233
+ }
234
+ // The harness-plugin launchers follow the SAME rule, not a second one: they
235
+ // speak to a daemon through ours-mcp, so a second daemon still on the machine
236
+ // still needs them, and the one condition above already decides that.
237
+ // A connector's package goes only when BOTH conditions hold: the operator
238
+ // confirmed removing its attachment in THIS run, and this was the last daemon —
239
+ // the same condition already decided once above.
240
+ //
241
+ // WHY NOT NIGHTLY'S THREE-WAY CHOICE. The nightly uninstaller asks per connector
242
+ // for detach / uninstall / reassign:<profile> and removes the package only on
243
+ // `uninstall`. v3 has no such lifecycle question and inventing one here would be
244
+ // deciding scope in a patch. What v3 DOES have is the operator's explicit yes to
245
+ // "remove its attachment too", which is a narrower thing than nightly's
246
+ // `uninstall` — so this stays behind the last-daemon condition rather than
247
+ // standing on the confirmation alone. A connector detached while another daemon
248
+ // survives keeps its package, because that daemon may still be using it.
249
+ const connectors = detachedComponents
250
+ .map((key) => CONNECTOR_PACKAGES[key])
251
+ .filter(Boolean);
252
+ return {
253
+ action: 'remove',
254
+ packages: ['@ours.network/cli', '@ours.network/mcp', ...pluginPackages, ...connectors],
255
+ stillNeededBy: [],
256
+ };
257
+ }
258
+
259
+ // -----------------------------------------------------------------------------
260
+ // §8 — the harness plugins the installer wrote
261
+ // -----------------------------------------------------------------------------
262
+
263
+ /**
264
+ * The sentinels the plugin installers stamp around everything they write. They
265
+ * are the ONLY thing that makes a block removable: without them we would be
266
+ * editing a config file on a guess.
267
+ */
268
+ export const YAML_BLOCK = { start: '# >>> ours.network plugin (managed block)', end: '# <<< ours.network plugin' };
269
+ export const MD_BLOCK = { start: '<!-- >>> ours.network plugin (managed block) -->', end: '<!-- <<< ours.network plugin -->' };
270
+
271
+ /**
272
+ * Remove OUR block from a config file, or refuse.
273
+ *
274
+ * SAME IDENTIFICATION DISCIPLINE AS THE SYSTEMD UNIT, and for the same reason.
275
+ * The installer refuses to overwrite a unit it cannot positively identify rather
276
+ * than guessing; this refuses to edit a config it cannot positively identify.
277
+ * Three outcomes, and the third is the one that matters:
278
+ *
279
+ * absent — no start sentinel. The file is not ours to edit; left untouched.
280
+ * strip — both sentinels present. Exactly the delimited span is removed.
281
+ * refuse — a start sentinel with NO end. v2 deleted to end-of-file here,
282
+ * which would take everything the user added after our block with
283
+ * it. An unterminated block is damage we cannot bound, so it is
284
+ * reported and left alone.
285
+ */
286
+ export function stripManagedBlock(text, markers) {
287
+ if (typeof text !== 'string' || !text.includes(markers.start)) {
288
+ return { action: 'absent', reason: 'no ours managed block in this file' };
289
+ }
290
+ const lines = text.split('\n');
291
+ const out = [];
292
+ let inside = false;
293
+ let closed = false;
294
+ for (const line of lines) {
295
+ if (!inside && line.includes(markers.start)) { inside = true; continue; }
296
+ if (inside) {
297
+ if (line.includes(markers.end)) { inside = false; closed = true; }
298
+ continue;
299
+ }
300
+ out.push(line);
301
+ }
302
+ if (!closed) {
303
+ return {
304
+ action: 'refuse',
305
+ reason: 'the ours managed block has no closing marker; refusing to guess where it ends',
306
+ };
307
+ }
308
+ return { action: 'strip', text: out.join('\n') };
309
+ }
310
+
311
+ /**
312
+ * The three harnesses this uninstaller knows, in the order they are offered.
313
+ * Same names and same order as the nightly picker and as `canonHarnesses`, so a
314
+ * selection written for one is a selection for the other.
315
+ */
316
+ export const HARNESS_ORDER = ['claude-code', 'codex', 'hermes'];
317
+
318
+ /**
319
+ * What the harness-plugin half of an uninstall removes.
320
+ *
321
+ * `lastDaemon` is the same condition planGlobalPackages decides on, passed in
322
+ * rather than recomputed: while another daemon is still on this machine its
323
+ * harnesses still need their plugins, so nothing here is touched at all.
324
+ *
325
+ * `explicitSelection` OVERRIDES that keep, and only that. The keep is a guess
326
+ * made on the operator's behalf — "another daemon is here, so you probably still
327
+ * want these". When the operator has named the harnesses themselves (the picker,
328
+ * or OURS_UNINSTALL), the guess has been answered and must not outrank the
329
+ * answer. Nothing else about the plan changes: WHICH of the discovered harnesses
330
+ * are then acted on is decided by the caller, not here, so this stays the single
331
+ * description of what exists on disk.
332
+ *
333
+ * Claude Code is deliberately manual-only. Its plugin lives in the in-app
334
+ * marketplace, and there is no file on disk we own — so this prints the two
335
+ * commands and claims nothing, which is the same never-dead-end contract the
336
+ * installer applies to a harness it cannot drive.
337
+ *
338
+ * Every path is EXACT — the precise file or directory the plugin installer
339
+ * writes. Nothing here is a glob, and nothing walks a tree looking for matches.
340
+ */
341
+ export function planPluginRemoval({ home, env = {}, exists = () => false, lastDaemon = true, explicitSelection = false } = {}) {
342
+ const hermesDir = env.HERMES_DIR || join(home, '.hermes');
343
+ const codexDir = env.CODEX_DIR || join(home, '.codex');
344
+ const skillsDir = env.SKILLS_DIR || join(home, '.agents', 'skills');
345
+
346
+ const manual = {
347
+ key: 'claude-code',
348
+ label: 'Claude Code plugin',
349
+ action: 'manual',
350
+ reason: "its plugin lives in Claude Code's in-app marketplace, so nothing on disk is ours to remove",
351
+ steps: ['/plugin uninstall ours', '/plugin marketplace remove adapt-toolkit/ours-claude-marketplace'],
352
+ };
353
+ if (!lastDaemon && !explicitSelection) {
354
+ return {
355
+ action: 'keep',
356
+ reason: 'another daemon on this machine still uses these plugins',
357
+ harnesses: [],
358
+ manual: [manual],
359
+ packages: [],
360
+ };
361
+ }
362
+
363
+ const harnesses = [];
364
+ const hermesConfig = join(hermesDir, 'config.yaml');
365
+ if (exists(hermesConfig) || exists(hermesDir)) {
366
+ harnesses.push({
367
+ key: 'hermes',
368
+ label: 'Hermes plugin',
369
+ blocks: [{ path: hermesConfig, markers: YAML_BLOCK }],
370
+ dirs: [
371
+ join(hermesDir, 'skills', 'communication', 'ours'),
372
+ join(hermesDir, 'skills', 'communication', 'writing-agent-bios'),
373
+ ],
374
+ files: [join(hermesDir, 'ours-connector.env'), join(hermesDir, 'ours-connector.log')],
375
+ pkg: '@ours.network/hermes',
376
+ });
377
+ }
378
+ if (exists(join(codexDir, 'config.toml')) || exists(codexDir)) {
379
+ harnesses.push({
380
+ key: 'codex',
381
+ label: 'Codex plugin',
382
+ blocks: [
383
+ { path: join(codexDir, 'config.toml'), markers: YAML_BLOCK },
384
+ { path: join(codexDir, 'AGENTS.md'), markers: MD_BLOCK },
385
+ ],
386
+ dirs: [join(skillsDir, 'ours'), join(skillsDir, 'writing-agent-bios')],
387
+ files: [],
388
+ pkg: '@ours.network/codex',
389
+ });
390
+ }
391
+ return {
392
+ action: 'remove',
393
+ harnesses,
394
+ manual: [manual],
395
+ packages: harnesses.map((h) => h.pkg),
396
+ };
397
+ }
398
+
399
+ /**
400
+ * §8 — WHICH harnesses to detach (inventory item 9.5).
401
+ *
402
+ * The nightly uninstaller let the operator choose: a `checkboxSelect` picker on a
403
+ * terminal, `OURS_UNINSTALL` without one, and NOTHING removed when it had
404
+ * neither. v3 had no choice at all — it removed every plugin artefact it found
405
+ * whenever this was the last daemon. A user who wanted to detach one harness had
406
+ * no way to say so, and an unattended run removed plugins that leaving
407
+ * `OURS_UNINSTALL` unset used to protect.
408
+ *
409
+ * This decides the three cases; the orchestrator only does the asking, so the
410
+ * rule and the terminal I/O stay apart.
411
+ *
412
+ * explicit — a selection was given (OURS_UNINSTALL, or the answers to the
413
+ * per-harness questions). Exactly those, intersected with what is
414
+ * actually there. A name for a harness that is not installed is
415
+ * reported as `ignored`, not silently dropped.
416
+ * keep — unattended with no selection. Nothing is removed, and the caller
417
+ * says how to select. This is the conservative side: an uninstall
418
+ * nobody is watching does not decide on its own that a harness
419
+ * should lose its plugin.
420
+ * ask — a terminal and no selection. One question per harness, DEFAULTING
421
+ * TO YES, because the daemon these plugins talk to is going away
422
+ * and a plugin left behind advertises tools that no longer resolve.
423
+ * An operator who just presses Enter gets exactly what v3 does
424
+ * today; the only new thing is that they can now say no.
425
+ *
426
+ * A checkbox picker would match nightly's chrome more closely, but the effects
427
+ * contract this uninstaller runs on has `ask`, not `checkboxSelect` — and the
428
+ * question here is which harnesses, not which widget.
429
+ */
430
+ export function planHarnessSelection(plugins, { selection = null, assumeYes = false } = {}) {
431
+ const offered = [...(plugins.manual ?? []), ...(plugins.harnesses ?? [])]
432
+ .map((h) => ({ key: h.key, label: h.label }))
433
+ .sort((a, b) => HARNESS_ORDER.indexOf(a.key) - HARNESS_ORDER.indexOf(b.key));
434
+ if (selection !== null) {
435
+ const keys = offered.map((o) => o.key);
436
+ return {
437
+ mode: 'explicit',
438
+ offered,
439
+ chosen: keys.filter((k) => selection.includes(k)),
440
+ ignored: selection.filter((k) => !keys.includes(k)),
441
+ };
442
+ }
443
+ if (assumeYes) {
444
+ return {
445
+ mode: 'keep',
446
+ offered,
447
+ chosen: [],
448
+ ignored: [],
449
+ reason: 'no OURS_UNINSTALL was set and there is nobody to ask',
450
+ hint: `set OURS_UNINSTALL="${HARNESS_ORDER.join(' ')}" (or a subset, or "all") to remove harness plugins unattended`,
451
+ };
452
+ }
453
+ return { mode: 'ask', offered, chosen: null, ignored: [] };
454
+ }
455
+
456
+ /**
457
+ * Narrow a plugin plan to the chosen harnesses. Pure, so the orchestrator never
458
+ * decides what "chosen" means to a plan — it only supplies the answers.
459
+ *
460
+ * `packages` follows the harnesses it narrows to: a plugin package belongs to
461
+ * the harness that was removed, so a harness that was kept keeps its package.
462
+ */
463
+ export function selectHarnesses(plugins, chosen) {
464
+ const keep = (h) => chosen.includes(h.key);
465
+ const harnesses = (plugins.harnesses ?? []).filter(keep);
466
+ return {
467
+ ...plugins,
468
+ harnesses,
469
+ manual: (plugins.manual ?? []).filter(keep),
470
+ packages: harnesses.map((h) => h.pkg),
471
+ };
472
+ }
473
+
474
+ /**
475
+ * The whole §8 order, refusing at step 1 rather than starting and stopping
476
+ * half-way.
477
+ */
478
+ export function planUninstall({ home, env = {}, endpoint, stateDir, purge = false, assumeYes = false, confirmedComponents = [], readJson, readText, exists = () => true, cliStartedIt = true, otherStateDirsWithConfig = [], typedConfirmation = null, explicitHarnessSelection = false, platform = 'linux' }) {
479
+ const dir = resolve(stateDir);
480
+ const lastDaemon = otherStateDirsWithConfig.map((d) => resolve(d)).filter((d) => d !== dir).length === 0;
481
+ const plugins = planPluginRemoval({ home, env, exists, lastDaemon, explicitSelection: explicitHarnessSelection });
482
+
483
+ // BEFORE the pointing question, and not resolvable by confirming anything: a
484
+ // component config that will not parse cannot be proven not to name this
485
+ // daemon. Fail closed, name the file, remove nothing. This mirrors the nightly
486
+ // uninstaller's refusal (lib/nightly-uninstall.mjs:244) rather than inventing a
487
+ // second wording for the same event.
488
+ const unreadable = unreadableComponentConfigs({ home, env, readText });
489
+ if (unreadable.length > 0) {
490
+ return {
491
+ action: 'refuse',
492
+ exitCode: 2,
493
+ reason: 'component-config-unreadable',
494
+ components: unreadable,
495
+ removed: [],
496
+ 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.`,
497
+ };
498
+ }
499
+
500
+ const pointing = componentsPointingHere({ home, env, endpoint, stateDir: dir, readJson });
501
+ const unconfirmed = pointing.filter((p) => !confirmedComponents.includes(p.key));
502
+ if (unconfirmed.length > 0) {
503
+ return {
504
+ action: 'refuse',
505
+ exitCode: 2,
506
+ reason: 'component-still-points-here',
507
+ components: unconfirmed,
508
+ removed: [],
509
+ 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.`,
510
+ };
511
+ }
512
+ return {
513
+ action: 'uninstall',
514
+ stateDir: dir,
515
+ detach: pointing.map((p) => ({ key: p.key, service: [`ours-${p.key === 'tg' ? 'tg-connector' : 'cowork'}`, 'uninstall-service'] })),
516
+ daemon: planDaemonRemoval({ stateDir: dir, cliStartedIt }),
517
+ state: planStatePurge({ stateDir: dir, purge, assumeYes, exists, typedConfirmation }),
518
+ plugins,
519
+ packages: planGlobalPackages({
520
+ stateDir: dir,
521
+ otherStateDirsWithConfig,
522
+ pluginPackages: plugins.packages,
523
+ // Only the components the operator explicitly confirmed removing — never
524
+ // every component that merely happens to point here.
525
+ detachedComponents: pointing.map((p) => p.key).filter((key) => confirmedComponents.includes(key)),
526
+ }),
527
+ };
528
+ }
529
+
530
+ // -----------------------------------------------------------------------------
531
+ // §9 — the non-interactive contract
532
+ // -----------------------------------------------------------------------------
533
+
534
+ /**
535
+ * What each question answers to under OURS_ASSUME_YES.
536
+ *
537
+ * The `false` entries are the point of the table: assume-yes never turns a
538
+ * component on that was off, never MOVES one that already exists, never removes
539
+ * a harness's plugin, and never deletes state. It suppresses questions; it does
540
+ * not consent on the operator's behalf to anything irreversible or to anything
541
+ * that changes where an existing component is pointing.
542
+ *
543
+ * `plugins: false` is the one that answers a question this uninstaller used to
544
+ * answer the other way. A harness plugin is not this daemon's to give away
545
+ * unasked, and the operator has a way to ask for it by name — OURS_UNINSTALL —
546
+ * which is precisely the interface an unattended run should have to go through.
547
+ */
548
+ export const NON_INTERACTIVE_ANSWERS = {
549
+ daemon: true,
550
+ mcp: true,
551
+ tg: false,
552
+ cowork: false,
553
+ repointExistingConnector: false,
554
+ plugins: false,
555
+ purge: false,
556
+ };
557
+
558
+ /**
559
+ * OURS_ASSUME_YES suppresses questions; it NEVER suppresses a refusal. Every
560
+ * refusal in this specification applies unchanged in non-interactive mode and
561
+ * exits 2 without writing anything.
562
+ */
563
+ export function refusalSurvivesAssumeYes(refusal) {
564
+ return refusal && refusal.action === 'refuse' ? { ...refusal, exitCode: 2 } : refusal;
565
+ }
566
+
567
+ // -----------------------------------------------------------------------------
568
+ // §9 — the OURS_UNINSTALL_* contract (inventory item 10.9)
569
+ // -----------------------------------------------------------------------------
570
+
571
+ /**
572
+ * The seven variables. THIS IS A DOCUMENTED PUBLIC INTERFACE, not an internal
573
+ * detail: all seven are in packages/installer/README.md, two of them are in
574
+ * uninstall.sh's header as well, and somebody's unattended uninstall script is
575
+ * written against them. v3 honoured none of them and said nothing, which is the
576
+ * worst of the three available behaviours — a documented variable that quietly
577
+ * does nothing is worse than one that errors, because the operator believes it
578
+ * worked.
579
+ *
580
+ * Each is in exactly one of two categories, and which one is not a matter of
581
+ * taste. A variable is HONOURED when v3 can deliver what it documents. A
582
+ * variable is REFUSED — exit 2, nothing removed, naming the variable and naming
583
+ * the replacement — when what it documents depends on something v3 does not
584
+ * have. Nothing is silently ignored and nothing is silently obeyed.
585
+ *
586
+ * HONOURED
587
+ * OURS_UNINSTALL which harness plugins to remove. Parsed by the same
588
+ * canonHarnesses the nightly picker used, so names,
589
+ * numbers, "all" and "none" all still mean what they
590
+ * meant. Feeds item 9.5's selection.
591
+ * OURS_UNINSTALL_DAEMON whether the daemon goes. See below — this is the one
592
+ * that matters most.
593
+ * OURS_UNINSTALL_TELEGRAM
594
+ * OURS_UNINSTALL_ROOMS at the value `detach`, which is exactly the
595
+ * confirmation §8 step 1 asks a human for and which an
596
+ * unattended run otherwise cannot give, so today the
597
+ * run refuses instead of detaching.
598
+ *
599
+ * REFUSED
600
+ * OURS_UNINSTALL_DATA=yes v3 never deletes state without a human
601
+ * present (§9), and that rule protects private
602
+ * keys that exist nowhere else.
603
+ * OURS_UNINSTALL_PROFILE names an entry in the daemon registry. v3
604
+ * has no registry; the selector is --state-dir.
605
+ * OURS_UNINSTALL_FORGET_PROFILE asks to forget a daemon as metadata while it
606
+ * keeps running. v3 has no metadata to forget.
607
+ * OURS_UNINSTALL_TELEGRAM/_ROOMS at `uninstall` (v3 detaches, it does not
608
+ * remove connector packages) or
609
+ * `reassign:<profile-id>` (again the registry).
610
+ *
611
+ * WHY THE WHOLE CONTRACT ENGAGES OR NONE OF IT DOES. v2 gated on
612
+ * `OURS_UNINSTALL != null || OURS_UNINSTALL_DATA || OURS_UNINSTALL_DAEMON` and
613
+ * then asked nothing at all. That gate is kept exactly: with none of the seven
614
+ * set, `ours-uninstall` behaves precisely as it does today, so the blast radius
615
+ * of this whole change is "an operator who set one of the documented variables".
616
+ *
617
+ * AND THE ESCALATION THIS CLOSES. Before this, `OURS_UNINSTALL="hermes"` — a
618
+ * script that asked for ONE HARNESS PLUGIN to be removed — reached v3, which
619
+ * read none of it, and removed THE DAEMON: its service, its process, and the
620
+ * global packages. Honouring OURS_UNINSTALL_DAEMON is what makes a request to
621
+ * detach a plugin stay a request to detach a plugin.
622
+ */
623
+ export const UNINSTALL_ENV_VARS = [
624
+ 'OURS_UNINSTALL',
625
+ 'OURS_UNINSTALL_PROFILE',
626
+ 'OURS_UNINSTALL_FORGET_PROFILE',
627
+ 'OURS_UNINSTALL_DAEMON',
628
+ 'OURS_UNINSTALL_DATA',
629
+ 'OURS_UNINSTALL_TELEGRAM',
630
+ 'OURS_UNINSTALL_ROOMS',
631
+ ];
632
+
633
+ /**
634
+ * Is the contract engaged at all? PRESENCE, not truth — `OURS_UNINSTALL=""` is a
635
+ * deliberate "no harnesses", exactly as v2's `!= null` read it, and is not the
636
+ * same as never having set it.
637
+ */
638
+ export function uninstallEnvEngaged(env = {}) {
639
+ return UNINSTALL_ENV_VARS.some((name) => env[name] !== undefined && env[name] !== null);
640
+ }
641
+
642
+ const CONNECTOR_VARS = [
643
+ { name: 'OURS_UNINSTALL_TELEGRAM', key: 'tg', pkg: '@ours.network/tg-connector' },
644
+ { name: 'OURS_UNINSTALL_ROOMS', key: 'cowork', pkg: '@ours.network/cowork' },
645
+ ];
646
+
647
+ function connectorRefusal(name, value, pkg) {
648
+ if (value === 'uninstall') {
649
+ 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.`;
650
+ }
651
+ if (value.startsWith('reassign:')) {
652
+ 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.`;
653
+ }
654
+ return `${name}=${value} is not a value this uninstaller understands. The only supported value is 'detach'.`;
655
+ }
656
+
657
+ /**
658
+ * Read the contract, or refuse it. Pure: the caller passes the environment in.
659
+ *
660
+ * A refusal is returned rather than thrown, in the same shape planUninstall uses
661
+ * for its own refusals, so the orchestrator has one thing to print and one exit
662
+ * code to return — and so a test can assert the whole set of refusals at once
663
+ * instead of catching them one at a time.
664
+ */
665
+ export function parseUninstallEnv(env = {}) {
666
+ const engaged = uninstallEnvEngaged(env);
667
+ const value = (name) => (typeof env[name] === 'string' ? env[name].trim() : '');
668
+ const refusals = [];
669
+
670
+ if (value('OURS_UNINSTALL_DATA') === 'yes') {
671
+ refusals.push({
672
+ variable: 'OURS_UNINSTALL_DATA',
673
+ value: 'yes',
674
+ 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.",
675
+ });
676
+ }
677
+ const profile = value('OURS_UNINSTALL_PROFILE');
678
+ if (profile) {
679
+ refusals.push({
680
+ variable: 'OURS_UNINSTALL_PROFILE',
681
+ value: profile,
682
+ 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>'.`,
683
+ });
684
+ }
685
+ if (value('OURS_UNINSTALL_FORGET_PROFILE') === 'yes') {
686
+ refusals.push({
687
+ variable: 'OURS_UNINSTALL_FORGET_PROFILE',
688
+ value: 'yes',
689
+ 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.',
690
+ });
691
+ }
692
+
693
+ const components = {};
694
+ for (const connector of CONNECTOR_VARS) {
695
+ const raw = value(connector.name);
696
+ if (!raw) continue;
697
+ if (raw === 'detach') { components[connector.key] = 'detach'; continue; }
698
+ refusals.push({
699
+ variable: connector.name,
700
+ value: raw,
701
+ message: connectorRefusal(connector.name, raw, connector.pkg),
702
+ });
703
+ }
704
+
705
+ const canon = engaged ? canonHarnesses(env.OURS_UNINSTALL ?? '') : null;
706
+ // A token we cannot map is a request we cannot deliver. The nightly picker
707
+ // reported these and carried on, which under an unattended run means
708
+ // OURS_UNINSTALL="hermez" removes nothing and says so into a log nobody reads.
709
+ // Same rule as the rest of this contract: name it, refuse, remove nothing.
710
+ for (const token of canon?.unknown ?? []) {
711
+ refusals.push({
712
+ variable: 'OURS_UNINSTALL',
713
+ value: token,
714
+ message: `OURS_UNINSTALL names "${token}", which is not a harness this uninstaller knows. Use ${HARNESS_ORDER.join(', ')}, or "all", or "none".`,
715
+ });
716
+ }
717
+
718
+ const contract = {
719
+ engaged,
720
+ // Not engaged means "decide this the way you always did": null selection, and
721
+ // the daemon removal that IS this command.
722
+ harnesses: canon ? canon.names : null,
723
+ unknownHarnessTokens: canon ? canon.unknown : [],
724
+ daemon: engaged ? value('OURS_UNINSTALL_DAEMON') === 'yes' : true,
725
+ confirmedComponents: Object.keys(components),
726
+ };
727
+ if (refusals.length === 0) return contract;
728
+ return {
729
+ ...contract,
730
+ action: 'refuse',
731
+ exitCode: 2,
732
+ reason: 'uninstall-env-unsupported',
733
+ refusals,
734
+ message: `${refusals.map((r) => r.message).join('\n')}\nNothing was removed.`,
735
+ };
736
+ }