@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.
@@ -19,7 +19,7 @@
19
19
 
20
20
  import { join } from 'node:path';
21
21
  import { parseInstallArgs, InstallUsageError } from './target.mjs';
22
- import { planUninstall, planComponentDetach, planStatePurge, stripManagedBlock } from './uninstall.mjs';
22
+ import { planUninstall, planComponentDetach, planStatePurge, stripManagedBlock, planHarnessSelection, selectHarnesses, planGlobalPackages, planPluginRemoval, parseUninstallEnv } from './uninstall.mjs';
23
23
  import { tgConfigPath, coworkConfigPath } from './components.mjs';
24
24
  import { configJournal, reportRollback } from './journal.mjs';
25
25
  import { UNINSTALL_USAGE } from './usage.mjs';
@@ -81,6 +81,33 @@ export async function runUninstall(argv, effects) {
81
81
  effects.out(heading(`ours-uninstall --state-dir ${dir}`));
82
82
  if (args.dryRun) effects.out(info('dry-run: nothing will be removed or stopped'));
83
83
 
84
+ // §9 — the documented OURS_UNINSTALL_* contract (item 10.9), read BEFORE any
85
+ // file is opened. A variable this uninstaller cannot deliver stops the run
86
+ // here, naming itself and naming the replacement, rather than being silently
87
+ // ignored while the operator's script reports success.
88
+ const contract = parseUninstallEnv(effects.env);
89
+ if (contract.action === 'refuse') {
90
+ effects.out(warn(`ours: ${contract.message}`));
91
+ return EXIT_REFUSED;
92
+ }
93
+
94
+ // OURS_UNINSTALL_DAEMON decides whether this is an uninstall of the daemon at
95
+ // all. Before this gate, OURS_UNINSTALL="hermes" — a script asking for one
96
+ // harness plugin — tore down the daemon, its service and the global packages,
97
+ // because none of the seven variables was read. A request to detach a plugin
98
+ // stays a request to detach a plugin.
99
+ if (!contract.daemon) {
100
+ effects.out(info(`the daemon at ${dir} is KEPT — OURS_UNINSTALL is set and OURS_UNINSTALL_DAEMON is not "yes"`));
101
+ const plugins = planPluginRemoval({ home: effects.home, env: effects.env, exists: effects.exists, lastDaemon: false, explicitSelection: true });
102
+ const outcome = await runPluginPhase(plugins, { args, effects, selection: contract.harnesses });
103
+ // Only the launchers of the harnesses that actually went. @ours.network/cli
104
+ // and /mcp belong to the daemon, and the daemon is staying.
105
+ for (const pkg of outcome.packages) {
106
+ await perform(effects, args.dryRun, `npm rm -g ${pkg}`, () => effects.run('npm', ['rm', '-g', pkg]));
107
+ }
108
+ return EXIT_OK;
109
+ }
110
+
84
111
  // Ask first, mutate second. The question is about resolving the step-1
85
112
  // refusal, so it has to come before the plan that would refuse.
86
113
  // `readText` is passed alongside `readJson` so the planner can tell an ABSENT
@@ -93,7 +120,13 @@ export async function runUninstall(argv, effects) {
93
120
  return EXIT_REFUSED;
94
121
  }
95
122
  const pointing = probe.action === 'refuse' ? probe.components : [];
96
- const confirmedComponents = await confirmComponentRemoval(pointing, { assumeYes: args.assumeYes, effects });
123
+ // With the contract engaged there is nobody to ask, and OURS_UNINSTALL_TELEGRAM
124
+ // / _ROOMS at `detach` is exactly the answer the question wants. Without it, an
125
+ // unattended run against a daemon a connector points at refuses — which is the
126
+ // right outcome, and the reason honouring `detach` is worth doing.
127
+ const confirmedComponents = contract.engaged
128
+ ? contract.confirmedComponents
129
+ : await confirmComponentRemoval(pointing, { assumeYes: args.assumeYes, effects });
97
130
 
98
131
  const plan = planUninstall({
99
132
  home: effects.home,
@@ -106,9 +139,12 @@ export async function runUninstall(argv, effects) {
106
139
  readJson: effects.readJson,
107
140
  readText: effects.readText,
108
141
  exists: effects.exists,
109
- cliStartedIt: effects.readJson(join(dir, 'ours-cli-daemon.json')) !== null,
142
+ // Accept the current CLI record and the legacy ours-mcp pid file during migration.
143
+ cliStartedIt: effects.readJson(join(dir, 'ours-cli-daemon.json')) !== null
144
+ || effects.readText(join(dir, 'daemon.pid')) !== null,
110
145
  otherStateDirsWithConfig: effects.knownStateDirs(),
111
146
  typedConfirmation: null,
147
+ explicitHarnessSelection: contract.engaged,
112
148
  });
113
149
 
114
150
  if (plan.action === 'refuse') {
@@ -156,16 +192,32 @@ export async function runUninstall(argv, effects) {
156
192
  }
157
193
 
158
194
  // 5. The harness plugins the installer wrote.
159
- await runPluginPhase(plan.plugins, { args, effects });
195
+ const pluginOutcome = await runPluginPhase(plan.plugins, { args, effects, selection: contract.harnesses });
160
196
 
161
197
  // 6. State. Last, because it is the only irreversible thing here.
162
198
  await runPurgePhase({ dir, purge, args, effects });
163
199
 
164
200
  // 7. Global packages, only when this was the last daemon.
165
- if (plan.packages.action === 'keep') {
166
- effects.out(info(`@ours.network/cli kept — ${plan.packages.reason}`));
201
+ //
202
+ // Recomputed from what the plugin phase ACTUALLY removed, not from what it
203
+ // found. A harness the operator kept keeps its launcher: removing the package
204
+ // out from under a plugin that is still registered is the same broken
205
+ // half-state, one layer down.
206
+ const packages = planGlobalPackages({
207
+ stateDir: dir,
208
+ otherStateDirsWithConfig: effects.knownStateDirs(),
209
+ pluginPackages: pluginOutcome.packages,
210
+ // The plugin packages are recomputed; the CONNECTOR packages are not. They
211
+ // are decided by what the operator confirmed detaching, which the plugin
212
+ // phase does not touch, so this repeats the plan's own answer rather than
213
+ // inventing a second one — and rather than dropping it, which would quietly
214
+ // undo the connector-package removal one line up the file.
215
+ detachedComponents: plan.detach.map((d) => d.key),
216
+ });
217
+ if (packages.action === 'keep') {
218
+ effects.out(info(`@ours.network/cli kept — ${packages.reason}`));
167
219
  } else {
168
- for (const pkg of plan.packages.packages) {
220
+ for (const pkg of packages.packages) {
169
221
  await perform(effects, args.dryRun, `npm rm -g ${pkg}`, () => effects.run('npm', ['rm', '-g', pkg]));
170
222
  }
171
223
  }
@@ -224,24 +276,45 @@ export async function rollBackDetach(effects, journal, detached, args) {
224
276
  * harnesses still need these plugins — the same condition that keeps the global
225
277
  * packages, decided once.
226
278
  */
227
- export async function runPluginPhase(plugins, { args, effects }) {
279
+ export async function runPluginPhase(plugins, { args, effects, selection = null }) {
228
280
  effects.out(heading('Harness plugins'));
229
- for (const step of plugins.manual) {
230
- // Never a dead end, and never a claim: Claude Code's plugin is not ours to
231
- // remove, so the run says so and prints the two commands that do it.
232
- effects.out(info(`${step.label} — ${step.reason}. Inside Claude Code, run:`));
233
- for (const command of step.steps) effects.out(info(` ${command}`));
234
- }
235
281
  if (plugins.action === 'keep') {
282
+ for (const step of plugins.manual) announceManual(step, effects);
236
283
  effects.out(info(`harness plugins kept — ${plugins.reason}`));
237
- return;
284
+ return { removed: [], packages: [] };
285
+ }
286
+
287
+ // WHICH harnesses (item 9.5). Asked before the first block is stripped, so a
288
+ // "no" costs nothing and a "yes" is the operator's, not this file's.
289
+ const choice = planHarnessSelection(plugins, { selection, assumeYes: args.assumeYes });
290
+ let chosen = choice.chosen;
291
+ if (choice.mode === 'ask') {
292
+ chosen = [];
293
+ for (const harness of choice.offered) {
294
+ if (await effects.ask(`Remove the ${harness.label}?`, true)) chosen.push(harness.key);
295
+ }
296
+ }
297
+ for (const name of choice.ignored) {
298
+ effects.out(info(`${name} was named for removal but no ${name} plugin is installed here — nothing to do for it`));
238
299
  }
239
- if (plugins.harnesses.length === 0) {
240
- effects.out(info('no Hermes or Codex plugin files found — nothing of ours to remove'));
241
- return;
300
+ if (choice.mode === 'keep') {
301
+ effects.out(info(`harness plugins kept — ${choice.reason}`));
302
+ effects.out(info(choice.hint));
303
+ return { removed: [], packages: [] };
242
304
  }
243
305
 
244
- for (const harness of plugins.harnesses) {
306
+ const selected = selectHarnesses(plugins, chosen);
307
+ for (const step of selected.manual) announceManual(step, effects);
308
+ if (selected.harnesses.length === 0) {
309
+ effects.out(info(
310
+ chosen.length === 0
311
+ ? 'no harness plugin selected — none removed'
312
+ : 'no Hermes or Codex plugin files found — nothing of ours to remove',
313
+ ));
314
+ return { removed: chosen, packages: [] };
315
+ }
316
+
317
+ for (const harness of selected.harnesses) {
245
318
  for (const block of harness.blocks) {
246
319
  const before = effects.readText(block.path);
247
320
  if (before === null) continue;
@@ -254,6 +327,9 @@ export async function runPluginPhase(plugins, { args, effects }) {
254
327
  effects.out(warn(`${block.path}: ${stripped.reason}. Remove it by hand.`));
255
328
  continue;
256
329
  }
330
+ // Deliberately NOT journalled: this write IS the state its bytes describe, so
331
+ // nothing behind it can fail and leave them untrue. Adding one would undo a
332
+ // completed removal.
257
333
  await perform(effects, args.dryRun, `remove the ours managed block from ${block.path} (file kept)`, () => effects.writeText(block.path, stripped.text));
258
334
  }
259
335
  for (const dir of harness.dirs) {
@@ -265,6 +341,16 @@ export async function runPluginPhase(plugins, { args, effects }) {
265
341
  await perform(effects, args.dryRun, `remove ${file}`, () => effects.removeFile(file));
266
342
  }
267
343
  }
344
+ return { removed: chosen, packages: selected.packages };
345
+ }
346
+
347
+ /**
348
+ * Never a dead end, and never a claim: Claude Code's plugin is not ours to
349
+ * remove, so the run says so and prints the two commands that do it.
350
+ */
351
+ function announceManual(step, effects) {
352
+ effects.out(info(`${step.label} — ${step.reason}. Inside Claude Code, run:`));
353
+ for (const command of step.steps) effects.out(info(` ${command}`));
268
354
  }
269
355
 
270
356
  /**
@@ -29,12 +29,12 @@ import { planDaemonConfig, planServiceInstall, serviceInstallCommand } from './p
29
29
  import {
30
30
  COMPONENTS,
31
31
  planComponentSelection, planMcpAttachment, planTgAttachment, planCoworkAttachment,
32
- tgConfigPath, coworkConfigPath, summarizeComponentRun, componentSpec,
32
+ tgConfigPath, coworkConfigPath, summarizeComponentRun, componentSpec, componentByKey,
33
33
  } from './components.mjs';
34
- import { planHarnessPlugins, planFleet, planVoice, buildHandoffPromptV3, restartHints } from './extras.mjs';
34
+ import { planHarnessPlugins, planFleet, buildHandoffPromptV3, restartHints } from './extras.mjs';
35
35
  import { summarizeRun } from './rerun.mjs';
36
36
  import { configJournal, reportRollback } from './journal.mjs';
37
- import { detectDaemons, planDaemonSelection, resolveSelection, legacyRegistryPath } from './detect.mjs';
37
+ import { detectDaemons, planDaemonSelection, resolveSelection } from './detect.mjs';
38
38
  import { detectPlatform, resolveChannel, validateBroker } from './logic.mjs';
39
39
  import { daemonEnv } from './effects.mjs';
40
40
  import { USAGE } from './usage.mjs';
@@ -100,7 +100,7 @@ function pairFor(plan, target) {
100
100
  }
101
101
 
102
102
  /**
103
- * Which daemon is this run for? (C1, owner ruling 2026-08-17.)
103
+ * Which daemon is this run for?
104
104
  *
105
105
  * Never asks for a PATH — spec §2 stands — but when several daemons are DETECTED
106
106
  * it shows them and lets the operator pick, because choosing from what was found
@@ -111,14 +111,6 @@ function pairFor(plan, target) {
111
111
  * not know a screen happened, which is what keeps the flags path byte-identical.
112
112
  */
113
113
  export async function runSelectionPhase(args, effects) {
114
- // Said once, and only when the file is actually there: a file that looks live is
115
- // worse than a file that says it is not. Never deleted — quietly removing
116
- // something that describes an operator's daemons is not this installer's
117
- // business.
118
- const legacy = legacyRegistryPath(effects.home);
119
- if (effects.exists(legacy)) {
120
- effects.out(info(`${legacy} is left over from the older nightly installer and is no longer read — this run detects daemons directly. It is left alone; you can delete it.`));
121
- }
122
114
  const detected = detectDaemons({
123
115
  candidates: effects.knownStateDirs(),
124
116
  exists: effects.exists,
@@ -176,6 +168,7 @@ export async function runDaemonPhase(args, effects) {
176
168
  portExplicit: args.portExplicit,
177
169
  probe: effects.probe,
178
170
  readJson: effects.readJson,
171
+ readText: effects.readText,
179
172
  isTaken: effects.isTaken,
180
173
  });
181
174
 
@@ -208,20 +201,26 @@ export async function runDaemonPhase(args, effects) {
208
201
 
209
202
  // Asked ONCE, and only when this run is creating the daemon — an existing
210
203
  // daemon's broker is its own record, and re-asking would invite an operator to
211
- // change it from a screen that is not about changing it. Owner ruling: the
212
- // broker question stays in v3. It is orthogonal to --state-dir/--port, so it
204
+ // change it from a screen that is not about changing it. The broker question
205
+ // stays in v3: it is orthogonal to --state-dir/--port, so it
213
206
  // does not violate spec §2's "nothing about a state directory or a port
214
207
  // appears in any prompt".
215
208
  if (creating) args.brokerUrl = await askBroker(args, effects);
216
209
 
217
210
  const steps = [];
218
211
 
212
+ // The operator CLI owns the shared daemon; ours-mcp is the per-session stdio
213
+ // adapter each harness spawns. Both are required, but only `ours daemon`
214
+ // participates in lifecycle or service management.
215
+ const mcpPkg = componentSpec(componentByKey('mcp'), args.channel);
216
+ await perform(effects, args.dryRun, `MCP server installed (npm i -g ${mcpPkg})`, () => effects.run('npm', ['i', '-g', mcpPkg]));
217
+ steps.push({ id: 'mcp-package', changed: true, packageRefresh: true });
219
218
  await perform(effects, args.dryRun, 'ours CLI installed (npm i -g @ours.network/cli)', () => effects.run('npm', ['i', '-g', '@ours.network/cli']));
220
219
  steps.push({ id: 'cli', changed: true, packageRefresh: true });
221
220
 
222
221
  // The config file — merged, never rewritten, and untouched when it already
223
- // matches. No provenance marker is written: the owner ruled that --purge works
224
- // on any state directory, so a `createdBy` key would have had no consumer, and
222
+ // matches. No provenance marker is written: --purge works on any state
223
+ // directory, so a `createdBy` key would have no consumer, and
225
224
  // an unread key in a user's config file is future confusion for nothing.
226
225
  const configPath = join(dir, 'config.json');
227
226
  const merged = planDaemonConfig(
@@ -239,6 +238,7 @@ export async function runDaemonPhase(args, effects) {
239
238
  // lookup exists to prevent; this is what stops the installer from setting up the
240
239
  // conditions for it.
241
240
  const journal = configJournal(effects, { dryRun: args.dryRun });
241
+ let serviceUnsupported = null;
242
242
  if (merged.changed) {
243
243
  journal.snapshot(configPath);
244
244
  await perform(effects, args.dryRun, `write ${configPath} (port ${target.port})`, () => effects.writeJson(configPath, merged.text));
@@ -253,7 +253,8 @@ export async function runDaemonPhase(args, effects) {
253
253
  steps.push({ id: 'start', changed: true });
254
254
  }
255
255
 
256
- const service = await runServicePhase(args, effects, dir);
256
+ const service = await runServicePhase(args, effects, dir, target.port);
257
+ if (service.unsupported) serviceUnsupported = service.unsupported;
257
258
  if (service.refused) {
258
259
  // A REFUSAL IS A FAILURE TO REACH THE STATE, not a special case. An unknown
259
260
  // unit file stops the run just as a failed start does, and it stops it with
@@ -274,7 +275,7 @@ export async function runDaemonPhase(args, effects) {
274
275
  // The nightly flow re-runs `start` and, crucially, tells the two outcomes
275
276
  // apart: "the service failed but the daemon is back" is a bad evening, and "the
276
277
  // service failed AND it will not come back" is the one that needs a human now.
277
- const recovery = error?.servicePlan ? await recoverDaemon(args, effects, dir, configPath) : null;
278
+ const recovery = error?.servicePlan ? await recoverDaemon(args, effects, dir, configPath, target.port) : null;
278
279
  rollBack(effects, journal, args, 'the daemon did not reach the state its config describes — putting the config back', {
279
280
  replacedUnit: error?.servicePlan?.action === 'adopt' ? error.servicePlan.unitPath : null,
280
281
  });
@@ -287,7 +288,7 @@ export async function runDaemonPhase(args, effects) {
287
288
  throw error;
288
289
  }
289
290
 
290
- return { target, steps };
291
+ return { target, steps, serviceUnsupported };
291
292
  }
292
293
 
293
294
  /**
@@ -302,7 +303,7 @@ export async function runDaemonPhase(args, effects) {
302
303
  * is REPORTED, never thrown: the caller is already carrying the real error, and
303
304
  * losing it to a second one would hide what actually went wrong.
304
305
  */
305
- async function recoverDaemon(args, effects, dir, configPath) {
306
+ async function recoverDaemon(args, effects, dir, configPath, port) {
306
307
  if (args.dryRun) return null;
307
308
  try {
308
309
  await effects.run('ours', ['daemon', 'start', '--config', configPath]);
@@ -345,11 +346,24 @@ function rollBack(effects, journal, args, why, { packagesInstalled = true, repla
345
346
  * The boot service: §4 step 4, including the legacy-unit case.
346
347
  *
347
348
  * A legacy ours-mcp unit is adopted SILENTLY, with one informational line naming
348
- * the file — the owner's decision. `--force` is passed ONLY here, only for a unit
349
+ * the file. `--force` is passed ONLY here, only for a unit
349
350
  * positively identified as ours-mcp's, and never for one we cannot identify.
350
351
  */
351
- export async function runServicePhase(args, effects, dir) {
352
- const plan = planServiceInstall({ stateDir: dir, home: effects.home, readText: effects.readText });
352
+ export async function runServicePhase(args, effects, dir, port) {
353
+ const plan = planServiceInstall({
354
+ stateDir: dir, home: effects.home, readText: effects.readText, platform: effects.platform?.platform,
355
+ });
356
+ // NOT a refusal and NOT a failure: the daemon is running and correct, and only
357
+ // the boot service could not be installed — `ours daemon install-service` throws
358
+ // on any non-linux platform. The run CONTINUES, says so, and the summary marks
359
+ // it, because the person this hurts reboots in a fortnight and finds nothing
360
+ // listening.
361
+ if (plan.action === 'unsupported') {
362
+ effects.out(warn(`ours: ${plan.message}`));
363
+ effects.out(info(`Your daemon is installed and running now. To start it after a reboot, run: ${plan.manual.join(' ')} ${join(dir, 'config.json')}`));
364
+ effects.out(info('Nothing else in this run depends on the boot service.'));
365
+ return { step: { id: 'service', changed: false, reason: 'not available on this platform' }, plan, unsupported: plan };
366
+ }
353
367
  if (plan.action === 'refuse') {
354
368
  effects.out(warn(`ours: refusing to continue — ${plan.message}`));
355
369
  return { refused: plan };
@@ -357,6 +371,14 @@ export async function runServicePhase(args, effects, dir) {
357
371
  const adopting = plan.action === 'adopt';
358
372
  if (adopting) effects.out(info(plan.notice));
359
373
  const command = serviceInstallCommand({ stateDir: dir, adoptLegacyUnit: adopting });
374
+ // The unit's bytes BEFORE, so "did it change?" survives the loss of --json.
375
+ // `ours daemon install-service --json` used to answer that itself; ours-mcp's
376
+ // takes no flags and reports nothing machine-readable. Assuming `changed: true`
377
+ // would make every re-run claim it rewrote the unit, which turns the honest
378
+ // "everything already correct" line into one that never appears — a screen that
379
+ // is wrong in the reassuring direction. So the installer does the comparison it
380
+ // used to delegate: it already reads this file to classify it.
381
+ const unitBefore = plan.unitPath ? effects.readText(plan.unitPath) : null;
360
382
  let outcome;
361
383
  try {
362
384
  outcome = await perform(effects, args.dryRun, `boot service ${plan.unit} installed and enabled`, () => effects.run(command[0], command.slice(1)));
@@ -368,11 +390,16 @@ export async function runServicePhase(args, effects, dir) {
368
390
  error.servicePlan = plan;
369
391
  throw error;
370
392
  }
371
- // Whether the unit actually changed is the CLI's answer, not ours: it does the
372
- // byte comparison and returns `changed` in its --json plan. Guessing here would
373
- // let a run that rewrote a unit report "nothing changed". Unreadable output is
374
- // treated as "changed", which is the safe direction for a summary line.
375
- const changed = readChanged(outcome.stdout);
393
+ // Ours now, by the same byte comparison the CLI used to do. A dry run changed
394
+ // nothing by definition; an unreadable file either side is treated as "changed",
395
+ // which is the safe direction for a summary line.
396
+ const changed = args.dryRun
397
+ ? false
398
+ : (() => {
399
+ const after = plan.unitPath ? effects.readText(plan.unitPath) : null;
400
+ if (unitBefore === null || after === null) return true;
401
+ return unitBefore !== after;
402
+ })();
376
403
  return { step: { id: 'service', changed, reason: changed ? undefined : 'unit unchanged' }, plan };
377
404
  }
378
405
 
@@ -388,6 +415,47 @@ function readChanged(stdout) {
388
415
  }
389
416
  }
390
417
 
418
+ /**
419
+ * THE QUESTION NOBODY WAS ASKING.
420
+ *
421
+ * `planComponentSelection` has always read an `answers` map — and NOTHING EVER
422
+ * POPULATED IT. `args.answers` is not produced by parseInstallArgs and is not
423
+ * written by any phase, so it was permanently `{}`, every optional component fell
424
+ * to its own default, and tg and cowork (both default false) were skipped in
425
+ * silence. The screen then printed "cowork — not installed" as though that had
426
+ * been a decision.
427
+ *
428
+ * This was the ONE phase with no question. Harness plugins ask, ours-fleet asks,
429
+ * voice asks, the broker asks. Components did not.
430
+ *
431
+ * IT IS NOT A COWORK BUG, and fixing only the component that got reported would
432
+ * have left the identical defect live under a different label: the Telegram
433
+ * connector is unreachable by exactly the same route, and nobody noticed only
434
+ * because tg is usually configured somewhere else.
435
+ *
436
+ * A REQUIRED component is never asked — since #74 the MCP package IS the daemon,
437
+ * the daemon phase has already installed it, and a question whose only honest
438
+ * answer is yes is not a question. Non-interactive runs are unchanged: assumeYes
439
+ * takes each component's own default, which is what NON_INTERACTIVE_ANSWERS
440
+ * documents (mcp yes, tg no, cowork no), so nothing about the unattended contract
441
+ * moves.
442
+ */
443
+ export async function askComponents(args, effects) {
444
+ const answers = { ...(args.answers ?? {}) };
445
+ if (args.assumeYes) return answers;
446
+ const optional = COMPONENTS.filter((c) => !c.required && answers[c.key] === undefined);
447
+ if (optional.length === 0) return answers;
448
+ effects.out(heading('What else should this daemon carry?'));
449
+ for (const component of optional) {
450
+ const already = (args.installed ?? {})[component.key] === true;
451
+ answers[component.key] = await effects.ask(
452
+ already ? `Keep ${component.label}?` : `Install ${component.label}?`,
453
+ already || component.default,
454
+ );
455
+ }
456
+ return answers;
457
+ }
458
+
391
459
  /**
392
460
  * Components: §5. A component that fails is reported with its retry command and
393
461
  * the run CONTINUES — a failed component is never a reason to undo a successful
@@ -397,8 +465,9 @@ export async function runComponentPhase(args, effects, target) {
397
465
  const dir = target.stateDir;
398
466
  const endpoint = `http://127.0.0.1:${target.port}`;
399
467
  const isDefaultStateDir = dir === join(effects.home, '.ours');
468
+ const answers = await askComponents(args, effects);
400
469
  const chosen = planComponentSelection({
401
- answers: args.answers ?? {},
470
+ answers,
402
471
  installed: args.installed ?? {},
403
472
  assumeYes: args.assumeYes,
404
473
  });
@@ -531,7 +600,7 @@ async function attachComponent(component, { args, effects, dir, endpoint, isDefa
531
600
  }
532
601
 
533
602
  /**
534
- * The broker question (v2's Step 0a, kept by the owner's ruling).
603
+ * The broker question (v2's Step 0a, deliberately kept).
535
604
  *
536
605
  * Consent-first, with the undo built in: a mistaken custom address is one
537
606
  * keystroke back to the standard broker, because the alternative is an operator
@@ -594,6 +663,9 @@ export function runPreflight(effects) {
594
663
  return { ok: false, platform: plat };
595
664
  }
596
665
  effects.out(ok(`Platform: ${plat.label} (supported)`));
666
+ if (effects.platform?.platform && effects.platform.platform !== 'linux') {
667
+ effects.out(info(`On ${plat.label} the daemon runs, but installing a BOOT SERVICE is not available — you will start it yourself after a reboot.`));
668
+ }
597
669
  const version = String(effects.nodeVersion ?? '0');
598
670
  if (Number.parseInt(version.split('.')[0], 10) < 20) {
599
671
  effects.out(warn(`Node.js ${version} — ours needs v20 or newer. Update Node and re-run.`));
@@ -604,11 +676,9 @@ export function runPreflight(effects) {
604
676
  }
605
677
 
606
678
  /**
607
- * The human identity: `ours-mcp create-root`, kept by the owner's ruling.
679
+ * The human identity: `ours identity create-root`.
608
680
  *
609
- * It needs `ours-mcp` on PATH and a reachable daemon, so under v3 it lands here
610
- * — after the component phase installed the MCP server, not back in the daemon
611
- * phase where v2 had it. Already-exists is a friendly keep, never an error, and
681
+ * It needs the operator CLI and a reachable daemon. Already-exists is a friendly keep, never an error, and
612
682
  * an unreachable daemon gets the exact retry command rather than "ask your agent
613
683
  * later"; the hand-off's identity step is the fallback for both failures.
614
684
  */
@@ -628,16 +698,11 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
628
698
 
629
699
  const env = daemonEnv(target.stateDir, target.port);
630
700
  if (args.dryRun) {
631
- effects.out(info(wouldPrefix(`ours-mcp create-root "${name}"`)));
701
+ effects.out(info(wouldPrefix(`ours identity create-root --name "${name}"`)));
632
702
  return { key: 'identity', label: 'Human identity', state: 'installed', note: name };
633
703
  }
634
704
  try {
635
- const result = await effects.run('ours-mcp', ['create-root', name], { env });
636
- const existing = String(result?.stdout ?? '').match(/already exists \("([^"]+)"\)/);
637
- if (existing) {
638
- effects.out(ok(`You already have a human identity ("${existing[1]}") — keeping it.`));
639
- return { key: 'identity', label: 'Human identity', state: 'current', note: existing[1] };
640
- }
705
+ await effects.run('ours', ['identity', 'create-root', '--name', name, '--json'], { env });
641
706
  effects.out(ok(`Your human identity "${name}" is created.`));
642
707
  return { key: 'identity', label: 'Human identity', state: 'installed', note: name };
643
708
  } catch (error) {
@@ -648,11 +713,11 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
648
713
  }
649
714
  if (/not running|not reachable|ECONNREFUSED|connect/i.test(text)) {
650
715
  effects.out(warn("The daemon isn't reachable yet — couldn't create your human identity."));
651
- effects.out(info(`Fix: run 'ours daemon start --config ${env.OURS_CONFIG}', then 'ours-mcp create-root "${name}"'.`));
716
+ effects.out(info(`Fix: run 'ours daemon start --config ${env.OURS_CONFIG}', then 'ours identity create-root --config ${env.OURS_CONFIG} --name "${name}"'.`));
652
717
  return { key: 'identity', label: 'Human identity', state: 'failed', note: 'daemon not reachable' };
653
718
  }
654
719
  effects.out(warn(`Couldn't create your human identity: ${text.split('\n')[0]}`));
655
- effects.out(info(`Retry any time: 'ours-mcp create-root "${name}"'.`));
720
+ effects.out(info(`Retry any time: 'ours identity create-root --config ${env.OURS_CONFIG} --name "${name}"'.`));
656
721
  return { key: 'identity', label: 'Human identity', state: 'failed', note: 'create-root failed' };
657
722
  }
658
723
  }
@@ -781,7 +846,6 @@ export async function runFleetPhase(args, effects, { target, isDefaultStateDir }
781
846
  // Passing it is harmless if extras.mjs is right and load-bearing if
782
847
  // nightly-install.mjs is. When the cheap action is safe under both readings and
783
848
  // the expensive one is only safe under one, take the cheap one. (Coordinator
784
- // ruling, 2026-08-17.)
785
849
  //
786
850
  // Passed for EVERY state directory, not only a non-default one, exactly as the
787
851
  // nightly flow does: `init` is a one-time host setup and the pair is what names
@@ -799,81 +863,11 @@ export async function runFleetPhase(args, effects, { target, isDefaultStateDir }
799
863
  return { key: 'fleet', label: plan.label, state: 'installed', note: 'CLI + core-plugin discovery' };
800
864
  }
801
865
 
802
- /**
803
- * Voice transcription — and the restart beat the installer now owns.
804
- *
805
- * Under v3 `ours-mcp voice-setup` classifies every daemon as `external`, because
806
- * a v3 daemon is started by `ours daemon start` and leaves no ours-mcp pid
807
- * record. So voice-setup writes the config and returns, correctly declining to
808
- * restart a daemon it does not manage — and the restart nobody now owns is
809
- * this phase's, because the installer is the only process that knows the daemon
810
- * is CLI-managed. Failure never rolls the daemon back: voice-setup leaves the
811
- * prior config intact on its own failure path, so the recovery is a retry.
812
- */
866
+ /** Voice configuration belongs to the shared daemon, not the MCP adapter. */
813
867
  export async function runVoicePhase(args, effects, { target, mcpReady }) {
814
868
  effects.out(heading('Voice messages'));
815
- const env = daemonEnv(target.stateDir, target.port);
816
- const ready = mcpReady && !args.dryRun ? await voiceReady(effects, env) : false;
817
-
818
- const offer = planVoice({
819
- mcpInstalled: mcpReady, ready, assumeYes: args.assumeYes, stateDir: target.stateDir, port: target.port,
820
- });
821
- if (offer.action === 'skip') {
822
- effects.out(offer.reason === 'already-configured' ? ok(offer.message) : info(offer.message));
823
- return {
824
- key: 'voice',
825
- label: 'Voice transcription',
826
- state: offer.reason === 'already-configured' ? 'current' : 'skipped',
827
- note: offer.reason === 'already-configured' ? 'configured' : offer.reason,
828
- };
829
- }
830
-
831
- effects.out(info('Voice notes can be transcribed by a provider you choose. Audio is sent to that'));
832
- effects.out(info('provider; use a self-hosted endpoint if it must stay local. The API key is hidden.'));
833
- if (!(await effects.ask('Set up voice transcription now?', true))) {
834
- const declined = planVoice({ mcpInstalled: mcpReady, ready, accepted: false, stateDir: target.stateDir, port: target.port });
835
- effects.out(info(declined.message));
836
- return { key: 'voice', label: 'Voice transcription', state: 'skipped', note: 'declined; offered again on re-run' };
837
- }
838
-
839
- if (args.dryRun) {
840
- effects.out(info(wouldPrefix(offer.setup.join(' '))));
841
- return { key: 'voice', label: 'Voice transcription', state: 'skipped', note: 'dry run' };
842
- }
843
- // Interactive on purpose: voice-setup owns the provider choice and the masked
844
- // key prompt, and the installer keeps no second implementation of either.
845
- const setup = await effects.runInteractive(offer.setup[0], offer.setup.slice(1), { env });
846
- if (!setup.ok) {
847
- effects.out(warn('Voice setup did not complete; no installer-side credential fallback was used.'));
848
- effects.out(info(`Run '${offer.setup.join(' ')}' directly to try again.`));
849
- return { key: 'voice', label: 'Voice transcription', state: 'failed', note: 'voice-setup did not complete' };
850
- }
851
-
852
- const applied = planVoice({
853
- mcpInstalled: mcpReady, ready, accepted: true, configChanged: true, stateDir: target.stateDir, port: target.port,
854
- });
855
- const restart = await attempt(effects, args.dryRun, applied.restart.join(' '), () => effects.run(applied.restart[0], applied.restart.slice(1)));
856
- if (!restart.ok) {
857
- effects.out(info(`the configuration is saved; apply it with: ${applied.retryHint}`));
858
- return { key: 'voice', label: 'Voice transcription', state: 'failed', note: 'saved, but the daemon did not restart' };
859
- }
860
- if (await voiceReady(effects, env)) {
861
- effects.out(ok('Voice transcription is ready; the API key remains hidden.'));
862
- return { key: 'voice', label: 'Voice transcription', state: 'installed', note: 'ready' };
863
- }
864
- effects.out(warn(`Voice configuration was saved, but readiness was not confirmed — check '${offer.statusCheck.join(' ')}'.`));
865
- return { key: 'voice', label: 'Voice transcription', state: 'failed', note: 'readiness not confirmed' };
866
- }
867
-
868
- // Read-only, and deliberately lenient: an unreadable answer is "not ready",
869
- // which offers voice setup again rather than skipping it on a bad parse.
870
- async function voiceReady(effects, env) {
871
- try {
872
- const result = await effects.run('ours-mcp', ['voice-status', '--json'], { env });
873
- return JSON.parse(String(result?.stdout ?? '').trim())?.ready === true;
874
- } catch {
875
- return false;
876
- }
869
+ effects.out(info('Voice transcription setup is not managed by ours-mcp. Existing daemon configuration is left unchanged.'));
870
+ return { key: 'voice', label: 'Voice transcription', state: 'skipped', note: 'configure on the shared daemon' };
877
871
  }
878
872
 
879
873
  /**
@@ -976,7 +970,19 @@ export async function runInstall(argv, effects) {
976
970
  if (args.version) { effects.out(`ours-install v${effects.version ?? '?'}`); return EXIT_OK; }
977
971
 
978
972
  args.brokerUrl = args.brokerUrl ?? effects.brokerUrl;
979
- args.channel = resolveChannel(effects.env.OURS_CHANNEL ?? effects.env.OURS_INSTALL_CHANNEL);
973
+ // THE INSTALLER'S OWN VERSION IS THE CHANNEL SIGNAL WHEN NOTHING SAYS OTHERWISE.
974
+ //
975
+ // resolveChannel falls back to `selfVersion` only when the environment is
976
+ // silent, and this call passed no selfVersion — so a NIGHTLY installer with no
977
+ // OURS_CHANNEL set resolved to `latest` and installed the whole stack at latest.
978
+ // That is what put fleet@latest and a stable ours-mcp on a nightly machine.
979
+ //
980
+ // The v2 bin has always done this correctly (install.mjs:53 passes pkgVersion());
981
+ // the v3 orchestrator dropped the argument. @ours.network/install is published on
982
+ // BOTH dist-tags from one lockstep bump, so its own version is the only thing
983
+ // that distinguishes a nightly installer from a stable one when the operator has
984
+ // said nothing.
985
+ args.channel = resolveChannel(effects.env.OURS_CHANNEL ?? effects.env.OURS_INSTALL_CHANNEL, effects.version);
980
986
 
981
987
  effects.out(banner());
982
988
  effects.out(heading(`ours: target ${args.stateDir}${args.portExplicit ? `, port ${args.port}` : ''}`));
@@ -1003,6 +1009,16 @@ export async function runInstall(argv, effects) {
1003
1009
  state: target.action === 'create' ? 'installed' : 'current',
1004
1010
  note: `port ${target.port}`,
1005
1011
  }];
1012
+ // The skip must not read as success: same "needs attention" mark a failed
1013
+ // component gets, so the closing screen cannot say everything is clean.
1014
+ if (daemon.serviceUnsupported) {
1015
+ summary.push({
1016
+ key: 'service',
1017
+ label: 'Boot service',
1018
+ state: 'failed',
1019
+ note: `not available on ${daemon.serviceUnsupported.platform === 'darwin' ? 'macOS' : daemon.serviceUnsupported.platform} — start the daemon yourself after a reboot`,
1020
+ });
1021
+ }
1006
1022
 
1007
1023
  const components = await runComponentPhase(args, effects, target);
1008
1024
  for (const component of COMPONENTS) {