@ours.network/install 0.17.0-nightly.15 → 0.17.0-nightly.17

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,12 +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".
26
- // `required` is not a stronger default — it is the absence of a choice. The MCP
27
- // package IS the daemon now (the daemon phase installs and starts it), so an
28
- // operator who declined it would have no daemon at all, which is not a decision
29
- // anyone means to make. Declining has to be impossible rather than discouraged.
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.
30
30
  export const COMPONENTS = [
31
- { key: 'mcp', label: 'ours daemon + MCP server', pkg: '@ours.network/mcp', specKey: 'mcp', default: true, required: true },
31
+ { key: 'mcp', label: 'MCP server for your harness', pkg: '@ours.network/mcp', specKey: 'mcp', default: true, required: true },
32
32
  { key: 'tg', label: 'Telegram connector', pkg: '@ours.network/tg-connector', specKey: 'tg-connector', default: false },
33
33
  { key: 'cowork', label: 'cowork', pkg: '@ours.network/cowork', specKey: 'cowork', default: false },
34
34
  ];
@@ -45,7 +45,7 @@ export const COMPONENTS = [
45
45
  * over. Every install path for these three now goes through here.
46
46
  *
47
47
  * All three publish a real `nightly` dist-tag (verified against the registry,
48
- * 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`
49
49
  * for anything unmapped rather than inventing a tag, so an unknown component
50
50
  * degrades to today's behaviour instead of failing.
51
51
  *
@@ -88,10 +88,10 @@ export const COWORK_DAEMON_FLOOR = '0.4.1-nightly.20260816.4aaf940';
88
88
  * daemon side, and anything the installer clears here is gone. The installer's
89
89
  * entire business with the connector is three keys in one config file.
90
90
  *
91
- * This also keeps the door open for the route migration the owner is considering
92
- * — moving each route's packet into the shared daemon. That migration has to read
93
- * this registry to know which daemon identity corresponds to which route; an
94
- * 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.
95
95
  */
96
96
  export const TG_STATE_DIR_NAME = '.ours-telegram';
97
97
  export const TG_REGISTRY_FILES = ['bots.json'];
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.
@@ -113,8 +110,8 @@ export function deriveNewStateDir(home, taken = [], { limit = 64 } = {}) {
113
110
  export const SELECT_CREATE = '__create__';
114
111
 
115
112
  // 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.
113
+ // detection replaced it — so anyone who used that flow has a file describing
114
+ // daemons that nothing consults any more.
118
115
  //
119
116
  // It is NOT deleted. Quietly removing a file that describes someone's daemons is
120
117
  // not an installer's business, and the file is harmless. But leaving it looking
package/lib/extras.mjs CHANGED
@@ -1,8 +1,7 @@
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
6
  // They are not four more screens carried across, because v2 and v3 disagree
8
7
  // about which package IS the daemon. In v2 @ours.network/mcp is the daemon and
@@ -52,7 +51,7 @@ export const HARNESSES = [
52
51
  *
53
52
  * So §5's guarantee is ALREADY unmet today for every non-default state
54
53
  * directory, silently: the harness attaches to ~/.ours while the operator was
55
- * told the run targeted somewhere else. Owner ruling Q1 settles the shape:
54
+ * told the run targeted somewhere else. The shape is:
56
55
  *
57
56
  * default state directory today's behaviour, byte for byte.
58
57
  * Hermes, non-default real: the pair is handed to ours-hermes-install's
@@ -270,7 +269,7 @@ export function planFleet({ stateDir, isDefaultStateDir, wanted = true, channel
270
269
  // -----------------------------------------------------------------------------
271
270
 
272
271
  /**
273
- * WHY THE INSTALLER OWNS THE RESTART NOW (owner ruling Q2).
272
+ * WHY THE INSTALLER OWNS THE RESTART.
274
273
  *
275
274
  * cmdVoiceSetup computes `managed = runningPid() !== null` from ours-mcp's OWN
276
275
  * pid record. A v3 daemon is started by `ours daemon start`, which writes
@@ -283,8 +282,8 @@ export function planFleet({ stateDir, isDefaultStateDir, wanted = true, channel
283
282
  * CLI-started daemon, which is right. But v2's entire restart protocol
284
283
  * (restartHandled, the exit-2 branch, "voice setup performed the required
285
284
  * 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.
285
+ * ship a branch that can never be taken. Nothing else owns that beat, and the
286
+ * installer is the only process that knows the daemon is CLI-managed.
288
287
  *
289
288
  * One phase, AFTER the component phase — voice-setup is an `ours-mcp`
290
289
  * subcommand and v3 installs @ours.network/mcp as a COMPONENT, so it is not on
@@ -337,8 +336,8 @@ export function planVoice({
337
336
  // TOUCHES THIS NEXT — the reason the installer owns this beat at all may have
338
337
  // just evaporated: cmdVoiceSetup computes `managed = runningPid() !== null`
339
338
  // from ours-mcp's OWN pid record, and an ours-mcp daemon HAS one. Under the
340
- // SDK-CLI daemon it never did, which is why the restart had no owner and the
341
- // installer took it. If voice-setup now classifies the daemon as managed it
339
+ // SDK-CLI daemon it never did, which is why the installer took the restart.
340
+ // If voice-setup now classifies the daemon as managed it
342
341
  // will run its own restart protocol, and this becomes a second restart rather
343
342
  // than the only one. Not changed here: that is a behaviour question for
344
343
  // packages/core, not a rename.
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
@@ -600,7 +600,10 @@ export function parseVersion(text) {
600
600
  export function parseStatus(text) {
601
601
  const s = String(text || '');
602
602
  const bm = s.match(/^\s*broker:\s*(\S+)/m);
603
- const pm = s.match(/url:\s*https?:\/\/[^:\s/]+:(\d+)/i);
603
+ // `api:` is the current line and `url:` the historical one. Both are matched
604
+ // because a newly installed CLI can be asked to report on an older running
605
+ // daemon, and a parser that knows only today's wording reads that as "no port".
606
+ const pm = s.match(/(?:api|url):\s*https?:\/\/[^:\s/]+:(\d+)/i);
604
607
  return {
605
608
  broker: bm ? bm[1] : null,
606
609
  port: pm ? Number.parseInt(pm[1], 10) : null,
@@ -644,7 +647,7 @@ export function detectPlatform({ platform, release = '', env = {} } = {}) {
644
647
  // tell the user plainly + how to fix, and ALWAYS still offer a manual path
645
648
  // 'unsafe' — on PATH but the probe failed/looked wrong → don't auto-drive; offer manual path
646
649
  // '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
650
+ // The golden rule: 'alias'/'unsafe'/'absent' NEVER dead-end — the caller always
648
651
  // prints a manual-install path so the component still gets installed.
649
652
  export function classifyHarnessProbe({ onPath, versionOk, timedOut, shellType = '' } = {}) {
650
653
  if (versionOk) return { status: 'ok', detail: 'real program' };
@@ -330,6 +330,9 @@ export async function runPluginPhase(plugins, { args, effects, selection = null
330
330
  effects.out(warn(`${block.path}: ${stripped.reason}. Remove it by hand.`));
331
331
  continue;
332
332
  }
333
+ // Deliberately NOT journalled: this write IS the state its bytes describe, so
334
+ // nothing behind it can fail and leave them untrue. Adding one would undo a
335
+ // completed removal.
333
336
  await perform(effects, args.dryRun, `remove the ours managed block from ${block.path} (file kept)`, () => effects.writeText(block.path, stripped.text));
334
337
  }
335
338
  for (const dir of harness.dirs) {
@@ -100,7 +100,7 @@ function pairFor(plan, target) {
100
100
  }
101
101
 
102
102
  /**
103
- * Which daemon is this run for? (C1, owner ruling 2026-08-17.)
103
+ * Which daemon is this run for?
104
104
  *
105
105
  * Never asks for a PATH — spec §2 stands — but when several daemons are DETECTED
106
106
  * it shows them and lets the operator pick, because choosing from what was found
@@ -209,15 +209,18 @@ export async function runDaemonPhase(args, effects) {
209
209
 
210
210
  // Asked ONCE, and only when this run is creating the daemon — an existing
211
211
  // daemon's broker is its own record, and re-asking would invite an operator to
212
- // change it from a screen that is not about changing it. Owner ruling: the
213
- // broker question stays in v3. It is orthogonal to --state-dir/--port, so it
212
+ // change it from a screen that is not about changing it. The broker question
213
+ // stays in v3: it is orthogonal to --state-dir/--port, so it
214
214
  // does not violate spec §2's "nothing about a state directory or a port
215
215
  // appears in any prompt".
216
216
  if (creating) args.brokerUrl = await askBroker(args, effects);
217
217
 
218
218
  const steps = [];
219
219
 
220
- // THE DAEMON IS ours-mcp, SO ITS PACKAGE COMES FIRST.
220
+ // The daemon is the ours-sdk CLI; ours-mcp is the per-session MCP server the
221
+ // harness spawns, and it reaches the daemon over the HTTP API. Both are
222
+ // installed here and both are required: an install without ours-mcp leaves a
223
+ // machine with a daemon and no MCP at all.
221
224
  //
222
225
  // This used to install @ours.network/cli, because the daemon used to be
223
226
  // `ours daemon serve`. That daemon does not mount /mcp — the SDK serves that
@@ -230,13 +233,15 @@ export async function runDaemonPhase(args, effects) {
230
233
  //
231
234
  // The order therefore inverts: the MCP package was a COMPONENT installed after
232
235
  // this phase, and it is now the thing this phase starts.
233
- const daemonPkg = componentSpec(componentByKey('mcp'), args.channel);
234
- await perform(effects, args.dryRun, `ours daemon installed (npm i -g ${daemonPkg})`, () => effects.run('npm', ['i', '-g', daemonPkg]));
235
- steps.push({ id: 'daemon-package', changed: true, packageRefresh: true });
236
+ const mcpPkg = componentSpec(componentByKey('mcp'), args.channel);
237
+ await perform(effects, args.dryRun, `MCP server installed (npm i -g ${mcpPkg})`, () => effects.run('npm', ['i', '-g', mcpPkg]));
238
+ steps.push({ id: 'mcp-package', changed: true, packageRefresh: true });
239
+ await perform(effects, args.dryRun, 'ours CLI installed (npm i -g @ours.network/cli)', () => effects.run('npm', ['i', '-g', '@ours.network/cli']));
240
+ steps.push({ id: 'cli', changed: true, packageRefresh: true });
236
241
 
237
242
  // The config file — merged, never rewritten, and untouched when it already
238
- // matches. No provenance marker is written: the owner ruled that --purge works
239
- // on any state directory, so a `createdBy` key would have had no consumer, and
243
+ // matches. No provenance marker is written: --purge works on any state
244
+ // directory, so a `createdBy` key would have no consumer, and
240
245
  // an unread key in a user's config file is future confusion for nothing.
241
246
  const configPath = join(dir, 'config.json');
242
247
  const merged = planDaemonConfig(
@@ -254,6 +259,7 @@ export async function runDaemonPhase(args, effects) {
254
259
  // lookup exists to prevent; this is what stops the installer from setting up the
255
260
  // conditions for it.
256
261
  const journal = configJournal(effects, { dryRun: args.dryRun });
262
+ let serviceUnsupported = null;
257
263
  if (merged.changed) {
258
264
  journal.snapshot(configPath);
259
265
  await perform(effects, args.dryRun, `write ${configPath} (port ${target.port})`, () => effects.writeJson(configPath, merged.text));
@@ -264,18 +270,12 @@ export async function runDaemonPhase(args, effects) {
264
270
 
265
271
  try {
266
272
  if (creating) {
267
- // `start`, not `serve`: start backgrounds the daemon and polls until the port
268
- // is open, which is the same observable readiness the old path had. `serve`
269
- // runs in the foreground and would never return.
270
- //
271
- // Selected by ENVIRONMENT rather than flags — ours-mcp reads OURS_CONFIG /
272
- // OURS_PORT / OURS_STATE_DIR — and daemonEnv is the one place that pair is
273
- // built, so the daemon this run created is the daemon that starts.
274
- await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours-mcp', ['start'], { env: daemonEnv(dir, target.port) }));
273
+ await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'start', '--config', configPath]));
275
274
  steps.push({ id: 'start', changed: true });
276
275
  }
277
276
 
278
277
  const service = await runServicePhase(args, effects, dir, target.port);
278
+ if (service.unsupported) serviceUnsupported = service.unsupported;
279
279
  if (service.refused) {
280
280
  // A REFUSAL IS A FAILURE TO REACH THE STATE, not a special case. An unknown
281
281
  // unit file stops the run just as a failed start does, and it stops it with
@@ -304,12 +304,12 @@ export async function runDaemonPhase(args, effects) {
304
304
  effects.out(recovery.recovered
305
305
  ? ok('your daemon is running again — nothing was committed, and the service is unchanged')
306
306
  : warn('and the daemon did NOT come back up — start it yourself before anything else: '
307
- + `OURS_CONFIG=${configPath} ours-mcp start`));
307
+ + `ours daemon start --config ${configPath}`));
308
308
  }
309
309
  throw error;
310
310
  }
311
311
 
312
- return { target, steps };
312
+ return { target, steps, serviceUnsupported };
313
313
  }
314
314
 
315
315
  /**
@@ -327,7 +327,7 @@ export async function runDaemonPhase(args, effects) {
327
327
  async function recoverDaemon(args, effects, dir, configPath, port) {
328
328
  if (args.dryRun) return null;
329
329
  try {
330
- await effects.run('ours-mcp', ['start'], { env: daemonEnv(dir, port) });
330
+ await effects.run('ours', ['daemon', 'start', '--config', configPath]);
331
331
  return { recovered: true };
332
332
  } catch (recoveryError) {
333
333
  return { recovered: false, reason: reason(recoveryError) };
@@ -367,11 +367,24 @@ function rollBack(effects, journal, args, why, { packagesInstalled = true, repla
367
367
  * The boot service: §4 step 4, including the legacy-unit case.
368
368
  *
369
369
  * A legacy ours-mcp unit is adopted SILENTLY, with one informational line naming
370
- * the file — the owner's decision. `--force` is passed ONLY here, only for a unit
370
+ * the file. `--force` is passed ONLY here, only for a unit
371
371
  * positively identified as ours-mcp's, and never for one we cannot identify.
372
372
  */
373
373
  export async function runServicePhase(args, effects, dir, port) {
374
- const plan = planServiceInstall({ stateDir: dir, home: effects.home, readText: effects.readText });
374
+ const plan = planServiceInstall({
375
+ stateDir: dir, home: effects.home, readText: effects.readText, platform: effects.platform?.platform,
376
+ });
377
+ // NOT a refusal and NOT a failure: the daemon is running and correct, and only
378
+ // the boot service could not be installed — `ours daemon install-service` throws
379
+ // on any non-linux platform. The run CONTINUES, says so, and the summary marks
380
+ // it, because the person this hurts reboots in a fortnight and finds nothing
381
+ // listening.
382
+ if (plan.action === 'unsupported') {
383
+ effects.out(warn(`ours: ${plan.message}`));
384
+ effects.out(info(`Your daemon is installed and running now. To start it after a reboot, run: ${plan.manual.join(' ')} ${join(dir, 'config.json')}`));
385
+ effects.out(info('Nothing else in this run depends on the boot service.'));
386
+ return { step: { id: 'service', changed: false, reason: 'not available on this platform' }, plan, unsupported: plan };
387
+ }
375
388
  if (plan.action === 'refuse') {
376
389
  effects.out(warn(`ours: refusing to continue — ${plan.message}`));
377
390
  return { refused: plan };
@@ -608,7 +621,7 @@ async function attachComponent(component, { args, effects, dir, endpoint, isDefa
608
621
  }
609
622
 
610
623
  /**
611
- * The broker question (v2's Step 0a, kept by the owner's ruling).
624
+ * The broker question (v2's Step 0a, deliberately kept).
612
625
  *
613
626
  * Consent-first, with the undo built in: a mistaken custom address is one
614
627
  * keystroke back to the standard broker, because the alternative is an operator
@@ -671,6 +684,9 @@ export function runPreflight(effects) {
671
684
  return { ok: false, platform: plat };
672
685
  }
673
686
  effects.out(ok(`Platform: ${plat.label} (supported)`));
687
+ if (effects.platform?.platform && effects.platform.platform !== 'linux') {
688
+ effects.out(info(`On ${plat.label} the daemon runs, but installing a BOOT SERVICE is not available — you will start it yourself after a reboot.`));
689
+ }
674
690
  const version = String(effects.nodeVersion ?? '0');
675
691
  if (Number.parseInt(version.split('.')[0], 10) < 20) {
676
692
  effects.out(warn(`Node.js ${version} — ours needs v20 or newer. Update Node and re-run.`));
@@ -681,7 +697,7 @@ export function runPreflight(effects) {
681
697
  }
682
698
 
683
699
  /**
684
- * The human identity: `ours-mcp create-root`, kept by the owner's ruling.
700
+ * The human identity: `ours-mcp create-root`.
685
701
  *
686
702
  * It needs `ours-mcp` on PATH and a reachable daemon, so under v3 it lands here
687
703
  * — after the component phase installed the MCP server, not back in the daemon
@@ -743,7 +759,7 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
743
759
  // by environment instead, so the pair travels as an env prefix rather than a
744
760
  // flag. A retry command that omits it would start the DEFAULT daemon — which
745
761
  // is how someone ends up with an identity on a daemon they did not choose.
746
- effects.out(info(`Fix: run 'OURS_CONFIG=${env.OURS_CONFIG} ours-mcp start', then 'OURS_CONFIG=${env.OURS_CONFIG} ours-mcp create-root "${name}"'.`));
762
+ effects.out(info(`Fix: run 'ours daemon start --config ${env.OURS_CONFIG}', then 'OURS_CONFIG=${env.OURS_CONFIG} ours-mcp create-root "${name}"'.`));
747
763
  return { key: 'identity', label: 'Human identity', state: 'failed', note: 'daemon not reachable' };
748
764
  }
749
765
  effects.out(warn(`Couldn't create your human identity: ${text.split('\n')[0]}`));
@@ -876,7 +892,6 @@ export async function runFleetPhase(args, effects, { target, isDefaultStateDir }
876
892
  // Passing it is harmless if extras.mjs is right and load-bearing if
877
893
  // nightly-install.mjs is. When the cheap action is safe under both readings and
878
894
  // the expensive one is only safe under one, take the cheap one. (Coordinator
879
- // ruling, 2026-08-17.)
880
895
  //
881
896
  // Passed for EVERY state directory, not only a non-default one, exactly as the
882
897
  // nightly flow does: `init` is a one-time host setup and the pair is what names
@@ -1110,6 +1125,17 @@ export async function runInstall(argv, effects) {
1110
1125
  state: target.action === 'create' ? 'installed' : 'current',
1111
1126
  note: `port ${target.port}`,
1112
1127
  }];
1128
+ // The skip must not read as success: same "needs attention" mark a failed
1129
+ // component gets, so the closing screen cannot say everything is clean.
1130
+ if (daemon.serviceUnsupported) {
1131
+ summary.push({
1132
+ key: 'service',
1133
+ label: 'Boot service',
1134
+ state: 'failed',
1135
+ note: `not available on ${daemon.serviceUnsupported.platform === 'darwin' ? 'macOS' : daemon.serviceUnsupported.platform} — start the daemon yourself after a reboot`,
1136
+ });
1137
+ }
1138
+
1113
1139
  const components = await runComponentPhase(args, effects, target);
1114
1140
  for (const component of COMPONENTS) {
1115
1141
  const state = components.installed.includes(component.key) ? 'installed'
package/lib/plan.mjs CHANGED
@@ -94,10 +94,9 @@ export function classifyUnit(text) {
94
94
  * informational line naming it
95
95
  * { action: 'refuse', exitCode: 2, … } — unknown unit, or unusable state dir
96
96
  *
97
- * Adoption of a legacy unit is SILENT by the owner's decision: no prompt, no
98
- * question, so an upgrading user has no manual step. The one line of output
99
- * exists so the replacement is not literally invisible; it does not block and it
100
- * is not a warning.
97
+ * Adoption of a legacy unit is SILENT: no prompt, no question, so an upgrading
98
+ * user has no manual step. The one line of output exists so the replacement is not
99
+ * literally invisible; it does not block and it is not a warning.
101
100
  *
102
101
  * BECAUSE THERE IS NO PROMPT, `classifyUnit`'s `legacy` match is now the ENTIRE
103
102
  * safety boundary between a stranger's file and a silent rewrite. It must stay
@@ -130,18 +129,20 @@ export function planServiceInstall({ stateDir, home, readText, platform = 'linux
130
129
  // A real launchd adapter belongs in the SDK CLI, not here. Nothing in this
131
130
  // package can install a launchd agent, and pretending otherwise by writing a
132
131
  // plist ourselves would put a second service implementation in a second repo.
133
- // THE macOS SKIP IS GONE, AND THAT IS A CONSEQUENCE OF THE DAEMON CHANGE.
134
- //
135
- // It existed because `ours daemon install-service` refuses on any non-linux
136
- // platform createLinuxUserSystemdAdapter() throws on its first line, and the
137
- // whole @ours.network/cli package contains no launchd support. That was true and
138
- // is no longer the relevant question: the unit is now installed by ours-mcp's
139
- // own install-service, which handles systemd AND launchd
140
- // (packages/core/src/cli.ts writes ~/Library/LaunchAgents and runs launchctl
141
- // bootstrap). So macOS gets a real boot service rather than a named gap.
142
- //
143
- // A gap list that warns about something already fixed is as misleading as one
144
- // that hides something broken, which is why the warning goes with the skip.
132
+ // `ours daemon install-service` supports Linux/systemd only: its adapter throws
133
+ // for any other platform, so calling it would fail the run rather than degrade.
134
+ // Skip it and say so; the daemon itself is unaffected.
135
+ if (platform && platform !== 'linux') {
136
+ return {
137
+ action: 'unsupported',
138
+ platform,
139
+ reason: 'no-service-manager',
140
+ message: platform === 'darwin'
141
+ ? 'installing a boot service is not available on macOS — the ours CLI can only manage a Linux user systemd service'
142
+ : `installing a boot service is not available on ${platform} the ours CLI can only manage a Linux user systemd service`,
143
+ manual: ['ours', 'daemon', 'serve', '--config'],
144
+ };
145
+ }
145
146
  const derived = unitPathForStateDir(stateDir, home);
146
147
  if (!derived.ok) {
147
148
  return { action: 'refuse', exitCode: 2, reason: 'unusable-state-dir', message: derived.reason };
@@ -211,35 +212,12 @@ export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
211
212
  // --json so the caller can read back whether the unit actually CHANGED. The
212
213
  // CLI owns that byte-comparison, and an installer that guessed at it would
213
214
  // report "nothing changed" on a run that rewrote a unit.
214
- // ours-mcp's install-service, NOT the SDK CLI's, and the difference is not
215
- // cosmetic. `ours daemon install-service` writes a unit whose ExecStart runs
216
- // `ours daemon serve` a daemon that does not mount /mcp so the boot service
217
- // would resurrect exactly the daemon the /mcp 404 came from. ours-mcp's writes
218
- // ExecStart=<node> <ours-mcp> serve (packages/core/src/service-instance.ts:106),
219
- // and it handles launchd as well as systemd.
220
- //
221
- // It takes no arguments: like `start`, it is selected by OURS_CONFIG /
222
- // OURS_PORT / OURS_STATE_DIR, and it BAKES those resolved values into the unit.
223
- // The caller passes the pair as an environment, which is why this returns a bare
224
- // command and the orchestrator supplies daemonEnv.
225
- //
226
- // WHAT IS LOST, stated rather than discovered later. ours-mcp's install-service
227
- // takes NO flags:
228
- //
229
- // · no --json, so the caller cannot read back whether the unit actually
230
- // changed. `changed` is now assumed true, which is the safe direction for a
231
- // summary line but is an assumption where it used to be an answer.
232
- // · no --force, and no marker check either: it writes the unit file
233
- // unconditionally. The SDK CLI refused to overwrite a unit it had not
234
- // marked, and that refusal was the backstop behind classifyUnit. It is gone.
235
- // classifyUnit's `foreign` refusal is now the ONLY thing standing between a
236
- // stranger's unit file and an overwrite, so `adoptLegacyUnit` no longer
237
- // changes the COMMAND — it records that the caller already decided, and the
238
- // decision is enforced entirely by refusing to get here at all.
239
- //
240
- // That is a real reduction in defence in depth and it belongs in the PR, not in
241
- // a comment nobody reads.
242
- return ['ours-mcp', 'install-service'];
215
+ // --json so the caller can read back whether the unit actually CHANGED rather
216
+ // than assuming it did. --force is reachable only through the explicit argument
217
+ // above, and the CLI refuses to overwrite a unit it did not write.
218
+ const cmd = ['ours', 'daemon', 'install-service', '--yes', '--json', '--state-dir', dir, '--config', join(dir, 'config.json')];
219
+ if (adoptLegacyUnit) cmd.push('--force');
220
+ return cmd;
243
221
  }
244
222
 
245
223
  // -----------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "0.17.0-nightly.15",
3
+ "version": "0.17.0-nightly.17",
4
4
  "private": false,
5
5
  "description": "The unified ours.network stack installer (ours-install): one guided ~3-minute flow for ours core (the daemon) + the harness plugins (Claude Code / Codex) + ours-fleet + the Telegram connector, then a single copy-paste hand-off prompt. Self-contained (Node built-ins only); run as `ours-install` or via curl|bash (install.sh).",
6
6
  "type": "module",