@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,984 @@
1
+ // ours-install v3 — the orchestrator.
2
+ //
3
+ // This is the part that cannot be pure: it walks the flow, renders the screens
4
+ // and runs the commands. Everything it DECIDES comes from lib/target.mjs,
5
+ // lib/plan.mjs, lib/components.mjs, lib/rerun.mjs and lib/uninstall.mjs, which
6
+ // stay pure and separately tested.
7
+ //
8
+ // The seam is `effects`: every side effect the run can have arrives through one
9
+ // injected object, so the whole orchestration is testable without a socket, a
10
+ // filesystem, a subprocess or a terminal. That is not a testing convenience — it
11
+ // is what makes `--dry-run` trustworthy, because a dry run is the same walk with
12
+ // the mutating effects replaced by a recorder.
13
+ //
14
+ // Effects contract (all injected; the real ones live in lib/effects.mjs):
15
+ // probe(port) -> { ok: true, stateDir } | { ok: false, reason }
16
+ // isTaken(port) -> boolean
17
+ // readJson(path) -> object | null
18
+ // readText(path) -> string | null
19
+ // writeJson(path, text) -> void (atomic; never called on a dry run)
20
+ // run(cmd, args) -> { ok, code, stdout } (never on a dry run)
21
+ // installedVersion(pkg) -> string | null
22
+ // out(line) -> void
23
+ // ask(prompt, default) -> boolean (never called when assumeYes)
24
+ // now() -> number
25
+
26
+ import { join } from 'node:path';
27
+ import { parseInstallArgs, resolveTarget, InstallUsageError } from './target.mjs';
28
+ import { planDaemonConfig, planServiceInstall, serviceInstallCommand } from './plan.mjs';
29
+ import {
30
+ COMPONENTS,
31
+ planComponentSelection, planMcpAttachment, planTgAttachment, planCoworkAttachment,
32
+ tgConfigPath, coworkConfigPath, summarizeComponentRun, componentSpec, componentByKey,
33
+ } from './components.mjs';
34
+ import { planHarnessPlugins, planFleet, buildHandoffPromptV3, restartHints } from './extras.mjs';
35
+ import { summarizeRun } from './rerun.mjs';
36
+ import { configJournal, reportRollback } from './journal.mjs';
37
+ import { detectDaemons, planDaemonSelection, resolveSelection } from './detect.mjs';
38
+ import { detectPlatform, resolveChannel } from './logic.mjs';
39
+ import { daemonEnv } from './effects.mjs';
40
+ import { USAGE } from './usage.mjs';
41
+ import { ok, info, warn, heading, banner, box, c, progress } from './ui.mjs';
42
+
43
+ export const EXIT_OK = 0;
44
+ export const EXIT_REFUSED = 2;
45
+
46
+ /**
47
+ * A dry run prints what it WOULD do and performs no mutation. The prefix is the
48
+ * existing installer's, kept so the two flows read the same.
49
+ */
50
+ const wouldPrefix = (label) => `[dry-run] would: ${label}`;
51
+
52
+ /**
53
+ * One mutating step. `dryRun` is checked HERE, once, rather than at each call
54
+ * site — a side effect that forgets the check is the way a dry run stops being
55
+ * one, and there is exactly one place to get it wrong.
56
+ */
57
+ async function perform(effects, dryRun, label, thunk) {
58
+ if (dryRun) {
59
+ effects.out(info(wouldPrefix(label)));
60
+ return { performed: false, dryRun: true };
61
+ }
62
+ const result = await thunk();
63
+ effects.out(ok(label));
64
+ return { performed: true, ...(result ?? {}) };
65
+ }
66
+
67
+ const reason = (error) => (error instanceof Error ? error.message : String(error));
68
+
69
+ /**
70
+ * A step that is allowed to fail without ending the run.
71
+ *
72
+ * Everything after the daemon phase is an EXTRA: a harness plugin, ours-fleet,
73
+ * voice. None of them is a reason to undo a daemon that came up correctly, and
74
+ * v2's golden rule — never dead-end — is the same rule stated for a whole phase
75
+ * rather than one harness. So a failure here is one honest line plus whatever
76
+ * the caller wants to say about retrying, and the walk continues.
77
+ */
78
+ async function attempt(effects, dryRun, label, thunk) {
79
+ try {
80
+ return { ok: true, ...(await perform(effects, dryRun, label, thunk)) };
81
+ } catch (error) {
82
+ effects.out(warn(`${label} — did not complete: ${reason(error)}`));
83
+ return { ok: false, error };
84
+ }
85
+ }
86
+
87
+ /**
88
+ * The pair, for one child invocation, from a plan that says it can carry one.
89
+ *
90
+ * lib/extras.mjs states the INTENT (`env: { OURS_CONFIG }` or `{}`); daemonEnv
91
+ * builds the whole thing. The two are deliberately not the same object: a plan
92
+ * is pure and knows only the state directory, while a pair also needs the port,
93
+ * and it is the half-pair — one name set, the rest defaulted to ~/.ours — that
94
+ * silently attaches a child to a daemon the operator never chose.
95
+ */
96
+ function pairFor(plan, target) {
97
+ return plan && plan.env && Object.keys(plan.env).length > 0
98
+ ? daemonEnv(target.stateDir, target.port)
99
+ : undefined;
100
+ }
101
+
102
+ /**
103
+ * Which daemon is this run for?
104
+ *
105
+ * Never asks for a PATH — spec §2 stands — but when several daemons are DETECTED
106
+ * it shows them and lets the operator pick, because choosing from what was found
107
+ * is not prompting for a state directory.
108
+ *
109
+ * Runs BEFORE the daemon phase and only changes `args.stateDir`. Everything
110
+ * downstream — resolveTarget, the refusals, the journal — is untouched and does
111
+ * not know a screen happened, which is what keeps the flags path byte-identical.
112
+ */
113
+ export async function runSelectionPhase(args, effects) {
114
+ const detected = detectDaemons({
115
+ candidates: effects.knownStateDirs(),
116
+ exists: effects.exists,
117
+ readJson: effects.readJson,
118
+ });
119
+ const plan = planDaemonSelection({
120
+ candidates: detected,
121
+ stateDirExplicit: args.stateDirExplicit,
122
+ portExplicit: args.portExplicit,
123
+ assumeYes: args.assumeYes,
124
+ home: effects.home,
125
+ });
126
+
127
+ if (plan.action === 'flags' || plan.action === 'create') return { ...plan, detected };
128
+ if (plan.action === 'use') {
129
+ // A question with one answer is not a choice, it is a keystroke tax — but the
130
+ // operator still has to be TOLD which daemon this run is about.
131
+ effects.out(info(`using the ours daemon at ${plan.stateDir}${plan.only.port ? ` (port ${plan.only.port})` : ''} — the only one found`));
132
+ args.stateDir = plan.stateDir;
133
+ return { ...plan, detected };
134
+ }
135
+
136
+ effects.out(heading('Which ours daemon is this for?'));
137
+ plan.candidates.forEach((candidate, index) => {
138
+ effects.out(` ${index + 1}) ${candidate.stateDir}${candidate.port ? ` port ${candidate.port}` : ''}`);
139
+ });
140
+ if (plan.createOption.stateDir) {
141
+ effects.out(` ${plan.candidates.length + 1}) create a new one at ${plan.createOption.stateDir}`);
142
+ }
143
+ const answer = await effects.askLine(`Choose 1-${plan.candidates.length + (plan.createOption.stateDir ? 1 : 0)}: `, '1');
144
+ const chosen = resolveSelection(answer, plan);
145
+ if (chosen.action === 'invalid') {
146
+ // Refused rather than guessed. Interpreting an unrecognised answer as a path
147
+ // would be the "type a state directory" prompt spec §2 forbids, arriving
148
+ // through the back door.
149
+ effects.out(warn(`ours: ${chosen.reason}. Nothing was changed.`));
150
+ effects.out(info('Re-run and pick one of the numbers, or name a daemon directly with --state-dir.'));
151
+ return { action: 'refuse', exitCode: EXIT_REFUSED, detected };
152
+ }
153
+ args.stateDir = chosen.stateDir;
154
+ effects.out(ok(chosen.action === 'create'
155
+ ? `creating a new daemon at ${chosen.stateDir}`
156
+ : `using the ours daemon at ${chosen.stateDir}`));
157
+ return { ...chosen, detected };
158
+ }
159
+
160
+ /**
161
+ * The daemon half of a run: §§2-4. Returns the target decision plus the step
162
+ * outcomes, or a refusal.
163
+ */
164
+ export async function runDaemonPhase(args, effects) {
165
+ const target = await resolveTarget({
166
+ stateDir: args.stateDir,
167
+ port: args.port,
168
+ portExplicit: args.portExplicit,
169
+ probe: effects.probe,
170
+ readJson: effects.readJson,
171
+ readText: effects.readText,
172
+ isTaken: effects.isTaken,
173
+ });
174
+
175
+ if (target.action === 'refuse') {
176
+ effects.out(warn(`ours: refusing to continue — ${target.message}`));
177
+ if (target.reason === 'foreign-daemon') {
178
+ effects.out(info('Fix: re-run with --port for a free port, or with --state-dir naming the state directory that daemon actually owns.'));
179
+ }
180
+ if (target.reason === 'port-mismatch') {
181
+ effects.out(info('Re-run without --port to use the recorded port. Nothing was written.'));
182
+ }
183
+ return { refused: target };
184
+ }
185
+
186
+ const dir = target.stateDir;
187
+ const creating = target.action === 'create';
188
+ effects.out(heading(creating ? `target ${dir} — creating a daemon here` : `target ${dir} — daemon found on port ${target.port}`));
189
+ if (target.stalePidRecord) {
190
+ effects.out(info(`a PID record names port ${target.stalePidRecord} but nothing answers there; treating it as stale`));
191
+ }
192
+ if (target.defaultPortHeldBy) {
193
+ // Reported, never silent: the operator should know why this daemon did not
194
+ // get the port they might have expected, and whose daemon has it.
195
+ const held = target.defaultPortHeldBy;
196
+ effects.out(info(`port ${held.port} is held by another ours daemon${held.stateDir ? ` (state directory ${held.stateDir})` : ''}; this one uses ${target.port}`));
197
+ }
198
+ if (target.reservedNotice) {
199
+ effects.out(info(`port ${target.reservedNotice} is the Telegram connector's default port`));
200
+ }
201
+
202
+ // Asked ONCE, and only when this run is creating the daemon — an existing
203
+ // daemon's broker is its own record, and re-asking would invite an operator to
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
206
+ // does not violate spec §2's "nothing about a state directory or a port
207
+ // appears in any prompt".
208
+ if (creating) args.brokerUrl = await askBroker(args, effects);
209
+
210
+ const steps = [];
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 });
218
+ await perform(effects, args.dryRun, 'ours CLI installed (npm i -g @ours.network/cli)', () => effects.run('npm', ['i', '-g', '@ours.network/cli']));
219
+ steps.push({ id: 'cli', changed: true, packageRefresh: true });
220
+
221
+ // The config file — merged, never rewritten, and untouched when it already
222
+ // matches. No provenance marker is written: --purge works on any state
223
+ // directory, so a `createdBy` key would have no consumer, and
224
+ // an unread key in a user's config file is future confusion for nothing.
225
+ const configPath = join(dir, 'config.json');
226
+ const merged = planDaemonConfig(
227
+ effects.readJson(configPath),
228
+ { port: target.port, stateDir: dir, brokerUrl: args.brokerUrl },
229
+ );
230
+ // THE BYTES AND THE DAEMON THEY DESCRIBE ARE ONE UNIT OF WORK.
231
+ //
232
+ // config.json is written here, and only the two steps AFTER it make what it says
233
+ // true. Without the journal, a start or a service install that failed left a
234
+ // file naming a port nothing listens on — and the next run reads that file FIRST
235
+ // (lib/target.mjs findDaemon), probes the wrong port, and has only the
236
+ // ours-cli-daemon.json lookup between it and creating a SECOND daemon on this
237
+ // state directory. Two writers on one state_data.bin is the corruption case that
238
+ // lookup exists to prevent; this is what stops the installer from setting up the
239
+ // conditions for it.
240
+ const journal = configJournal(effects, { dryRun: args.dryRun });
241
+ let serviceUnsupported = null;
242
+ if (merged.changed) {
243
+ journal.snapshot(configPath);
244
+ await perform(effects, args.dryRun, `write ${configPath} (port ${target.port})`, () => effects.writeJson(configPath, merged.text));
245
+ } else {
246
+ effects.out(ok(`${configPath} already correct — not touched`));
247
+ }
248
+ steps.push({ id: 'config', changed: merged.changed, reason: merged.changed ? undefined : 'already correct' });
249
+
250
+ try {
251
+ if (creating) {
252
+ await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'start', '--config', configPath]));
253
+ steps.push({ id: 'start', changed: true });
254
+ }
255
+
256
+ const service = await runServicePhase(args, effects, dir, target.port);
257
+ if (service.unsupported) serviceUnsupported = service.unsupported;
258
+ if (service.refused) {
259
+ // A REFUSAL IS A FAILURE TO REACH THE STATE, not a special case. An unknown
260
+ // unit file stops the run just as a failed start does, and it stops it with
261
+ // config.json already rewritten — indistinguishable to the operator. Same
262
+ // rollback, same report. Every exit path that leaves written bytes
263
+ // describing a state we did not reach gets this treatment.
264
+ rollBack(effects, journal, args, 'the daemon did not reach the state its config describes — putting the config back');
265
+ return { target, refused: service.refused, steps };
266
+ }
267
+ steps.push(service.step);
268
+ } catch (error) {
269
+ // THE DAEMON MAY BE DOWN, AND NOT BECAUSE ANYTHING ASKED IT TO BE.
270
+ //
271
+ // `install-service` can STOP a running daemon before it fails — it installs a
272
+ // unit that will own the process, and a failure after that point leaves nothing
273
+ // running. v3 simply ended the run there, so a person who typed ours-install
274
+ // and got an error was also, silently, left without the daemon they had before.
275
+ // The nightly flow re-runs `start` and, crucially, tells the two outcomes
276
+ // apart: "the service failed but the daemon is back" is a bad evening, and "the
277
+ // service failed AND it will not come back" is the one that needs a human now.
278
+ const recovery = error?.servicePlan ? await recoverDaemon(args, effects, dir, configPath, target.port) : null;
279
+ rollBack(effects, journal, args, 'the daemon did not reach the state its config describes — putting the config back', {
280
+ replacedUnit: error?.servicePlan?.action === 'adopt' ? error.servicePlan.unitPath : null,
281
+ });
282
+ if (recovery) {
283
+ effects.out(recovery.recovered
284
+ ? ok('your daemon is running again — nothing was committed, and the service is unchanged')
285
+ : warn('and the daemon did NOT come back up — start it yourself before anything else: '
286
+ + `ours daemon start --config ${configPath}`));
287
+ }
288
+ throw error;
289
+ }
290
+
291
+ return { target, steps, serviceUnsupported };
292
+ }
293
+
294
+ /**
295
+ * Put the daemon back after a failed boot-service install.
296
+ *
297
+ * Only attempted when the failure came from the SERVICE step (`error.servicePlan`
298
+ * is what says so) — a daemon that never started has nothing to recover, and
299
+ * running `start` after a failed `start` would just fail again with a second, less
300
+ * useful error on top of the first.
301
+ *
302
+ * A dry run recovers nothing because it stopped nothing. The recovery's own failure
303
+ * is REPORTED, never thrown: the caller is already carrying the real error, and
304
+ * losing it to a second one would hide what actually went wrong.
305
+ */
306
+ async function recoverDaemon(args, effects, dir, configPath, port) {
307
+ if (args.dryRun) return null;
308
+ try {
309
+ await effects.run('ours', ['daemon', 'start', '--config', configPath]);
310
+ return { recovered: true };
311
+ } catch (recoveryError) {
312
+ return { recovered: false, reason: reason(recoveryError) };
313
+ }
314
+ }
315
+
316
+ /**
317
+ * One rollback, one report, one function — because a rollback whose report is
318
+ * missing reads to the operator exactly like a run that quietly did nothing, and
319
+ * with four call sites the way to guarantee the report is to make it impossible to
320
+ * skip.
321
+ *
322
+ * Package installs are deliberately named as not-rolled-back: by every call site
323
+ * at least one has run, and saying so is the honest boundary rather than an
324
+ * apology.
325
+ *
326
+ * `replacedUnit` is the substitute for something this package must NOT do. A
327
+ * legacy ours-mcp unit adopted under --force cannot be put back: writing unit
328
+ * bytes into ~/.config/systemd/user would break the invariant that systemd is
329
+ * reached only through `ours daemon install-service`, and without a daemon-reload
330
+ * it would not even mean anything. So the unit is NAMED instead — the one
331
+ * informational line the operator already scrolled past, repeated at the moment it
332
+ * matters. This is a report, not a fix, and it is recorded as still-open in the
333
+ * behaviour inventory rather than allowed to look covered.
334
+ */
335
+ function rollBack(effects, journal, args, why, { packagesInstalled = true, replacedUnit = null } = {}) {
336
+ if (args.dryRun) return;
337
+ effects.out(warn(why));
338
+ const reported = reportRollback(effects, journal.restoreAll(), { packagesInstalled });
339
+ if (replacedUnit) {
340
+ effects.out(warn(`${replacedUnit} was already replaced with the CLI-managed unit and is NOT restored — the older ours-mcp unit is gone. Your state directory is untouched; re-run ours-install once the cause above is fixed.`));
341
+ }
342
+ return reported;
343
+ }
344
+
345
+ /**
346
+ * The boot service: §4 step 4, including the legacy-unit case.
347
+ *
348
+ * A legacy ours-mcp unit is adopted SILENTLY, with one informational line naming
349
+ * the file. `--force` is passed ONLY here, only for a unit
350
+ * positively identified as ours-mcp's, and never for one we cannot identify.
351
+ */
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
+ }
367
+ if (plan.action === 'refuse') {
368
+ effects.out(warn(`ours: refusing to continue — ${plan.message}`));
369
+ return { refused: plan };
370
+ }
371
+ const adopting = plan.action === 'adopt';
372
+ if (adopting) effects.out(info(plan.notice));
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;
382
+ let outcome;
383
+ try {
384
+ outcome = await perform(effects, args.dryRun, `boot service ${plan.unit} installed and enabled`, () => effects.run(command[0], command.slice(1)));
385
+ } catch (error) {
386
+ // The plan travels with the failure so the caller's rollback can say WHICH
387
+ // unit was replaced. It cannot re-derive that afterwards: once --force has
388
+ // rewritten the file, classifying it again reports a cli-managed unit and the
389
+ // fact that a legacy one was adopted is gone.
390
+ error.servicePlan = plan;
391
+ throw error;
392
+ }
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
+ })();
403
+ return { step: { id: 'service', changed, reason: changed ? undefined : 'unit unchanged' }, plan };
404
+ }
405
+
406
+ // The CLI's --json plan, when we can read it. Deliberately lenient: this is a
407
+ // summary line, not a decision, and it must never throw on unexpected output.
408
+ function readChanged(stdout) {
409
+ if (typeof stdout !== 'string' || !stdout.trim()) return true;
410
+ try {
411
+ const parsed = JSON.parse(stdout);
412
+ return typeof parsed?.changed === 'boolean' ? parsed.changed : true;
413
+ } catch {
414
+ return true;
415
+ }
416
+ }
417
+
418
+ /**
419
+ * THE QUESTION NOBODY WAS ASKING.
420
+ *
421
+ * The public installer has one product, not a package-selection questionnaire:
422
+ * MCP, Telegram, and cowork are all installed. Explicit injected answers remain
423
+ * only as a compatibility/test seam for callers that deliberately omit a piece.
424
+ */
425
+ export async function askComponents(args, effects) {
426
+ // The product is the complete stack. Asking the operator to reconstruct that
427
+ // product from package names was choice theatre and made unattended installs
428
+ // incomplete. Explicit injected answers remain a test/compatibility seam.
429
+ return { ...(args.answers ?? {}) };
430
+ }
431
+
432
+ /**
433
+ * Components: §5. A component that fails is reported with its retry command and
434
+ * the run CONTINUES — a failed component is never a reason to undo a successful
435
+ * one, or to undo the daemon.
436
+ */
437
+ export async function runComponentPhase(args, effects, target) {
438
+ const dir = target.stateDir;
439
+ const endpoint = `http://127.0.0.1:${target.port}`;
440
+ const isDefaultStateDir = dir === join(effects.home, '.ours');
441
+ const answers = await askComponents(args, effects);
442
+ const chosen = planComponentSelection({
443
+ answers,
444
+ installed: args.installed ?? {},
445
+ assumeYes: args.assumeYes,
446
+ });
447
+
448
+ const results = [];
449
+ for (const component of chosen) {
450
+ if (component.action === 'skip' || component.action === 'leave-alone') {
451
+ effects.out(info(`${component.label} — ${component.action === 'skip' ? 'not installed' : 'left as it is'}`));
452
+ results.push({ key: component.key, state: 'skipped' });
453
+ continue;
454
+ }
455
+ try {
456
+ results.push(await attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir }));
457
+ } catch (error) {
458
+ // Reported with its reason and the exact manual command; the run continues.
459
+ // The retry carries the CHANNEL — a nightly run that hands the operator a
460
+ // stable retry command sends them straight into the split-brain install
461
+ // this phase exists to avoid.
462
+ const retry = `npm i -g ${componentSpec(component, args.channel)}`;
463
+ effects.out(warn(`${component.label} failed: ${error instanceof Error ? error.message : String(error)}`));
464
+ effects.out(info(`retry manually: ${retry}`));
465
+ results.push({ key: component.key, state: 'failed', reason: String(error?.message ?? error), retry });
466
+ }
467
+ }
468
+ return summarizeComponentRun(results);
469
+ }
470
+
471
+ async function attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir }) {
472
+ if (component.key === 'mcp') {
473
+ const plan = planMcpAttachment({ stateDir: dir, isDefaultStateDir, channel: args.channel });
474
+ await perform(effects, args.dryRun, `install ${plan.install[3]}`, () => effects.run(plan.install[0], plan.install.slice(1)));
475
+ if (Object.keys(plan.harnessEnv).length > 0) {
476
+ effects.out(info(`harness registration carries OURS_CONFIG=${plan.harnessEnv.OURS_CONFIG}`));
477
+ }
478
+ return { key: 'mcp', state: 'installed' };
479
+ }
480
+
481
+ if (component.key === 'tg') {
482
+ const path = tgConfigPath(effects.home, effects.env);
483
+ const plan = planTgAttachment({
484
+ existing: effects.readJson(path),
485
+ endpoint,
486
+ stateDir: dir,
487
+ brokerUrl: args.brokerUrl,
488
+ assumeYes: args.assumeYes,
489
+ channel: args.channel,
490
+ });
491
+ if (plan.action === 'skip-repoint') {
492
+ effects.out(info('the Telegram connector points at another daemon; never repointed non-interactively'));
493
+ return { key: 'tg', state: 'skipped' };
494
+ }
495
+ if (plan.action === 'confirm-repoint') {
496
+ // Moving a connector is not recoverable by re-running the way a replaced
497
+ // unit file is: the operator's routes would be talking to a daemon they
498
+ // never chose. So this one asks.
499
+ if (!(await effects.ask(plan.prompt, false))) {
500
+ effects.out(info('left where it is'));
501
+ return { key: 'tg', state: 'skipped' };
502
+ }
503
+ }
504
+ await perform(effects, args.dryRun, `install ${plan.install[3]}`, () => effects.run(plan.install[0], plan.install.slice(1)));
505
+ if (plan.changed) {
506
+ await perform(effects, args.dryRun, `write ${path}`, () => effects.writeJson(path, `${JSON.stringify(plan.config, null, 2)}\n`));
507
+ } else {
508
+ effects.out(ok(`${path} already points here — not touched`));
509
+ }
510
+ effects.out(ok('Telegram connector installed and configured, but not started.'));
511
+ effects.out(info('After adding a bot, start it explicitly with: ours-tg-connector install-service'));
512
+ return { key: 'tg', state: 'installed', note: 'configured; stopped' };
513
+ }
514
+
515
+ const path = coworkConfigPath(effects.home, effects.env);
516
+ // cowork is the ONE component installed before its plan exists, because the
517
+ // version floor applies to what is now on disk rather than what was requested
518
+ // — so the spec is built here rather than read off the plan. It still goes
519
+ // through componentSpec, so the channel reaches it like every other package.
520
+ const spec = componentSpec(component, args.channel);
521
+ await perform(effects, args.dryRun, `install ${spec}`, () => effects.run('npm', ['i', '-g', spec]));
522
+ // The installed version is read AFTER installing, because the floor applies to
523
+ // what is now on disk rather than what was requested. Read by the BARE package
524
+ // name: `npm ls -g` knows nothing about the dist-tag it was installed from.
525
+ const plan = planCoworkAttachment({
526
+ existing: effects.readJson(path),
527
+ endpoint,
528
+ stateDir: dir,
529
+ installedVersion: effects.installedVersion(component.pkg),
530
+ channel: args.channel,
531
+ // The broker is a value the INSTALLER knows and cowork cannot guess — the one
532
+ // the operator chose in this run. `home` is for cowork's OWN state directory,
533
+ // never the daemon's.
534
+ brokerUrl: args.brokerUrl,
535
+ home: effects.home,
536
+ });
537
+ if (plan.action === 'refuse' || plan.action === 'leave-embedded') {
538
+ effects.out(warn(`cowork: ${plan.message}`));
539
+ return { key: 'cowork', state: 'skipped', reason: plan.reason };
540
+ }
541
+ // Same unit of work, and cowork's version is the worse failure: its boot is
542
+ // fail-closed on this block, so a written block with a service that never came up
543
+ // does not fall back to embedded mode — it does not start at all.
544
+ const journal = configJournal(effects, { dryRun: args.dryRun });
545
+ if (plan.changed) {
546
+ journal.snapshot(path);
547
+ await perform(effects, args.dryRun, `write ${path}`, () => effects.writeJson(path, `${JSON.stringify(plan.config, null, 2)}\n`));
548
+ } else {
549
+ effects.out(ok(`${path} already points here — not touched`));
550
+ }
551
+ try {
552
+ await perform(effects, args.dryRun, 'cowork service installed', () => effects.run(plan.service[0], plan.service.slice(1)));
553
+ } catch (error) {
554
+ rollBack(effects, journal, args, 'the cowork service did not come up — putting its config back, which leaves cowork embedded as it was');
555
+ throw error;
556
+ }
557
+ return { key: 'cowork', state: 'installed' };
558
+ }
559
+
560
+ /**
561
+ * The broker question (v2's Step 0a, deliberately kept).
562
+ *
563
+ * Consent-first, with the undo built in: a mistaken custom address is one
564
+ * keystroke back to the standard broker, because the alternative is an operator
565
+ * whose agents cannot find each other and no obvious way back.
566
+ */
567
+ export async function askBroker(args, effects) {
568
+ const standard = args.brokerUrl;
569
+ // Custom deployments remain available through OURS_BROKER_URL. The ordinary
570
+ // install is intentionally linear and explains the standard safe default.
571
+ effects.out(info(effects.env.OURS_BROKER_URL
572
+ ? 'All services use the end-to-end encrypted broker configured in OURS_BROKER_URL.'
573
+ : 'All services use the standard end-to-end encrypted ours broker.'));
574
+ return standard;
575
+ }
576
+
577
+ /**
578
+ * Pre-flight: the two disasters worth catching before anything is touched.
579
+ *
580
+ * Carried over from the v2 body deliberately. Native Windows and a Node older
581
+ * than 20 are not "the install went badly", they are "this cannot work here",
582
+ * and finding that out after the daemon package is on disk helps nobody.
583
+ *
584
+ * NOT carried over: v2 also exited when no harness was found. Under v3 the
585
+ * daemon is the product and the harness plugins are one extra among several, so
586
+ * a machine with no harness still gets a working daemon and is told what is
587
+ * missing. That is a deliberate divergence from v2, recorded here rather than
588
+ * discovered.
589
+ */
590
+ export function runPreflight(effects) {
591
+ effects.out(heading('Checking your machine'));
592
+ const plat = detectPlatform({
593
+ platform: effects.platform?.platform,
594
+ release: effects.platform?.release ?? '',
595
+ env: effects.env,
596
+ });
597
+ if (!plat.supported) {
598
+ if (plat.os === 'windows') {
599
+ effects.out(warn(`${plat.label} isn't supported directly yet.`));
600
+ effects.out(info('Install this inside WSL (Windows Subsystem for Linux), then re-run there:'));
601
+ effects.out(info('https://learn.microsoft.com/windows/wsl/install'));
602
+ } else {
603
+ effects.out(warn(`Platform "${plat.label}" isn't supported. ours runs on Linux, macOS, or WSL.`));
604
+ }
605
+ return { ok: false, platform: plat };
606
+ }
607
+ effects.out(ok(`Platform: ${plat.label} (supported)`));
608
+ if (effects.platform?.platform && effects.platform.platform !== 'linux') {
609
+ 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.`));
610
+ }
611
+ const version = String(effects.nodeVersion ?? '0');
612
+ if (Number.parseInt(version.split('.')[0], 10) < 20) {
613
+ effects.out(warn(`Node.js ${version} — ours needs v20 or newer. Update Node and re-run.`));
614
+ return { ok: false, platform: plat, node: version };
615
+ }
616
+ effects.out(ok(`Node.js ${version}`));
617
+ return { ok: true, platform: plat, node: version };
618
+ }
619
+
620
+ /**
621
+ * The human identity: `ours identity create-root`.
622
+ *
623
+ * It needs the operator CLI and a reachable daemon. Already-exists is a friendly keep, never an error, and
624
+ * an unreachable daemon gets the exact retry command rather than "ask your agent
625
+ * later"; the hand-off's identity step is the fallback for both failures.
626
+ */
627
+ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
628
+ effects.out(heading('Your human identity'));
629
+ if (!mcpReady) {
630
+ effects.out(info('this needs the MCP server, which this run did not install — re-run ours-install to add both.'));
631
+ return { key: 'identity', label: 'Human identity', state: 'skipped', note: 'no MCP server' };
632
+ }
633
+ effects.out(info('This is you — the human. Your agents act on your behalf, and it lets you message'));
634
+ effects.out(info('people. (Internally this is your ours root; you just give it a name.)'));
635
+
636
+ const fallback = effects.username();
637
+ const name = (args.assumeYes
638
+ ? fallback
639
+ : String(await effects.askLine(`What name should others see? [${fallback}]: `, fallback) || fallback)).trim() || fallback;
640
+
641
+ const env = daemonEnv(target.stateDir, target.port);
642
+ if (args.dryRun) {
643
+ effects.out(info(wouldPrefix(`ours identity create-root --name "${name}"`)));
644
+ return { key: 'identity', label: 'Human identity', state: 'installed', note: name };
645
+ }
646
+ try {
647
+ await effects.run('ours', ['identity', 'create-root', '--name', name, '--json'], { env });
648
+ effects.out(ok(`Your human identity "${name}" is created.`));
649
+ return { key: 'identity', label: 'Human identity', state: 'installed', note: name };
650
+ } catch (error) {
651
+ const text = reason(error);
652
+ if (/already exists/.test(text)) {
653
+ effects.out(ok('You already have a human identity — keeping it.'));
654
+ return { key: 'identity', label: 'Human identity', state: 'current', note: 'existing identity kept' };
655
+ }
656
+ if (/not running|not reachable|ECONNREFUSED|connect/i.test(text)) {
657
+ effects.out(warn("The daemon isn't reachable yet — couldn't create your human identity."));
658
+ effects.out(info(`Fix: run 'ours daemon start --config ${env.OURS_CONFIG}', then 'ours identity create-root --config ${env.OURS_CONFIG} --name "${name}"'.`));
659
+ return { key: 'identity', label: 'Human identity', state: 'failed', note: 'daemon not reachable' };
660
+ }
661
+ effects.out(warn(`Couldn't create your human identity: ${text.split('\n')[0]}`));
662
+ effects.out(info(`Retry any time: 'ours identity create-root --config ${env.OURS_CONFIG} --name "${name}"'.`));
663
+ return { key: 'identity', label: 'Human identity', state: 'failed', note: 'create-root failed' };
664
+ }
665
+ }
666
+
667
+ /**
668
+ * The harness plugins (spec §5's other half).
669
+ *
670
+ * Two things this phase must never do, both inherited rules rather than new
671
+ * ones. It never DRIVES a command it could not identify — an alias or a wrapper
672
+ * that would not answer `--version` is printed as manual steps instead. And it
673
+ * never CLAIMS the pair travelled when it did not: Claude Code's and Codex's
674
+ * registrations cannot carry a value, so for a non-default state directory they
675
+ * get the exact export line and no promise. Hermes' writer is ours, so its
676
+ * invocation carries the whole pair and the claim is true.
677
+ */
678
+ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir }) {
679
+ effects.out(heading('Harness plugins'));
680
+ const detected = await effects.detectHarnesses();
681
+ for (const h of detected) {
682
+ if (h.status === 'ok') effects.out(ok(`'${h.command ?? h.name}' → ${h.detail ?? 'real program'} (its plugin can be installed)`));
683
+ else if (h.status === 'alias') effects.out(warn(`'${h.command ?? h.name}' → ${h.detail} (I won't call it — manual steps below)`));
684
+ else if (h.status === 'unsafe') effects.out(warn(`'${h.command ?? h.name}' → on your PATH but didn't answer safely (manual steps below)`));
685
+ else effects.out(info(`'${h.command ?? h.name}' → not installed (skipped)`));
686
+ }
687
+ if (detected.every((h) => h.status === 'absent')) {
688
+ effects.out(info('No Claude Code, Codex or Hermes found — install one and re-run to wire it up.'));
689
+ effects.out(info('Your daemon is unaffected; nothing else in this run depends on a harness.'));
690
+ return [];
691
+ }
692
+
693
+ const plans = planHarnessPlugins({
694
+ harnesses: detected.map((h) => ({ name: h.name, status: h.status })),
695
+ stateDir: target.stateDir,
696
+ isDefaultStateDir,
697
+ channel: args.channel,
698
+ assumeYes: true,
699
+ answers: {},
700
+ });
701
+
702
+ const rows = [];
703
+ for (const plan of plans) {
704
+ const row = { key: plan.name, label: `${plan.label} plugin` };
705
+ if (plan.action === 'skip') {
706
+ if (plan.reason !== 'not installed') effects.out(info(`${plan.label} — ${plan.reason}`));
707
+ rows.push({ ...row, state: 'skipped', note: plan.reason });
708
+ continue;
709
+ }
710
+ if (plan.action === 'manual') {
711
+ // NEVER a dead end: the plugin is still installable, by hand, and the run
712
+ // says so instead of pretending the harness does not exist.
713
+ effects.out(warn(`${plan.label} — ${plan.reason}; install it yourself with:`));
714
+ for (const step of plan.manual) effects.out(info(` ${step}`));
715
+ rows.push({ ...row, state: 'skipped', note: plan.reason });
716
+ continue;
717
+ }
718
+
719
+ const env = pairFor(plan, target);
720
+ let failed = null;
721
+ for (const step of plan.steps) {
722
+ const outcome = await attempt(effects, args.dryRun, step.join(' '), () => effects.run(step[0], step.slice(1), env ? { env } : {}));
723
+ if (!outcome.ok) { failed = outcome; break; }
724
+ }
725
+ if (failed) {
726
+ effects.out(info(`${plan.label} can still be installed by hand:`));
727
+ for (const step of plan.manual) effects.out(info(` ${step}`));
728
+ rows.push({ ...row, state: 'failed', note: 'install step failed' });
729
+ continue;
730
+ }
731
+ if (plan.envLine) {
732
+ // The honest line. §5's promise does NOT hold for this harness, and the
733
+ // screen says exactly what is true and exactly what to do about it.
734
+ effects.out(warn(`${plan.label}'s registration cannot carry a value, so it will attach to the DEFAULT daemon.`));
735
+ effects.out(info(`Add this to your shell profile so it uses this one instead: ${plan.envLine}`));
736
+ }
737
+ rows.push({ ...row, state: 'installed', note: plan.envLine ? 'needs the env line above' : 'ready' });
738
+ }
739
+ return rows;
740
+ }
741
+
742
+ /** Install Fleet, initialize its host support, and stage a stopped starter config. */
743
+ export async function runFleetPhase(args, effects, { target, isDefaultStateDir }) {
744
+ effects.out(heading('ours-fleet (your always-online agent team)'));
745
+ effects.out(info('This makes your harnesses PERSISTENT: they stop being just a terminal session and'));
746
+ effects.out(info('become always-online agents that survive a reboot.'));
747
+ const plan = planFleet({
748
+ home: effects.home, stateDir: target.stateDir, isDefaultStateDir, wanted: true, channel: args.channel,
749
+ });
750
+ if (plan.action === 'skip') {
751
+ effects.out(info('skipped cleanly — re-run ours-install any time to add it.'));
752
+ return { key: 'fleet', label: plan.label, state: 'skipped' };
753
+ }
754
+ const install = await attempt(effects, args.dryRun, plan.install.join(' '), () => effects.run(plan.install[0], plan.install.slice(1)));
755
+ // Pass the complete daemon tuple to host initialization. The generated role
756
+ // also carries OURS_CONFIG when the chosen daemon is non-default.
757
+ const initEnv = daemonEnv(target.stateDir, target.port);
758
+ const init = install.ok
759
+ ? await attempt(effects, args.dryRun, `${plan.init.join(' ')} (one-time host setup: units, dirs, linger)`, () => effects.run(plan.init[0], plan.init.slice(1), { env: initEnv }))
760
+ : install;
761
+ if (!init.ok) {
762
+ effects.out(info(`retry manually: ${plan.init.join(' ')}`));
763
+ return { key: 'fleet', label: plan.label, state: 'failed', note: 'ours-fleet init failed' };
764
+ }
765
+ if (effects.readText(plan.configPath) === null) {
766
+ await perform(effects, args.dryRun, `write starter fleet config ${plan.configPath}`, () => effects.writeText(plan.configPath, plan.config));
767
+ } else {
768
+ effects.out(ok(`${plan.configPath} already exists — not touched`));
769
+ }
770
+ effects.out(ok('ours-fleet installed and initialized; no fleet roles were started.'));
771
+ if (plan.instruction) effects.out(info(plan.instruction));
772
+ return { key: 'fleet', label: plan.label, state: 'installed', note: `configured at ${plan.configPath}; stopped` };
773
+ }
774
+
775
+ /** Voice configuration belongs to the shared daemon, not the MCP adapter. */
776
+ export async function runVoicePhase(args, effects, { target, mcpReady }) {
777
+ effects.out(heading('Voice messages'));
778
+ effects.out(info('Voice transcription setup is not managed by ours-mcp. Existing daemon configuration is left unchanged.'));
779
+ return { key: 'voice', label: 'Voice transcription', state: 'skipped', note: 'configure on the shared daemon' };
780
+ }
781
+
782
+ /**
783
+ * The final screen and the copy-paste hand-off.
784
+ *
785
+ * The hand-off is the installer's actual product: everything it could not do
786
+ * conversationally is handed to an agent that can. Steps for pieces this run did
787
+ * not install drop out and the rest renumber, so nobody is told to configure
788
+ * something they do not have.
789
+ */
790
+ export async function endScreen(args, effects, { summary, target, isDefaultStateDir, brokerUrl }) {
791
+ const rule = '═'.repeat(64);
792
+ effects.out('');
793
+ effects.out(` ${c.cyan(rule)}`);
794
+ effects.out(` ${c.bold('ours.network — install complete')}`);
795
+ effects.out(` ${c.gray(`State directory: ${target.stateDir} • Port: ${target.port}`)}`);
796
+ effects.out(` ${c.gray(`Broker: ${brokerUrl === effects.brokerUrl ? 'standard' : 'custom'}`)}`);
797
+ effects.out(` ${c.cyan(rule)}`);
798
+ for (const row of summary) {
799
+ const mark = row.state === 'failed' ? c.red('✗') : row.state === 'skipped' ? c.gray('·') : c.green('✓');
800
+ const state = (row.state === 'installed' || row.state === 'current')
801
+ ? (row.note || 'ready')
802
+ : row.state === 'skipped'
803
+ ? c.gray(`skipped${row.note ? ` (${row.note})` : ''}`)
804
+ : c.red(`needs attention${row.note ? ` — ${row.note}` : ''}`);
805
+ effects.out(` ${mark} ${String(row.label).padEnd(26)}${(row.version ? `v${row.version}` : '').padEnd(9)}${state}`);
806
+ }
807
+ effects.out('');
808
+ effects.out(summary.some((r) => r.state === 'failed')
809
+ ? ` ${c.yellow('Some pieces need a hand — see the notes above; re-run ours-install after fixing.')}`
810
+ : ` ${c.green('Everything installed cleanly. No problems.')}`);
811
+
812
+ // Said BEFORE the hand-off prompt, because it is the only thing here the
813
+ // operator must do himself for any of the rest to work. A harness that was
814
+ // running when its plugin landed spawns no ours MCP server until it restarts,
815
+ // and someone who goes back to that harness, finds no ours tools and reads a
816
+ // successful install as a failed one is the exact outcome this prevents.
817
+ const restarts = restartHints(summary);
818
+ if (restarts.length > 0) {
819
+ effects.out('');
820
+ effects.out(` ${c.bold('Before this works:')} your harness spawns the ours MCP server when it starts, so`);
821
+ effects.out(' a harness that was already open has not picked it up yet.');
822
+ for (const hint of restarts) effects.out(` ${c.green('→')} ${hint.action}`);
823
+ }
824
+
825
+ const has = (key) => summary.some((r) => r.key === key && (r.state === 'installed' || r.state === 'current'));
826
+ if (has('tg') || has('fleet')) {
827
+ effects.out('');
828
+ effects.out(` ${c.bold('Installed but intentionally stopped')}`);
829
+ if (has('tg')) {
830
+ effects.out(` ${c.gray('• Telegram: add a bot/route first, then run')}`);
831
+ effects.out(` ${c.cyan('ours-tg-connector install-service')}`);
832
+ }
833
+ if (has('fleet')) {
834
+ effects.out(` ${c.gray('• Fleet: review the generated coordinator/watchdog config, then run')}`);
835
+ effects.out(` ${c.cyan('ours-fleet doctor && ours-fleet config && ours-fleet up')}`);
836
+ effects.out(` ${c.cyan('ours-fleet ls')}`);
837
+ }
838
+ }
839
+
840
+ const { text, empty } = buildHandoffPromptV3({
841
+ identity: !has('identity'),
842
+ fleet: has('fleet'),
843
+ telegram: has('tg'),
844
+ stateDir: target.stateDir,
845
+ isDefaultStateDir,
846
+ });
847
+ if (empty) {
848
+ effects.out('');
849
+ effects.out(` ${c.green("You're all set — open your harness and just start talking to your agent.")}`);
850
+ } else {
851
+ effects.out('');
852
+ effects.out(` ${c.gray('─'.repeat(64))}`);
853
+ effects.out(` ${c.bold('ONE LAST STEP')} — paste this prompt into Claude Code, Codex, or Hermes.`);
854
+ effects.out(` ${c.gray('─'.repeat(64))}`);
855
+ effects.out('');
856
+ effects.out(box(text.split('\n'), 'paste this into your agent'));
857
+ if (!args.dryRun && effects.clipboard(text)) effects.out(` ${c.gray('(copied to your clipboard.)')}`);
858
+ }
859
+ effects.out('');
860
+ effects.out(` ${c.gray('Re-run ')}${c.cyan('ours-install')}${c.gray(' any time to add a skipped piece or update.')}`);
861
+ effects.out(` ${c.cyan(rule)}`);
862
+ }
863
+
864
+ /**
865
+ * The whole run. Returns an exit code: 0, or 2 for any refusal.
866
+ *
867
+ * The order is not arbitrary and is the one thing here worth reading twice:
868
+ *
869
+ * daemon → components → identity → harness plugins → ours-fleet → voice
870
+ *
871
+ * The daemon comes first because everything else attaches to one. The COMPONENTS
872
+ * come second because `ours-mcp` is what the identity step and the voice step
873
+ * both invoke, and under v3 it is a component rather than the daemon — so
874
+ * anything that shells out to it has to wait for this phase, which is exactly
875
+ * why v2's placement of those two steps could not simply be carried across.
876
+ *
877
+ * Every refusal in this specification applies unchanged in non-interactive mode
878
+ * and exits 2 without writing anything — OURS_ASSUME_YES suppresses questions,
879
+ * never a refusal.
880
+ */
881
+ export async function runInstall(argv, effects) {
882
+ let args;
883
+ try {
884
+ args = parseInstallArgs(argv, effects.env, { home: effects.home });
885
+ } catch (error) {
886
+ if (error instanceof InstallUsageError) {
887
+ effects.out(warn(`ours: ${error.message}`));
888
+ return EXIT_REFUSED;
889
+ }
890
+ throw error;
891
+ }
892
+ if (args.help) { effects.out(USAGE); return EXIT_OK; }
893
+ if (args.version) { effects.out(`ours-install v${effects.version ?? '?'}`); return EXIT_OK; }
894
+
895
+ args.brokerUrl = args.brokerUrl ?? effects.brokerUrl;
896
+ // THE INSTALLER'S OWN VERSION IS THE CHANNEL SIGNAL WHEN NOTHING SAYS OTHERWISE.
897
+ //
898
+ // resolveChannel falls back to `selfVersion` only when the environment is
899
+ // silent, and this call passed no selfVersion — so a NIGHTLY installer with no
900
+ // OURS_CHANNEL set resolved to `latest` and installed the whole stack at latest.
901
+ // That is what put fleet@latest and a stable ours-mcp on a nightly machine.
902
+ //
903
+ // The v2 bin has always done this correctly (install.mjs:53 passes pkgVersion());
904
+ // the v3 orchestrator dropped the argument. @ours.network/install is published on
905
+ // BOTH dist-tags from one lockstep bump, so its own version is the only thing
906
+ // that distinguishes a nightly installer from a stable one when the operator has
907
+ // said nothing.
908
+ args.channel = resolveChannel(effects.env.OURS_CHANNEL ?? effects.env.OURS_INSTALL_CHANNEL, effects.version);
909
+
910
+ effects.out(banner());
911
+ effects.out(heading(`ours: target ${args.stateDir}${args.portExplicit ? `, port ${args.port}` : ''}`));
912
+ if (args.dryRun) effects.out(info('dry-run: nothing will be installed or changed'));
913
+ effects.out(progress(1, 8, 'Check the host', 'Verify the platform and Node.js before changing anything.'));
914
+
915
+ // An unsupported platform is not a refusal of an incoherent selection, it is a
916
+ // machine this cannot run on. v2 exited 0 there and so does this, so a script
917
+ // that wrapped the old installer keeps its meaning.
918
+ if (!runPreflight(effects).ok) return EXIT_OK;
919
+
920
+ // Which daemon, before anything is decided about it. Only args.stateDir can
921
+ // change here; every refusal downstream is unaffected.
922
+ effects.out(progress(2, 8, 'Choose one daemon', 'Reuse the only detected daemon or create one coherent shared target.'));
923
+ const selection = await runSelectionPhase(args, effects);
924
+ if (selection.action === 'refuse') return EXIT_REFUSED;
925
+
926
+ effects.out(progress(3, 8, 'Prepare the shared daemon', 'Install the CLI, write config, start it, and enable boot persistence.'));
927
+ const daemon = await runDaemonPhase(args, effects);
928
+ if (daemon.refused) return EXIT_REFUSED;
929
+ const target = daemon.target;
930
+ const isDefaultStateDir = target.stateDir === join(effects.home, '.ours');
931
+
932
+ const summary = [{
933
+ key: 'core',
934
+ label: 'ours core (daemon)',
935
+ state: target.action === 'create' ? 'installed' : 'current',
936
+ note: `port ${target.port}`,
937
+ }];
938
+ // The skip must not read as success: same "needs attention" mark a failed
939
+ // component gets, so the closing screen cannot say everything is clean.
940
+ if (daemon.serviceUnsupported) {
941
+ summary.push({
942
+ key: 'service',
943
+ label: 'Boot service',
944
+ state: 'failed',
945
+ note: `not available on ${daemon.serviceUnsupported.platform === 'darwin' ? 'macOS' : daemon.serviceUnsupported.platform} — start the daemon yourself after a reboot`,
946
+ });
947
+ }
948
+
949
+ effects.out(progress(4, 8, 'Install the complete stack', 'Attach MCP, Telegram, and cowork to the same daemon; Telegram stays stopped.'));
950
+ const components = await runComponentPhase(args, effects, target);
951
+ for (const component of COMPONENTS) {
952
+ const state = components.installed.includes(component.key) ? 'installed'
953
+ : components.failed.some((f) => f.key === component.key) ? 'failed' : 'skipped';
954
+ summary.push({
955
+ key: component.key,
956
+ label: component.label,
957
+ state,
958
+ version: state === 'installed' && !args.dryRun ? (effects.installedVersion(component.pkg) ?? '') : '',
959
+ note: components.failed.find((f) => f.key === component.key)?.reason
960
+ ?? (state === 'installed' && component.key === 'tg' ? 'configured; stopped'
961
+ : state === 'installed' && component.key === 'cowork' ? 'configured; service running' : undefined),
962
+ });
963
+ }
964
+ const mcpReady = components.installed.includes('mcp');
965
+
966
+ effects.out(progress(5, 8, 'Create the Human identity', 'Create the daemon root identity once, or keep the existing one.'));
967
+ summary.push(await runIdentityPhase(args, effects, { target, mcpReady }));
968
+ effects.out(progress(6, 8, 'Wire detected harnesses', 'Install the ours plugin into each safe Claude Code, Codex, or Hermes installation.'));
969
+ summary.push(...await runHarnessPhase(args, effects, { target, isDefaultStateDir }));
970
+ effects.out(progress(7, 8, 'Stage the fleet', 'Install Fleet and write a stopped coordinator + watchdog + health-loop starter config.'));
971
+ summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir }));
972
+ summary.push(await runVoicePhase(args, effects, { target, mcpReady }));
973
+
974
+ const changes = summarizeRun(daemon.steps);
975
+ if (!changes.changedAnything) {
976
+ effects.out(ok('everything already correct — nothing changed except refreshed packages'));
977
+ }
978
+ for (const failure of components.failed) {
979
+ effects.out(warn(`${failure.key} did not install: ${failure.reason}`));
980
+ }
981
+ effects.out(progress(8, 8, 'Finish', 'Summarize what is running, what is stopped, and the exact next commands.'));
982
+ await endScreen(args, effects, { summary, target, isDefaultStateDir, brokerUrl: args.brokerUrl });
983
+ return EXIT_OK;
984
+ }