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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,8 +7,7 @@
7
7
  //
8
8
  // The three components are the MCP server, the Telegram connector and cowork.
9
9
  // None of them IS the daemon; all three attach to one. The messenger is out of
10
- // scope by the owner's instruction, and ours-fleet sits on top and is not
11
- // installed here.
10
+ // scope here, and ours-fleet sits on top and is not installed here.
12
11
 
13
12
  import { join, resolve } from 'node:path';
14
13
  import { pkgSpec, resolveChannel } from './logic.mjs';
@@ -23,8 +22,13 @@ import { pkgSpec, resolveChannel } from './logic.mjs';
23
22
  // effects.installedVersion('@ours.network/mcp@nightly') return null forever,
24
23
  // which fails the cowork version floor CLOSED and blanks the version column —
25
24
  // a silent regression that looks like "cowork is too old".
25
+ // `required` is not a stronger default — it is the absence of a choice. The
26
+ // daemon is the ours-sdk CLI and this package is the MCP server every harness
27
+ // speaks to it through, so an operator who declined it would have a daemon no
28
+ // harness can reach — which is not a decision anyone means to make. Declining
29
+ // has to be impossible rather than discouraged.
26
30
  export const COMPONENTS = [
27
- { key: 'mcp', label: 'MCP server', pkg: '@ours.network/mcp', specKey: 'mcp', default: true },
31
+ { key: 'mcp', label: 'MCP server for your harness', pkg: '@ours.network/mcp', specKey: 'mcp', default: true, required: true },
28
32
  { key: 'tg', label: 'Telegram connector', pkg: '@ours.network/tg-connector', specKey: 'tg-connector', default: false },
29
33
  { key: 'cowork', label: 'cowork', pkg: '@ours.network/cowork', specKey: 'cowork', default: false },
30
34
  ];
