@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.
package/install.mjs CHANGED
@@ -2,16 +2,9 @@
2
2
  // ours.network — the unified `ours-install` experience (the real UX behind install.sh's thin
3
3
  // bootstrap, and the `ours-install` command once the stack is on the machine).
4
4
  //
5
- // ONE installer for the WHOLE stack — the shared ours daemon + the harness plugins (Claude Code /
6
- // Codex / Hermes) + ours-fleet + the Telegram connector + Rooms (ours-cowork) — for someone who
7
- // ALREADY has Claude, Codex, and/or Hermes.
8
- //
9
- // TOPOLOGY. Step 1 installs and configures ONE shared daemon and the user picks its listen port
10
- // there; every consumer below is wired to that endpoint. The Telegram connector may instead be
11
- // given its OWN daemon — its own port, state directory and boot unit — chosen independently, and
12
- // defaulting to the shared one so Enter and non-interactive runs keep the historical topology.
13
- // Rooms answers the same question, plus a third answer the connector has no use for: keeping
14
- // cowork's own EMBEDDED daemon, which is what every pre-PR#9 cowork install runs (see step 5).
5
+ // ONE installer for the WHOLE stack — ours core (the daemon) + the harness plugins (Claude Code /
6
+ // Codex / Hermes) + ours-fleet + the Telegram connector — for someone who ALREADY has Claude,
7
+ // Codex, and/or Hermes.
15
8
  // Its whole job: install the stack cleanly, then hand back ONE copy-paste prompt the user drops
16
9
  // into their agent to finish remaining configuration conversationally. Voice API credentials are
17
10
  // the one guided secret flow: interactive, masked, optional, and written atomically with mode 0600.
@@ -27,30 +20,21 @@
27
20
  import { spawn, spawnSync } from 'node:child_process';
28
21
  import { readFileSync, existsSync } from 'node:fs';
29
22
  import { homedir, userInfo, platform as osPlatform, release as osRelease } from 'node:os';
30
- import { join, resolve } from 'node:path';
23
+ import { join } from 'node:path';
31
24
  import { banner, heading, ok, info, warn, c, box, withSpinner, openTty, makeWriter, closeSync } from './lib/ui.mjs';
32
25
  import { askLine, askYesNo, isCancel } from './lib/prompt.mjs';
33
26
  import {
34
27
  suggestPort, parsePort, validateBroker, mergeConfig, parseVersion, parseStatus,
35
28
  detectPlatform, classifyHarnessProbe, buildHandoffPrompt,
36
- voiceSetupStatus, resolveSharedBroker, tgConfigPath, planTgDaemonConfig, daemonEndpoint,
29
+ voiceSetupStatus,
37
30
  DEFAULT_PORT, resolveChannel, pkgSpec,
38
- validateDaemonPort, planPorts, dedicatedDaemonPaths, DEDICATED_INSTANCES,
39
- coworkConfigPath, planCoworkConfig, COWORK_DEFAULT_PORT, coworkDaemonMode,
40
- coworkSupportsExternalDaemon, COWORK_EXTERNAL_MIN_VERSION,
41
31
  } from './lib/logic.mjs';
42
32
  import { atomicWriteConfig } from './lib/config.mjs';
43
- import { realEffects } from './lib/effects.mjs';
44
- import { runInstall as runInstallV3 } from './lib/orchestrate.mjs';
45
33
 
46
34
  const NPM = process.env.OURS_NPM || 'npm';
47
- // Release channel: OURS_CHANNEL=nightly installs each package's PRERELEASE dist-tag —
48
- // @nightly for mcp/tg-connector/fleet/the plugin launchers, and @latest for cowork,
49
- // and @next for cowork, whose repo has always called its prerelease line `next`
50
- // (see PKG_CHANNEL_TAGS in lib/logic.mjs). With no explicit
51
- // selection the installer follows its OWN channel, so a nightly installer builds a
52
- // nightly stack instead of silently mixing tags across an architecture boundary.
53
- const CHANNEL = resolveChannel(process.env.OURS_CHANNEL || process.env.OURS_INSTALL_CHANNEL, pkgVersion());
35
+ // Release channel: OURS_CHANNEL=nightly installs @nightly for mcp/tg-connector/plugin
36
+ // launchers but keeps @ours.network/fleet at @latest (fleet has no nightly). Default: latest.
37
+ const CHANNEL = resolveChannel(process.env.OURS_CHANNEL || process.env.OURS_INSTALL_CHANNEL);
54
38
  const spec = (pkgKey) => pkgSpec(pkgKey, CHANNEL); // → "@ours.network/<key>@<tag>"
55
39
  let DRY = !!process.env.OURS_INSTALL_DRY_RUN;
56
40
  const SELFHOST_URL = 'ours.network';
@@ -62,11 +46,10 @@ const line = (s = '') => sink(`${s}\n`);
62
46
  const say = (s) => sink(`ours: ${s}\n`);
63
47
 
64
48
  // --- external command helpers (never throw; the installer degrades, it doesn't crash) ----------
