@ours.network/install 0.17.0-nightly.9 → 0.17.0

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.
@@ -1,1035 +0,0 @@
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,
33
- } from './components.mjs';
34
- import { planHarnessPlugins, planFleet, planVoice, buildHandoffPromptV3, restartHints } from './extras.mjs';
35
- import { summarizeRun } from './rerun.mjs';
36
- import { configJournal, reportRollback } from './journal.mjs';
37
- import { detectDaemons, planDaemonSelection, resolveSelection, legacyRegistryPath } from './detect.mjs';
38
- import { detectPlatform, resolveChannel, validateBroker } from './logic.mjs';
39
- import { daemonEnv } from './effects.mjs';
40
- import { USAGE } from './usage.mjs';
41
- import { ok, info, warn, heading, banner, box, c } 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? (C1, owner ruling 2026-08-17.)
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
- // 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
- const detected = detectDaemons({
123
- candidates: effects.knownStateDirs(),
124
- exists: effects.exists,
125
- readJson: effects.readJson,
126
- });
127
- const plan = planDaemonSelection({
128
- candidates: detected,
129
- stateDirExplicit: args.stateDirExplicit,
130
- portExplicit: args.portExplicit,
131
- assumeYes: args.assumeYes,
132
- home: effects.home,
133
- });
134
-
135
- if (plan.action === 'flags' || plan.action === 'create') return { ...plan, detected };
136
- if (plan.action === 'use') {
137
- // A question with one answer is not a choice, it is a keystroke tax — but the
138
- // operator still has to be TOLD which daemon this run is about.
139
- effects.out(info(`using the ours daemon at ${plan.stateDir}${plan.only.port ? ` (port ${plan.only.port})` : ''} — the only one found`));
140
- args.stateDir = plan.stateDir;
141
- return { ...plan, detected };
142
- }
143
-
144
- effects.out(heading('Which ours daemon is this for?'));
145
- plan.candidates.forEach((candidate, index) => {
146
- effects.out(` ${index + 1}) ${candidate.stateDir}${candidate.port ? ` port ${candidate.port}` : ''}`);
147
- });
148
- if (plan.createOption.stateDir) {
149
- effects.out(` ${plan.candidates.length + 1}) create a new one at ${plan.createOption.stateDir}`);
150
- }
151
- const answer = await effects.askLine(`Choose 1-${plan.candidates.length + (plan.createOption.stateDir ? 1 : 0)}: `, '1');
152
- const chosen = resolveSelection(answer, plan);
153
- if (chosen.action === 'invalid') {
154
- // Refused rather than guessed. Interpreting an unrecognised answer as a path
155
- // would be the "type a state directory" prompt spec §2 forbids, arriving
156
- // through the back door.
157
- effects.out(warn(`ours: ${chosen.reason}. Nothing was changed.`));
158
- effects.out(info('Re-run and pick one of the numbers, or name a daemon directly with --state-dir.'));
159
- return { action: 'refuse', exitCode: EXIT_REFUSED, detected };
160
- }
161
- args.stateDir = chosen.stateDir;
162
- effects.out(ok(chosen.action === 'create'
163
- ? `creating a new daemon at ${chosen.stateDir}`
164
- : `using the ours daemon at ${chosen.stateDir}`));
165
- return { ...chosen, detected };
166
- }
167
-
168
- /**
169
- * The daemon half of a run: §§2-4. Returns the target decision plus the step
170
- * outcomes, or a refusal.
171
- */
172
- export async function runDaemonPhase(args, effects) {
173
- const target = await resolveTarget({
174
- stateDir: args.stateDir,
175
- port: args.port,
176
- portExplicit: args.portExplicit,
177
- probe: effects.probe,
178
- readJson: effects.readJson,
179
- isTaken: effects.isTaken,
180
- });
181
-
182
- if (target.action === 'refuse') {
183
- effects.out(warn(`ours: refusing to continue — ${target.message}`));
184
- if (target.reason === 'foreign-daemon') {
185
- effects.out(info('Fix: re-run with --port for a free port, or with --state-dir naming the state directory that daemon actually owns.'));
186
- }
187
- if (target.reason === 'port-mismatch') {
188
- effects.out(info('Re-run without --port to use the recorded port. Nothing was written.'));
189
- }
190
- return { refused: target };
191
- }
192
-
193
- const dir = target.stateDir;
194
- const creating = target.action === 'create';
195
- effects.out(heading(creating ? `target ${dir} — creating a daemon here` : `target ${dir} — daemon found on port ${target.port}`));
196
- if (target.stalePidRecord) {
197
- effects.out(info(`a PID record names port ${target.stalePidRecord} but nothing answers there; treating it as stale`));
198
- }
199
- if (target.defaultPortHeldBy) {
200
- // Reported, never silent: the operator should know why this daemon did not
201
- // get the port they might have expected, and whose daemon has it.
202
- const held = target.defaultPortHeldBy;
203
- effects.out(info(`port ${held.port} is held by another ours daemon${held.stateDir ? ` (state directory ${held.stateDir})` : ''}; this one uses ${target.port}`));
204
- }
205
- if (target.reservedNotice) {
206
- effects.out(info(`port ${target.reservedNotice} is the Telegram connector's default port`));
207
- }
208
-
209
- // Asked ONCE, and only when this run is creating the daemon — an existing
210
- // 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
213
- // does not violate spec §2's "nothing about a state directory or a port
214
- // appears in any prompt".
215
- if (creating) args.brokerUrl = await askBroker(args, effects);
216
-
217
- const steps = [];
218
-
219
- await perform(effects, args.dryRun, 'ours CLI installed (npm i -g @ours.network/cli)', () => effects.run('npm', ['i', '-g', '@ours.network/cli']));
220
- steps.push({ id: 'cli', changed: true, packageRefresh: true });
221
-
222
- // 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
225
- // an unread key in a user's config file is future confusion for nothing.
226
- const configPath = join(dir, 'config.json');
227
- const merged = planDaemonConfig(
228
- effects.readJson(configPath),
229
- { port: target.port, stateDir: dir, brokerUrl: args.brokerUrl },
230
- );
231
- // THE BYTES AND THE DAEMON THEY DESCRIBE ARE ONE UNIT OF WORK.
232
- //
233
- // config.json is written here, and only the two steps AFTER it make what it says
234
- // true. Without the journal, a start or a service install that failed left a
235
- // file naming a port nothing listens on — and the next run reads that file FIRST
236
- // (lib/target.mjs findDaemon), probes the wrong port, and has only the
237
- // ours-cli-daemon.json lookup between it and creating a SECOND daemon on this
238
- // state directory. Two writers on one state_data.bin is the corruption case that
239
- // lookup exists to prevent; this is what stops the installer from setting up the
240
- // conditions for it.
241
- const journal = configJournal(effects, { dryRun: args.dryRun });
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);
257
- if (service.refused) {
258
- // A REFUSAL IS A FAILURE TO REACH THE STATE, not a special case. An unknown
259
- // unit file stops the run just as a failed start does, and it stops it with
260
- // config.json already rewritten — indistinguishable to the operator. Same
261
- // rollback, same report. Every exit path that leaves written bytes
262
- // describing a state we did not reach gets this treatment.
263
- rollBack(effects, journal, args, 'the daemon did not reach the state its config describes — putting the config back');
264
- return { target, refused: service.refused, steps };
265
- }
266
- steps.push(service.step);
267
- } catch (error) {
268
- // THE DAEMON MAY BE DOWN, AND NOT BECAUSE ANYTHING ASKED IT TO BE.
269
- //
270
- // `install-service` can STOP a running daemon before it fails — it installs a
271
- // unit that will own the process, and a failure after that point leaves nothing
272
- // running. v3 simply ended the run there, so a person who typed ours-install
273
- // and got an error was also, silently, left without the daemon they had before.
274
- // The nightly flow re-runs `start` and, crucially, tells the two outcomes
275
- // apart: "the service failed but the daemon is back" is a bad evening, and "the
276
- // 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
- rollBack(effects, journal, args, 'the daemon did not reach the state its config describes — putting the config back', {
279
- replacedUnit: error?.servicePlan?.action === 'adopt' ? error.servicePlan.unitPath : null,
280
- });
281
- if (recovery) {
282
- effects.out(recovery.recovered
283
- ? ok('your daemon is running again — nothing was committed, and the service is unchanged')
284
- : warn('and the daemon did NOT come back up — start it yourself before anything else: '
285
- + `ours daemon start --config ${configPath}`));
286
- }
287
- throw error;
288
- }
289
-
290
- return { target, steps };
291
- }
292
-
293
- /**
294
- * Put the daemon back after a failed boot-service install.
295
- *
296
- * Only attempted when the failure came from the SERVICE step (`error.servicePlan`
297
- * is what says so) — a daemon that never started has nothing to recover, and
298
- * running `start` after a failed `start` would just fail again with a second, less
299
- * useful error on top of the first.
300
- *
301
- * A dry run recovers nothing because it stopped nothing. The recovery's own failure
302
- * is REPORTED, never thrown: the caller is already carrying the real error, and
303
- * losing it to a second one would hide what actually went wrong.
304
- */
305
- async function recoverDaemon(args, effects, dir, configPath) {
306
- if (args.dryRun) return null;
307
- try {
308
- await effects.run('ours', ['daemon', 'start', '--config', configPath]);
309
- return { recovered: true };
310
- } catch (recoveryError) {
311
- return { recovered: false, reason: reason(recoveryError) };
312
- }
313
- }
314
-
315
- /**
316
- * One rollback, one report, one function — because a rollback whose report is
317
- * missing reads to the operator exactly like a run that quietly did nothing, and
318
- * with four call sites the way to guarantee the report is to make it impossible to
319
- * skip.
320
- *
321
- * Package installs are deliberately named as not-rolled-back: by every call site
322
- * at least one has run, and saying so is the honest boundary rather than an
323
- * apology.
324
- *
325
- * `replacedUnit` is the substitute for something this package must NOT do. A
326
- * legacy ours-mcp unit adopted under --force cannot be put back: writing unit
327
- * bytes into ~/.config/systemd/user would break the invariant that systemd is
328
- * reached only through `ours daemon install-service`, and without a daemon-reload
329
- * it would not even mean anything. So the unit is NAMED instead — the one
330
- * informational line the operator already scrolled past, repeated at the moment it
331
- * matters. This is a report, not a fix, and it is recorded as still-open in the
332
- * behaviour inventory rather than allowed to look covered.
333
- */
334
- function rollBack(effects, journal, args, why, { packagesInstalled = true, replacedUnit = null } = {}) {
335
- if (args.dryRun) return;
336
- effects.out(warn(why));
337
- const reported = reportRollback(effects, journal.restoreAll(), { packagesInstalled });
338
- if (replacedUnit) {
339
- 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.`));
340
- }
341
- return reported;
342
- }
343
-
344
- /**
345
- * The boot service: §4 step 4, including the legacy-unit case.
346
- *
347
- * 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
- * positively identified as ours-mcp's, and never for one we cannot identify.
350
- */
351
- export async function runServicePhase(args, effects, dir) {
352
- const plan = planServiceInstall({ stateDir: dir, home: effects.home, readText: effects.readText });
353
- if (plan.action === 'refuse') {
354
- effects.out(warn(`ours: refusing to continue — ${plan.message}`));
355
- return { refused: plan };
356
- }
357
- const adopting = plan.action === 'adopt';
358
- if (adopting) effects.out(info(plan.notice));
359
- const command = serviceInstallCommand({ stateDir: dir, adoptLegacyUnit: adopting });
360
- let outcome;
361
- try {
362
- outcome = await perform(effects, args.dryRun, `boot service ${plan.unit} installed and enabled`, () => effects.run(command[0], command.slice(1)));
363
- } catch (error) {
364
- // The plan travels with the failure so the caller's rollback can say WHICH
365
- // unit was replaced. It cannot re-derive that afterwards: once --force has
366
- // rewritten the file, classifying it again reports a cli-managed unit and the
367
- // fact that a legacy one was adopted is gone.
368
- error.servicePlan = plan;
369
- throw error;
370
- }
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);
376
- return { step: { id: 'service', changed, reason: changed ? undefined : 'unit unchanged' }, plan };
377
- }
378
-
379
- // The CLI's --json plan, when we can read it. Deliberately lenient: this is a
380
- // summary line, not a decision, and it must never throw on unexpected output.
381
- function readChanged(stdout) {
382
- if (typeof stdout !== 'string' || !stdout.trim()) return true;
383
- try {
384
- const parsed = JSON.parse(stdout);
385
- return typeof parsed?.changed === 'boolean' ? parsed.changed : true;
386
- } catch {
387
- return true;
388
- }
389
- }
390
-
391
- /**
392
- * Components: §5. A component that fails is reported with its retry command and
393
- * the run CONTINUES — a failed component is never a reason to undo a successful
394
- * one, or to undo the daemon.
395
- */
396
- export async function runComponentPhase(args, effects, target) {
397
- const dir = target.stateDir;
398
- const endpoint = `http://127.0.0.1:${target.port}`;
399
- const isDefaultStateDir = dir === join(effects.home, '.ours');
400
- const chosen = planComponentSelection({
401
- answers: args.answers ?? {},
402
- installed: args.installed ?? {},
403
- assumeYes: args.assumeYes,
404
- });
405
-
406
- const results = [];
407
- for (const component of chosen) {
408
- if (component.action === 'skip' || component.action === 'leave-alone') {
409
- effects.out(info(`${component.label} — ${component.action === 'skip' ? 'not installed' : 'left as it is'}`));
410
- results.push({ key: component.key, state: 'skipped' });
411
- continue;
412
- }
413
- try {
414
- results.push(await attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir }));
415
- } catch (error) {
416
- // Reported with its reason and the exact manual command; the run continues.
417
- // The retry carries the CHANNEL — a nightly run that hands the operator a
418
- // stable retry command sends them straight into the split-brain install
419
- // this phase exists to avoid.
420
- const retry = `npm i -g ${componentSpec(component, args.channel)}`;
421
- effects.out(warn(`${component.label} failed: ${error instanceof Error ? error.message : String(error)}`));
422
- effects.out(info(`retry manually: ${retry}`));
423
- results.push({ key: component.key, state: 'failed', reason: String(error?.message ?? error), retry });
424
- }
425
- }
426
- return summarizeComponentRun(results);
427
- }
428
-
429
- async function attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir }) {
430
- if (component.key === 'mcp') {
431
- const plan = planMcpAttachment({ stateDir: dir, isDefaultStateDir, channel: args.channel });
432
- await perform(effects, args.dryRun, `install ${plan.install[3]}`, () => effects.run(plan.install[0], plan.install.slice(1)));
433
- if (Object.keys(plan.harnessEnv).length > 0) {
434
- effects.out(info(`harness registration carries OURS_CONFIG=${plan.harnessEnv.OURS_CONFIG}`));
435
- }
436
- return { key: 'mcp', state: 'installed' };
437
- }
438
-
439
- if (component.key === 'tg') {
440
- const path = tgConfigPath(effects.home, effects.env);
441
- const plan = planTgAttachment({
442
- existing: effects.readJson(path),
443
- endpoint,
444
- stateDir: dir,
445
- brokerUrl: args.brokerUrl,
446
- assumeYes: args.assumeYes,
447
- channel: args.channel,
448
- });
449
- if (plan.action === 'skip-repoint') {
450
- effects.out(info('the Telegram connector points at another daemon; never repointed non-interactively'));
451
- return { key: 'tg', state: 'skipped' };
452
- }
453
- if (plan.action === 'confirm-repoint') {
454
- // Moving a connector is not recoverable by re-running the way a replaced
455
- // unit file is: the operator's routes would be talking to a daemon they
456
- // never chose. So this one asks.
457
- if (!(await effects.ask(plan.prompt, false))) {
458
- effects.out(info('left where it is'));
459
- return { key: 'tg', state: 'skipped' };
460
- }
461
- }
462
- await perform(effects, args.dryRun, `install ${plan.install[3]}`, () => effects.run(plan.install[0], plan.install.slice(1)));
463
- // ONE UNIT OF WORK: these bytes and the service that reads them. If the
464
- // service does not come up, the connector's config names this daemon while its
465
- // unit still carries the OLD environment — and the phase's own catch would
466
- // report one failed line and let the run print "install complete".
467
- const journal = configJournal(effects, { dryRun: args.dryRun });
468
- if (plan.changed) {
469
- // Written BEFORE the service: install-service bakes these values into the
470
- // unit as environment, and environment outranks the config file after.
471
- journal.snapshot(path);
472
- await perform(effects, args.dryRun, `write ${path}`, () => effects.writeJson(path, `${JSON.stringify(plan.config, null, 2)}\n`));
473
- } else {
474
- effects.out(ok(`${path} already points here — not touched`));
475
- }
476
- try {
477
- await perform(effects, args.dryRun, 'Telegram connector service installed', () => effects.run(plan.service[0], plan.service.slice(1)));
478
- } catch (error) {
479
- // Scoped to THIS component: the daemon came up correctly and is not undone
480
- // by a connector that did not. That is the rule this journal's per-unit scope
481
- // exists to keep.
482
- rollBack(effects, journal, args, 'the Telegram connector service did not come up — putting its config back');
483
- throw error;
484
- }
485
- return { key: 'tg', state: 'installed' };
486
- }
487
-
488
- const path = coworkConfigPath(effects.home, effects.env);
489
- // cowork is the ONE component installed before its plan exists, because the
490
- // version floor applies to what is now on disk rather than what was requested
491
- // — so the spec is built here rather than read off the plan. It still goes
492
- // through componentSpec, so the channel reaches it like every other package.
493
- const spec = componentSpec(component, args.channel);
494
- await perform(effects, args.dryRun, `install ${spec}`, () => effects.run('npm', ['i', '-g', spec]));
495
- // The installed version is read AFTER installing, because the floor applies to
496
- // what is now on disk rather than what was requested. Read by the BARE package
497
- // name: `npm ls -g` knows nothing about the dist-tag it was installed from.
498
- const plan = planCoworkAttachment({
499
- existing: effects.readJson(path),
500
- endpoint,
501
- stateDir: dir,
502
- installedVersion: effects.installedVersion(component.pkg),
503
- channel: args.channel,
504
- // The broker is a value the INSTALLER knows and cowork cannot guess — the one
505
- // the operator chose in this run. `home` is for cowork's OWN state directory,
506
- // never the daemon's.
507
- brokerUrl: args.brokerUrl,
508
- home: effects.home,
509
- });
510
- if (plan.action === 'refuse' || plan.action === 'leave-embedded') {
511
- effects.out(warn(`cowork: ${plan.message}`));
512
- return { key: 'cowork', state: 'skipped', reason: plan.reason };
513
- }
514
- // Same unit of work, and cowork's version is the worse failure: its boot is
515
- // fail-closed on this block, so a written block with a service that never came up
516
- // does not fall back to embedded mode — it does not start at all.
517
- const journal = configJournal(effects, { dryRun: args.dryRun });
518
- if (plan.changed) {
519
- journal.snapshot(path);
520
- await perform(effects, args.dryRun, `write ${path}`, () => effects.writeJson(path, `${JSON.stringify(plan.config, null, 2)}\n`));
521
- } else {
522
- effects.out(ok(`${path} already points here — not touched`));
523
- }
524
- try {
525
- await perform(effects, args.dryRun, 'cowork service installed', () => effects.run(plan.service[0], plan.service.slice(1)));
526
- } catch (error) {
527
- rollBack(effects, journal, args, 'the cowork service did not come up — putting its config back, which leaves cowork embedded as it was');
528
- throw error;
529
- }
530
- return { key: 'cowork', state: 'installed' };
531
- }
532
-
533
- /**
534
- * The broker question (v2's Step 0a, kept by the owner's ruling).
535
- *
536
- * Consent-first, with the undo built in: a mistaken custom address is one
537
- * keystroke back to the standard broker, because the alternative is an operator
538
- * whose agents cannot find each other and no obvious way back.
539
- */
540
- export async function askBroker(args, effects) {
541
- const standard = args.brokerUrl;
542
- if (args.assumeYes) return standard;
543
- effects.out(heading('Your broker'));
544
- effects.out(info('Your agents connect through a "broker" — a shared meeting point that lets them find'));
545
- effects.out(info("each other. It's secure: your messages are end-to-end encrypted, so the broker never"));
546
- effects.out(info('sees what they say. Almost everyone uses the standard one — just press Enter.'));
547
- if (!(await effects.ask('Use a custom broker address?', false))) {
548
- effects.out(ok('using the standard broker.'));
549
- return standard;
550
- }
551
- const entered = String(await effects.askLine('Enter the broker address: ', '')).trim();
552
- const checked = validateBroker(entered);
553
- if (!entered || !checked.ok || checked.empty) {
554
- if (entered) effects.out(warn(`"${entered}" doesn't look like a ws:// address — using the standard broker.`));
555
- else effects.out(ok('using the standard broker.'));
556
- return standard;
557
- }
558
- if (await effects.ask(`Use "${checked.value}"? (No = go back to the standard broker)`, true)) {
559
- effects.out(ok(`broker set to ${checked.value}.`));
560
- return checked.value;
561
- }
562
- effects.out(ok('using the standard broker.'));
563
- return standard;
564
- }
565
-
566
- /**
567
- * Pre-flight: the two disasters worth catching before anything is touched.
568
- *
569
- * Carried over from the v2 body deliberately. Native Windows and a Node older
570
- * than 20 are not "the install went badly", they are "this cannot work here",
571
- * and finding that out after the daemon package is on disk helps nobody.
572
- *
573
- * NOT carried over: v2 also exited when no harness was found. Under v3 the
574
- * daemon is the product and the harness plugins are one extra among several, so
575
- * a machine with no harness still gets a working daemon and is told what is
576
- * missing. That is a deliberate divergence from v2, recorded here rather than
577
- * discovered.
578
- */
579
- export function runPreflight(effects) {
580
- effects.out(heading('Checking your machine'));
581
- const plat = detectPlatform({
582
- platform: effects.platform?.platform,
583
- release: effects.platform?.release ?? '',
584
- env: effects.env,
585
- });
586
- if (!plat.supported) {
587
- if (plat.os === 'windows') {
588
- effects.out(warn(`${plat.label} isn't supported directly yet.`));
589
- effects.out(info('Install this inside WSL (Windows Subsystem for Linux), then re-run there:'));
590
- effects.out(info('https://learn.microsoft.com/windows/wsl/install'));
591
- } else {
592
- effects.out(warn(`Platform "${plat.label}" isn't supported. ours runs on Linux, macOS, or WSL.`));
593
- }
594
- return { ok: false, platform: plat };
595
- }
596
- effects.out(ok(`Platform: ${plat.label} (supported)`));
597
- const version = String(effects.nodeVersion ?? '0');
598
- if (Number.parseInt(version.split('.')[0], 10) < 20) {
599
- effects.out(warn(`Node.js ${version} — ours needs v20 or newer. Update Node and re-run.`));
600
- return { ok: false, platform: plat, node: version };
601
- }
602
- effects.out(ok(`Node.js ${version}`));
603
- return { ok: true, platform: plat, node: version };
604
- }
605
-
606
- /**
607
- * The human identity: `ours-mcp create-root`, kept by the owner's ruling.
608
- *
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
612
- * an unreachable daemon gets the exact retry command rather than "ask your agent
613
- * later"; the hand-off's identity step is the fallback for both failures.
614
- */
615
- export async function runIdentityPhase(args, effects, { target, mcpReady }) {
616
- effects.out(heading('Your human identity'));
617
- if (!mcpReady) {
618
- effects.out(info('this needs the MCP server, which this run did not install — re-run ours-install to add both.'));
619
- return { key: 'identity', label: 'Human identity', state: 'skipped', note: 'no MCP server' };
620
- }
621
- effects.out(info('This is you — the human. Your agents act on your behalf, and it lets you message'));
622
- effects.out(info('people. (Internally this is your ours root; you just give it a name.)'));
623
-
624
- const fallback = effects.username();
625
- const name = (args.assumeYes
626
- ? fallback
627
- : String(await effects.askLine(`What name should others see? [${fallback}]: `, fallback) || fallback)).trim() || fallback;
628
-
629
- const env = daemonEnv(target.stateDir, target.port);
630
- if (args.dryRun) {
631
- effects.out(info(wouldPrefix(`ours-mcp create-root "${name}"`)));
632
- return { key: 'identity', label: 'Human identity', state: 'installed', note: name };
633
- }
634
- 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
- }
641
- effects.out(ok(`Your human identity "${name}" is created.`));
642
- return { key: 'identity', label: 'Human identity', state: 'installed', note: name };
643
- } catch (error) {
644
- const text = reason(error);
645
- if (/already exists/.test(text)) {
646
- effects.out(ok('You already have a human identity — keeping it.'));
647
- return { key: 'identity', label: 'Human identity', state: 'current', note: 'existing identity kept' };
648
- }
649
- if (/not running|not reachable|ECONNREFUSED|connect/i.test(text)) {
650
- 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}"'.`));
652
- return { key: 'identity', label: 'Human identity', state: 'failed', note: 'daemon not reachable' };
653
- }
654
- 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}"'.`));
656
- return { key: 'identity', label: 'Human identity', state: 'failed', note: 'create-root failed' };
657
- }
658
- }
659
-
660
- /**
661
- * The harness plugins (spec §5's other half).
662
- *
663
- * Two things this phase must never do, both inherited rules rather than new
664
- * ones. It never DRIVES a command it could not identify — an alias or a wrapper
665
- * that would not answer `--version` is printed as manual steps instead. And it
666
- * never CLAIMS the pair travelled when it did not: Claude Code's and Codex's
667
- * registrations cannot carry a value, so for a non-default state directory they
668
- * get the exact export line and no promise. Hermes' writer is ours, so its
669
- * invocation carries the whole pair and the claim is true.
670
- */
671
- export async function runHarnessPhase(args, effects, { target, isDefaultStateDir }) {
672
- effects.out(heading('Harness plugins'));
673
- const detected = await effects.detectHarnesses();
674
- for (const h of detected) {
675
- if (h.status === 'ok') effects.out(ok(`'${h.command ?? h.name}' → ${h.detail ?? 'real program'} (its plugin can be installed)`));
676
- else if (h.status === 'alias') effects.out(warn(`'${h.command ?? h.name}' → ${h.detail} (I won't call it — manual steps below)`));
677
- else if (h.status === 'unsafe') effects.out(warn(`'${h.command ?? h.name}' → on your PATH but didn't answer safely (manual steps below)`));
678
- else effects.out(info(`'${h.command ?? h.name}' → not installed (skipped)`));
679
- }
680
- if (detected.every((h) => h.status === 'absent')) {
681
- effects.out(info('No Claude Code, Codex or Hermes found — install one and re-run to wire it up.'));
682
- effects.out(info('Your daemon is unaffected; nothing else in this run depends on a harness.'));
683
- return [];
684
- }
685
-
686
- // Asked BEFORE planning, because the plan's `wanted` is the answer. Only a
687
- // harness we can actually drive is worth a question — the others are going to
688
- // print manual steps whatever the operator says.
689
- const answers = {};
690
- if (!args.assumeYes) {
691
- for (const h of detected) {
692
- if (h.status !== 'ok') continue;
693
- answers[h.name] = await effects.ask(`Install the ours plugin into ${h.label ?? h.name}?`, true);
694
- }
695
- }
696
-
697
- const plans = planHarnessPlugins({
698
- harnesses: detected.map((h) => ({ name: h.name, status: h.status })),
699
- stateDir: target.stateDir,
700
- isDefaultStateDir,
701
- channel: args.channel,
702
- assumeYes: args.assumeYes,
703
- answers,
704
- });
705
-
706
- const rows = [];
707
- for (const plan of plans) {
708
- const row = { key: plan.name, label: `${plan.label} plugin` };
709
- if (plan.action === 'skip') {
710
- if (plan.reason !== 'not installed') effects.out(info(`${plan.label} — ${plan.reason}`));
711
- rows.push({ ...row, state: 'skipped', note: plan.reason });
712
- continue;
713
- }
714
- if (plan.action === 'manual') {
715
- // NEVER a dead end: the plugin is still installable, by hand, and the run
716
- // says so instead of pretending the harness does not exist.
717
- effects.out(warn(`${plan.label} — ${plan.reason}; install it yourself with:`));
718
- for (const step of plan.manual) effects.out(info(` ${step}`));
719
- rows.push({ ...row, state: 'skipped', note: plan.reason });
720
- continue;
721
- }
722
-
723
- const env = pairFor(plan, target);
724
- let failed = null;
725
- for (const step of plan.steps) {
726
- const outcome = await attempt(effects, args.dryRun, step.join(' '), () => effects.run(step[0], step.slice(1), env ? { env } : {}));
727
- if (!outcome.ok) { failed = outcome; break; }
728
- }
729
- if (failed) {
730
- effects.out(info(`${plan.label} can still be installed by hand:`));
731
- for (const step of plan.manual) effects.out(info(` ${step}`));
732
- rows.push({ ...row, state: 'failed', note: 'install step failed' });
733
- continue;
734
- }
735
- if (plan.envLine) {
736
- // The honest line. §5's promise does NOT hold for this harness, and the
737
- // screen says exactly what is true and exactly what to do about it.
738
- effects.out(warn(`${plan.label}'s registration cannot carry a value, so it will attach to the DEFAULT daemon.`));
739
- effects.out(info(`Add this to your shell profile so it uses this one instead: ${plan.envLine}`));
740
- }
741
- rows.push({ ...row, state: 'installed', note: plan.envLine ? 'needs the env line above' : 'ready' });
742
- }
743
- return rows;
744
- }
745
-
746
- /**
747
- * ours-fleet. The feature that needed zero code anywhere.
748
- *
749
- * `ours-fleet init` takes no daemon argument and reads no daemon config; fleet
750
- * resolves a daemon per role, from the role's own env. So the installer installs
751
- * it, runs its one-time host setup, and — for a non-default state directory —
752
- * SAYS the one fleet.yaml line. Saying it is the whole feature.
753
- */
754
- export async function runFleetPhase(args, effects, { target, isDefaultStateDir }) {
755
- effects.out(heading('ours-fleet (your always-online agent team)'));
756
- effects.out(info('This makes your harnesses PERSISTENT: they stop being just a terminal session and'));
757
- effects.out(info('become always-online agents that survive a reboot.'));
758
- const wanted = args.assumeYes ? true : await effects.ask('Install it?', true);
759
- const plan = planFleet({
760
- stateDir: target.stateDir, isDefaultStateDir, wanted, channel: args.channel,
761
- });
762
- if (plan.action === 'skip') {
763
- effects.out(info('skipped cleanly — re-run ours-install any time to add it.'));
764
- return { key: 'fleet', label: plan.label, state: 'skipped' };
765
- }
766
- const install = await attempt(effects, args.dryRun, plan.install.join(' '), () => effects.run(plan.install[0], plan.install.slice(1)));
767
- // THE PAIR IS PASSED AS DELIBERATE INSURANCE AGAINST AN UNRESOLVED
768
- // CONTRADICTION, not because the question was settled.
769
- //
770
- // Two written analyses disagree, and NEITHER was verified — ours-fleet is not in
771
- // this repo:
772
- // lib/nightly-install.mjs:611 says fleet resolves its daemon from
773
- // OURS_CONFIG / OURS_PORT / OURS_STATE_DIR and has no concept of a registry,
774
- // so an `init` run without the pair points every role at the historical
775
- // default daemon — which, when the selected daemon is not the default, may
776
- // be one the user does not even have.
777
- // lib/extras.mjs:180 says `init` takes no daemon argument of any kind, reads no
778
- // daemon config, and resolves per role through
779
- // resolveEndpoint({ ...process.env, ...role.env }) — so the pair is
780
- // unnecessary here.
781
- // Passing it is harmless if extras.mjs is right and load-bearing if
782
- // nightly-install.mjs is. When the cheap action is safe under both readings and
783
- // the expensive one is only safe under one, take the cheap one. (Coordinator
784
- // ruling, 2026-08-17.)
785
- //
786
- // Passed for EVERY state directory, not only a non-default one, exactly as the
787
- // nightly flow does: `init` is a one-time host setup and the pair is what names
788
- // the daemon it was set up beside.
789
- const initEnv = daemonEnv(target.stateDir, target.port);
790
- const init = install.ok
791
- ? 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 }))
792
- : install;
793
- if (!init.ok) {
794
- effects.out(info(`retry manually: ${plan.init.join(' ')}`));
795
- return { key: 'fleet', label: plan.label, state: 'failed', note: 'ours-fleet init failed' };
796
- }
797
- effects.out(ok('ours-fleet ready — the core ours plugin discovers every option through `ours-fleet docs`.'));
798
- if (plan.instruction) effects.out(info(plan.instruction));
799
- return { key: 'fleet', label: plan.label, state: 'installed', note: 'CLI + core-plugin discovery' };
800
- }
801
-
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
- */
813
- export async function runVoicePhase(args, effects, { target, mcpReady }) {
814
- 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
- }
877
- }
878
-
879
- /**
880
- * The final screen and the copy-paste hand-off.
881
- *
882
- * The hand-off is the installer's actual product: everything it could not do
883
- * conversationally is handed to an agent that can. Steps for pieces this run did
884
- * not install drop out and the rest renumber, so nobody is told to configure
885
- * something they do not have.
886
- */
887
- export async function endScreen(args, effects, { summary, target, isDefaultStateDir, brokerUrl }) {
888
- const rule = '═'.repeat(64);
889
- effects.out('');
890
- effects.out(` ${c.cyan(rule)}`);
891
- effects.out(` ${c.bold('ours.network — install complete')}`);
892
- effects.out(` ${c.gray(`State directory: ${target.stateDir} • Port: ${target.port}`)}`);
893
- effects.out(` ${c.gray(`Broker: ${brokerUrl === effects.brokerUrl ? 'standard' : 'custom'}`)}`);
894
- effects.out(` ${c.cyan(rule)}`);
895
- for (const row of summary) {
896
- const mark = row.state === 'failed' ? c.red('✗') : row.state === 'skipped' ? c.gray('·') : c.green('✓');
897
- const state = (row.state === 'installed' || row.state === 'current')
898
- ? (row.note || 'ready')
899
- : row.state === 'skipped'
900
- ? c.gray(`skipped${row.note ? ` (${row.note})` : ''}`)
901
- : c.red(`needs attention${row.note ? ` — ${row.note}` : ''}`);
902
- effects.out(` ${mark} ${String(row.label).padEnd(26)}${(row.version ? `v${row.version}` : '').padEnd(9)}${state}`);
903
- }
904
- effects.out('');
905
- effects.out(summary.some((r) => r.state === 'failed')
906
- ? ` ${c.yellow('Some pieces need a hand — see the notes above; re-run ours-install after fixing.')}`
907
- : ` ${c.green('Everything installed cleanly. No problems.')}`);
908
-
909
- // Said BEFORE the hand-off prompt, because it is the only thing here the
910
- // operator must do himself for any of the rest to work. A harness that was
911
- // running when its plugin landed spawns no ours MCP server until it restarts,
912
- // and someone who goes back to that harness, finds no ours tools and reads a
913
- // successful install as a failed one is the exact outcome this prevents.
914
- const restarts = restartHints(summary);
915
- if (restarts.length > 0) {
916
- effects.out('');
917
- effects.out(` ${c.bold('Before this works:')} your harness spawns the ours MCP server when it starts, so`);
918
- effects.out(' a harness that was already open has not picked it up yet.');
919
- for (const hint of restarts) effects.out(` ${c.green('→')} ${hint.action}`);
920
- }
921
-
922
- const has = (key) => summary.some((r) => r.key === key && (r.state === 'installed' || r.state === 'current'));
923
- const { text, empty } = buildHandoffPromptV3({
924
- identity: !has('identity'),
925
- fleet: has('fleet'),
926
- telegram: has('tg'),
927
- stateDir: target.stateDir,
928
- isDefaultStateDir,
929
- });
930
- if (empty) {
931
- effects.out('');
932
- effects.out(` ${c.green("You're all set — open your harness and just start talking to your agent.")}`);
933
- } else {
934
- effects.out('');
935
- effects.out(` ${c.gray('─'.repeat(64))}`);
936
- effects.out(` ${c.bold('ONE LAST STEP')} — copy the prompt below and paste it into your agent.`);
937
- effects.out(` ${c.gray('─'.repeat(64))}`);
938
- effects.out('');
939
- effects.out(box(text.split('\n'), 'paste this into your agent'));
940
- if (!args.dryRun && effects.clipboard(text)) effects.out(` ${c.gray('(copied to your clipboard.)')}`);
941
- }
942
- effects.out('');
943
- effects.out(` ${c.gray('Re-run ')}${c.cyan('ours-install')}${c.gray(' any time to add a skipped piece or update.')}`);
944
- effects.out(` ${c.cyan(rule)}`);
945
- }
946
-
947
- /**
948
- * The whole run. Returns an exit code: 0, or 2 for any refusal.
949
- *
950
- * The order is not arbitrary and is the one thing here worth reading twice:
951
- *
952
- * daemon → components → identity → harness plugins → ours-fleet → voice
953
- *
954
- * The daemon comes first because everything else attaches to one. The COMPONENTS
955
- * come second because `ours-mcp` is what the identity step and the voice step
956
- * both invoke, and under v3 it is a component rather than the daemon — so
957
- * anything that shells out to it has to wait for this phase, which is exactly
958
- * why v2's placement of those two steps could not simply be carried across.
959
- *
960
- * Every refusal in this specification applies unchanged in non-interactive mode
961
- * and exits 2 without writing anything — OURS_ASSUME_YES suppresses questions,
962
- * never a refusal.
963
- */
964
- export async function runInstall(argv, effects) {
965
- let args;
966
- try {
967
- args = parseInstallArgs(argv, effects.env, { home: effects.home });
968
- } catch (error) {
969
- if (error instanceof InstallUsageError) {
970
- effects.out(warn(`ours: ${error.message}`));
971
- return EXIT_REFUSED;
972
- }
973
- throw error;
974
- }
975
- if (args.help) { effects.out(USAGE); return EXIT_OK; }
976
- if (args.version) { effects.out(`ours-install v${effects.version ?? '?'}`); return EXIT_OK; }
977
-
978
- args.brokerUrl = args.brokerUrl ?? effects.brokerUrl;
979
- args.channel = resolveChannel(effects.env.OURS_CHANNEL ?? effects.env.OURS_INSTALL_CHANNEL);
980
-
981
- effects.out(banner());
982
- effects.out(heading(`ours: target ${args.stateDir}${args.portExplicit ? `, port ${args.port}` : ''}`));
983
- if (args.dryRun) effects.out(info('dry-run: nothing will be installed or changed'));
984
-
985
- // An unsupported platform is not a refusal of an incoherent selection, it is a
986
- // machine this cannot run on. v2 exited 0 there and so does this, so a script
987
- // that wrapped the old installer keeps its meaning.
988
- if (!runPreflight(effects).ok) return EXIT_OK;
989
-
990
- // Which daemon, before anything is decided about it. Only args.stateDir can
991
- // change here; every refusal downstream is unaffected.
992
- const selection = await runSelectionPhase(args, effects);
993
- if (selection.action === 'refuse') return EXIT_REFUSED;
994
-
995
- const daemon = await runDaemonPhase(args, effects);
996
- if (daemon.refused) return EXIT_REFUSED;
997
- const target = daemon.target;
998
- const isDefaultStateDir = target.stateDir === join(effects.home, '.ours');
999
-
1000
- const summary = [{
1001
- key: 'core',
1002
- label: 'ours core (daemon)',
1003
- state: target.action === 'create' ? 'installed' : 'current',
1004
- note: `port ${target.port}`,
1005
- }];
1006
-
1007
- const components = await runComponentPhase(args, effects, target);
1008
- for (const component of COMPONENTS) {
1009
- const state = components.installed.includes(component.key) ? 'installed'
1010
- : components.failed.some((f) => f.key === component.key) ? 'failed' : 'skipped';
1011
- summary.push({
1012
- key: component.key,
1013
- label: component.label,
1014
- state,
1015
- version: state === 'installed' && !args.dryRun ? (effects.installedVersion(component.pkg) ?? '') : '',
1016
- note: components.failed.find((f) => f.key === component.key)?.reason,
1017
- });
1018
- }
1019
- const mcpReady = components.installed.includes('mcp');
1020
-
1021
- summary.push(await runIdentityPhase(args, effects, { target, mcpReady }));
1022
- summary.push(...await runHarnessPhase(args, effects, { target, isDefaultStateDir }));
1023
- summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir }));
1024
- summary.push(await runVoicePhase(args, effects, { target, mcpReady }));
1025
-
1026
- const changes = summarizeRun(daemon.steps);
1027
- if (!changes.changedAnything) {
1028
- effects.out(ok('everything already correct — nothing changed except refreshed packages'));
1029
- }
1030
- for (const failure of components.failed) {
1031
- effects.out(warn(`${failure.key} did not install: ${failure.reason}`));
1032
- }
1033
- await endScreen(args, effects, { summary, target, isDefaultStateDir, brokerUrl: args.brokerUrl });
1034
- return EXIT_OK;
1035
- }