@ours.network/install 0.17.0-nightly.2 → 0.17.0-nightly.4

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,9 +2,16 @@
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 — 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.
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).
8
15
  // Its whole job: install the stack cleanly, then hand back ONE copy-paste prompt the user drops
9
16
  // into their agent to finish remaining configuration conversationally. Voice API credentials are
10
17
  // the one guided secret flow: interactive, masked, optional, and written atomically with mode 0600.
@@ -28,15 +35,20 @@ import {
28
35
  detectPlatform, classifyHarnessProbe, buildHandoffPrompt,
29
36
  voiceSetupStatus, resolveSharedBroker, tgConfigPath, planTgDaemonConfig, daemonEndpoint,
30
37
  DEFAULT_PORT, resolveChannel, pkgSpec,
38
+ validateDaemonPort, planPorts, dedicatedDaemonPaths, DEDICATED_INSTANCES,
39
+ coworkConfigPath, planCoworkConfig, COWORK_DEFAULT_PORT, coworkDaemonMode,
40
+ coworkSupportsExternalDaemon, COWORK_EXTERNAL_MIN_VERSION,
31
41
  } from './lib/logic.mjs';
32
42
  import { atomicWriteConfig } from './lib/config.mjs';
43
+ import { runNightlyInstaller } from './lib/nightly-install.mjs';
33
44
 
34
45
  const NPM = process.env.OURS_NPM || 'npm';
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). With no
37
- // explicit selection the installer follows its OWN channel, so a nightly installer
38
- // builds a nightly stack instead of silently mixing tags across the connector's
39
- // architecture boundary (see resolveChannel).
46
+ // Release channel: OURS_CHANNEL=nightly installs each package's PRERELEASE dist-tag —
47
+ // @nightly for mcp/tg-connector/fleet/the plugin launchers, and @latest for cowork,
48
+ // and @next for cowork, whose repo has always called its prerelease line `next`
49
+ // (see PKG_CHANNEL_TAGS in lib/logic.mjs). With no explicit
50
+ // selection the installer follows its OWN channel, so a nightly installer builds a
51
+ // nightly stack instead of silently mixing tags across an architecture boundary.
40
52
  const CHANNEL = resolveChannel(process.env.OURS_CHANNEL || process.env.OURS_INSTALL_CHANNEL, pkgVersion());
41
53
  const spec = (pkgKey) => pkgSpec(pkgKey, CHANNEL); // → "@ours.network/<key>@<tag>"
42
54
  let DRY = !!process.env.OURS_INSTALL_DRY_RUN;
@@ -49,10 +61,11 @@ const line = (s = '') => sink(`${s}\n`);
49
61
  const say = (s) => sink(`ours: ${s}\n`);
50
62
 
51
63
  // --- external command helpers (never throw; the installer degrades, it doesn't crash) ----------