65
- function run(bin, args, { capture = false, timeout, env } = {}) {
49
+ function run(bin, args, { capture = false, timeout } = {}) {
66
50
  const r = spawnSync(bin, args, {
67
51
  encoding: 'utf8',
68
52
  timeout,
69
- env: env ? { ...process.env, ...env } : process.env,
70
53
  stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
71
54
  });
72
55
  const timedOut = !!(r.error && (r.error.code === 'ETIMEDOUT' || r.signal === 'SIGTERM'));
@@ -104,30 +87,16 @@ async function actSpin(label, desc, fn) {
104
87
 
105
88
  // --- daemon probes (always safe to run — read-only) --------------------------------------------
106
89
  const daemonVersionLine = () => (run('ours-mcp', ['--version'], { capture: true }).out.split('\n')[0] || '').trim();
107
- const daemonStatusText = (env) => run('ours-mcp', ['status'], { capture: true, ...(env ? { env } : {}) }).out;
90
+ const daemonStatusText = () => run('ours-mcp', ['status'], { capture: true }).out;
108
91
  function daemonLifecycleState() {
109
92
  const status = run('ours-mcp', ['status'], { capture: true });
110
93
  if (!status.ok) return 'stopped';
111
94
  return /^\s*pid:\s*\d+/m.test(status.out) ? 'managed' : 'external';
112
95
  }
113
96
  const daemonRunning = () => daemonLifecycleState() !== 'stopped';
114
- // LISTENING, not merely alive — and the difference is not academic. `ours-mcp start`
115
- // exits 0 when it only FINDS a live pid in the state directory (core cmdStart: it
116
- // checks `isAlive(pid)` and nothing else, so a pid file left by a previous boot whose
117
- // number now belongs to any other process reads as "already running"). `ours-mcp
118
- // status` then exits 0 for that same pid while printing "(port not answering!)" —
119
- // core cmdStatus only sets a non-zero exit for the no-pid case. So neither exit code
120
- // proves a daemon is there, and a readiness line built from one can tell the user
121
- // "ready — no problems" about a port nothing is bound to. The status line the daemon
122
- // prints only when the port actually answered is the honest signal, so use it.
123
- const daemonReachable = (env) => /\(reachable\)/.test(daemonStatusText(env));
124
- // The installed version of a global package, INCLUDING any prerelease suffix. The
125
- // suffix is not cosmetic here: the Rooms daemon guard compares against an exact
126
- // `0.4.1-nightly.<date>.<sha>` floor, and truncating at the dash would make every
127
- // 0.4.1 nightly look alike — including ones published before the mode existed.
128
97
  const globalVersion = (pkg) => {
129
98
  const ls = run(NPM, ['ls', '-g', pkg], { capture: true }).out;
130
- const m = ls.match(new RegExp(pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') + '@(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?)'));
99
+ const m = ls.match(new RegExp(pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') + '@([0-9][0-9.]*)'));
131
100
  return m ? m[1] : '';
132
101
  };
133
102
 
@@ -140,18 +109,6 @@ function writeConfigPatch(patch) {
140
109
  return p;
141
110
  }
142
111
 
143
- // The daemon's state directory, resolved exactly the way packages/core/src/config.ts
144
- // resolves it (env > config file > ~/.ours). The Telegram connector needs the ABSOLUTE
145
- // path: the SDK reads the daemon's API token from it and refuses to send that token to
146
- // a separately-chosen endpoint unless the state dir was chosen just as deliberately.
147
- function daemonStateDir() {
148
- const fromEnv = process.env.OURS_STATE_DIR?.trim();
149
- if (fromEnv) return resolve(fromEnv);
150
- const fromFile = readConfigObject().stateDir;
151
- if (typeof fromFile === 'string' && fromFile.trim()) return resolve(fromFile.trim());
152
- return join(homedir(), '.ours');
153
- }
154
-
155
112
  function daemonVoiceCapability() {
156
113
  const r = run('ours-mcp', ['voice-status', '--json'], { capture: true, timeout: 6000 });
157
114
  if (!r.ok) return null;
@@ -222,13 +179,10 @@ const USAGE = `ours-install — the unified ours.network stack installer.
222
179
 
223
180
  ours-install [--dry-run] [--help] [--version]
224
181
 
225
- Guided ~3-minute setup for the whole stack: the shared ours daemon (you pick its
226
- port), the harness plugins (Claude Code + Codex + Hermes), ours-fleet, the Telegram
227
- connector, and Rooms (ours-cowork) — then one copy-paste hand-off prompt. You approve
228
- each step; re-run any time to add a piece or update.
229
-
230
- Telegram can share the daemon from step 1 or be given its own (its own port, state
231
- directory and boot service); Enter keeps the shared one.
182
+ Guided ~3-minute setup for the whole stack: ours core (the daemon), the harness
183
+ plugins (Claude Code + Codex + Hermes), ours-fleet, and the Telegram connector — then one
184
+ copy-paste hand-off prompt. You approve each step; re-run any time to add a piece
185
+ or update.
232
186
 
233
187
  --dry-run walk the whole flow and print what it WOULD do — install/change nothing
234
188
  --help show this help and exit
@@ -240,28 +194,6 @@ Env: OURS_ASSUME_YES=1 (accept defaults, no prompts) · OURS_INSTALL_DRY_RUN=1
240
194
  // ===============================================================================================
241
195
  async function main() {
242
196
  const argv = process.argv.slice(2);
243
-
244
- // ─── THE CHANNEL FORK ───────────────────────────────────────────────────────
245
- // Nightly is the v3 installer, end to end. Owner ruling 2026-08-17: v3 SUBSUMES
246
- // the nightly flow rather than being hosted by it, so this hands the whole run
247
- // over — arguments, screens, refusals and exit code — and never returns.
248
- //
249
- // FIRST IN main(), BEFORE --help/--version, ON PURPOSE. Those used to be
250
- // answered by the v2 body above, which would have printed v2's usage describing
251
- // v2's flags for a run that is about to be v3's. Whatever prints the help must
252
- // be whatever runs.
253
- //
254
- // The latest/stable body below is untouched and still serves that channel byte
255
- // for byte; nothing a stable user does changes.
256
- if (CHANNEL === 'nightly') {
257
- const ttyFd = openTty();
258
- const code = await runInstallV3(argv, realEffects({
259
- write: makeWriter(ttyFd), ttyFd, env: process.env, version: pkgVersion(),
260
- }));
261
- finish(ttyFd);
262
- process.exit(code);
263
- }
264
-
265
197
  if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(USAGE + '\n'); return; }
266
198
  if (argv.includes('--version') || argv.includes('-V')) { process.stdout.write(`ours-install v${pkgVersion()}\n`); return; }
267
199
  if (argv.includes('--dry-run')) DRY = true;
@@ -349,44 +281,56 @@ async function main() {
349
281
  line('');
350
282
  cont();
351
283
 
284
+ // ============================================================================================
285
+ // STEP 0 — the two config questions (asked ONCE, up front). SKIPPED entirely when a daemon is
286
+ // already configured (update path reuses its port/broker; delta #1859).
287
+ // ============================================================================================
352
288
  const status0 = parseStatus(daemonStatusText());
353
289
  let chosenBroker; // undefined = keep default / existing
354
290
  let chosenPort = status0.port || DEFAULT_PORT;
355
291
  const configFirst = !daemonInstalled;
356
292
 
357
- // Every port this run has committed to, so a later daemon can't be handed one an
358
- // earlier daemon claimed. A live bind probe cannot see these — nothing is
359
- // listening on them yet — which is exactly why they're tracked by hand.
360
- const claimedPorts = [];
361
- // The finished topology, for the end-of-run cross-check. Each entry is one thing that
362
- // will try to BIND a port, named so a collision can be reported in the user's terms.
363
- const topology = [];
364
- const claimPort = (port, label) => {
365
- if (!Number.isInteger(port)) return;
366
- if (!claimedPorts.includes(port)) claimedPorts.push(port);
367
- if (label) topology.push({ label, port });
368
- };
293
+ if (configFirst) {
294
+ line(heading('A couple of quick settings'));
295
+ // 0a — broker (owner edit #1: SECURE wording; owner edit #2: self-host → website only).
296
+ line(info('Your agents connect through a "broker" — a shared meeting point that lets them find'));
297
+ line(info("each other. It's secure: your messages are end-to-end encrypted, so the broker never"));
298
+ line(info('sees what they say. Almost everyone uses the standard one — just press Enter.'));
299
+ const custom = yes(' Use a custom broker address?', false);
300
+ if (custom) {
301
+ line(info(`(Only needed if you run your own broker. More at ${SELFHOST_URL}.)`));
302
+ const entered = ask(' Enter the broker address: ', '');
303
+ const v = validateBroker(entered);
304
+ if (entered && v.ok && !v.empty) {
305
+ // Undo safety net: a mistaken custom entry is one keystroke back to the standard broker.
306
+ const keep = yes(` Use "${v.value}"? (No = go back to the standard broker)`, true);
307
+ if (keep) { chosenBroker = v.value; line(ok(`broker set to ${chosenBroker}.`)); }
308
+ else line(ok('using the standard broker.'));
309
+ } else {
310
+ if (entered) line(warn(`"${entered}" doesn't look like a ws:// address — using the standard broker.`));
311
+ else line(ok('using the standard broker.'));
312
+ }
313
+ } else {
314
+ line(ok('using the standard broker.'));
315
+ }
369
316
 
370
- // Ask for ONE daemon's port, validate it, and keep asking until the answer is
371
- // usable. Enter (and every non-interactive run) takes `def` unchanged — that is
372
- // what keeps the historical behaviour and scripted installs identical.
373
- const askDaemonPort = (prompt, def) => {
374
- let candidate = def;
375
- for (let attempt = 0; attempt < 3; attempt++) {
376
- const raw = ask(` ${prompt} ${c.gray(`[Enter for ${candidate}]`)}: `, String(candidate));
377
- const v = validateDaemonPort(raw, { fallback: candidate, isTaken: portTakenSync, taken: claimedPorts });
378
- if (v.ok) return v.port;
379
- line(warn(`${v.reason}.`));
380
- if (!interactive) break; // no one to re-ask; fall through to a suggestion
381
- candidate = suggestPort(v.port + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
382
- line(info(`Suggesting ${candidate} instead.`));
317
+ // 0b — port: probe 3050; only ask if busy. Minimize the concept.
318
+ if (!portTakenSync(DEFAULT_PORT)) {
319
+ chosenPort = DEFAULT_PORT;
320
+ line(ok(`Using local port ${DEFAULT_PORT}.`));
321
+ } else {
322
+ line(info(`The standard local port (${DEFAULT_PORT}) is already in use on your machine.`));
323
+ let candidate = suggestPort(DEFAULT_PORT + 1, portTakenSync);
324
+ const raw = ask(` Pick another number for the ours daemon? ${c.gray(`[Enter for ${candidate}]`)}: `, String(candidate));
325
+ const parsed = parsePort(raw, candidate);
326
+ candidate = suggestPort(parsed.ok ? parsed.port : candidate, portTakenSync);
327
+ chosenPort = candidate;
328
+ line(ok(`Using local port ${chosenPort}.`));
383
329
  }
384
- // Out of attempts (or headless): take the first genuinely free port rather
385
- // than persisting one we know is unusable.
386
- const fallback = suggestPort(candidate, (p) => claimedPorts.includes(p) || portTakenSync(p));
387
- line(ok(`Using port ${fallback}.`));
388
- return fallback;
389
- };
330
+ line('');
331
+ line(ok(`Config ready — broker: ${chosenBroker ? 'custom' : 'standard'}, port: ${chosenPort}.`));
332
+ cont();
333
+ }
390
334
 
391
335
  // Track outcomes for the summary + hand-off.
392
336
  const summary = [];
@@ -473,57 +417,13 @@ async function main() {
473
417
  };
474
418
 
475
419
  // ============================================================================================
476
- // STEP 1 / 5 — the SHARED ours daemon. Its own visible step, and it owns its own configuration
477
- // (broker + listen port) rather than a nameless "quick settings" preamble: every consumer below
478
- // is wired to the endpoint chosen HERE, so the choice belongs to the step that makes it.
479
- // Config-first within the step: choose → write config → optional voice → start ONCE.
420
+ // STEP 1 / 4 — ours core (the daemon). Config-first: write config → optional voice → start ONCE.
480
421
  // ============================================================================================
481
- line(heading('1/5 — the shared ours daemon'));
482
- line(info('This is the piece that lets your agents talk to each other securely. The harness'));
483
- line(info('plugins, ours-fleet and the Telegram connector all connect to it — Telegram can be'));
484
- line(info('given its own instead, later — and so can Rooms.'));
422
+ line(heading('1/4 — ours core (the daemon)'));
423
+ line(info('This is the piece that lets your agents talk to each other securely. Everything else'));
424
+ line(info('needs it.'));
485
425
  const before = parseVersion(versionBefore);
486
426
 
487
- if (configFirst) {
488
- // Broker (owner edit #1: SECURE wording; owner edit #2: self-host → website only).
489
- line('');
490
- line(info('Your agents connect through a "broker" — a shared meeting point that lets them find'));
491
- line(info("each other. It's secure: your messages are end-to-end encrypted, so the broker never"));
492
- line(info('sees what they say. Almost everyone uses the standard one — just press Enter.'));
493
- const custom = yes(' Use a custom broker address?', false);
494
- if (custom) {
495
- line(info(`(Only needed if you run your own broker. More at ${SELFHOST_URL}.)`));
496
- const entered = ask(' Enter the broker address: ', '');
497
- const v = validateBroker(entered);
498
- if (entered && v.ok && !v.empty) {
499
- // Undo safety net: a mistaken custom entry is one keystroke back to the standard broker.
500
- const keep = yes(` Use "${v.value}"? (No = go back to the standard broker)`, true);
501
- if (keep) { chosenBroker = v.value; line(ok(`broker set to ${chosenBroker}.`)); }
502
- else line(ok('using the standard broker.'));
503
- } else {
504
- if (entered) line(warn(`"${entered}" doesn't look like a ws:// address — using the standard broker.`));
505
- else line(ok('using the standard broker.'));
506
- }
507
- } else {
508
- line(ok('using the standard broker.'));
509
- }
510
-
511
- // Listen port. ALWAYS asked now, so the shared daemon's endpoint is a decision the
512
- // user makes rather than one they only hear about when 3050 happens to be busy.
513
- // The default is still 3050 (the next free port when it is taken), so Enter and
514
- // every non-interactive run land exactly where they always did.
515
- line('');
516
- line(info('The daemon listens on a local port. Everything else in this install is pointed at it.'));
517
- const portDefault = portTakenSync(DEFAULT_PORT) ? suggestPort(DEFAULT_PORT + 1, portTakenSync) : DEFAULT_PORT;
518
- if (portDefault !== DEFAULT_PORT) line(info(`The standard port (${DEFAULT_PORT}) is already in use on your machine.`));
519
- chosenPort = askDaemonPort('Which local port should the shared daemon use?', portDefault);
520
- line(ok(`Shared daemon: port ${chosenPort}, broker ${chosenBroker ? 'custom' : 'standard'}.`));
521
- line('');
522
- } else {
523
- line(ok(`Shared daemon already configured — port ${chosenPort}. Keeping it.`));
524
- }
525
- claimPort(chosenPort, 'the shared ours daemon');
526
-
527
427
  if (!daemonInstalled) {
528
428
  const goCore = yes(' Install and start it?', true);
529
429
  if (!goCore) {
@@ -539,24 +439,10 @@ async function main() {
539
439
  const voice = offerVoiceSetup({ readinessAfterStart: true });
540
440
  const started = await act(`ours-mcp start (port ${chosenPort})`, async () => run('ours-mcp', ['start']));
541
441
  const svc = await act('ours-mcp install-service (survives reboot)', async () => run('ours-mcp', ['install-service']));
542
- // `ours-mcp install-service` STOPS the daemon before it writes the unit (core's
543
- // cmdInstallService), then exits non-zero if `systemctl --user enable --now` fails —
544
- // no linger, no user bus, a container, WSL without systemd. Left alone that turns a
545
- // WORKING daemon into no daemon at all, while the line below still said "ready": the
546
- // human identity and every MCP client then fail against a port nothing is listening
547
- // on. Put the one shared daemon back up and report what is actually true.
548
- // `started.ok` is the exit code of `ours-mcp start`, which is not evidence that
549
- // anything is listening (see daemonReachable). Ask the daemon instead.
550
- let running = DRY ? started.ok : daemonReachable();
551
- if (!DRY && !svc.ok && !running) {
552
- line(info('the boot-service step stopped the daemon before it failed — restarting it.'));
553
- run('ours-mcp', ['start']);
554
- running = daemonReachable();
555
- }
556
- if (running) line(ok(`ours core ready — running on port ${chosenPort}. No problems.`));
442
+ if (started.ok) line(ok(`ours core ready — running on port ${chosenPort}. No problems.`));
557
443
  else line(warn(`could not auto-start — run '${c.cyan('ours-mcp start')}' to bring it up.`));
558
444
  if (!svc.ok && !svc.dry) line(warn(`boot-service not installed — retry '${c.cyan('ours-mcp install-service')}' later.`));
559
- if (voice.setupRan && !DRY && running) {
445
+ if (voice.setupRan && !DRY && started.ok) {
560
446
  const verified = daemonVoiceCapability();
561
447
  if (verified?.ready) {
562
448
  line(ok(`Voice transcription readiness confirmed (${verified.provider}) after the first start.`));
@@ -569,13 +455,7 @@ async function main() {
569
455
  }
570
456
  }
571
457
  }
572
- record({
573
- key: 'core',
574
- label: 'ours core (daemon)',
575
- state: running ? 'installed' : 'failed',
576
- version: parseVersion(daemonVersionLine()),
577
- note: svc.ok ? 'starts on boot' : 'running; no boot service',
578
- });
458
+ record({ key: 'core', label: 'ours core (daemon)', state: started.ok ? 'installed' : 'failed', version: parseVersion(daemonVersionLine()), note: 'starts on boot' });
579
459
  } else {
580
460
  // Installed: offer an update; never re-ask config; reuse the running port everywhere.
581
461
  const daemonState = daemonLifecycleState();
@@ -632,11 +512,9 @@ async function main() {
632
512
  line(ok(`Your human identity "${name}" is created.`));
633
513
  record({ key: 'identity', label: 'Human identity', state: 'installed', note: name });
634
514
  } else {
635
- // A freshly-started daemon may need a moment to BIND ITS PORT before create-root
636
- // can reach it — which is the one thing a liveness check cannot see, so this
637
- // waits on the port answering rather than on a process existing.
638
- let reachable = daemonReachable();
639
- for (let i = 0; i < 6 && !reachable; i++) { sleepMs(400); reachable = daemonReachable(); }
515
+ // A freshly-started daemon may need a moment to bind its port before create-root can reach it.
516
+ let reachable = daemonRunning();
517
+ for (let i = 0; i < 6 && !reachable; i++) { sleepMs(400); reachable = daemonRunning(); }
640
518
  const r = reachable ? run('ours-mcp', ['create-root', name], { capture: true }) : { ok: false, out: '', err: 'daemon not running' };
641
519
  const outText = `${r.out} ${r.err}`;
642
520
  const existing = outText.match(/already exists \("([^"]+)"\)/);
@@ -715,10 +593,10 @@ async function main() {
715
593
  }
716
594
 
717
595
  // ============================================================================================
718
- // STEP 2 / 5 — harness plugins (Claude Code + Codex + Hermes). The installer drives the plugin
596
+ // STEP 2 / 4 — harness plugins (Claude Code + Codex + Hermes). The installer drives the plugin
719
597
  // CLIs for Claude/Codex; Hermes installs via npm + ours-hermes-install (no CLI driving).
720
598
  // ============================================================================================
721
- line(heading('2/5 — harness plugins'));
599
+ line(heading('2/4 — harness plugins'));
722
600
  line(info('These teach Claude Code, Codex, and Hermes the ours skills, so you can just talk to your'));
723
601
  line(info("agent to message people and set things up. I'll install them for you — no commands to type."));
724
602
  for (const h of harnesses) {
@@ -731,21 +609,16 @@ async function main() {
731
609
  }
732
610
 
733
611
  // ============================================================================================
734
- // STEP 3 / 5 — ours-fleet. Appealing wording (owner edit #4); default YES.
612
+ // STEP 3 / 4 — ours-fleet. Appealing wording (owner edit #4); default YES.
735
613
  // ============================================================================================
736
- line(heading('3/5 — ours-fleet (your always-online agent team)'));
614
+ line(heading('3/4 — ours-fleet (your always-online agent team)'));
737
615
  line(info('This makes your harnesses PERSISTENT: Claude Code and Codex stop being just a terminal'));
738
616
  line(info('session and become always-online daemons that survive a reboot. Stand up your own team'));
739
617
  line(info('of always-online developers, combine harnesses, run several Claude Codes, and link them'));
740
618
  line(info('over Telegram so they talk to each other — and it all configures maximally easily.'));
741
619
  const goFleet = yes(' Install it?', true);
742
620
  if (goFleet) {
743
- // ours-fleet FOLLOWS the channel, like everything else here: it publishes its own
744
- // nightly dist-tag from adapt-toolkit/ours-fleet, and lib/logic.mjs maps it
745
- // accordingly. (This comment used to claim the opposite — that fleet had no nightly
746
- // tag and was pinned to @latest even under OURS_CHANNEL=nightly — which stopped
747
- // being true when the map gained its `fleet` entry. The code always followed the
748
- // map; only the comment was stale, which is the more dangerous of the two.)
621
+ // ours-fleet is ALWAYS @latest — it has no nightly tag (pkgSpec pins it even under OURS_CHANNEL=nightly).
749
622
  await actSpin(`installing ${spec('fleet')}…`, `npm i -g ${spec('fleet')}`, () => runAsync(NPM, ['i', '-g', spec('fleet')]));
750
623
  const init = await act('ours-fleet init (one-time host setup: units, dirs, linger)', async () => run('ours-fleet', ['init']));
751
624
  if (!init.ok) line(warn(`ours-fleet host setup didn't finish — retry '${c.cyan('ours-fleet init')}'.`));
@@ -766,147 +639,24 @@ async function main() {
766
639
  }
767
640
  cont(goFleet);
768
641
 
769
- // The broker the WHOLE deployment shares, whichever daemon a consumer talks to.
770
- const sharedBroker = () => resolveSharedBroker({
771
- chosenBroker,
772
- statusBroker: status0.broker,
773
- configBroker: readConfigObject().brokerUrl,
774
- });
775
-
776
- // Provision a daemon that belongs to ONE consumer: its own config file, its own
777
- // state directory, its own port, and — via core's OURS_SERVICE_NAME — its own boot
778
- // unit, so `install-service` cannot overwrite the shared daemon's. Returns the
779
- // endpoint + state dir to wire that consumer to, and whether it came up.
780
- async function provisionDedicatedDaemon({ instance, port, label }) {
781
- const { stateDir, configPath: cfgPath, serviceName } = dedicatedDaemonPaths(homedir(), instance);
782
- const env = { OURS_CONFIG: cfgPath, OURS_STATE_DIR: stateDir, OURS_SERVICE_NAME: serviceName };
783
- // The dedicated daemon has to exist as a package before it can be started; on a
784
- // fresh machine step 1 already installed it, but a re-run that skipped core has not.
785
- await actSpin(`ensuring ${spec('mcp')}…`, `npm i -g ${spec('mcp')}`, () => runAsync(NPM, ['i', '-g', spec('mcp')]));
786
- const desired = { port, stateDir, serviceName };
787
- const broker = sharedBroker();
788
- if (broker) desired.brokerUrl = broker;
789
- let existing = {};
790
- try { existing = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch { /* absent or unreadable */ }
791
- const sameAlready = existing.port === port && existing.stateDir === stateDir && existing.serviceName === serviceName;
792
- if (sameAlready) {
793
- line(ok(`The ${label} daemon is already configured on port ${port} — no change.`));
794
- // Nothing to change AND it is already up: do not touch it. `install-service` STOPS
795
- // the daemon before rewriting the unit, so re-running it here would bounce a healthy
796
- // daemon for no reason.
797
- if (!DRY && daemonReachable(env)) {
798
- line(ok(`Dedicated ${label} daemon already running on port ${port} — left alone.`));
799
- return { endpoint: daemonEndpoint(port), stateDir, serviceName, configPath: cfgPath, running: true, port };
800
- }
801
- } else {
802
- await act(`write ${cfgPath} (dedicated ${label} daemon, port ${port}, state ${stateDir})`, async () => {
803
- atomicWriteConfig(cfgPath, mergeConfig(existing, desired));
804
- return { ok: true };
805
- });
806
- }
807
- const started = await act(`ours-mcp start (dedicated ${label} daemon, port ${port})`, async () => run('ours-mcp', ['start'], { env }));
808
- const svc = await act(`ours-mcp install-service (dedicated ${label} daemon, unit ours-${serviceName})`, async () => run('ours-mcp', ['install-service'], { env }));
809
- // Same recovery as the shared daemon: install-service STOPS the daemon before it
810
- // writes the unit, so a failure there leaves nothing listening on this port.
811
- // Same rule as the shared daemon: an exit code is not evidence of a bound port.
812
- let running = DRY ? started.ok : daemonReachable(env);
813
- if (!DRY && !svc.ok) {
814
- if (!running) {
815
- line(info(`the boot-service step stopped the ${label} daemon before it failed — restarting it.`));
816
- run('ours-mcp', ['start'], { env });
817
- running = daemonReachable(env);
818
- }
819
- line(warn(`the ${label} daemon has no boot service — retry '${c.cyan(`OURS_CONFIG=${cfgPath} OURS_SERVICE_NAME=${serviceName} ours-mcp install-service`)}'.`));
820
- }
821
- if (running || DRY) line(ok(`Dedicated ${label} daemon ready on port ${port} (state ${stateDir}, unit ours-${serviceName}).`));
822
- else line(warn(`could not start the dedicated ${label} daemon — run '${c.cyan(`OURS_CONFIG=${cfgPath} ours-mcp start`)}'.`));
823
- return { endpoint: daemonEndpoint(port), stateDir, serviceName, configPath: cfgPath, running: running || DRY, port };
824
- }
825
-
826
- // Ask one consumer whether it uses the COMMON daemon or gets its own. Enter and
827
- // every non-interactive run answer "common" — the historical topology.
828
- const askDaemonMode = (what) => {
829
- line(info(`${what} can share the daemon from step 1, or run against its own isolated one.`));
830
- line(info('Sharing is right for almost everyone — press Enter. A dedicated daemon gets its own'));
831
- line(info('port, state directory and boot service, and does not see the shared daemon\'s identities.'));
832
- return yes(` Give ${what} its OWN dedicated daemon?`, false) ? 'dedicated' : 'common';
833
- };
834
-
835
- // Give the Telegram connector the daemon it was assigned: that daemon's loopback
836
- // endpoint, the state directory that endpoint's API token belongs to, and — for a
837
- // pre-0.3.3 connector that still meets the daemon at a broker instead — that broker.
838
- // Idempotent: an unchanged selection writes nothing. Returns { changed, hadPrevious }
839
- // so the caller can warn about a service unit that froze an older selection.
840
- async function writeTgDaemonConfig({ endpoint, stateDir }) {
841
- const path = tgConfigPath(process.env, homedir());
842
- const desired = {
843
- daemonUrl: endpoint,
844
- daemonStateDir: stateDir,
845
- brokerUrl: sharedBroker(),
846
- };
847
- let existing = {};
848
- try { existing = JSON.parse(readFileSync(path, 'utf8')); } catch { /* absent or unreadable */ }
849
- const plan = planTgDaemonConfig(existing, desired);
850
- const hadPrevious = !!(plan.previous.daemonUrl || plan.previous.brokerUrl);
851
- if (!plan.changed) {
852
- line(ok(`Telegram connector already points at this daemon (${desired.daemonUrl}) — no change.`));
853
- return { changed: false, hadPrevious };
854
- }
855
- await act(`write ${path} (daemon ${desired.daemonUrl}, state ${stateDir})`, async () => {
856
- atomicWriteConfig(path, plan.text);
857
- return { ok: true };
858
- });
859
- line(ok(`Telegram connector configured to use this daemon (${desired.daemonUrl}).`));
860
- return { changed: true, hadPrevious };
861
- }
862
-
863
642
  // ============================================================================================
864
- // STEP 4 / 5 — Telegram connector. Install-only (no bot tokens here). Then: run as a service?
643
+ // STEP 4 / 4 — Telegram connector. Install-only (no bot tokens here). Then: run as a service?
865
644
  // ============================================================================================
866
- line(heading('4/5 — Telegram connector'));
645
+ line(heading('4/4 — Telegram connector'));
867
646
  line(info('This bridges a Telegram bot to your Ours node, so you can talk to your agent from'));
868
647
  line(info("Telegram. (You'll set up the actual bot later, with your agent — not here.)"));
869
648
  const goTg = yes(' Install it?', false);
870
649
  if (goTg) {
871
650
  await actSpin(`installing ${spec('tg-connector')}…`, `npm i -g ${spec('tg-connector')}`, () => runAsync(NPM, ['i', '-g', spec('tg-connector')]));
872
- // WHICH daemon — asked independently of every other consumer, and answered
873
- // "common" by Enter / non-interactive so the historical topology is the default.
874
- line('');
875
- const tgMode = askDaemonMode('the Telegram connector');
876
- let tgDaemon = { endpoint: daemonEndpoint(chosenPort), stateDir: daemonStateDir(), port: chosenPort, mode: 'common' };
877
- if (tgMode === 'dedicated') {
878
- const instance = DEDICATED_INSTANCES.telegram;
879
- const suggested = suggestPort(chosenPort + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
880
- const port = askDaemonPort('Which local port should the Telegram daemon use?', suggested);
881
- claimPort(port, 'the dedicated Telegram daemon');
882
- const provisioned = await provisionDedicatedDaemon({ instance, port, label: 'Telegram' });
883
- tgDaemon = { ...provisioned, mode: 'dedicated' };
884
- } else {
885
- line(ok(`Telegram will use the shared daemon on port ${chosenPort}.`));
886
- }
887
- // POINT IT AT THAT DAEMON — BEFORE it is started or installed as a service.
888
- // The connector never inherits ~/.ours/config.json (its SDK reports configPath:
889
- // null unless told otherwise), and `install-service` bakes whatever it resolves
890
- // into the unit as environment variables that outrank the file from then on. So
891
- // the daemon's identity has to be in its config BEFORE either happens. See
892
- // planTgDaemonConfig for why all three keys are written.
893
- const tgConfigured = await writeTgDaemonConfig(tgDaemon);
894
- const where = tgDaemon.mode === 'dedicated' ? `its own daemon on port ${tgDaemon.port}` : `the shared daemon on port ${tgDaemon.port}`;
895
651
  const asService = yes(' Keep it running in the background so it starts automatically on boot?', true);
896
652
  if (asService) {
897
653
  const svc = await act('ours-tg-connector install-service (starts on boot)', async () => run('ours-tg-connector', ['install-service']));
898
- if (svc.ok) line(ok(`Telegram connector installed and running as a service (starts on boot), pointed at ${where}. No problems.`));
654
+ if (svc.ok) line(ok('Telegram connector installed and running as a service (starts on boot). No problems.'));
899
655
  else line(warn(`connector installed, but the service didn't start — retry '${c.cyan('ours-tg-connector install-service')}'.`));
900
- record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: `service (boot) · ${tgDaemon.mode} daemon ${tgDaemon.port}` });
656
+ record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: 'service (boot)' });
901
657
  } else {
902
- line(ok(`Telegram connector installed, pointed at ${where}. Start it any time with '${c.cyan('ours-tg-connector start')}'. No problems.`));
903
- // A connector already installed as a service froze its OLD daemon selection into
904
- // the unit's environment, which outranks the file we just wrote. Config alone
905
- // cannot repair that — say so plainly rather than let it look fixed.
906
- if (tgConfigured.changed && tgConfigured.hadPrevious) {
907
- line(warn(`if you previously ran '${c.cyan('ours-tg-connector install-service')}', re-run it — the old service froze the previous daemon selection in its unit.`));
908
- }
909
- record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: `start on demand · ${tgDaemon.mode} daemon ${tgDaemon.port}` });
658
+ line(ok(`Telegram connector installed. Start it any time with '${c.cyan('ours-tg-connector start')}'. No problems.`));
659
+ record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: 'start on demand' });
910
660
  }
911
661
  } else {
912
662
  line(info('skipped cleanly.'));
@@ -914,146 +664,6 @@ async function main() {
914
664
  }
915
665
  cont(goTg);
916
666
 
917
- // ============================================================================================
918
- // STEP 5 / 5 — Rooms (ours-cowork). Two independent things get configured here.
919
- //
920
- // ITS OWN SURFACE — the deployment broker, its private state directory, and its loopback
921
- // console/REST port. Those it has always had.
922
- //
923
- // WHICH DAEMON — ours-cowork used to host its own, always. It now supports an EXTERNAL ours
924
- // daemon (cowork PR #9), so Rooms answers the same common-vs-dedicated question the Telegram
925
- // connector does, plus a third state the connector does not have: EMBEDDED, cowork's own.
926
- // Contract (see logic.mjs): the `daemon` block is optional; absent means embedded; external is
927
- // { mode:'external', endpoint, stateDir } and REQUIRES both halves, because cowork holds no
928
- // token and its SDK reads <stateDir>/daemon-token.
929
- //
930
- // Boot is FAIL-CLOSED on an unreachable endpoint, a non-ours daemon, or a mismatched state
931
- // directory — there is no embedded fallback. So an install that is ALREADY running embedded is
932
- // never migrated behind the user's back: non-interactively it is left exactly as it is, and
933
- // interactively the question is asked plainly before anything is written.
934
- // ============================================================================================
935
- line(heading('5/5 — Rooms (ours-cowork)'));
936
- line(info('Durable mission rooms: a room keeps its own ordered history, and people and agents'));
937
- line(info('join it as seats. It serves a local web console, and reaches everyone through the'));
938
- line(info('same broker as the rest of your install.'));
939
- const goRooms = yes(' Install it?', false);
940
- if (goRooms) {
941
- await actSpin(`installing ${spec('cowork')}…`, `npm i -g ${spec('cowork')}`, () => runAsync(NPM, ['i', '-g', spec('cowork')]));
942
- const roomsStateDir = join(homedir(), '.ours-cowork');
943
- const cfgPath = coworkConfigPath(process.env, homedir());
944
- let existingRooms = {};
945
- try { existingRooms = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch { /* absent or unreadable */ }
946
- const restDefault = Number.isInteger(existingRooms.rest?.port) ? existingRooms.rest.port : COWORK_DEFAULT_PORT;
947
- line('');
948
- line(info('Rooms serves a console on a loopback port — 127.0.0.1 only, never exposed.'));
949
- // COWORK_DEFAULT_PORT is in RESERVED_PORTS (so no ours daemon can be handed it),
950
- // so validate this one against the daemon ports only.
951
- const roomsPort = (() => {
952
- let candidate = restDefault;
953
- for (let attempt = 0; attempt < 3; attempt++) {
954
- const raw = ask(` Which local port should the Rooms console use? ${c.gray(`[Enter for ${candidate}]`)}: `, String(candidate));
955
- const v = validateDaemonPort(raw, {
956
- fallback: candidate, isTaken: (p) => (p === restDefault ? false : portTakenSync(p)),
957
- taken: claimedPorts, reserved: [],
958
- });
959
- if (v.ok) return v.port;
960
- line(warn(`${v.reason}.`));
961
- if (!interactive) break;
962
- candidate = suggestPort(v.port + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
963
- line(info(`Suggesting ${candidate} instead.`));
964
- }
965
- return candidate;
966
- })();
967
- claimPort(roomsPort, 'the Rooms console');
968
-
969
- // WHICH DAEMON. `undefined` means "leave whatever is there alone" — the answer for an
970
- // existing embedded install nobody asked to migrate.
971
- const wasEmbedded = coworkDaemonMode(existingRooms) === 'embedded';
972
- const hadConfig = existsSync(cfgPath);
973
- // Ask the BUILD, not the channel: this runs after the install above, so the version
974
- // read here is the one actually on the machine.
975
- const coworkVersion = globalVersion('@ours.network/cowork');
976
- const externalSupported = coworkSupportsExternalDaemon(coworkVersion);
977
- let roomsDaemon;
978
- let roomsDaemonLabel;
979
- if (!externalSupported) {
980
- // The build we just installed predates cowork's external-daemon mode. Its config
981
- // is a strict document and its boot fails closed, so writing a selection it cannot
982
- // honour would break Rooms rather than degrade it.
983
- line(info(`This Rooms build${coworkVersion ? ` (${coworkVersion})` : ''} hosts its own daemon; pointing it at the shared`));
984
- line(info(`one needs ${COWORK_EXTERNAL_MIN_VERSION} or newer. Re-run with ${c.cyan('OURS_CHANNEL=nightly')} to get it.`));
985
- roomsDaemonLabel = 'embedded';
986
- } else if (hadConfig && wasEmbedded && !interactive) {
987
- // Fail-closed boot makes this migration a real risk; never do it unasked.
988
- line(info('Rooms already runs its own embedded daemon — leaving that alone.'));
989
- line(info(`To point it at this install's daemon, re-run ${c.cyan('ours-install')} in a terminal.`));
990
- roomsDaemonLabel = 'embedded (unchanged)';
991
- } else {
992
- line('');
993
- const roomsMode = askDaemonMode('Rooms');
994
- if (roomsMode === 'dedicated') {
995
- const instance = DEDICATED_INSTANCES.rooms;
996
- const suggested = suggestPort(chosenPort + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
997
- const port = askDaemonPort('Which local port should the Rooms daemon use?', suggested);
998
- claimPort(port, 'the dedicated Rooms daemon');
999
- const provisioned = await provisionDedicatedDaemon({ instance, port, label: 'Rooms' });
1000
- roomsDaemon = { endpoint: provisioned.endpoint, stateDir: provisioned.stateDir };
1001
- roomsDaemonLabel = `dedicated daemon ${port}`;
1002
- } else {
1003
- roomsDaemon = { endpoint: daemonEndpoint(chosenPort), stateDir: daemonStateDir() };
1004
- roomsDaemonLabel = `common daemon ${chosenPort}`;
1005
- line(ok(`Rooms will use the shared daemon on port ${chosenPort}.`));
1006
- }
1007
- }
1008
-
1009
- const roomsPlan = planCoworkConfig(existingRooms, {
1010
- brokerUrl: sharedBroker(),
1011
- stateDir: roomsStateDir,
1012
- restPort: roomsPort,
1013
- daemon: roomsDaemon,
1014
- });
1015
- if (roomsPlan.error) {
1016
- // Only reachable if a daemon selection lost half of itself; a half-written block
1017
- // would fail closed at cowork's boot, so refuse rather than write it.
1018
- line(warn(`not changing the Rooms daemon selection — ${roomsPlan.error}.`));
1019
- } else if (roomsPlan.changed) {
1020
- await act(`write ${cfgPath} (console port ${roomsPort}, state ${roomsStateDir}${roomsDaemon ? `, daemon ${roomsDaemon.endpoint}` : ''})`, async () => {
1021
- atomicWriteConfig(cfgPath, roomsPlan.text);
1022
- return { ok: true };
1023
- });
1024
- if (roomsDaemon) line(ok(`Rooms configured to use ${roomsDaemonLabel} (${roomsDaemon.endpoint}, state ${roomsDaemon.stateDir}).`));
1025
- } else {
1026
- line(ok(`Rooms is already configured for this deployment (console port ${roomsPort}) — no change.`));
1027
- }
1028
- const svc = await act('ours-cowork install-service (starts on boot)', async () => run('ours-cowork', ['install-service']));
1029
- if (svc.ok) {
1030
- line(ok(`Rooms ready — console at ${c.cyan(`http://127.0.0.1:${roomsPort}/`)}, sharing your broker. No problems.`));
1031
- } else {
1032
- line(warn(`Rooms installed, but its service didn't start — retry '${c.cyan('ours-cowork install-service')}'.`));
1033
- line(info(`You can also run it in the foreground: '${c.cyan('ours-cowork web')}'.`));
1034
- }
1035
- record({
1036
- key: 'rooms',
1037
- label: 'Rooms (ours-cowork)',
1038
- state: svc.ok ? 'installed' : 'failed',
1039
- version: coworkVersion,
1040
- note: svc.ok ? `console ${roomsPort} · ${roomsDaemonLabel}` : 'ours-cowork install-service failed',
1041
- });
1042
- } else {
1043
- line(info('skipped cleanly — re-run ours-install any time to add it.'));
1044
- record({ key: 'rooms', label: 'Rooms (ours-cowork)', state: 'skipped' });
1045
- }
1046
- cont(goRooms);
1047
-
1048
- // Last guard on the whole topology: no two daemons in this install may share a port.
1049
- // Each answer was validated as it was given, but only the finished plan proves the set.
1050
- const portPlan = planPorts(topology);
1051
- if (!portPlan.ok) {
1052
- for (const d of portPlan.duplicates) {
1053
- line(warn(`port ${d.port} ended up claimed by both ${d.labels[0]} and ${d.labels[1]} — one of them will fail to bind.`));
1054
- }
1055
- }
1056
-
1057
667
  return endScreen({ ttyFd, summary, chosenPort, chosenBroker });
1058
668
  }
1059
669
 
@@ -1132,9 +742,7 @@ function endScreen({ ttyFd, summary, chosenPort, chosenBroker }) {
1132
742
  const has = (k) => summary.some((r) => r.key === k && (r.state === 'installed' || r.state === 'current'));
1133
743
  if (has('core')) {
1134
744
  const identityDone = has('identity');
1135
- const { text, empty } = buildHandoffPrompt({
1136
- identity: !identityDone, fleet: has('fleet'), telegram: has('telegram'), rooms: has('rooms'),
1137
- });
745
+ const { text, empty } = buildHandoffPrompt({ identity: !identityDone, fleet: has('fleet'), telegram: has('telegram') });
1138
746
  if (empty) {
1139
747
  // Nothing left to finish (identity created in-install, no fleet/Telegram). Don't show an empty box.
1140
748
  line('');