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

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.
@@ -141,6 +141,7 @@ export async function runUninstall(argv, effects) {
141
141
  exists: effects.exists,
142
142
  cliStartedIt: effects.readJson(join(dir, 'ours-cli-daemon.json')) !== null,
143
143
  otherStateDirsWithConfig: effects.knownStateDirs(),
144
+ platform: effects.platform?.platform,
144
145
  typedConfirmation: null,
145
146
  explicitHarnessSelection: contract.engaged,
146
147
  });
@@ -159,6 +160,7 @@ export async function runUninstall(argv, effects) {
159
160
  // removal makes that true. If step 3 or 4 fails, the operator is left with a
160
161
  // stopped, detached connector NEXT TO A DAEMON THAT IS STILL THERE — a world the
161
162
  // bytes no longer describe.
163
+ let unsupportedService = null;
162
164
  const journal = configJournal(effects, { dryRun: args.dryRun });
163
165
  const detached = [];
164
166
  for (const component of plan.detach) {
@@ -178,6 +180,13 @@ export async function runUninstall(argv, effects) {
178
180
  // 3-4. The boot service, then the daemon. Both delegate their refusals.
179
181
  try {
180
182
  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
+ }
181
190
  if (step.command === null) {
182
191
  effects.out(info(`${step.note} — nothing signalled`));
183
192
  continue;
@@ -204,6 +213,7 @@ export async function runUninstall(argv, effects) {
204
213
  const packages = planGlobalPackages({
205
214
  stateDir: dir,
206
215
  otherStateDirsWithConfig: effects.knownStateDirs(),
216
+ platform: effects.platform?.platform,
207
217
  pluginPackages: pluginOutcome.packages,
208
218
  // The plugin packages are recomputed; the CONNECTOR packages are not. They
209
219
  // are decided by what the operator confirmed detaching, which the plugin
@@ -219,6 +229,15 @@ export async function runUninstall(argv, effects) {
219
229
  await perform(effects, args.dryRun, `npm rm -g ${pkg}`, () => effects.run('npm', ['rm', '-g', pkg]));
220
230
  }
221
231
  }
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
+ }
222
241
  return EXIT_OK;
223
242
  }
224
243
 
@@ -239,6 +239,7 @@ export async function runDaemonPhase(args, effects) {
239
239
  // lookup exists to prevent; this is what stops the installer from setting up the
240
240
  // conditions for it.
241
241
  const journal = configJournal(effects, { dryRun: args.dryRun });
242
+ let serviceUnsupported = null;
242
243
  if (merged.changed) {
243
244
  journal.snapshot(configPath);
244
245
  await perform(effects, args.dryRun, `write ${configPath} (port ${target.port})`, () => effects.writeJson(configPath, merged.text));
@@ -254,6 +255,7 @@ export async function runDaemonPhase(args, effects) {
254
255
  }
255
256
 
256
257
  const service = await runServicePhase(args, effects, dir);
258
+ if (service.unsupported) serviceUnsupported = service.unsupported;
257
259
  if (service.refused) {
258
260
  // A REFUSAL IS A FAILURE TO REACH THE STATE, not a special case. An unknown
259
261
  // unit file stops the run just as a failed start does, and it stops it with
@@ -287,7 +289,7 @@ export async function runDaemonPhase(args, effects) {
287
289
  throw error;
288
290
  }
289
291
 
290
- return { target, steps };
292
+ return { target, steps, serviceUnsupported };
291
293
  }
292
294
 
293
295
  /**
@@ -349,7 +351,19 @@ function rollBack(effects, journal, args, why, { packagesInstalled = true, repla
349
351
  * positively identified as ours-mcp's, and never for one we cannot identify.
350
352
  */
351
353
  export async function runServicePhase(args, effects, dir) {
352
- const plan = planServiceInstall({ stateDir: dir, home: effects.home, readText: effects.readText });
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
+ }
353
367
  if (plan.action === 'refuse') {
354
368
  effects.out(warn(`ours: refusing to continue — ${plan.message}`));
355
369
  return { refused: plan };
@@ -594,6 +608,12 @@ export function runPreflight(effects) {
594
608
  return { ok: false, platform: plat };
595
609
  }
596
610
  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
+ }
597
617
  const version = String(effects.nodeVersion ?? '0');
598
618
  if (Number.parseInt(version.split('.')[0], 10) < 20) {
599
619
  effects.out(warn(`Node.js ${version} — ours needs v20 or newer. Update Node and re-run.`));
@@ -1003,6 +1023,18 @@ export async function runInstall(argv, effects) {
1003
1023
  state: target.action === 'create' ? 'installed' : 'current',
1004
1024
  note: `port ${target.port}`,
1005
1025
  }];
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
+ }
1006
1038
 
1007
1039
  const components = await runComponentPhase(args, effects, target);
1008
1040
  for (const component of COMPONENTS) {
package/lib/plan.mjs CHANGED
@@ -110,7 +110,39 @@ export function classifyUnit(text) {
110
110
  * without a question in front of it — it is what keeps --force from becoming a
111
111
  * default that spreads to the other cases.
112
112
  */
113
- export function planServiceInstall({ stateDir, home, readText }) {
113
+ export function planServiceInstall({ stateDir, home, readText, platform = 'linux' }) {
114
+ // THE BOOT SERVICE IS LINUX-ONLY, AND NOT BECAUSE THIS FILE SAYS SO.
115
+ //
116
+ // `ours daemon install-service` in @ours.network/cli builds its adapter with
117
+ // `createLinuxUserSystemdAdapter()` and no platform branch at all, and that
118
+ // factory's FIRST line is
119
+ // if (deps.platform !== 'linux') throw new Error('service management is not
120
+ // supported on <platform>; use an external launcher for `ours daemon serve`')
121
+ // — verified by reading the published 0.4.1 tarball, which contains zero
122
+ // occurrences of launchd, LaunchAgents or plist.
123
+ //
124
+ // So calling it on macOS does not degrade, it THROWS. Before this, a Mac user
125
+ // was told their platform was supported, watched the CLI install, the config
126
+ // write and the daemon start, and then got an exception and a rolled-back
127
+ // config. Skipping the step leaves them a working daemon and one true sentence
128
+ // instead — which is the whole of this change.
129
+ //
130
+ // A real launchd adapter belongs in the SDK CLI, not here. Nothing in this
131
+ // package can install a launchd agent, and pretending otherwise by writing a
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
+ }
114
146
  const derived = unitPathForStateDir(stateDir, home);
115
147
  if (!derived.ok) {
116
148
  return { action: 'refuse', exitCode: 2, reason: 'unusable-state-dir', message: derived.reason };
package/lib/uninstall.mjs CHANGED
@@ -168,16 +168,35 @@ 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 }) {
171
+ export function planDaemonRemoval({ stateDir, cliStartedIt, platform = 'linux' }) {
172
172
  const dir = resolve(stateDir);
173
173
  const unit = unitNameForStateDir(dir);
174
- return [
175
- {
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.
177
+ //
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
+ : {
176
193
  id: 'service',
177
194
  unit: unit.ok ? unit.unit : null,
178
195
  command: ['ours', 'daemon', 'uninstall-service', '--yes', '--state-dir', dir],
179
196
  note: 'refuses a unit not marked as CLI-managed',
180
- },
197
+ };
198
+ return [
199
+ service,
181
200
  cliStartedIt
182
201
  ? { id: 'stop', command: ['ours', 'daemon', 'stop', '--config', join(dir, 'config.json')] }
183
202
  : { id: 'stop-external', command: null, continues: true, note: 'this daemon was not started by the CLI; naming its launcher and continuing' },
@@ -474,7 +493,7 @@ export function selectHarnesses(plugins, chosen) {
474
493
  * The whole §8 order, refusing at step 1 rather than starting and stopping
475
494
  * half-way.
476
495
  */
477
- export function planUninstall({ home, env = {}, endpoint, stateDir, purge = false, assumeYes = false, confirmedComponents = [], readJson, readText, exists = () => true, cliStartedIt = true, otherStateDirsWithConfig = [], typedConfirmation = null, explicitHarnessSelection = false }) {
496
+ export function planUninstall({ home, env = {}, endpoint, stateDir, purge = false, assumeYes = false, confirmedComponents = [], readJson, readText, exists = () => true, cliStartedIt = true, otherStateDirsWithConfig = [], typedConfirmation = null, explicitHarnessSelection = false, platform = 'linux' }) {
478
497
  const dir = resolve(stateDir);
479
498
  const lastDaemon = otherStateDirsWithConfig.map((d) => resolve(d)).filter((d) => d !== dir).length === 0;
480
499
  const plugins = planPluginRemoval({ home, env, exists, lastDaemon, explicitSelection: explicitHarnessSelection });
@@ -512,7 +531,7 @@ export function planUninstall({ home, env = {}, endpoint, stateDir, purge = fals
512
531
  action: 'uninstall',
513
532
  stateDir: dir,
514
533
  detach: pointing.map((p) => ({ key: p.key, service: [`ours-${p.key === 'tg' ? 'tg-connector' : 'cowork'}`, 'uninstall-service'] })),
515
- daemon: planDaemonRemoval({ stateDir: dir, cliStartedIt }),
534
+ daemon: planDaemonRemoval({ stateDir: dir, cliStartedIt, platform }),
516
535
  state: planStatePurge({ stateDir: dir, purge, assumeYes, exists, typedConfirmation }),
517
536
  plugins,
518
537
  packages: planGlobalPackages({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "0.17.0-nightly.11",
3
+ "version": "0.17.0-nightly.12",
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",