52
- function run(bin, args, { capture = false, timeout } = {}) {
64
+ function run(bin, args, { capture = false, timeout, env } = {}) {
53
65
  const r = spawnSync(bin, args, {
54
66
  encoding: 'utf8',
55
67
  timeout,
68
+ env: env ? { ...process.env, ...env } : process.env,
56
69
  stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
57
70
  });
58
71
  const timedOut = !!(r.error && (r.error.code === 'ETIMEDOUT' || r.signal === 'SIGTERM'));
@@ -97,9 +110,13 @@ function daemonLifecycleState() {
97
110
  return /^\s*pid:\s*\d+/m.test(status.out) ? 'managed' : 'external';
98
111
  }
99
112
  const daemonRunning = () => daemonLifecycleState() !== 'stopped';
113
+ // The installed version of a global package, INCLUDING any prerelease suffix. The
114
+ // suffix is not cosmetic here: the Rooms daemon guard compares against an exact
115
+ // `0.4.1-nightly.<date>.<sha>` floor, and truncating at the dash would make every
116
+ // 0.4.1 nightly look alike — including ones published before the mode existed.
100
117
  const globalVersion = (pkg) => {
101
118
  const ls = run(NPM, ['ls', '-g', pkg], { capture: true }).out;
102
- const m = ls.match(new RegExp(pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') + '@([0-9][0-9.]*)'));
119
+ const m = ls.match(new RegExp(pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') + '@(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?)'));
103
120
  return m ? m[1] : '';
104
121
  };
105
122
 
@@ -194,10 +211,13 @@ const USAGE = `ours-install — the unified ours.network stack installer.
194
211
 
195
212
  ours-install [--dry-run] [--help] [--version]
196
213
 
197
- Guided ~3-minute setup for the whole stack: ours core (the daemon), the harness
198
- plugins (Claude Code + Codex + Hermes), ours-fleet, and the Telegram connector — then one
199
- copy-paste hand-off prompt. You approve each step; re-run any time to add a piece
200
- or update.
214
+ Guided ~3-minute setup for the whole stack: the shared ours daemon (you pick its
215
+ port), the harness plugins (Claude Code + Codex + Hermes), ours-fleet, the Telegram
216
+ connector, and Rooms (ours-cowork) — then one copy-paste hand-off prompt. You approve
217
+ each step; re-run any time to add a piece or update.
218
+
219
+ Telegram can share the daemon from step 1 or be given its own (its own port, state
220
+ directory and boot service); Enter keeps the shared one.
201
221
 
202
222
  --dry-run walk the whole flow and print what it WOULD do — install/change nothing
203
223
  --help show this help and exit
@@ -287,6 +307,16 @@ async function main() {
287
307
  finish(ttyFd); return;
288
308
  }
289
309
 
310
+ // HARD RELEASE BOUNDARY. Nightly owns the topology-first profile flow. The
311
+ // latest/stable consumer-first implementation below remains untouched and
312
+ // never reads or writes installer-profiles.json or emits --application.
313
+ if (CHANNEL === 'nightly') {
314
+ return runNightlyInstaller({
315
+ harnesses, ttyFd, interactive, write, yes, ask, cont, dry: DRY,
316
+ npm: NPM, run, runAsync, act, actSpin, finish,
317
+ });
318
+ }
319
+
290
320
  // Daemon state up front (decides first-install vs update, and whether Step 0 runs at all).
291
321
  const versionBefore = daemonVersionLine();
292
322
  const daemonInstalled = !!versionBefore;
@@ -296,56 +326,44 @@ async function main() {
296
326
  line('');
297
327
  cont();
298
328
 
299
- // ============================================================================================
300
- // STEP 0 — the two config questions (asked ONCE, up front). SKIPPED entirely when a daemon is
301
- // already configured (update path reuses its port/broker; delta #1859).
302
- // ============================================================================================
303
329
  const status0 = parseStatus(daemonStatusText());
304
330
  let chosenBroker; // undefined = keep default / existing
305
331
  let chosenPort = status0.port || DEFAULT_PORT;
306
332
  const configFirst = !daemonInstalled;
307
333
 
308
- if (configFirst) {
309
- line(heading('A couple of quick settings'));
310
- // 0a — broker (owner edit #1: SECURE wording; owner edit #2: self-host → website only).
311
- line(info('Your agents connect through a "broker" — a shared meeting point that lets them find'));
312
- line(info("each other. It's secure: your messages are end-to-end encrypted, so the broker never"));
313
- line(info('sees what they say. Almost everyone uses the standard one — just press Enter.'));
314
- const custom = yes(' Use a custom broker address?', false);
315
- if (custom) {
316
- line(info(`(Only needed if you run your own broker. More at ${SELFHOST_URL}.)`));
317
- const entered = ask(' Enter the broker address: ', '');
318
- const v = validateBroker(entered);
319
- if (entered && v.ok && !v.empty) {
320
- // Undo safety net: a mistaken custom entry is one keystroke back to the standard broker.
321
- const keep = yes(` Use "${v.value}"? (No = go back to the standard broker)`, true);
322
- if (keep) { chosenBroker = v.value; line(ok(`broker set to ${chosenBroker}.`)); }
323
- else line(ok('using the standard broker.'));
324
- } else {
325
- if (entered) line(warn(`"${entered}" doesn't look like a ws:// address — using the standard broker.`));
326
- else line(ok('using the standard broker.'));
327
- }
328
- } else {
329
- line(ok('using the standard broker.'));
330
- }
334
+ // Every port this run has committed to, so a later daemon can't be handed one an
335
+ // earlier daemon claimed. A live bind probe cannot see these — nothing is
336
+ // listening on them yet — which is exactly why they're tracked by hand.
337
+ const claimedPorts = [];
338
+ // The finished topology, for the end-of-run cross-check. Each entry is one thing that
339
+ // will try to BIND a port, named so a collision can be reported in the user's terms.
340
+ const topology = [];
341
+ const claimPort = (port, label) => {
342
+ if (!Number.isInteger(port)) return;
343
+ if (!claimedPorts.includes(port)) claimedPorts.push(port);
344
+ if (label) topology.push({ label, port });
345
+ };
331
346
 
332
- // 0b — port: probe 3050; only ask if busy. Minimize the concept.
333
- if (!portTakenSync(DEFAULT_PORT)) {
334
- chosenPort = DEFAULT_PORT;
335
- line(ok(`Using local port ${DEFAULT_PORT}.`));
336
- } else {
337
- line(info(`The standard local port (${DEFAULT_PORT}) is already in use on your machine.`));
338
- let candidate = suggestPort(DEFAULT_PORT + 1, portTakenSync);
339
- const raw = ask(` Pick another number for the ours daemon? ${c.gray(`[Enter for ${candidate}]`)}: `, String(candidate));
340
- const parsed = parsePort(raw, candidate);
341
- candidate = suggestPort(parsed.ok ? parsed.port : candidate, portTakenSync);
342
- chosenPort = candidate;
343
- line(ok(`Using local port ${chosenPort}.`));
347
+ // Ask for ONE daemon's port, validate it, and keep asking until the answer is
348
+ // usable. Enter (and every non-interactive run) takes `def` unchanged — that is
349
+ // what keeps the historical behaviour and scripted installs identical.
350
+ const askDaemonPort = (prompt, def) => {
351
+ let candidate = def;
352
+ for (let attempt = 0; attempt < 3; attempt++) {
353
+ const raw = ask(` ${prompt} ${c.gray(`[Enter for ${candidate}]`)}: `, String(candidate));
354
+ const v = validateDaemonPort(raw, { fallback: candidate, isTaken: portTakenSync, taken: claimedPorts });
355
+ if (v.ok) return v.port;
356
+ line(warn(`${v.reason}.`));
357
+ if (!interactive) break; // no one to re-ask; fall through to a suggestion
358
+ candidate = suggestPort(v.port + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
359
+ line(info(`Suggesting ${candidate} instead.`));
344
360
  }
345
- line('');
346
- line(ok(`Config ready — broker: ${chosenBroker ? 'custom' : 'standard'}, port: ${chosenPort}.`));
347
- cont();
348
- }
361
+ // Out of attempts (or headless): take the first genuinely free port rather
362
+ // than persisting one we know is unusable.
363
+ const fallback = suggestPort(candidate, (p) => claimedPorts.includes(p) || portTakenSync(p));
364
+ line(ok(`Using port ${fallback}.`));
365
+ return fallback;
366
+ };
349
367
 
350
368
  // Track outcomes for the summary + hand-off.
351
369
  const summary = [];
@@ -432,13 +450,57 @@ async function main() {
432
450
  };
433
451
 
434
452
  // ============================================================================================
435
- // STEP 1 / 4 — ours core (the daemon). Config-first: write config → optional voice → start ONCE.
453
+ // STEP 1 / 5 — the SHARED ours daemon. Its own visible step, and it owns its own configuration
454
+ // (broker + listen port) rather than a nameless "quick settings" preamble: every consumer below
455
+ // is wired to the endpoint chosen HERE, so the choice belongs to the step that makes it.
456
+ // Config-first within the step: choose → write config → optional voice → start ONCE.
436
457
  // ============================================================================================
437
- line(heading('1/4 — ours core (the daemon)'));
438
- line(info('This is the piece that lets your agents talk to each other securely. Everything else'));
439
- line(info('needs it.'));
458
+ line(heading('1/5 — the shared ours daemon'));
459
+ line(info('This is the piece that lets your agents talk to each other securely. The harness'));
460
+ line(info('plugins, ours-fleet and the Telegram connector all connect to it — Telegram can be'));
461
+ line(info('given its own instead, later — and so can Rooms.'));
440
462
  const before = parseVersion(versionBefore);
441
463
 
464
+ if (configFirst) {
465
+ // Broker (owner edit #1: SECURE wording; owner edit #2: self-host → website only).
466
+ line('');
467
+ line(info('Your agents connect through a "broker" — a shared meeting point that lets them find'));
468
+ line(info("each other. It's secure: your messages are end-to-end encrypted, so the broker never"));
469
+ line(info('sees what they say. Almost everyone uses the standard one — just press Enter.'));
470
+ const custom = yes(' Use a custom broker address?', false);
471
+ if (custom) {
472
+ line(info(`(Only needed if you run your own broker. More at ${SELFHOST_URL}.)`));
473
+ const entered = ask(' Enter the broker address: ', '');
474
+ const v = validateBroker(entered);
475
+ if (entered && v.ok && !v.empty) {
476
+ // Undo safety net: a mistaken custom entry is one keystroke back to the standard broker.
477
+ const keep = yes(` Use "${v.value}"? (No = go back to the standard broker)`, true);
478
+ if (keep) { chosenBroker = v.value; line(ok(`broker set to ${chosenBroker}.`)); }
479
+ else line(ok('using the standard broker.'));
480
+ } else {
481
+ if (entered) line(warn(`"${entered}" doesn't look like a ws:// address — using the standard broker.`));
482
+ else line(ok('using the standard broker.'));
483
+ }
484
+ } else {
485
+ line(ok('using the standard broker.'));
486
+ }
487
+
488
+ // Listen port. ALWAYS asked now, so the shared daemon's endpoint is a decision the
489
+ // user makes rather than one they only hear about when 3050 happens to be busy.
490
+ // The default is still 3050 (the next free port when it is taken), so Enter and
491
+ // every non-interactive run land exactly where they always did.
492
+ line('');
493
+ line(info('The daemon listens on a local port. Everything else in this install is pointed at it.'));
494
+ const portDefault = portTakenSync(DEFAULT_PORT) ? suggestPort(DEFAULT_PORT + 1, portTakenSync) : DEFAULT_PORT;
495
+ if (portDefault !== DEFAULT_PORT) line(info(`The standard port (${DEFAULT_PORT}) is already in use on your machine.`));
496
+ chosenPort = askDaemonPort('Which local port should the shared daemon use?', portDefault);
497
+ line(ok(`Shared daemon: port ${chosenPort}, broker ${chosenBroker ? 'custom' : 'standard'}.`));
498
+ line('');
499
+ } else {
500
+ line(ok(`Shared daemon already configured — port ${chosenPort}. Keeping it.`));
501
+ }
502
+ claimPort(chosenPort, 'the shared ours daemon');
503
+
442
504
  if (!daemonInstalled) {
443
505
  const goCore = yes(' Install and start it?', true);
444
506
  if (!goCore) {
@@ -628,10 +690,10 @@ async function main() {
628
690
  }
629
691
 
630
692
  // ============================================================================================
631
- // STEP 2 / 4 — harness plugins (Claude Code + Codex + Hermes). The installer drives the plugin
693
+ // STEP 2 / 5 — harness plugins (Claude Code + Codex + Hermes). The installer drives the plugin
632
694
  // CLIs for Claude/Codex; Hermes installs via npm + ours-hermes-install (no CLI driving).
633
695
  // ============================================================================================
634
- line(heading('2/4 — harness plugins'));
696
+ line(heading('2/5 — harness plugins'));
635
697
  line(info('These teach Claude Code, Codex, and Hermes the ours skills, so you can just talk to your'));
636
698
  line(info("agent to message people and set things up. I'll install them for you — no commands to type."));
637
699
  for (const h of harnesses) {
@@ -644,9 +706,9 @@ async function main() {
644
706
  }
645
707
 
646
708
  // ============================================================================================
647
- // STEP 3 / 4 — ours-fleet. Appealing wording (owner edit #4); default YES.
709
+ // STEP 3 / 5 — ours-fleet. Appealing wording (owner edit #4); default YES.
648
710
  // ============================================================================================
649
- line(heading('3/4 — ours-fleet (your always-online agent team)'));
711
+ line(heading('3/5 — ours-fleet (your always-online agent team)'));
650
712
  line(info('This makes your harnesses PERSISTENT: Claude Code and Codex stop being just a terminal'));
651
713
  line(info('session and become always-online daemons that survive a reboot. Stand up your own team'));
652
714
  line(info('of always-online developers, combine harnesses, run several Claude Codes, and link them'));
@@ -674,22 +736,82 @@ async function main() {
674
736
  }
675
737
  cont(goFleet);
676
738
 
677
- // Give the Telegram connector the ONE daemon this install just built: its loopback
739
+ // The broker the WHOLE deployment shares, whichever daemon a consumer talks to.
740
+ const sharedBroker = () => resolveSharedBroker({
741
+ chosenBroker,
742
+ statusBroker: status0.broker,
743
+ configBroker: readConfigObject().brokerUrl,
744
+ });
745
+
746
+ // Provision a daemon that belongs to ONE consumer: its own config file, its own
747
+ // state directory, its own port, and — via core's OURS_SERVICE_NAME — its own boot
748
+ // unit, so `install-service` cannot overwrite the shared daemon's. Returns the
749
+ // endpoint + state dir to wire that consumer to, and whether it came up.
750
+ async function provisionDedicatedDaemon({ instance, port, label }) {
751
+ const { stateDir, configPath: cfgPath, serviceName } = dedicatedDaemonPaths(homedir(), instance);
752
+ const env = { OURS_CONFIG: cfgPath, OURS_STATE_DIR: stateDir, OURS_SERVICE_NAME: serviceName };
753
+ // The dedicated daemon has to exist as a package before it can be started; on a
754
+ // fresh machine step 1 already installed it, but a re-run that skipped core has not.
755
+ await actSpin(`ensuring ${spec('mcp')}…`, `npm i -g ${spec('mcp')}`, () => runAsync(NPM, ['i', '-g', spec('mcp')]));
756
+ const desired = { port, stateDir, serviceName };
757
+ const broker = sharedBroker();
758
+ if (broker) desired.brokerUrl = broker;
759
+ let existing = {};
760
+ try { existing = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch { /* absent or unreadable */ }
761
+ const sameAlready = existing.port === port && existing.stateDir === stateDir && existing.serviceName === serviceName;
762
+ if (sameAlready) {
763
+ line(ok(`The ${label} daemon is already configured on port ${port} — no change.`));
764
+ // Nothing to change AND it is already up: do not touch it. `install-service` STOPS
765
+ // the daemon before rewriting the unit, so re-running it here would bounce a healthy
766
+ // daemon for no reason.
767
+ if (!DRY && run('ours-mcp', ['status'], { capture: true, env }).ok) {
768
+ line(ok(`Dedicated ${label} daemon already running on port ${port} — left alone.`));
769
+ return { endpoint: daemonEndpoint(port), stateDir, serviceName, configPath: cfgPath, running: true, port };
770
+ }
771
+ } else {
772
+ await act(`write ${cfgPath} (dedicated ${label} daemon, port ${port}, state ${stateDir})`, async () => {
773
+ atomicWriteConfig(cfgPath, mergeConfig(existing, desired));
774
+ return { ok: true };
775
+ });
776
+ }
777
+ const started = await act(`ours-mcp start (dedicated ${label} daemon, port ${port})`, async () => run('ours-mcp', ['start'], { env }));
778
+ const svc = await act(`ours-mcp install-service (dedicated ${label} daemon, unit ours-${serviceName})`, async () => run('ours-mcp', ['install-service'], { env }));
779
+ // Same recovery as the shared daemon: install-service STOPS the daemon before it
780
+ // writes the unit, so a failure there leaves nothing listening on this port.
781
+ let running = started.ok;
782
+ if (!DRY && !svc.ok) {
783
+ running = run('ours-mcp', ['status'], { capture: true, env }).ok;
784
+ if (!running) {
785
+ line(info(`the boot-service step stopped the ${label} daemon before it failed — restarting it.`));
786
+ running = run('ours-mcp', ['start'], { env }).ok;
787
+ }
788
+ line(warn(`the ${label} daemon has no boot service — retry '${c.cyan(`OURS_CONFIG=${cfgPath} OURS_SERVICE_NAME=${serviceName} ours-mcp install-service`)}'.`));
789
+ }
790
+ if (running || DRY) line(ok(`Dedicated ${label} daemon ready on port ${port} (state ${stateDir}, unit ours-${serviceName}).`));
791
+ else line(warn(`could not start the dedicated ${label} daemon — run '${c.cyan(`OURS_CONFIG=${cfgPath} ours-mcp start`)}'.`));
792
+ return { endpoint: daemonEndpoint(port), stateDir, serviceName, configPath: cfgPath, running: running || DRY, port };
793
+ }
794
+
795
+ // Ask one consumer whether it uses the COMMON daemon or gets its own. Enter and
796
+ // every non-interactive run answer "common" — the historical topology.
797
+ const askDaemonMode = (what) => {
798
+ line(info(`${what} can share the daemon from step 1, or run against its own isolated one.`));
799
+ line(info('Sharing is right for almost everyone — press Enter. A dedicated daemon gets its own'));
800
+ line(info('port, state directory and boot service, and does not see the shared daemon\'s identities.'));
801
+ return yes(` Give ${what} its OWN dedicated daemon?`, false) ? 'dedicated' : 'common';
802
+ };
803
+
804
+ // Give the Telegram connector the daemon it was assigned: that daemon's loopback
678
805
  // endpoint, the state directory that endpoint's API token belongs to, and — for a
679
806
  // pre-0.3.3 connector that still meets the daemon at a broker instead — that broker.
680
807
  // Idempotent: an unchanged selection writes nothing. Returns { changed, hadPrevious }
681
808
  // so the caller can warn about a service unit that froze an older selection.
682
- async function writeTgDaemonConfig({ chosenPort, chosenBroker, status0 }) {
809
+ async function writeTgDaemonConfig({ endpoint, stateDir }) {
683
810
  const path = tgConfigPath(process.env, homedir());
684
- const stateDir = daemonStateDir();
685
811
  const desired = {
686
- daemonUrl: daemonEndpoint(chosenPort),
812
+ daemonUrl: endpoint,
687
813
  daemonStateDir: stateDir,
688
- brokerUrl: resolveSharedBroker({
689
- chosenBroker,
690
- statusBroker: status0.broker,
691
- configBroker: readConfigObject().brokerUrl,
692
- }),
814
+ brokerUrl: sharedBroker(),
693
815
  };
694
816
  let existing = {};
695
817
  try { existing = JSON.parse(readFileSync(path, 'utf8')); } catch { /* absent or unreadable */ }
@@ -708,36 +830,52 @@ async function main() {
708
830
  }
709
831
 
710
832
  // ============================================================================================
711
- // STEP 4 / 4 — Telegram connector. Install-only (no bot tokens here). Then: run as a service?
833
+ // STEP 4 / 5 — Telegram connector. Install-only (no bot tokens here). Then: run as a service?
712
834
  // ============================================================================================
713
- line(heading('4/4 — Telegram connector'));
835
+ line(heading('4/5 — Telegram connector'));
714
836
  line(info('This bridges a Telegram bot to your Ours node, so you can talk to your agent from'));
715
837
  line(info("Telegram. (You'll set up the actual bot later, with your agent — not here.)"));
716
838
  const goTg = yes(' Install it?', false);
717
839
  if (goTg) {
718
840
  await actSpin(`installing ${spec('tg-connector')}…`, `npm i -g ${spec('tg-connector')}`, () => runAsync(NPM, ['i', '-g', spec('tg-connector')]));
719
- // POINT IT AT THE ONE DAEMON — BEFORE it is started or installed as a service.
841
+ // WHICH daemon — asked independently of every other consumer, and answered
842
+ // "common" by Enter / non-interactive so the historical topology is the default.
843
+ line('');
844
+ const tgMode = askDaemonMode('the Telegram connector');
845
+ let tgDaemon = { endpoint: daemonEndpoint(chosenPort), stateDir: daemonStateDir(), port: chosenPort, mode: 'common' };
846
+ if (tgMode === 'dedicated') {
847
+ const instance = DEDICATED_INSTANCES.telegram;
848
+ const suggested = suggestPort(chosenPort + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
849
+ const port = askDaemonPort('Which local port should the Telegram daemon use?', suggested);
850
+ claimPort(port, 'the dedicated Telegram daemon');
851
+ const provisioned = await provisionDedicatedDaemon({ instance, port, label: 'Telegram' });
852
+ tgDaemon = { ...provisioned, mode: 'dedicated' };
853
+ } else {
854
+ line(ok(`Telegram will use the shared daemon on port ${chosenPort}.`));
855
+ }
856
+ // POINT IT AT THAT DAEMON — BEFORE it is started or installed as a service.
720
857
  // The connector never inherits ~/.ours/config.json (its SDK reports configPath:
721
858
  // null unless told otherwise), and `install-service` bakes whatever it resolves
722
859
  // into the unit as environment variables that outrank the file from then on. So
723
860
  // the daemon's identity has to be in its config BEFORE either happens. See
724
861
  // planTgDaemonConfig for why all three keys are written.
725
- const tgConfigured = await writeTgDaemonConfig({ chosenPort, chosenBroker, status0 });
862
+ const tgConfigured = await writeTgDaemonConfig(tgDaemon);
863
+ const where = tgDaemon.mode === 'dedicated' ? `its own daemon on port ${tgDaemon.port}` : `the shared daemon on port ${tgDaemon.port}`;
726
864
  const asService = yes(' Keep it running in the background so it starts automatically on boot?', true);
727
865
  if (asService) {
728
866
  const svc = await act('ours-tg-connector install-service (starts on boot)', async () => run('ours-tg-connector', ['install-service']));
729
- if (svc.ok) line(ok(`Telegram connector installed and running as a service (starts on boot), pointed at the daemon on port ${chosenPort}. No problems.`));
867
+ if (svc.ok) line(ok(`Telegram connector installed and running as a service (starts on boot), pointed at ${where}. No problems.`));
730
868
  else line(warn(`connector installed, but the service didn't start — retry '${c.cyan('ours-tg-connector install-service')}'.`));
731
- record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: `service (boot) · daemon ${chosenPort}` });
869
+ record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: `service (boot) · ${tgDaemon.mode} daemon ${tgDaemon.port}` });
732
870
  } else {
733
- line(ok(`Telegram connector installed, pointed at the daemon on port ${chosenPort}. Start it any time with '${c.cyan('ours-tg-connector start')}'. No problems.`));
871
+ line(ok(`Telegram connector installed, pointed at ${where}. Start it any time with '${c.cyan('ours-tg-connector start')}'. No problems.`));
734
872
  // A connector already installed as a service froze its OLD daemon selection into
735
873
  // the unit's environment, which outranks the file we just wrote. Config alone
736
874
  // cannot repair that — say so plainly rather than let it look fixed.
737
875
  if (tgConfigured.changed && tgConfigured.hadPrevious) {
738
876
  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.`));
739
877
  }
740
- record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: `start on demand · daemon ${chosenPort}` });
878
+ record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: `start on demand · ${tgDaemon.mode} daemon ${tgDaemon.port}` });
741
879
  }
742
880
  } else {
743
881
  line(info('skipped cleanly.'));
@@ -745,6 +883,146 @@ async function main() {
745
883
  }
746
884
  cont(goTg);
747
885
 
886
+ // ============================================================================================
887
+ // STEP 5 / 5 — Rooms (ours-cowork). Two independent things get configured here.
888
+ //
889
+ // ITS OWN SURFACE — the deployment broker, its private state directory, and its loopback
890
+ // console/REST port. Those it has always had.
891
+ //
892
+ // WHICH DAEMON — ours-cowork used to host its own, always. It now supports an EXTERNAL ours
893
+ // daemon (cowork PR #9), so Rooms answers the same common-vs-dedicated question the Telegram
894
+ // connector does, plus a third state the connector does not have: EMBEDDED, cowork's own.
895
+ // Contract (see logic.mjs): the `daemon` block is optional; absent means embedded; external is
896
+ // { mode:'external', endpoint, stateDir } and REQUIRES both halves, because cowork holds no
897
+ // token and its SDK reads <stateDir>/daemon-token.
898
+ //
899
+ // Boot is FAIL-CLOSED on an unreachable endpoint, a non-ours daemon, or a mismatched state
900
+ // directory — there is no embedded fallback. So an install that is ALREADY running embedded is
901
+ // never migrated behind the user's back: non-interactively it is left exactly as it is, and
902
+ // interactively the question is asked plainly before anything is written.
903
+ // ============================================================================================
904
+ line(heading('5/5 — Rooms (ours-cowork)'));
905
+ line(info('Durable mission rooms: a room keeps its own ordered history, and people and agents'));
906
+ line(info('join it as seats. It serves a local web console, and reaches everyone through the'));
907
+ line(info('same broker as the rest of your install.'));
908
+ const goRooms = yes(' Install it?', false);
909
+ if (goRooms) {
910
+ await actSpin(`installing ${spec('cowork')}…`, `npm i -g ${spec('cowork')}`, () => runAsync(NPM, ['i', '-g', spec('cowork')]));
911
+ const roomsStateDir = join(homedir(), '.ours-cowork');
912
+ const cfgPath = coworkConfigPath(process.env, homedir());
913
+ let existingRooms = {};
914
+ try { existingRooms = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch { /* absent or unreadable */ }
915
+ const restDefault = Number.isInteger(existingRooms.rest?.port) ? existingRooms.rest.port : COWORK_DEFAULT_PORT;
916
+ line('');
917
+ line(info('Rooms serves a console on a loopback port — 127.0.0.1 only, never exposed.'));
918
+ // COWORK_DEFAULT_PORT is in RESERVED_PORTS (so no ours daemon can be handed it),
919
+ // so validate this one against the daemon ports only.
920
+ const roomsPort = (() => {
921
+ let candidate = restDefault;
922
+ for (let attempt = 0; attempt < 3; attempt++) {
923
+ const raw = ask(` Which local port should the Rooms console use? ${c.gray(`[Enter for ${candidate}]`)}: `, String(candidate));
924
+ const v = validateDaemonPort(raw, {
925
+ fallback: candidate, isTaken: (p) => (p === restDefault ? false : portTakenSync(p)),
926
+ taken: claimedPorts, reserved: [],
927
+ });
928
+ if (v.ok) return v.port;
929
+ line(warn(`${v.reason}.`));
930
+ if (!interactive) break;
931
+ candidate = suggestPort(v.port + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
932
+ line(info(`Suggesting ${candidate} instead.`));
933
+ }
934
+ return candidate;
935
+ })();
936
+ claimPort(roomsPort, 'the Rooms console');
937
+
938
+ // WHICH DAEMON. `undefined` means "leave whatever is there alone" — the answer for an
939
+ // existing embedded install nobody asked to migrate.
940
+ const wasEmbedded = coworkDaemonMode(existingRooms) === 'embedded';
941
+ const hadConfig = existsSync(cfgPath);
942
+ // Ask the BUILD, not the channel: this runs after the install above, so the version
943
+ // read here is the one actually on the machine.
944
+ const coworkVersion = globalVersion('@ours.network/cowork');
945
+ const externalSupported = coworkSupportsExternalDaemon(coworkVersion);
946
+ let roomsDaemon;
947
+ let roomsDaemonLabel;
948
+ if (!externalSupported) {
949
+ // The build we just installed predates cowork's external-daemon mode. Its config
950
+ // is a strict document and its boot fails closed, so writing a selection it cannot
951
+ // honour would break Rooms rather than degrade it.
952
+ line(info(`This Rooms build${coworkVersion ? ` (${coworkVersion})` : ''} hosts its own daemon; pointing it at the shared`));
953
+ line(info(`one needs ${COWORK_EXTERNAL_MIN_VERSION} or newer. Re-run with ${c.cyan('OURS_CHANNEL=nightly')} to get it.`));
954
+ roomsDaemonLabel = 'embedded';
955
+ } else if (hadConfig && wasEmbedded && !interactive) {
956
+ // Fail-closed boot makes this migration a real risk; never do it unasked.
957
+ line(info('Rooms already runs its own embedded daemon — leaving that alone.'));
958
+ line(info(`To point it at this install's daemon, re-run ${c.cyan('ours-install')} in a terminal.`));
959
+ roomsDaemonLabel = 'embedded (unchanged)';
960
+ } else {
961
+ line('');
962
+ const roomsMode = askDaemonMode('Rooms');
963
+ if (roomsMode === 'dedicated') {
964
+ const instance = DEDICATED_INSTANCES.rooms;
965
+ const suggested = suggestPort(chosenPort + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
966
+ const port = askDaemonPort('Which local port should the Rooms daemon use?', suggested);
967
+ claimPort(port, 'the dedicated Rooms daemon');
968
+ const provisioned = await provisionDedicatedDaemon({ instance, port, label: 'Rooms' });
969
+ roomsDaemon = { endpoint: provisioned.endpoint, stateDir: provisioned.stateDir };
970
+ roomsDaemonLabel = `dedicated daemon ${port}`;
971
+ } else {
972
+ roomsDaemon = { endpoint: daemonEndpoint(chosenPort), stateDir: daemonStateDir() };
973
+ roomsDaemonLabel = `common daemon ${chosenPort}`;
974
+ line(ok(`Rooms will use the shared daemon on port ${chosenPort}.`));
975
+ }
976
+ }
977
+
978
+ const roomsPlan = planCoworkConfig(existingRooms, {
979
+ brokerUrl: sharedBroker(),
980
+ stateDir: roomsStateDir,
981
+ restPort: roomsPort,
982
+ daemon: roomsDaemon,
983
+ });
984
+ if (roomsPlan.error) {
985
+ // Only reachable if a daemon selection lost half of itself; a half-written block
986
+ // would fail closed at cowork's boot, so refuse rather than write it.
987
+ line(warn(`not changing the Rooms daemon selection — ${roomsPlan.error}.`));
988
+ } else if (roomsPlan.changed) {
989
+ await act(`write ${cfgPath} (console port ${roomsPort}, state ${roomsStateDir}${roomsDaemon ? `, daemon ${roomsDaemon.endpoint}` : ''})`, async () => {
990
+ atomicWriteConfig(cfgPath, roomsPlan.text);
991
+ return { ok: true };
992
+ });
993
+ if (roomsDaemon) line(ok(`Rooms configured to use ${roomsDaemonLabel} (${roomsDaemon.endpoint}, state ${roomsDaemon.stateDir}).`));
994
+ } else {
995
+ line(ok(`Rooms is already configured for this deployment (console port ${roomsPort}) — no change.`));
996
+ }
997
+ const svc = await act('ours-cowork install-service (starts on boot)', async () => run('ours-cowork', ['install-service']));
998
+ if (svc.ok) {
999
+ line(ok(`Rooms ready — console at ${c.cyan(`http://127.0.0.1:${roomsPort}/`)}, sharing your broker. No problems.`));
1000
+ } else {
1001
+ line(warn(`Rooms installed, but its service didn't start — retry '${c.cyan('ours-cowork install-service')}'.`));
1002
+ line(info(`You can also run it in the foreground: '${c.cyan('ours-cowork web')}'.`));
1003
+ }
1004
+ record({
1005
+ key: 'rooms',
1006
+ label: 'Rooms (ours-cowork)',
1007
+ state: svc.ok ? 'installed' : 'failed',
1008
+ version: coworkVersion,
1009
+ note: svc.ok ? `console ${roomsPort} · ${roomsDaemonLabel}` : 'ours-cowork install-service failed',
1010
+ });
1011
+ } else {
1012
+ line(info('skipped cleanly — re-run ours-install any time to add it.'));
1013
+ record({ key: 'rooms', label: 'Rooms (ours-cowork)', state: 'skipped' });
1014
+ }
1015
+ cont(goRooms);
1016
+
1017
+ // Last guard on the whole topology: no two daemons in this install may share a port.
1018
+ // Each answer was validated as it was given, but only the finished plan proves the set.
1019
+ const portPlan = planPorts(topology);
1020
+ if (!portPlan.ok) {
1021
+ for (const d of portPlan.duplicates) {
1022
+ line(warn(`port ${d.port} ended up claimed by both ${d.labels[0]} and ${d.labels[1]} — one of them will fail to bind.`));
1023
+ }
1024
+ }
1025
+
748
1026
  return endScreen({ ttyFd, summary, chosenPort, chosenBroker });
749
1027
  }
750
1028
 
@@ -823,7 +1101,9 @@ function endScreen({ ttyFd, summary, chosenPort, chosenBroker }) {
823
1101
  const has = (k) => summary.some((r) => r.key === k && (r.state === 'installed' || r.state === 'current'));
824
1102
  if (has('core')) {
825
1103
  const identityDone = has('identity');
826
- const { text, empty } = buildHandoffPrompt({ identity: !identityDone, fleet: has('fleet'), telegram: has('telegram') });
1104
+ const { text, empty } = buildHandoffPrompt({
1105
+ identity: !identityDone, fleet: has('fleet'), telegram: has('telegram'), rooms: has('rooms'),
1106
+ });
827
1107
  if (empty) {
828
1108
  // Nothing left to finish (identity created in-install, no fleet/Telegram). Don't show an empty box.
829
1109
  line('');