@ours.network/install 0.17.0-nightly.9 → 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.
package/lib/plan.mjs CHANGED
@@ -94,10 +94,9 @@ export function classifyUnit(text) {
94
94
  * informational line naming it
95
95
  * { action: 'refuse', exitCode: 2, … } — unknown unit, or unusable state dir
96
96
  *
97
- * Adoption of a legacy unit is SILENT by the owner's decision: no prompt, no
98
- * question, so an upgrading user has no manual step. The one line of output
99
- * exists so the replacement is not literally invisible; it does not block and it
100
- * is not a warning.
97
+ * Adoption of a legacy unit is SILENT: no prompt, no question, so an upgrading
98
+ * user has no manual step. The one line of output exists so the replacement is not
99
+ * literally invisible; it does not block and it is not a warning.
101
100
  *
102
101
  * BECAUSE THERE IS NO PROMPT, `classifyUnit`'s `legacy` match is now the ENTIRE
103
102
  * safety boundary between a stranger's file and a silent rewrite. It must stay
@@ -110,7 +109,40 @@ export function classifyUnit(text) {
110
109
  * without a question in front of it — it is what keeps --force from becoming a
111
110
  * default that spreads to the other cases.
112
111
  */
113
- export function planServiceInstall({ stateDir, home, readText }) {
112
+ export function planServiceInstall({ stateDir, home, readText, platform = 'linux' }) {
113
+ // THE BOOT SERVICE IS LINUX-ONLY, AND NOT BECAUSE THIS FILE SAYS SO.
114
+ //
115
+ // `ours daemon install-service` in @ours.network/cli builds its adapter with
116
+ // `createLinuxUserSystemdAdapter()` and no platform branch at all, and that
117
+ // factory's FIRST line is
118
+ // if (deps.platform !== 'linux') throw new Error('service management is not
119
+ // supported on <platform>; use an external launcher for `ours daemon serve`')
120
+ // — verified by reading the published 0.4.1 tarball, which contains zero
121
+ // occurrences of launchd, LaunchAgents or plist.
122
+ //
123
+ // So calling it on macOS does not degrade, it THROWS. Before this, a Mac user
124
+ // was told their platform was supported, watched the CLI install, the config
125
+ // write and the daemon start, and then got an exception and a rolled-back
126
+ // config. Skipping the step leaves them a working daemon and one true sentence
127
+ // instead — which is the whole of this change.
128
+ //
129
+ // A real launchd adapter belongs in the SDK CLI, not here. Nothing in this
130
+ // package can install a launchd agent, and pretending otherwise by writing a
131
+ // plist ourselves would put a second service implementation in a second repo.
132
+ // `ours daemon install-service` supports Linux/systemd only: its adapter throws
133
+ // for any other platform, so calling it would fail the run rather than degrade.
134
+ // Skip it and say so; the daemon itself is unaffected.
135
+ if (platform && platform !== 'linux') {
136
+ return {
137
+ action: 'unsupported',
138
+ platform,
139
+ reason: 'no-service-manager',
140
+ message: platform === 'darwin'
141
+ ? 'installing a boot service is not available on macOS — the ours CLI can only manage a Linux user systemd service'
142
+ : `installing a boot service is not available on ${platform} — the ours CLI can only manage a Linux user systemd service`,
143
+ manual: ['ours', 'daemon', 'serve', '--config'],
144
+ };
145
+ }
114
146
  const derived = unitPathForStateDir(stateDir, home);
115
147
  if (!derived.ok) {
116
148
  return { action: 'refuse', exitCode: 2, reason: 'unusable-state-dir', message: derived.reason };
@@ -180,10 +212,10 @@ export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
180
212
  // --json so the caller can read back whether the unit actually CHANGED. The
181
213
  // CLI owns that byte-comparison, and an installer that guessed at it would
182
214
  // report "nothing changed" on a run that rewrote a unit.
215
+ // --json so the caller can read back whether the unit actually CHANGED rather
216
+ // than assuming it did. --force is reachable only through the explicit argument
217
+ // above, and the CLI refuses to overwrite a unit it did not write.
183
218
  const cmd = ['ours', 'daemon', 'install-service', '--yes', '--json', '--state-dir', dir, '--config', join(dir, 'config.json')];
184
- // --force is reachable ONLY through this explicit argument, which the
185
- // orchestrator passes only after the user answered yes to legacyReplacePrompt.
186
- // It is never a default and never appears in a plan.
187
219
  if (adoptLegacyUnit) cmd.push('--force');
188
220
  return cmd;
189
221
  }
package/lib/target.mjs CHANGED
@@ -31,6 +31,33 @@ export const INSTALL_RESERVED_PORTS = [3051, 3052];
31
31
  // The CLI-owned PID record that proves a daemon belongs to a state directory
32
32
  // even when nothing is recorded in its config (spec §1).
33
33
  export const CLI_PID_RECORD = 'ours-cli-daemon.json';
34
+ // The SAME record, written by a different daemon. `ours daemon start` writes
35
+ // ours-cli-daemon.json; prerelease ours-mcp wrote daemon.pid (packages/core
36
+ // cli.ts PID_PATH). BOTH are read, and neither replaces the other: a machine
37
+ // installed yesterday has the first and a machine installed today has the
38
+ // second, and detection that goes blind on either one concludes "no daemon
39
+ // here" and creates a SECOND daemon on a state directory that already has one.
40
+ // Two writers on one state_data.bin is the corruption case this lookup exists
41
+ // to prevent, so the lookup has to know both spellings.
42
+ export const MCP_PID_RECORD = 'daemon.pid';
43
+ export const PID_RECORDS = [CLI_PID_RECORD, MCP_PID_RECORD];
44
+
45
+ /**
46
+ * The port a PID record names, whichever daemon wrote it.
47
+ *
48
+ * ours-cli-daemon.json is JSON with a `port`. daemon.pid is a bare pid — no
49
+ * port — so it proves a daemon EXISTS for this state directory without saying
50
+ * where. That distinction is the whole reason this returns both fields.
51
+ */
52
+ export function readPidRecords(target, readJson, readText) {
53
+ const record = readJson(join(target, CLI_PID_RECORD));
54
+ const port = record && typeof record.port === 'number' && Number.isFinite(record.port) ? record.port : null;
55
+ const rawPid = typeof readText === 'function' ? readText(join(target, MCP_PID_RECORD)) : null;
56
+ const pid = rawPid !== null && rawPid !== undefined && /^\s*\d+\s*$/.test(String(rawPid))
57
+ ? Number.parseInt(String(rawPid).trim(), 10)
58
+ : null;
59
+ return { port, pid };
60
+ }
34
61
  export const DAEMON_CONFIG = 'config.json';
35
62
 
36
63
  export class InstallUsageError extends Error {
@@ -192,7 +219,7 @@ export function classifyProbe(probe, targetStateDir) {
192
219
  * is still no I/O here, and `await` on a non-promise is the same value back, so
193
220
  * every existing fake keeps working unchanged.
194
221
  */
195
- export async function findDaemon({ stateDir, probe, readJson }) {
222
+ export async function findDaemon({ stateDir, probe, readJson, readText }) {
196
223
  const target = resolve(stateDir);
197
224
  const config = readJson(join(target, DAEMON_CONFIG));
198
225
  const recordedPort = config && typeof config.port === 'number' && Number.isFinite(config.port) ? config.port : null;
@@ -204,8 +231,8 @@ export async function findDaemon({ stateDir, probe, readJson }) {
204
231
  if (byConfig.kind === 'present') return { ...byConfig, port: first, via: 'config', config };
205
232
  if (byConfig.kind === 'foreign' && !guessed) return { ...byConfig, port: first, via: 'config', config };
206
233
 
207
- const record = readJson(join(target, CLI_PID_RECORD));
208
- const recorded = record && typeof record.port === 'number' && Number.isFinite(record.port) ? record.port : null;
234
+ const records = readPidRecords(target, readJson, readText);
235
+ const recorded = records.port;
209
236
  if (recorded !== null && recorded !== first) {
210
237
  const byRecord = classifyProbe(await probe(recorded), target);
211
238
  if (byRecord.kind === 'present') return { ...byRecord, port: recorded, via: 'pid-record', config };
@@ -229,6 +256,12 @@ export async function findDaemon({ stateDir, probe, readJson }) {
229
256
  // daemon owns THIS directory. A foreign answer on a port the directory
230
257
  // actually RECORDED still refuses, because there the operator's own file said
231
258
  // the daemon was there and something else is.
259
+ // A bare pid record with no port still says a daemon OWNS this directory. It
260
+ // cannot say where, so this is not "present" — but it is a reason to report a
261
+ // stale record rather than silently create a second daemon beside it.
262
+ if (records.pid !== null && byConfig.kind !== 'present') {
263
+ return { kind: 'absent', reason: 'a daemon pid record exists but nothing answers', stalePidRecord: first, port: first, via: 'config', config };
264
+ }
232
265
  if (byConfig.kind === 'foreign') {
233
266
  return {
234
267
  kind: 'absent',
@@ -265,9 +298,9 @@ export async function findDaemon({ stateDir, probe, readJson }) {
265
298
  * if it is occupied that is a refusal, not a reason to shift. Only a derived port
266
299
  * is searched, from 3050 upward, skipping the reserved defaults.
267
300
  */
268
- export async function resolveTarget({ stateDir, port = null, portExplicit = false, probe, readJson, isTaken }) {
301
+ export async function resolveTarget({ stateDir, port = null, portExplicit = false, probe, readJson, readText, isTaken }) {
269
302
  const target = resolve(stateDir);
270
- const found = await findDaemon({ stateDir: target, probe, readJson });
303
+ const found = await findDaemon({ stateDir: target, probe, readJson, readText });
271
304
 
272
305
  if (found.kind === 'foreign') {
273
306
  return {
package/lib/uninstall.mjs CHANGED
@@ -17,6 +17,7 @@
17
17
  import { join, resolve } from 'node:path';
18
18
  import { unitNameForStateDir } from './plan.mjs';
19
19
  import { tgConfigPath, coworkConfigPath } from './components.mjs';
20
+ import { canonHarnesses } from './logic.mjs';
20
21
 
21
22
  /**
22
23
  * Does this directory even look like an ours state directory?
@@ -170,15 +171,16 @@ export function planComponentDetach(key, existing) {
170
171
  export function planDaemonRemoval({ stateDir, cliStartedIt }) {
171
172
  const dir = resolve(stateDir);
172
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
+ };
173
180
  return [
174
- {
175
- id: 'service',
176
- unit: unit.ok ? unit.unit : null,
177
- command: ['ours', 'daemon', 'uninstall-service', '--yes', '--state-dir', dir],
178
- note: 'refuses a unit not marked as CLI-managed',
179
- },
181
+ service,
180
182
  cliStartedIt
181
- ? { id: 'stop', command: ['ours', 'daemon', 'stop', '--config', join(dir, 'config.json')] }
183
+ ? { id: 'stop', command: ['ours', 'daemon', 'stop', '--state-dir', dir, '--config', join(dir, 'config.json')] }
182
184
  : { id: 'stop-external', command: null, continues: true, note: 'this daemon was not started by the CLI; naming its launcher and continuing' },
183
185
  ];
184
186
  }
@@ -306,6 +308,13 @@ export function stripManagedBlock(text, markers) {
306
308
  return { action: 'strip', text: out.join('\n') };
307
309
  }
308
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
+
309
318
  /**
310
319
  * What the harness-plugin half of an uninstall removes.
311
320
  *
@@ -313,6 +322,14 @@ export function stripManagedBlock(text, markers) {
313
322
  * rather than recomputed: while another daemon is still on this machine its
314
323
  * harnesses still need their plugins, so nothing here is touched at all.
315
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
+ *
316
333
  * Claude Code is deliberately manual-only. Its plugin lives in the in-app
317
334
  * marketplace, and there is no file on disk we own — so this prints the two
318
335
  * commands and claims nothing, which is the same never-dead-end contract the
@@ -321,7 +338,7 @@ export function stripManagedBlock(text, markers) {
321
338
  * Every path is EXACT — the precise file or directory the plugin installer
322
339
  * writes. Nothing here is a glob, and nothing walks a tree looking for matches.
323
340
  */
324
- export function planPluginRemoval({ home, env = {}, exists = () => false, lastDaemon = true } = {}) {
341
+ export function planPluginRemoval({ home, env = {}, exists = () => false, lastDaemon = true, explicitSelection = false } = {}) {
325
342
  const hermesDir = env.HERMES_DIR || join(home, '.hermes');
326
343
  const codexDir = env.CODEX_DIR || join(home, '.codex');
327
344
  const skillsDir = env.SKILLS_DIR || join(home, '.agents', 'skills');
@@ -333,7 +350,7 @@ export function planPluginRemoval({ home, env = {}, exists = () => false, lastDa
333
350
  reason: "its plugin lives in Claude Code's in-app marketplace, so nothing on disk is ours to remove",
334
351
  steps: ['/plugin uninstall ours', '/plugin marketplace remove adapt-toolkit/ours-claude-marketplace'],
335
352
  };
336
- if (!lastDaemon) {
353
+ if (!lastDaemon && !explicitSelection) {
337
354
  return {
338
355
  action: 'keep',
339
356
  reason: 'another daemon on this machine still uses these plugins',
@@ -379,14 +396,89 @@ export function planPluginRemoval({ home, env = {}, exists = () => false, lastDa
379
396
  };
380
397
  }
381
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
+
382
474
  /**
383
475
  * The whole §8 order, refusing at step 1 rather than starting and stopping
384
476
  * half-way.
385
477
  */
386
- export function planUninstall({ home, env = {}, endpoint, stateDir, purge = false, assumeYes = false, confirmedComponents = [], readJson, readText, exists = () => true, cliStartedIt = true, otherStateDirsWithConfig = [], typedConfirmation = null }) {
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' }) {
387
479
  const dir = resolve(stateDir);
388
480
  const lastDaemon = otherStateDirsWithConfig.map((d) => resolve(d)).filter((d) => d !== dir).length === 0;
389
- const plugins = planPluginRemoval({ home, env, exists, lastDaemon });
481
+ const plugins = planPluginRemoval({ home, env, exists, lastDaemon, explicitSelection: explicitHarnessSelection });
390
482
 
391
483
  // BEFORE the pointing question, and not resolvable by confirming anything: a
392
484
  // component config that will not parse cannot be proven not to name this
@@ -442,11 +534,16 @@ export function planUninstall({ home, env = {}, endpoint, stateDir, purge = fals
442
534
  /**
443
535
  * What each question answers to under OURS_ASSUME_YES.
444
536
  *
445
- * The two `false` entries are the point of the table: assume-yes never turns a
446
- * component on that was off, never MOVES one that already exists, and never
447
- * deletes state. It suppresses questions; it does not consent on the operator's
448
- * behalf to anything irreversible or to anything that changes where an existing
449
- * component is pointing.
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.
450
547
  */
451
548
  export const NON_INTERACTIVE_ANSWERS = {
452
549
  daemon: true,
@@ -454,6 +551,7 @@ export const NON_INTERACTIVE_ANSWERS = {
454
551
  tg: false,
455
552
  cowork: false,
456
553
  repointExistingConnector: false,
554
+ plugins: false,
457
555
  purge: false,
458
556
  };
459
557
 
@@ -465,3 +563,174 @@ export const NON_INTERACTIVE_ANSWERS = {
465
563
  export function refusalSurvivesAssumeYes(refusal) {
466
564
  return refusal && refusal.action === 'refuse' ? { ...refusal, exitCode: 2 } : refusal;
467
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "0.17.0-nightly.9",
3
+ "version": "0.18.0-nightly.1",
4
4
  "private": false,
5
5
  "description": "The unified ours.network stack installer (ours-install): one guided ~3-minute flow for ours core (the daemon) + the harness plugins (Claude Code / Codex) + ours-fleet + the Telegram connector, then a single copy-paste hand-off prompt. Self-contained (Node built-ins only); run as `ours-install` or via curl|bash (install.sh).",
6
6
  "type": "module",