@ours.network/install 0.17.0-nightly.12 → 0.17.0-nightly.13

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.
@@ -23,8 +23,12 @@ import { pkgSpec, resolveChannel } from './logic.mjs';
23
23
  // effects.installedVersion('@ours.network/mcp@nightly') return null forever,
24
24
  // which fails the cowork version floor CLOSED and blanks the version column —
25
25
  // 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.
26
30
  export const COMPONENTS = [
27
- { key: 'mcp', label: 'MCP server', pkg: '@ours.network/mcp', specKey: 'mcp', default: true },
31
+ { key: 'mcp', label: 'ours daemon + MCP server', 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
  ];
@@ -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/extras.mjs CHANGED
@@ -333,10 +333,19 @@ export function planVoice({
333
333
  // The beat. Only owed when the config actually changed: an unchanged config
334
334
  // is not a reason to bounce a daemon somebody else may be using.
335
335
  restartOwed: Boolean(configChanged),
336
- restart: configChanged && config ? ['ours', 'daemon', 'restart', '--config', config] : null,
336
+ // ours-mcp's restart, because ours-mcp is the daemon now. NOTE FOR WHOEVER
337
+ // TOUCHES THIS NEXT — the reason the installer owns this beat at all may have
338
+ // just evaporated: cmdVoiceSetup computes `managed = runningPid() !== null`
339
+ // 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
342
+ // will run its own restart protocol, and this becomes a second restart rather
343
+ // than the only one. Not changed here: that is a behaviour question for
344
+ // packages/core, not a rename.
345
+ restart: configChanged && config ? ['ours-mcp', 'restart'] : null,
337
346
  // Never rolls the daemon back: voice-setup leaves the prior config intact on
338
347
  // its own failure path, so the recovery is a retry, not an undo.
339
- retryHint: config ? `ours daemon restart --config ${config}` : null,
348
+ retryHint: config ? `OURS_CONFIG=${config} ours-mcp restart` : null,
340
349
  port: Number.isInteger(port) ? port : null,
341
350
  };
342
351
  }
@@ -139,9 +139,13 @@ export async function runUninstall(argv, effects) {
139
139
  readJson: effects.readJson,
140
140
  readText: effects.readText,
141
141
  exists: effects.exists,
142
- cliStartedIt: effects.readJson(join(dir, 'ours-cli-daemon.json')) !== null,
142
+ // EITHER record proves a managed daemon: ours-cli-daemon.json is what
143
+ // `ours daemon start` wrote yesterday, daemon.pid is what `ours-mcp start`
144
+ // writes today. Reading only one makes an uninstall on the other kind decline
145
+ // to stop a daemon it could have stopped.
146
+ cliStartedIt: effects.readJson(join(dir, 'ours-cli-daemon.json')) !== null
147
+ || effects.readText(join(dir, 'daemon.pid')) !== null,
143
148
  otherStateDirsWithConfig: effects.knownStateDirs(),
144
- platform: effects.platform?.platform,
145
149
  typedConfirmation: null,
146
150
  explicitHarnessSelection: contract.engaged,
147
151
  });