@@ -41,7 +45,7 @@ export const COMPONENTS = [
41
45
  * over. Every install path for these three now goes through here.
42
46
  *
43
47
  * All three publish a real `nightly` dist-tag (verified against the registry,
44
- * 2026-08-17), so none of these pins can 404. `pkgSpec` falls back to `latest`
48
+ * so none of these pins can 404. `pkgSpec` falls back to `latest`
45
49
  * for anything unmapped rather than inventing a tag, so an unknown component
46
50
  * degrades to today's behaviour instead of failing.
47
51
  *
@@ -84,10 +88,10 @@ export const COWORK_DAEMON_FLOOR = '0.4.1-nightly.20260816.4aaf940';
84
88
  * daemon side, and anything the installer clears here is gone. The installer's
85
89
  * entire business with the connector is three keys in one config file.
86
90
  *
87
- * This also keeps the door open for the route migration the owner is considering
88
- * — moving each route's packet into the shared daemon. That migration has to read
89
- * this registry to know which daemon identity corresponds to which route; an
90
- * installer that had trampled it would have destroyed the mapping.
91
+ * It also keeps a future route migration possible — moving each route's packet
92
+ * into the shared daemon has to read this registry to know which daemon identity
93
+ * corresponds to which route, and an installer that trampled it would have
94
+ * destroyed the mapping.
91
95
  */
92
96
  export const TG_STATE_DIR_NAME = '.ours-telegram';
93
97
  export const TG_REGISTRY_FILES = ['bots.json'];
@@ -114,7 +118,12 @@ export function planComponentSelection({ answers = {}, installed = {}, assumeYes
114
118
  return COMPONENTS.map((component) => {
115
119
  const already = installed[component.key] === true;
116
120
  const answer = assumeYes ? component.default : answers[component.key];
117
- const wanted = answer === undefined ? (already || component.default) : answer === true;
121
+ // A required component ignores the answer entirely, including an explicit no.
122
+ // The daemon phase has already installed and started it by the time this runs;
123
+ // "skip" here would only produce a screen that contradicts the machine.
124
+ const wanted = component.required
125
+ ? true
126
+ : (answer === undefined ? (already || component.default) : answer === true);
118
127
  return {
119
128
  ...component,
120
129
  already,
package/lib/detect.mjs CHANGED
@@ -1,19 +1,17 @@
1
1
  // ours-install v3 — which daemons are already on this machine, and which one this
2
2
  // run is for.
3
3
  //
4
- // OWNER RULING (C1, 2026-08-17), and the whole shape of this file follows from it:
5
- // never ask the user to TYPE a state directory or a port — that is what spec §2
6
- // forbids and it stands — but when several daemons are DETECTED, show them and let
7
- // the user pick. Selection from what was found is not prompting for a path.
4
+ // Never ask for a state directory or a port to be TYPED (spec §2). When several
5
+ // daemons are DETECTED, show them and let one be picked: selecting from what was
6
+ // found is not prompting for a path.
8
7
  //
9
8
  // Pure, like target.mjs and plan.mjs: the caller injects the directory listing and
10
9
  // the file reads, so the whole decision is testable without a filesystem.
11
10
  //
12
- // DETECTION, NOT A REGISTRY. Coordinator ruling: build this from what is actually
13
- // on disk rather than from a persisted ~/.ours/installer-profiles.json. A stored
14
- // list is a second source of truth that goes stale against the daemons that really
15
- // exist, and staleness in exactly that file is how an installer ends up
16
- // confidently offering a daemon that is gone.
11
+ // DETECTION, NOT A REGISTRY. This is built from what is on disk rather than from a
12
+ // persisted list: a stored list is a second source of truth that goes stale against
13
+ // the daemons that really exist, which is how an installer ends up confidently
14
+ // offering a daemon that is gone.
17
15
 
18
16
  import { basename, join, resolve } from 'node:path';
19
17
 
@@ -93,8 +91,7 @@ export function detectDaemons({ candidates = [], exists, readJson }) {
93
91
  /**
94
92
  * A state directory for a daemon this run would CREATE, derived and never typed.
95
93
  *
96
- * Spec §2 forbids asking for a path, and the C1 ruling did not change that — it
97
- * added "pick from what was found". So "create a new one" has to derive somewhere
94
+ * Spec §2 forbids asking for a path, so "create a new one" has to derive somewhere
98
95
  * to put it: `~/.ours` when free, else the first free `~/.ours-2`, `~/.ours-3`…
99
96
  * An operator who wants a specific path still has `--state-dir`, which bypasses
100
97
  * this screen entirely.
@@ -112,16 +109,6 @@ export function deriveNewStateDir(home, taken = [], { limit = 64 } = {}) {
112
109
 
113
110
  export const SELECT_CREATE = '__create__';
114
111
 
115
- // The nightly flow kept a registry of daemon profiles here. v3 does not read it —
116
- // detection replaced it (coordinator ruling) — so anyone who used that flow has a
117
- // file describing daemons that nothing consults any more.
118
- //
119
- // It is NOT deleted. Quietly removing a file that describes someone's daemons is
120
- // not an installer's business, and the file is harmless. But leaving it looking
121
- // live is worse than saying it is not, so the run says so once.
122
- export const LEGACY_PROFILE_REGISTRY = 'installer-profiles.json';
123
- export const legacyRegistryPath = (home) => join(resolve(home), '.ours', LEGACY_PROFILE_REGISTRY);
124
-
125
112
  /**
126
113
  * What this run should do about choosing a daemon (C1's five rules, in one place
127
114
  * so they can be read together and tested without a terminal):
package/lib/effects.mjs CHANGED
@@ -18,6 +18,7 @@ import { dirname, join, resolve } from 'node:path';
18
18
  import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
19
19
  import { askYesNo, askLine as askLineOnTty } from './prompt.mjs';
20
20
  import { classifyHarnessProbe } from './logic.mjs';
21
+ import { classifyStateDir } from './detect.mjs';
21
22
 
22
23
  /** GET http://127.0.0.1:<port>/state-dir — the unauthenticated identity probe. */
23
24
  async function probePort(port, { timeoutMs = 1500 } = {}) {
@@ -156,24 +157,41 @@ function copyToClipboard(text) {
156
157
  }
157
158
 
158
159
  /**
159
- * Every state directory on this machine that still has a daemon config.
160
+ * Every DAEMON state directory on this machine.
160
161
  *
161
- * `ours-uninstall` asks this exactly once, to answer one question: are the
162
- * GLOBAL packages still needed by somebody else? Getting it wrong the optimistic
163
- * way (reporting none) uninstalls the CLI out from under a second daemon that is
164
- * still running, so the search is deliberately conservative — it looks only where
165
- * a state directory can actually be, and an unreadable home means "there might be
166
- * others", not "there are none".
162
+ * ONE DEFINITION OF WHAT A DAEMON IS, and this function is why that matters.
167
163
  *
168
- * Where they can be: the default `~/.ours`, plus any `~/.ours*` sibling, which is
169
- * the shape every other part of this installer uses for a second daemon. A state
170
- * directory somewhere else entirely will not be found, and that is a KNOWN limit
171
- * rather than a claim — the failure is keeping a global package that could have
172
- * been removed, which is the harmless direction.
164
+ * It used to count any `~/.ours*` directory containing a config.json. Two of those
165
+ * are not daemons on a perfectly normal machine: `~/.ours-telegram/config.json` is
166
+ * the Telegram connector's and `~/.ours-cowork/config.json` is cowork's. So the
167
+ * uninstaller reported "@ours.network/cli kept — still used by the daemon at
168
+ * ~/.ours-telegram", and two things followed silently:
169
+ *
170
+ * · planGlobalPackages kept cli, mcp and the plugin packages FOREVER on any
171
+ * machine with the connector installed, naming a connector's config directory
172
+ * as a daemon;
173
+ * · worse, planPluginRemoval's `lastDaemon` went false, so the whole harness
174
+ * plugin phase was skipped — with a reason that was not true. A plain
175
+ * interactive `ours-uninstall` on a machine with the connector removed no
176
+ * plugins at all.
177
+ *
178
+ * The selection screen had already closed exactly this: config.json is the one
179
+ * piece of evidence that is AMBIGUOUS, so it cannot be the test. That predicate
180
+ * lives in lib/detect.mjs and this now calls it rather than keeping a second,
181
+ * naive copy that drifted. A daemon is identified by an artefact only a daemon
182
+ * writes, or by a config whose SHAPE is a daemon's.
183
+ *
184
+ * Still deliberately conservative about WHERE it looks: only `~/.ours` and its
185
+ * `~/.ours*` siblings. A state directory somewhere else is not found, and an
186
+ * unreadable home means "there might be others", not "there are none" — because
187
+ * the caller uses this to decide whether a GLOBAL package is still needed, and
188
+ * being wrong the optimistic way uninstalls the CLI out from under a running
189
+ * daemon.
173
190
  */
174
191
  function knownStateDirsIn(home) {
175
192
  const found = [];
176
- const consider = (dir) => { if (existsSync(join(dir, 'config.json'))) found.push(dir); };
193
+ const io = { exists: existsSync, readJson: readJsonFile };
194
+ const consider = (dir) => { if (classifyStateDir(dir, io).isDaemon) found.push(dir); };
177
195
  consider(join(home, '.ours'));
178
196
  try {
179
197
  for (const entry of readdirSync(home, { withFileTypes: true })) {
@@ -278,7 +296,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
278
296
  };
279
297
  }
280
298
 
281
- export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, installedVersionOf };
299
+ export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, installedVersionOf, knownStateDirsIn };
282
300
 
283
301
  // -----------------------------------------------------------------------------
284
302
  // THE PAIR
package/lib/extras.mjs CHANGED
@@ -1,14 +1,10 @@
1
1
  // ours-install v3 — the four retained extras, re-pointed at the v3 arrangement.
2
2
  //
3
- // Owner ruling 2026-08-17: the v3 installer KEEPS harness plugins, ours-fleet,
4
- // voice setup and the copy-paste hand-off prompt. Spec v3's silence about them
5
- // was an oversight, not a decision.
3
+ // The v3 installer keeps harness plugins, ours-fleet, voice setup and the
4
+ // copy-paste hand-off prompt; spec v3's silence about them was an oversight.
6
5
  //
7
- // They are not four more screens carried across, because v2 and v3 disagree
8
- // about which package IS the daemon. In v2 @ours.network/mcp is the daemon and
9
- // gets the unit; in v3 the daemon is the ours-sdk CLI and ours-mcp is a
10
- // per-session stdio proxy with no unit. Each of the four had assumed the v2
11
- // arrangement somewhere, and this module is where the new assumption lives.
6
+ // The shared daemon belongs to the operator CLI. ours-mcp is only a per-session
7
+ // stdio adapter with no unit, and these extra phases preserve that boundary.
12
8
  //
13
9
  // Pure, like target.mjs / plan.mjs / components.mjs: no I/O, no subprocess, no
14
10
  // terminal. Every function takes what was observed and returns a plan; the
@@ -52,7 +48,7 @@ export const HARNESSES = [
52
48
  *
53
49
  * So §5's guarantee is ALREADY unmet today for every non-default state
54
50
  * directory, silently: the harness attaches to ~/.ours while the operator was
55
- * told the run targeted somewhere else. Owner ruling Q1 settles the shape:
51
+ * told the run targeted somewhere else. The shape is:
56
52
  *
57
53
  * default state directory today's behaviour, byte for byte.
58
54
  * Hermes, non-default real: the pair is handed to ours-hermes-install's
@@ -265,82 +261,6 @@ export function planFleet({ stateDir, isDefaultStateDir, wanted = true, channel
265
261
  return wanted ? { ...plan, action: 'install' } : { ...plan, action: 'skip', offerOnRerun: true };
266
262
  }
267
263
 
268
- // -----------------------------------------------------------------------------
269
- // voice setup — the installer takes the restart beat
270
- // -----------------------------------------------------------------------------
271
-
272
- /**
273
- * WHY THE INSTALLER OWNS THE RESTART NOW (owner ruling Q2).
274
- *
275
- * cmdVoiceSetup computes `managed = runningPid() !== null` from ours-mcp's OWN
276
- * pid record. A v3 daemon is started by `ours daemon start`, which writes
277
- * <state-dir>/ours-cli-daemon.json — not that record. So runningPid() is null
278
- * and EVERY v3 daemon classifies as `external`. transactVoiceConfig then returns
279
- * at its `if (daemonState !== 'managed') return { ok: true, stage: 'write' }`:
280
- * config written, no apply, no restart, no readiness check, exit 0.
281
- *
282
- * Nothing goes wrong — voice-setup will not run `ours-mcp restart` against a
283
- * CLI-started daemon, which is right. But v2's entire restart protocol
284
- * (restartHandled, the exit-2 branch, "voice setup performed the required
285
- * restart") can only fire for a `managed` daemon, so carrying it across would
286
- * ship a branch that can never be taken. The beat it owned now has no owner, and
287
- * the installer is the only process that knows the daemon is CLI-managed.
288
- *
289
- * One phase, AFTER the component phase — voice-setup is an `ours-mcp`
290
- * subcommand and v3 installs @ours.network/mcp as a COMPONENT, so it is not on
291
- * PATH any earlier. The create path needs no special case: in v3 the first
292
- * `ours daemon start` already happened back in the daemon phase.
293
- *
294
- * FILED, NOT FIXED HERE: voice-setup's own hints still say `ours-mcp start` and
295
- * "restart its external launcher" (packages/core/src/cli.ts:542,547). Under v3
296
- * the answer is `ours daemon restart --config <cfg>`. That is a packages/core
297
- * change with its own blast radius and does not belong in an installer PR.
298
- */
299
- export function planVoice({
300
- mcpInstalled = false,
301
- ready = false,
302
- assumeYes = false,
303
- accepted = null,
304
- configChanged = false,
305
- stateDir,
306
- port,
307
- } = {}) {
308
- const config = stateDir ? cfgPath(stateDir) : null;
309
- const skip = (reason, message, extra = {}) => ({
310
- key: 'voice', action: 'skip', reason, message, offerOnRerun: true, restartOwed: false, ...extra,
311
- });
312
-
313
- if (!mcpInstalled) {
314
- return skip('no-mcp', 'voice setup needs the MCP server, which this run did not install — re-run ours-install to add both.');
315
- }
316
- if (ready) {
317
- return { key: 'voice', action: 'skip', reason: 'already-configured', message: 'voice transcription is already configured', offerOnRerun: false, restartOwed: false };
318
- }
319
- if (assumeYes) {
320
- return skip('non-interactive', 'voice setup is interactive and this run is not — re-run ours-install to configure it.');
321
- }
322
- if (accepted === false) {
323
- return skip('declined', 'skipped cleanly — re-run ours-install any time to configure voice.');
324
- }
325
-
326
- const env = config ? { OURS_CONFIG: config } : {};
327
- return {
328
- key: 'voice',
329
- action: 'setup',
330
- env,
331
- setup: ['ours-mcp', 'voice-setup'],
332
- statusCheck: ['ours-mcp', 'voice-status', '--json'],
333
- // The beat. Only owed when the config actually changed: an unchanged config
334
- // is not a reason to bounce a daemon somebody else may be using.
335
- restartOwed: Boolean(configChanged),
336
- restart: configChanged && config ? ['ours', 'daemon', 'restart', '--config', config] : null,
337
- // Never rolls the daemon back: voice-setup leaves the prior config intact on
338
- // its own failure path, so the recovery is a retry, not an undo.
339
- retryHint: config ? `ours daemon restart --config ${config}` : null,
340
- port: Number.isInteger(port) ? port : null,
341
- };
342
- }
343
-
344
264
  // -----------------------------------------------------------------------------
345
265
  // the copy-paste hand-off prompt
346
266
  // -----------------------------------------------------------------------------
package/lib/journal.mjs CHANGED
@@ -71,7 +71,10 @@ export function configJournal(effects, { dryRun = false } = {}) {
71
71
  for (const entry of entries.slice().reverse()) {
72
72
  try {
73
73
  effects.restore(entry.path, entry.snapshot);
74
- restored.push({ path: entry.path, existed: entry.snapshot?.exists !== false });
74
+ // A write that RETURNED is not a file that HOLDS. Read the bytes back.
75
+ const mismatch = verifyRestored(effects, entry);
76
+ if (mismatch) failed.push({ path: entry.path, reason: mismatch });
77
+ else restored.push({ path: entry.path, existed: entry.snapshot?.exists !== false });
75
78
  } catch (error) {
76
79
  failed.push({ path: entry.path, reason: error instanceof Error ? error.message : String(error) });
77
80
  }
@@ -82,6 +85,48 @@ export function configJournal(effects, { dryRun = false } = {}) {
82
85
  };
83
86
  }
84
87
 
88
+ /**
89
+ * Did the restore actually take? Returns null when it did, or the reason it did not.
90
+ *
91
+ * WHY THIS EXISTS. `restore` returning is evidence that a call completed, not that
92
+ * a file holds the bytes it was given. A full disk, a read-only mount, a rename
93
+ * that lands somewhere else, an editor holding the inode — each of those can let
94
+ * the write return and leave the old contents in place. Reporting on the call
95
+ * rather than on the state is how a rollback comes to LIE, and a rollback that
96
+ * lies is worse than one that admits it failed: the operator is told the machine
97
+ * is back where it was and stops looking.
98
+ *
99
+ * The read-back seam is `effects.snapshot`, the same one used to record the bytes
100
+ * in the first place — it already returns exactly the shape being compared, so
101
+ * this needs no second seam and no second notion of what a file's state is.
102
+ *
103
+ * Its own failure is a mismatch, not an exception: if the file cannot be read
104
+ * after being written, that is precisely the case this check exists to catch.
105
+ */
106
+ function verifyRestored(effects, entry) {
107
+ const expected = entry.snapshot;
108
+ let actual;
109
+ try {
110
+ actual = effects.snapshot(entry.path);
111
+ } catch (error) {
112
+ return `restored, but the file could not be read back to confirm it (${error instanceof Error ? error.message : String(error)})`;
113
+ }
114
+ const shouldExist = expected?.exists !== false;
115
+ if (!shouldExist) {
116
+ return actual?.exists ? 'this run created it and the removal did not take — the file is still there' : null;
117
+ }
118
+ if (!actual?.exists) return 'the restore reported success but the file is not there';
119
+ if (actual.text !== expected.text) return 'the restore reported success but the bytes on disk are not the previous ones';
120
+ // Bytes first, permissions second: the contents are back either way, so this
121
+ // must not be reported as "could not restore the config".
122
+ if (expected.mode !== undefined && actual.mode !== undefined && actual.mode !== expected.mode) {
123
+ return `contents restored, but the permissions are ${fmtMode(actual.mode)} and were ${fmtMode(expected.mode)} — check them before re-running`;
124
+ }
125
+ return null;
126
+ }
127
+
128
+ const fmtMode = (mode) => `0${(mode & 0o777).toString(8).padStart(3, '0')}`;
129
+
85
130
  /**
86
131
  * The report, and it is half the feature.
87
132
  *
package/lib/logic.mjs CHANGED
@@ -26,7 +26,7 @@ export function canonHarnesses(raw) {
26
26
  return { names, unknown };
27
27
  }
28
28
 
29
- // ── Release CHANNEL / npm dist-tag selection (owner 2026-07-17) ─────────────────
29
+ // ── Release CHANNEL / npm dist-tag selection ───────────────────────────────────
30
30
  // The installer normally installs everything at @latest (stable). Setting
31
31
  // OURS_CHANNEL=nightly (or OURS_INSTALL_CHANNEL) makes it install each package's
32
32
  // PRERELEASE dist-tag instead — for the packages that publish one.
@@ -37,8 +37,8 @@ export function canonHarnesses(raw) {
37
37
  // from this repo by .github/workflows/scripts/bump-versions.sh)
38
38
  // · fleet → `nightly` (its own repo,
39
39
  // adapt-toolkit/ours-fleet, publishes a nightly dist-tag of its own)
40
- // · cowork (rooms) → `nightly` (owner decision
41
- // 2026-08-16: cowork aligns with every other service rather than keeping its
40
+ // · cowork (rooms) → `nightly` (cowork aligns
41
+ // with every other service rather than keeping its
42
42
  // historical `next` tag)
43
43
  //
44
44
  // WHY FLEET FOLLOWS THE CHANNEL NOW. It used to be pinned to @latest with the note
@@ -49,7 +49,7 @@ export function canonHarnesses(raw) {
49
49
  // channel exists to prevent.
50
50
  //
51
51
  // WHY COWORK STILL NEEDS ITS OWN ENTRY. It historically published its prerelease
52
- // line as `next`; the owner's decision on 2026-08-16 is that it aligns with every
52
+ // line as `next`; the decision is that it aligns with every
53
53
  // other service and publishes `nightly` instead. The entry stays because the map,
54
54
  // not a hardcoded string, is what makes such a change one line — and because an
55
55
  // UNMAPPED package deliberately falls back to `latest` rather than a guessed tag.
@@ -60,7 +60,7 @@ export function canonHarnesses(raw) {
60
60
  // build that predates it — the same architecture-boundary mismatch this whole
61
61
  // mechanism exists to prevent, pointed at Rooms instead of Telegram.
62
62
  //
63
- // SEQUENCING — load-bearing, and not yet satisfied. As of 2026-08-16 cowork
63
+ // SEQUENCING — load-bearing, and not yet satisfied. cowork
64
64
  // publishes NO `nightly` dist-tag at all (its tags are latest=0.4.0 and
65
65
  // next=0.3.7-nightly.20260815.80ea770). `npm i -g @ours.network/cowork@nightly`
66
66
  // therefore 404s today, and a 404 fails the WHOLE install. So the nightly
@@ -81,7 +81,7 @@ const PKG_CHANNEL_TAGS = {
81
81
  codex: { nightly: 'nightly' },
82
82
  hermes: { nightly: 'nightly' },
83
83
  fleet: { nightly: 'nightly' },
84
- cowork: { nightly: 'nightly' }, // aligned with every other service (owner, 2026-08-16)
84
+ cowork: { nightly: 'nightly' }, // aligned with every other service
85
85
  };
86
86
 
87
87
  // Normalize a raw channel selection to 'latest' | 'nightly'. Anything unrecognized
@@ -133,81 +133,6 @@ export const RESERVED_PORTS = [3051, 3052];
133
133
  export const DEFAULT_PORT = 3050;
134
134
  export const DEFAULT_BROKER = 'wss://broker1.ours.network';
135
135
 
136
- // ── Daemon topology: one COMMON daemon, plus optional DEDICATED ones ───────────
137
- // Every consumer defaults to the common daemon on the common port — that is the
138
- // backward-compatible answer and what Enter / non-interactive mode picks. A
139
- // consumer may instead be given its OWN daemon, which needs three things to be
140
- // genuinely isolated and not merely differently-addressed:
141
- // · its own PORT (nothing else may bind it)
142
- // · its own STATE DIRECTORY (the daemon's API token lives there; sharing one
143
- // state dir between two daemons corrupts both)
144
- // · its own SERVICE NAME (ours-mcp's boot unit — without a distinct name,
145
- // `install-service` overwrites the common daemon's
146
- // unit; see packages/core/src/service-instance.ts)
147
- // The config file is separate too, since ours-mcp resolves port + stateDir from
148
- // whatever OURS_CONFIG points at.
149
- export const DAEMON_MODES = ['common', 'dedicated'];
150
-
151
- // Fixed instance names, one per consumer that can own a daemon. These are what
152
- // core validates and turns into `ours-<name>.service`, so they must satisfy its
153
- // rules (alphanumeric, no separators at the ends).
154
- export const DEDICATED_INSTANCES = { telegram: 'tg', rooms: 'rooms' };
155
-
156
- // Where a dedicated daemon's private config + state live. Derived from the
157
- // instance name so two consumers can never be handed the same directory.
158
- export function dedicatedDaemonPaths(home, instance) {
159
- const name = String(instance || '').trim();
160
- const stateDir = `${home}/.ours-${name}`;
161
- return { stateDir, configPath: `${stateDir}/config.json`, serviceName: name };
162
- }
163
-
164
- // Normalize a daemon-mode answer. Anything unrecognized (including empty and
165
- // non-interactive) is 'common' — the backward-compatible default.
166
- export function resolveDaemonMode(raw) {
167
- const v = String(raw || '').trim().toLowerCase();
168
- return v === 'dedicated' || v === 'own' || v === 'separate' ? 'dedicated' : 'common';
169
- }
170
-
171
- // Validate a port the user picked for a daemon, against the reserved list AND
172
- // every port this install has already committed to. `isTaken(port)` probes a real
173
- // bind; `taken` is the set of ports already chosen in THIS run, which a live probe
174
- // cannot see (nothing is listening on them yet). Returns
175
- // { ok, port, reason } — ok=false means "ask again", never "silently substitute".
176
- export function validateDaemonPort(input, { fallback = DEFAULT_PORT, isTaken = () => false, taken = [], reserved = RESERVED_PORTS } = {}) {
177
- // Stricter than parsePort on purpose: this answer becomes a persisted listen port,
178
- // so "3.5.1" must be a question repeated, not silently accepted as port 3.
179
- if (!/^\d+$/.test(String(input ?? '').trim())) {
180
- return { ok: false, port: fallback, reason: 'that is not a port number between 1 and 65535' };
181
- }
182
- const parsed = parsePort(input, fallback);
183
- if (!parsed.ok) return { ok: false, port: fallback, reason: 'that is not a port number between 1 and 65535' };
184
- const port = parsed.port;
185
- if (reserved.includes(port)) {
186
- return { ok: false, port, reason: `port ${port} is reserved by another part of the stack` };
187
- }
188
- if (taken.includes(port)) {
189
- return { ok: false, port, reason: `port ${port} is already being used by another daemon in this install` };
190
- }
191
- if (isTaken(port)) {
192
- return { ok: false, port, reason: `port ${port} is already in use on this machine` };
193
- }
194
- return { ok: true, port, reason: '' };
195
- }
196
-
197
- // The whole install's port plan, checked as a set. Returns { ok, duplicates } so
198
- // the caller can refuse a topology where two daemons would fight over one port
199
- // even though each looked fine on its own.
200
- export function planPorts(entries = []) {
201
- const seen = new Map();
202
- const duplicates = [];
203
- for (const { label, port } of entries) {
204
- if (!Number.isInteger(port)) continue;
205
- if (seen.has(port)) duplicates.push({ port, labels: [seen.get(port), label] });
206
- else seen.set(port, label);
207
- }
208
- return { ok: duplicates.length === 0, duplicates };
209
- }
210
-
211
136
  // ── Handing the Telegram connector the ONE shared daemon ───────────────────────
212
137
  // The connector has its OWN config file and never inherits the daemon's. Two
213
138
  // generations of it are in the wild and the installer must satisfy BOTH, because
@@ -246,7 +171,7 @@ export function daemonEndpoint(port) {
246
171
 
247
172
  // The broker the whole deployment shares, for a <=0.3.2 connector.
248
173
  // Precedence: what the user chose in THIS run > what the running daemon actually
249
- // resolved (`ours-mcp status`, which already accounts for OURS_BROKER_URL) > what
174
+ // resolved (`ours daemon status`, which accounts for the selected config) > what
250
175
  // the daemon's config file says > the built-in default (identical to the daemon's
251
176
  // DEFAULT_CONFIG.brokerUrl, so "no answer anywhere" still agrees).
252
177
  export function resolveSharedBroker({ chosenBroker, statusBroker, configBroker } = {}) {
@@ -592,7 +517,7 @@ export function parseVersion(text) {
592
517
  return m ? m[0] : '';
593
518
  }
594
519
 
595
- // parseStatus: read the daemon's RESOLVED broker + port out of `ours-mcp status` output, so we
520
+ // parseStatus: read the daemon's RESOLVED broker + port out of `ours daemon status` output, so we
596
521
  // prompt with what the daemon is actually using rather than a hardcoded guess. Returns
597
522
  // { broker, port } with either field null when the line isn't present (daemon stopped / older
598
523
  // build). Lines look like: " broker: wss://broker1.ours.network" and
@@ -600,7 +525,10 @@ export function parseVersion(text) {
600
525
  export function parseStatus(text) {
601
526
  const s = String(text || '');
602
527
  const bm = s.match(/^\s*broker:\s*(\S+)/m);
603
- const pm = s.match(/url:\s*https?:\/\/[^:\s/]+:(\d+)/i);
528
+ // `api:` is the current line and `url:` the historical one. Both are matched
529
+ // because a newly installed CLI can be asked to report on an older running
530
+ // daemon, and a parser that knows only today's wording reads that as "no port".
531
+ const pm = s.match(/(?:api|url):\s*https?:\/\/[^:\s/]+:(\d+)/i);
604
532
  return {
605
533
  broker: bm ? bm[1] : null,
606
534
  port: pm ? Number.parseInt(pm[1], 10) : null,
@@ -644,7 +572,7 @@ export function detectPlatform({ platform, release = '', env = {} } = {}) {
644
572
  // tell the user plainly + how to fix, and ALWAYS still offer a manual path
645
573
  // 'unsafe' — on PATH but the probe failed/looked wrong → don't auto-drive; offer manual path
646
574
  // 'absent' — genuinely not installed → this harness is skipped (with a note)
647
- // The golden rule (owner edit #3): 'alias'/'unsafe'/'absent' NEVER dead-end — the caller always
575
+ // The golden rule: 'alias'/'unsafe'/'absent' NEVER dead-end — the caller always
648
576
  // prints a manual-install path so the component still gets installed.
649
577
  export function classifyHarnessProbe({ onPath, versionOk, timedOut, shellType = '' } = {}) {
650
578
  if (versionOk) return { status: 'ok', detail: 'real program' };