@@ -160,7 +164,6 @@ export async function runUninstall(argv, effects) {
160
164
  // removal makes that true. If step 3 or 4 fails, the operator is left with a
161
165
  // stopped, detached connector NEXT TO A DAEMON THAT IS STILL THERE — a world the
162
166
  // bytes no longer describe.
163
- let unsupportedService = null;
164
167
  const journal = configJournal(effects, { dryRun: args.dryRun });
165
168
  const detached = [];
166
169
  for (const component of plan.detach) {
@@ -180,13 +183,6 @@ export async function runUninstall(argv, effects) {
180
183
  // 3-4. The boot service, then the daemon. Both delegate their refusals.
181
184
  try {
182
185
  for (const step of plan.daemon) {
183
- if (step.id === 'service-unsupported') {
184
- // Said, not skipped silently: the operator is entitled to know that the
185
- // thing they may have expected to be removed was never installed.
186
- effects.out(warn(`ours: ${step.note}`));
187
- unsupportedService = step;
188
- continue;
189
- }
190
186
  if (step.command === null) {
191
187
  effects.out(info(`${step.note} — nothing signalled`));
192
188
  continue;
@@ -213,7 +209,6 @@ export async function runUninstall(argv, effects) {
213
209
  const packages = planGlobalPackages({
214
210
  stateDir: dir,
215
211
  otherStateDirsWithConfig: effects.knownStateDirs(),
216
- platform: effects.platform?.platform,
217
212
  pluginPackages: pluginOutcome.packages,
218
213
  // The plugin packages are recomputed; the CONNECTOR packages are not. They
219
214
  // are decided by what the operator confirmed detaching, which the plugin
@@ -229,15 +224,6 @@ export async function runUninstall(argv, effects) {
229
224
  await perform(effects, args.dryRun, `npm rm -g ${pkg}`, () => effects.run('npm', ['rm', '-g', pkg]));
230
225
  }
231
226
  }
232
- // SAID AT THE END, where a closing screen is actually read, and not only in the
233
- // line it scrolled past during the run. The same rule as the install side: an
234
- // uninstall that quietly did less than the operator believes is the thing to
235
- // avoid, and "nothing of ours was there to remove" is only reassuring if it is
236
- // stated.
237
- if (unsupportedService) {
238
- effects.out(warn(`Boot service: NOT removed — ${unsupportedService.note}.`));
239
- effects.out(info('If you start the daemon yourself at login, remove that arrangement by hand.'));
240
- }
241
227
  return EXIT_OK;
242
228
  }
243
229
 
@@ -29,7 +29,7 @@ import { planDaemonConfig, planServiceInstall, serviceInstallCommand } from './p
29
29
  import {
30
30
  COMPONENTS,
31
31
  planComponentSelection, planMcpAttachment, planTgAttachment, planCoworkAttachment,
32
- tgConfigPath, coworkConfigPath, summarizeComponentRun, componentSpec,
32
+ tgConfigPath, coworkConfigPath, summarizeComponentRun, componentSpec, componentByKey,
33
33
  } from './components.mjs';
34
34
  import { planHarnessPlugins, planFleet, planVoice, buildHandoffPromptV3, restartHints } from './extras.mjs';
35
35
  import { summarizeRun } from './rerun.mjs';
@@ -176,6 +176,7 @@ export async function runDaemonPhase(args, effects) {
176
176
  portExplicit: args.portExplicit,
177
177
  probe: effects.probe,
178
178
  readJson: effects.readJson,
179
+ readText: effects.readText,
179
180
  isTaken: effects.isTaken,
180
181
  });
181
182
 
@@ -216,8 +217,22 @@ export async function runDaemonPhase(args, effects) {
216
217
 
217
218
  const steps = [];
218
219
 
219
- await perform(effects, args.dryRun, 'ours CLI installed (npm i -g @ours.network/cli)', () => effects.run('npm', ['i', '-g', '@ours.network/cli']));
220
- steps.push({ id: 'cli', changed: true, packageRefresh: true });
220
+ // THE DAEMON IS ours-mcp, SO ITS PACKAGE COMES FIRST.
221
+ //
222
+ // This used to install @ours.network/cli, because the daemon used to be
223
+ // `ours daemon serve`. That daemon does not mount /mcp — the SDK serves that
224
+ // route only when a host supplies an `mcp` option, and @ours.network/cli calls
225
+ // startDaemon() with no arguments — so every MCP client, including every harness
226
+ // session through `ours-mcp proxy`, got the daemon's own 404. The one
227
+ // MCP-capable daemon in the stack is ours-mcp's, which supplies the server
228
+ // factory, the transport constructor and the notification sink
229
+ // (packages/core/src/serve.ts).
230
+ //
231
+ // The order therefore inverts: the MCP package was a COMPONENT installed after
232
+ // 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 });
221
236
 
222
237
  // The config file — merged, never rewritten, and untouched when it already
223
238
  // matches. No provenance marker is written: the owner ruled that --purge works
@@ -239,7 +254,6 @@ export async function runDaemonPhase(args, effects) {
239
254
  // lookup exists to prevent; this is what stops the installer from setting up the
240
255
  // conditions for it.
241
256
  const journal = configJournal(effects, { dryRun: args.dryRun });
242
- let serviceUnsupported = null;
243
257
  if (merged.changed) {
244
258
  journal.snapshot(configPath);
245
259
  await perform(effects, args.dryRun, `write ${configPath} (port ${target.port})`, () => effects.writeJson(configPath, merged.text));
@@ -250,12 +264,18 @@ export async function runDaemonPhase(args, effects) {
250
264
 
251
265
  try {
252
266
  if (creating) {
253
- await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'start', '--config', configPath]));
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) }));
254
275
  steps.push({ id: 'start', changed: true });
255
276
  }
256
277
 
257
- const service = await runServicePhase(args, effects, dir);
258
- if (service.unsupported) serviceUnsupported = service.unsupported;
278
+ const service = await runServicePhase(args, effects, dir, target.port);
259
279
  if (service.refused) {
260
280
  // A REFUSAL IS A FAILURE TO REACH THE STATE, not a special case. An unknown
261
281
  // unit file stops the run just as a failed start does, and it stops it with
@@ -276,7 +296,7 @@ export async function runDaemonPhase(args, effects) {
276
296
  // The nightly flow re-runs `start` and, crucially, tells the two outcomes
277
297
  // apart: "the service failed but the daemon is back" is a bad evening, and "the
278
298
  // service failed AND it will not come back" is the one that needs a human now.
279
- const recovery = error?.servicePlan ? await recoverDaemon(args, effects, dir, configPath) : null;
299
+ const recovery = error?.servicePlan ? await recoverDaemon(args, effects, dir, configPath, target.port) : null;
280
300
  rollBack(effects, journal, args, 'the daemon did not reach the state its config describes — putting the config back', {
281
301
  replacedUnit: error?.servicePlan?.action === 'adopt' ? error.servicePlan.unitPath : null,
282
302
  });
@@ -284,12 +304,12 @@ export async function runDaemonPhase(args, effects) {
284
304
  effects.out(recovery.recovered
285
305
  ? ok('your daemon is running again — nothing was committed, and the service is unchanged')
286
306
  : warn('and the daemon did NOT come back up — start it yourself before anything else: '
287
- + `ours daemon start --config ${configPath}`));
307
+ + `OURS_CONFIG=${configPath} ours-mcp start`));
288
308
  }
289
309
  throw error;
290
310
  }
291
311
 
292
- return { target, steps, serviceUnsupported };
312
+ return { target, steps };
293
313
  }
294
314
 
295
315
  /**
@@ -304,10 +324,10 @@ export async function runDaemonPhase(args, effects) {
304
324
  * is REPORTED, never thrown: the caller is already carrying the real error, and
305
325
  * losing it to a second one would hide what actually went wrong.
306
326
  */
307
- async function recoverDaemon(args, effects, dir, configPath) {
327
+ async function recoverDaemon(args, effects, dir, configPath, port) {
308
328
  if (args.dryRun) return null;
309
329
  try {
310
- await effects.run('ours', ['daemon', 'start', '--config', configPath]);
330
+ await effects.run('ours-mcp', ['start'], { env: daemonEnv(dir, port) });
311
331
  return { recovered: true };
312
332
  } catch (recoveryError) {
313
333
  return { recovered: false, reason: reason(recoveryError) };
@@ -350,20 +370,8 @@ function rollBack(effects, journal, args, why, { packagesInstalled = true, repla
350
370
  * the file — the owner's decision. `--force` is passed ONLY here, only for a unit
351
371
  * positively identified as ours-mcp's, and never for one we cannot identify.
352
372
  */
353
- export async function runServicePhase(args, effects, dir) {
354
- const plan = planServiceInstall({
355
- stateDir: dir, home: effects.home, readText: effects.readText, platform: effects.platform?.platform,
356
- });
357
- // NOT a refusal and NOT a failure: the daemon is running and correct, and only
358
- // the boot service could not be installed. So the run CONTINUES — but it says
359
- // so, and the summary marks it, because the person this hurts is the one who
360
- // reboots in a fortnight and finds nothing listening.
361
- if (plan.action === 'unsupported') {
362
- effects.out(warn(`ours: ${plan.message}`));
363
- effects.out(info(`Your daemon is installed and running now. To start it after a reboot, run: ${plan.manual.join(' ')} ${join(dir, 'config.json')}`));
364
- effects.out(info('Nothing else in this run depends on the boot service.'));
365
- return { step: { id: 'service', changed: false, reason: 'not available on this platform' }, plan, unsupported: plan };
366
- }
373
+ export async function runServicePhase(args, effects, dir, port) {
374
+ const plan = planServiceInstall({ stateDir: dir, home: effects.home, readText: effects.readText });
367
375
  if (plan.action === 'refuse') {
368
376
  effects.out(warn(`ours: refusing to continue — ${plan.message}`));
369
377
  return { refused: plan };
@@ -371,6 +379,14 @@ export async function runServicePhase(args, effects, dir) {
371
379
  const adopting = plan.action === 'adopt';
372
380
  if (adopting) effects.out(info(plan.notice));
373
381
  const command = serviceInstallCommand({ stateDir: dir, adoptLegacyUnit: adopting });
382
+ // The unit's bytes BEFORE, so "did it change?" survives the loss of --json.
383
+ // `ours daemon install-service --json` used to answer that itself; ours-mcp's
384
+ // takes no flags and reports nothing machine-readable. Assuming `changed: true`
385
+ // would make every re-run claim it rewrote the unit, which turns the honest
386
+ // "everything already correct" line into one that never appears — a screen that
387
+ // is wrong in the reassuring direction. So the installer does the comparison it
388
+ // used to delegate: it already reads this file to classify it.
389
+ const unitBefore = plan.unitPath ? effects.readText(plan.unitPath) : null;
374
390
  let outcome;
375
391
  try {
376
392
  outcome = await perform(effects, args.dryRun, `boot service ${plan.unit} installed and enabled`, () => effects.run(command[0], command.slice(1)));
@@ -382,11 +398,16 @@ export async function runServicePhase(args, effects, dir) {
382
398
  error.servicePlan = plan;
383
399
  throw error;
384
400
  }
385
- // Whether the unit actually changed is the CLI's answer, not ours: it does the
386
- // byte comparison and returns `changed` in its --json plan. Guessing here would
387
- // let a run that rewrote a unit report "nothing changed". Unreadable output is
388
- // treated as "changed", which is the safe direction for a summary line.
389
- const changed = readChanged(outcome.stdout);
401
+ // Ours now, by the same byte comparison the CLI used to do. A dry run changed
402
+ // nothing by definition; an unreadable file either side is treated as "changed",
403
+ // which is the safe direction for a summary line.
404
+ const changed = args.dryRun
405
+ ? false
406
+ : (() => {
407
+ const after = plan.unitPath ? effects.readText(plan.unitPath) : null;
408
+ if (unitBefore === null || after === null) return true;
409
+ return unitBefore !== after;
410
+ })();
390
411
  return { step: { id: 'service', changed, reason: changed ? undefined : 'unit unchanged' }, plan };
391
412
  }
392
413
 
@@ -608,12 +629,6 @@ export function runPreflight(effects) {
608
629
  return { ok: false, platform: plat };
609
630
  }
610
631
  effects.out(ok(`Platform: ${plat.label} (supported)`));
611
- // Supported is still true — the daemon runs here. What is NOT available is the
612
- // boot service, and saying "supported" without that qualification is what let a
613
- // Mac user walk into a run that could not finish.
614
- if (effects.platform?.platform && effects.platform.platform !== 'linux') {
615
- effects.out(info(`On ${plat.label} the daemon runs, but installing a BOOT SERVICE is not available yet — you will start it yourself after a reboot.`));
616
- }
617
632
  const version = String(effects.nodeVersion ?? '0');
618
633
  if (Number.parseInt(version.split('.')[0], 10) < 20) {
619
634
  effects.out(warn(`Node.js ${version} — ours needs v20 or newer. Update Node and re-run.`));
@@ -651,6 +666,19 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
651
666
  effects.out(info(wouldPrefix(`ours-mcp create-root "${name}"`)));
652
667
  return { key: 'identity', label: 'Human identity', state: 'installed', note: name };
653
668
  }
669
+ // `ours-mcp create-root` — and it works BECAUSE the daemon is ours-mcp.
670
+ //
671
+ // It opens an MCP streamable-HTTP session to http://127.0.0.1:<port>/mcp. A
672
+ // daemon started by `ours daemon serve` does not mount that route (the SDK
673
+ // serves /mcp only when an `mcp` option is supplied, and @ours.network/cli calls
674
+ // startDaemon() with no arguments), which is why this exact command returned the
675
+ // daemon's own 404 body — "Not found" — on the first real install. An ours-mcp
676
+ // daemon supplies that option (packages/core/src/serve.ts), so the route exists
677
+ // and the command succeeds.
678
+ //
679
+ // Calling the SDK CLI's `ours identity create-root` instead would work too, over
680
+ // /api/v1/ — but it would mean installing and driving a second client purely to
681
+ // create an identity, on a daemon that already speaks the first one.
654
682
  try {
655
683
  const result = await effects.run('ours-mcp', ['create-root', name], { env });
656
684
  const existing = String(result?.stdout ?? '').match(/already exists \("([^"]+)"\)/);
@@ -668,11 +696,16 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
668
696
  }
669
697
  if (/not running|not reachable|ECONNREFUSED|connect/i.test(text)) {
670
698
  effects.out(warn("The daemon isn't reachable yet — couldn't create your human identity."));
671
- effects.out(info(`Fix: run 'ours daemon start --config ${env.OURS_CONFIG}', then 'ours-mcp create-root "${name}"'.`));
699
+ // The hint NAMES THIS DAEMON, and it has to keep doing that through the
700
+ // mechanism change. `ours daemon start` took --config; ours-mcp is selected
701
+ // by environment instead, so the pair travels as an env prefix rather than a
702
+ // flag. A retry command that omits it would start the DEFAULT daemon — which
703
+ // is how someone ends up with an identity on a daemon they did not choose.
704
+ effects.out(info(`Fix: run 'OURS_CONFIG=${env.OURS_CONFIG} ours-mcp start', then 'OURS_CONFIG=${env.OURS_CONFIG} ours-mcp create-root "${name}"'.`));
672
705
  return { key: 'identity', label: 'Human identity', state: 'failed', note: 'daemon not reachable' };
673
706
  }
674
707
  effects.out(warn(`Couldn't create your human identity: ${text.split('\n')[0]}`));
675
- effects.out(info(`Retry any time: 'ours-mcp create-root "${name}"'.`));
708
+ effects.out(info(`Retry any time: 'OURS_CONFIG=${env.OURS_CONFIG} ours-mcp create-root "${name}"'.`));
676
709
  return { key: 'identity', label: 'Human identity', state: 'failed', note: 'create-root failed' };
677
710
  }
678
711
  }
@@ -1023,19 +1056,6 @@ export async function runInstall(argv, effects) {
1023
1056
  state: target.action === 'create' ? 'installed' : 'current',
1024
1057
  note: `port ${target.port}`,
1025
1058
  }];
1026
- // THE SKIP MUST NOT READ AS SUCCESS. A line that scrolls past is not a warning
1027
- // — the summary is where someone looks, so an uninstallable boot service gets
1028
- // the same "needs attention" mark a failed component gets. If this screen read
1029
- // clean on macOS we would have replaced a failed install with a quieter lie.
1030
- if (daemon.serviceUnsupported) {
1031
- summary.push({
1032
- key: 'service',
1033
- label: 'Boot service',
1034
- state: 'failed',
1035
- note: `not available on ${daemon.serviceUnsupported.platform === 'darwin' ? 'macOS' : daemon.serviceUnsupported.platform} yet — start the daemon yourself after a reboot`,
1036
- });
1037
- }
1038
-
1039
1059
  const components = await runComponentPhase(args, effects, target);
1040
1060
  for (const component of COMPONENTS) {
1041
1061
  const state = components.installed.includes(component.key) ? 'installed'
package/lib/plan.mjs CHANGED
@@ -130,19 +130,18 @@ export function planServiceInstall({ stateDir, home, readText, platform = 'linux
130
130
  // A real launchd adapter belongs in the SDK CLI, not here. Nothing in this
131
131
  // package can install a launchd agent, and pretending otherwise by writing a
132
132
  // plist ourselves would put a second service implementation in a second repo.
133
- if (platform && platform !== 'linux') {
134
- return {
135
- action: 'unsupported',
136
- platform,
137
- reason: 'no-service-manager',
138
- message: platform === 'darwin'
139
- ? 'installing a boot service is not available on macOS yet — the ours CLI can only manage a Linux user systemd service'
140
- : `installing a boot service is not available on ${platform} — the ours CLI can only manage a Linux user systemd service`,
141
- // What the operator can do INSTEAD, which is the difference between a gap
142
- // and a dead end.
143
- manual: ['ours', 'daemon', 'serve', '--config'],
144
- };
145
- }
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.
146
145
  const derived = unitPathForStateDir(stateDir, home);
147
146
  if (!derived.ok) {
148
147
  return { action: 'refuse', exitCode: 2, reason: 'unusable-state-dir', message: derived.reason };
@@ -212,12 +211,35 @@ export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
212
211
  // --json so the caller can read back whether the unit actually CHANGED. The
213
212
  // CLI owns that byte-comparison, and an installer that guessed at it would
214
213
  // report "nothing changed" on a run that rewrote a unit.
215
- const cmd = ['ours', 'daemon', 'install-service', '--yes', '--json', '--state-dir', dir, '--config', join(dir, 'config.json')];
216
- // --force is reachable ONLY through this explicit argument, which the
217
- // orchestrator passes only after the user answered yes to legacyReplacePrompt.
218
- // It is never a default and never appears in a plan.
219
- if (adoptLegacyUnit) cmd.push('--force');
220
- return cmd;
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'];
221
243
  }
222
244
 
223
245
  // -----------------------------------------------------------------------------
package/lib/target.mjs CHANGED
@@ -31,6 +31,33 @@ export const INSTALL_RESERVED_PORTS = [3051, 3052];
31
31
  // The CLI-owned PID record that proves a daemon belongs to a state directory
32
32
  // even when nothing is recorded in its config (spec §1).
33
33
  export const CLI_PID_RECORD = 'ours-cli-daemon.json';
34
+ // The SAME record, written by a different daemon. `ours daemon start` writes
35
+ // ours-cli-daemon.json; `ours-mcp start` writes daemon.pid (packages/core
36
+ // cli.ts PID_PATH). BOTH are read, and neither replaces the other: a machine
37
+ // installed yesterday has the first and a machine installed today has the
38
+ // second, and detection that goes blind on either one concludes "no daemon
39
+ // here" and creates a SECOND daemon on a state directory that already has one.
40
+ // Two writers on one state_data.bin is the corruption case this lookup exists
41
+ // to prevent, so the lookup has to know both spellings.
42
+ export const MCP_PID_RECORD = 'daemon.pid';
43
+ export const PID_RECORDS = [CLI_PID_RECORD, MCP_PID_RECORD];
44
+
45
+ /**
46
+ * The port a PID record names, whichever daemon wrote it.
47
+ *
48
+ * ours-cli-daemon.json is JSON with a `port`. daemon.pid is a bare pid — no
49
+ * port — so it proves a daemon EXISTS for this state directory without saying
50
+ * where. That distinction is the whole reason this returns both fields.
51
+ */
52
+ export function readPidRecords(target, readJson, readText) {
53
+ const record = readJson(join(target, CLI_PID_RECORD));
54
+ const port = record && typeof record.port === 'number' && Number.isFinite(record.port) ? record.port : null;
55
+ const rawPid = typeof readText === 'function' ? readText(join(target, MCP_PID_RECORD)) : null;
56
+ const pid = rawPid !== null && rawPid !== undefined && /^\s*\d+\s*$/.test(String(rawPid))
57
+ ? Number.parseInt(String(rawPid).trim(), 10)
58
+ : null;
59
+ return { port, pid };
60
+ }
34
61
  export const DAEMON_CONFIG = 'config.json';
35
62
 
36
63
  export class InstallUsageError extends Error {
@@ -192,7 +219,7 @@ export function classifyProbe(probe, targetStateDir) {
192
219
  * is still no I/O here, and `await` on a non-promise is the same value back, so
193
220
  * every existing fake keeps working unchanged.
194
221
  */
195
- export async function findDaemon({ stateDir, probe, readJson }) {
222
+ export async function findDaemon({ stateDir, probe, readJson, readText }) {
196
223
  const target = resolve(stateDir);
197
224
  const config = readJson(join(target, DAEMON_CONFIG));
198
225
  const recordedPort = config && typeof config.port === 'number' && Number.isFinite(config.port) ? config.port : null;
@@ -204,8 +231,8 @@ export async function findDaemon({ stateDir, probe, readJson }) {
204
231
  if (byConfig.kind === 'present') return { ...byConfig, port: first, via: 'config', config };
205
232
  if (byConfig.kind === 'foreign' && !guessed) return { ...byConfig, port: first, via: 'config', config };
206
233
 
207
- const record = readJson(join(target, CLI_PID_RECORD));
208
- const recorded = record && typeof record.port === 'number' && Number.isFinite(record.port) ? record.port : null;
234
+ const records = readPidRecords(target, readJson, readText);
235
+ const recorded = records.port;
209
236
  if (recorded !== null && recorded !== first) {
210
237
  const byRecord = classifyProbe(await probe(recorded), target);
211
238
  if (byRecord.kind === 'present') return { ...byRecord, port: recorded, via: 'pid-record', config };
@@ -229,6 +256,12 @@ export async function findDaemon({ stateDir, probe, readJson }) {
229
256
  // daemon owns THIS directory. A foreign answer on a port the directory
230
257
  // actually RECORDED still refuses, because there the operator's own file said
231
258
  // the daemon was there and something else is.
259
+ // A bare pid record with no port still says a daemon OWNS this directory. It
260
+ // cannot say where, so this is not "present" — but it is a reason to report a
261
+ // stale record rather than silently create a second daemon beside it.
262
+ if (records.pid !== null && byConfig.kind !== 'present') {
263
+ return { kind: 'absent', reason: 'a daemon pid record exists but nothing answers', stalePidRecord: first, port: first, via: 'config', config };
264
+ }
232
265
  if (byConfig.kind === 'foreign') {
233
266
  return {
234
267
  kind: 'absent',
@@ -265,9 +298,9 @@ export async function findDaemon({ stateDir, probe, readJson }) {
265
298
  * if it is occupied that is a refusal, not a reason to shift. Only a derived port
266
299
  * is searched, from 3050 upward, skipping the reserved defaults.
267
300
  */
268
- export async function resolveTarget({ stateDir, port = null, portExplicit = false, probe, readJson, isTaken }) {
301
+ export async function resolveTarget({ stateDir, port = null, portExplicit = false, probe, readJson, readText, isTaken }) {
269
302
  const target = resolve(stateDir);
270
- const found = await findDaemon({ stateDir: target, probe, readJson });
303
+ const found = await findDaemon({ stateDir: target, probe, readJson, readText });
271
304
 
272
305
  if (found.kind === 'foreign') {
273
306
  return {
package/lib/uninstall.mjs CHANGED
@@ -168,37 +168,27 @@ export function planComponentDetach(key, existing) {
168
168
  * case the screen names the external launcher and the run CONTINUES — a daemon
169
169
  * someone else supervises is not a failure of this uninstall.
170
170
  */
171
- export function planDaemonRemoval({ stateDir, cliStartedIt, platform = 'linux' }) {
171
+ export function planDaemonRemoval({ stateDir, cliStartedIt }) {
172
172
  const dir = resolve(stateDir);
173
173
  const unit = unitNameForStateDir(dir);
174
- // THE MIRROR OF THE INSTALL SIDE, and half a fix here would be worse than none:
175
- // a Mac user who installed successfully and then cannot UNINSTALL is stuck with
176
- // something they were told worked.
174
+ // ours-mcp's own uninstall-service, for the same reason install-service is:
175
+ // the unit that exists was written by ours-mcp, and `ours daemon
176
+ // uninstall-service` refuses a unit it did not mark. It takes no flags and is
177
+ // selected by the environment, like every other ours-mcp lifecycle command.
177
178
  //
178
- // `ours daemon uninstall-service` goes through the same
179
- // createLinuxUserSystemdAdapter() that throws on the first line for any
180
- // non-linux platform, so calling it on darwin fails the run rather than
181
- // degrading it. There is also nothing there to remove: this package could never
182
- // have installed a launchd agent in the first place.
183
- const service = platform && platform !== 'linux'
184
- ? {
185
- id: 'service-unsupported',
186
- unit: null,
187
- command: null,
188
- platform,
189
- continues: true,
190
- note: `no boot service was installed on ${platform === 'darwin' ? 'macOS' : platform} — the ours CLI can only manage a Linux user systemd service, so there is nothing of ours to remove here`,
191
- }
192
- : {
193
- id: 'service',
194
- unit: unit.ok ? unit.unit : null,
195
- command: ['ours', 'daemon', 'uninstall-service', '--yes', '--state-dir', dir],
196
- note: 'refuses a unit not marked as CLI-managed',
197
- };
179
+ // The macOS skip that briefly lived here is gone with the daemon change:
180
+ // ours-mcp's uninstall-service handles launchd as well as systemd, so there is
181
+ // no platform on which this declines to try.
182
+ const service = {
183
+ id: 'service',
184
+ unit: unit.ok ? unit.unit : null,
185
+ command: ['ours-mcp', 'uninstall-service'],
186
+ note: 'removes the unit ours-mcp installed',
187
+ };
198
188
  return [
199
189
  service,
200
190
  cliStartedIt
201
- ? { id: 'stop', command: ['ours', 'daemon', 'stop', '--config', join(dir, 'config.json')] }
191
+ ? { id: 'stop', command: ['ours-mcp', 'stop'] }
202
192
  : { id: 'stop-external', command: null, continues: true, note: 'this daemon was not started by the CLI; naming its launcher and continuing' },
203
193
  ];
204
194
  }
@@ -531,7 +521,7 @@ export function planUninstall({ home, env = {}, endpoint, stateDir, purge = fals
531
521
  action: 'uninstall',
532
522
  stateDir: dir,
533
523
  detach: pointing.map((p) => ({ key: p.key, service: [`ours-${p.key === 'tg' ? 'tg-connector' : 'cowork'}`, 'uninstall-service'] })),
534
- daemon: planDaemonRemoval({ stateDir: dir, cliStartedIt, platform }),
524
+ daemon: planDaemonRemoval({ stateDir: dir, cliStartedIt }),
535
525
  state: planStatePurge({ stateDir: dir, purge, assumeYes, exists, typedConfirmation }),
536
526
  plugins,
537
527
  packages: planGlobalPackages({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "0.17.0-nightly.12",
3
+ "version": "0.17.0-nightly.13",
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",