@ours.network/install 1.2.0-nightly.2 → 1.2.1-nightly.10

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.
Files changed (39) hide show
  1. package/README.md +151 -131
  2. package/assets/Dockerfile +6 -3
  3. package/assets/docker-compose.yaml +21 -1
  4. package/assets/release-lock.json +7161 -0
  5. package/assets/release.json +57 -0
  6. package/assets/scripts/build/build-common.mjs +1 -1
  7. package/assets/scripts/build/build-sdk.mjs +6 -1
  8. package/assets/scripts/build/record-build.mjs +2 -0
  9. package/assets/scripts/maintenance/build-context.mjs +1 -1
  10. package/assets/scripts/maintenance/daemon-owner.mjs +15 -0
  11. package/assets/scripts/maintenance/docker-layout-conversion.mjs +3 -1
  12. package/assets/scripts/maintenance/release-graph.mjs +111 -0
  13. package/assets/scripts/maintenance/state-operation.mjs +4 -2
  14. package/assets/scripts/runtime/client-setup.mjs +3 -3
  15. package/assets/scripts/runtime/entrypoint.sh +1 -1
  16. package/assets/scripts/runtime/legacy-import.mjs +103 -0
  17. package/assets/scripts/runtime/runtime-common.mjs +2 -0
  18. package/assets/sources.json +96 -16
  19. package/install.mjs +2 -2
  20. package/install.sh +1 -1
  21. package/lib/build-transition.mjs +13 -7
  22. package/lib/client-cli.mjs +117 -0
  23. package/lib/docker-conversion-runtime.mjs +1 -0
  24. package/lib/docker-runtime-repair.mjs +125 -0
  25. package/lib/effects.mjs +163 -85
  26. package/lib/fleet-settings.mjs +43 -0
  27. package/lib/legacy-migration.mjs +202 -0
  28. package/lib/legacy-state.mjs +205 -0
  29. package/lib/managed-cli.mjs +164 -0
  30. package/lib/orchestrate.mjs +162 -58
  31. package/lib/plan.mjs +13 -7
  32. package/lib/prompt.mjs +113 -119
  33. package/lib/server-onboarding.mjs +114 -0
  34. package/lib/setup-options.mjs +253 -0
  35. package/lib/setup.mjs +154 -0
  36. package/lib/target.mjs +4 -4
  37. package/lib/uninstall.mjs +2 -2
  38. package/lib/usage.mjs +49 -44
  39. package/package.json +5 -4
@@ -1,3 +1,4 @@
1
+ import { executeLegacyMigration } from './legacy-migration.mjs';
1
2
  // ours-install v3 — the orchestrator.
2
3
  //
3
4
  // This is the part that cannot be pure: it walks the flow, renders the screens
@@ -25,7 +26,7 @@
25
26
 
26
27
  import { basename, dirname, join, resolve } from 'node:path';
27
28
  import { parseNetworkArgs, validateHostProfile, parseInstallArgs, resolveTarget, resolveProfileSelection, profileEnv, InstallUsageError } from './target.mjs';
28
- import { selectSourcePackages, validateInstallation, SERVER_SERVICES, planDaemonConfig, planServiceInstall, serviceInstallCommand } from './plan.mjs';
29
+ import { clientPackageNames, selectSourcePackages, validateInstallation, SERVER_SERVICES, planDaemonConfig, planServiceInstall, serviceInstallCommand } from './plan.mjs';
29
30
  import {
30
31
  COMPONENTS,
31
32
  planComponentSelection, planMcpAttachment, planTgAttachment, planCoworkAttachment,
@@ -85,6 +86,41 @@ async function perform(effects, dryRun, label, thunk) {
85
86
 
86
87
  const reason = (error) => (error instanceof Error ? error.message : String(error));
87
88
 
89
+ function clientDiagnostic(error) {
90
+ return reason(error)
91
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
92
+ .replace(/\bBearer\s+[^\s,;]+/gi, 'Bearer [redacted]')
93
+ .replace(/((?:api[_-]?key|api[_-]?token|access[_-]?token|refresh[_-]?token|token|password|secret|authorization|credential)\s*["']?\s*[:=]\s*["']?)[^\s"',;]+/gi, '$1[redacted]')
94
+ .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[redacted]@')
95
+ .replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 700);
96
+ }
97
+
98
+ function clientRetry(effects, imported, integrations) {
99
+ effects.out(info(`Saved server connection: ${imported.configPath}.`));
100
+ if (integrations.includes('fleet') && !imported.settings.fleetSettingsPath) {
101
+ effects.out(info('Saved profile and settings retained. Run ours-install, choose Connect to an existing server, and reuse the saved profile to finish the interactive Fleet configuration.'));
102
+ return;
103
+ }
104
+ const args = ['client', 'install', '--config', imported.configPath, '--integrations', integrations.join(',') || 'none', '--sources', imported.settings.sourcesPath];
105
+ if (imported.settings.fleetSettingsPath) args.push('--fleet-settings', imported.settings.fleetSettingsPath);
106
+ const command = `ours-install client install ${args.slice(2).map((value, i) => i % 2 === 0 ? value : shellQuote(value)).join(' ')}`;
107
+ effects.out(info(`Saved profile and settings retained; re-run ${command}`));
108
+ }
109
+
110
+ function explainClientFailure(effects, name, row) {
111
+ const label = { codex: 'Codex', 'claude-code': 'Claude Code', fleet: 'Fleet' }[name];
112
+ const command = name === 'claude-code' ? 'claude' : name;
113
+ if (row?.note === 'not installed') {
114
+ effects.out(warn(`${label}: executable not found on PATH. Install ${label} or make its executable available in this shell, then check ${command} --version before retrying.`));
115
+ } else if (row?.manual) {
116
+ effects.out(warn(`${label}: cannot register the plugin automatically — ${clientDiagnostic(row.note)}. Use the real executable (check ${command} --version), or register the displayed local marketplace manually.`));
117
+ } else {
118
+ effects.out(warn(`${label}: ${row?.failedStep ?? 'integration setup'} failed. ${clientDiagnostic(row?.detail ?? row?.note ?? 'The integration did not report successful completion. Review the messages above.')} Fix the reported command error before retrying.`));
119
+ if (row?.failedCommand) effects.out(info(`Failed command: ${row.failedCommand.map(shellQuote).join(' ')}`));
120
+ }
121
+ }
122
+
123
+
88
124
  function semverMajor(version) {
89
125
  const match = /^(?:[~^<>= ]*)(\d+)\./.exec(String(version ?? '').trim());
90
126
  return match ? Number(match[1]) : null;
@@ -109,6 +145,7 @@ function incompatibleUpgrade(target, cliDependencies) {
109
145
  async function prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatch) {
110
146
  const dir = target.stateDir;
111
147
  const configPath = join(dir, 'config.json');
148
+ const owner = effects.readJson(join(dir, 'ours-cli-daemon.json'))?.owner === '@ours.network/daemon' ? ['ours-daemon'] : ['ours', 'daemon'];
112
149
  if (mismatch.unknown) {
113
150
  effects.out(warn(`ours: cannot verify whether ${cliPkg} can restore daemon v${mismatch.runningVersion}. Nothing was changed.`));
114
151
  effects.out(info('Check npm registry access and re-run; compatibility checks fail closed.'));
@@ -152,13 +189,13 @@ async function prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatc
152
189
  }
153
190
 
154
191
  await perform(effects, false, 'stop the incompatible daemon', () => effects.run(
155
- 'ours', ['daemon', 'stop', '--state-dir', dir, '--config', configPath], { stream: true },
192
+ owner[0], [...owner.slice(1), 'stop', '--state-dir', dir, '--config', configPath], { stream: true },
156
193
  ));
157
194
  try {
158
195
  await perform(effects, false, `back up complete daemon state to ${backupPath}`, () => effects.copyDir(dir, backupPath));
159
196
  } catch (error) {
160
197
  try {
161
- await effects.run('ours', ['daemon', 'start', '--state-dir', dir, '--config', configPath], { stream: true });
198
+ await effects.run(owner[0], [...owner.slice(1), 'start', '--state-dir', dir, '--config', configPath], { stream: true });
162
199
  effects.out(ok('backup failed, but the old daemon was started again'));
163
200
  } catch {
164
201
  effects.out(warn(`backup failed and the old daemon did not restart; its state is still untouched at ${dir}`));
@@ -167,11 +204,11 @@ async function prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatc
167
204
  }
168
205
  try {
169
206
  await perform(effects, false, 'remove the incompatible daemon boot service', () => effects.run(
170
- 'ours', ['daemon', 'uninstall-service', '--yes', '--state-dir', dir, '--config', configPath], { stream: true },
207
+ owner[0], [...owner.slice(1), 'uninstall-service', '--yes', '--state-dir', dir, '--config', configPath], { stream: true },
171
208
  ));
172
209
  } catch (error) {
173
210
  try {
174
- await effects.run('ours', ['daemon', 'start', '--state-dir', dir, '--config', configPath], { stream: true });
211
+ await effects.run(owner[0], [...owner.slice(1), 'start', '--state-dir', dir, '--config', configPath], { stream: true });
175
212
  effects.out(ok(`service removal failed, but the old daemon was started again; backup retained at ${backupPath}`));
176
213
  } catch {
177
214
  effects.out(warn(`service removal failed and the old daemon did not restart; state remains at ${dir} and the backup is at ${backupPath}`));
@@ -192,11 +229,11 @@ async function prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatc
192
229
  * rather than one harness. So a failure here is one honest line plus whatever
193
230
  * the caller wants to say about retrying, and the walk continues.
194
231
  */
195
- async function attempt(effects, dryRun, label, thunk) {
232
+ async function attempt(effects, dryRun, label, thunk, formatReason = reason) {
196
233
  try {
197
234
  return { ok: true, ...(await perform(effects, dryRun, label, thunk)) };
198
235
  } catch (error) {
199
- effects.out(warn(`${label} — did not complete: ${reason(error)}`));
236
+ effects.out(warn(`${label} — did not complete: ${formatReason(error)}`));
200
237
  return { ok: false, error };
201
238
  }
202
239
  }
@@ -332,14 +369,13 @@ export async function runDaemonPhase(args, effects, exactSuite = null) {
332
369
  const mcpPkg = exactSuite?.packages?.mcp
333
370
  ? `@ours.network/mcp@${exactSuite.packages.mcp}`
334
371
  : componentSpec(componentByKey('mcp'), args.channel);
335
- // The CLI intentionally publishes only `latest`; unlike the lockstep MCP and
336
- // connector packages it has no nightly dist-tag. Keep this untagged on every
337
- // installer channel, and inspect that package's SDK dependency for the gate.
338
- const cliPkg = '@ours.network/cli';
372
+ // Inspect the runtime package SDK compatibility before replacing the owner.
373
+ const cliPkg = args.channel === 'nightly' ? '@ours.network/cli@nightly' : '@ours.network/cli';
374
+ const daemonPkg = args.channel === 'nightly' ? '@ours.network/daemon@nightly' : '@ours.network/daemon';
339
375
  if (!creating && target.daemonVersion) {
340
- const mismatch = incompatibleUpgrade(target, effects.packageDependencies(cliPkg));
376
+ const mismatch = incompatibleUpgrade(target, effects.packageDependencies(daemonPkg));
341
377
  if (mismatch) {
342
- const prepared = await prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatch);
378
+ const prepared = await prepareIncompatibleUpgrade(args, effects, target, daemonPkg, mismatch);
343
379
  if (prepared.refused) return { target, refused: prepared.refused, steps };
344
380
  creating = prepared.purged === true;
345
381
  target.backupPath = prepared.backupPath;
@@ -348,6 +384,18 @@ export async function runDaemonPhase(args, effects, exactSuite = null) {
348
384
  }
349
385
  await perform(effects, args.dryRun, `MCP server installed (npm i -g ${mcpPkg})`, () => effects.run('npm', ['i', '-g', mcpPkg]));
350
386
  steps.push({ id: 'mcp-package', changed: true, packageRefresh: true });
387
+ await perform(effects, args.dryRun, 'daemon runtime installed', () => effects.run('npm', ['i', '-g', daemonPkg]));
388
+ // A retained global unit still points into the old CLI package. Rewrite and
389
+ // activate that owned unit before npm replaces its executable with thin CLI.
390
+ let migratedService;
391
+ if (!creating) {
392
+ const retained = planServiceInstall({ stateDir: dir, home: effects.home, readText: effects.readText, platform: effects.platform?.platform });
393
+ if (retained.unitPath && effects.readText(retained.unitPath) !== null) {
394
+ try { migratedService = await runServicePhase(args, effects, dir, target.port); }
395
+ catch (error) { await recoverDaemon(args, effects, dir, join(dir, 'config.json'), target.port); throw error; }
396
+ if (migratedService.refused) return { target, refused: migratedService.refused, steps };
397
+ }
398
+ }
351
399
  await perform(effects, args.dryRun, `ours CLI installed (npm i -g ${cliPkg})`, () => effects.run('npm', ['i', '-g', cliPkg]));
352
400
  steps.push({ id: 'cli', changed: true, packageRefresh: true });
353
401
 
@@ -382,14 +430,14 @@ export async function runDaemonPhase(args, effects, exactSuite = null) {
382
430
 
383
431
  try {
384
432
  if (creating) {
385
- await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'start', '--config', configPath], { stream: true }));
433
+ await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours-daemon', [ 'start', '--config', configPath], { stream: true }));
386
434
  steps.push({ id: 'start', changed: true });
387
435
  } else {
388
- await perform(effects, args.dryRun, `restart the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'restart', '--config', configPath], { stream: true }));
436
+ await perform(effects, args.dryRun, `restart the daemon on port ${target.port}`, () => effects.run('ours-daemon', [ 'restart', '--config', configPath], { stream: true }));
389
437
  steps.push({ id: 'restart', changed: true });
390
438
  }
391
439
 
392
- const service = await runServicePhase(args, effects, dir, target.port);
440
+ const service = migratedService ?? await runServicePhase(args, effects, dir, target.port);
393
441
  if (service.unsupported) serviceUnsupported = service.unsupported;
394
442
  if (service.refused) {
395
443
  // A REFUSAL IS A FAILURE TO REACH THE STATE, not a special case. An unknown
@@ -419,7 +467,7 @@ export async function runDaemonPhase(args, effects, exactSuite = null) {
419
467
  effects.out(recovery.recovered
420
468
  ? ok('your daemon is running again — nothing was committed, and the service is unchanged')
421
469
  : warn('and the daemon did NOT come back up — start it yourself before anything else: '
422
- + `ours daemon start --config ${configPath}`));
470
+ + `ours-daemon start --config ${configPath}`));
423
471
  }
424
472
  throw error;
425
473
  }
@@ -442,7 +490,7 @@ export async function runDaemonPhase(args, effects, exactSuite = null) {
442
490
  async function recoverDaemon(args, effects, dir, configPath, port) {
443
491
  if (args.dryRun) return null;
444
492
  try {
445
- await effects.run('ours', ['daemon', 'start', '--config', configPath]);
493
+ await effects.run('ours-daemon', [ 'start', '--config', configPath]);
446
494
  return { recovered: true };
447
495
  } catch (recoveryError) {
448
496
  return { recovered: false, reason: reason(recoveryError) };
@@ -793,7 +841,7 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
793
841
  }
794
842
  if (/not running|not reachable|ECONNREFUSED|connect/i.test(text)) {
795
843
  effects.out(warn("The daemon isn't reachable yet — couldn't create your human identity."));
796
- effects.out(info(`Fix: run 'ours daemon start --config ${env.OURS_CONFIG}', then 'ours identity create-root --config ${env.OURS_CONFIG} --name "${name}"'.`));
844
+ effects.out(info(`Fix: run 'ours-daemon start --config ${env.OURS_CONFIG}', then 'ours identity create-root --config ${env.OURS_CONFIG} --name "${name}"'.`));
797
845
  return { key: 'identity', label: 'Human identity', state: 'failed', note: 'daemon not reachable' };
798
846
  }
799
847
  effects.out(warn(`Couldn't create your human identity: ${text.split('\n')[0]}`));
@@ -816,6 +864,9 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
816
864
  export async function runHarnessPhase(args, effects, { target, isDefaultStateDir, exactSuite = null }) {
817
865
  effects.out(heading('Harness plugins'));
818
866
  const detected = (await effects.detectHarnesses()).filter(h => !args.clientIntegrations || args.clientIntegrations.includes(h.name));
867
+ for (const name of args.clientIntegrations ?? []) {
868
+ if (name !== 'fleet' && !detected.some(h => h.name === name)) detected.push({ name, status: 'absent' });
869
+ }
819
870
  for (const h of detected) {
820
871
  if (h.status === 'ok') effects.out(ok(`'${h.command ?? h.name}' → ${h.detail ?? 'real program'} (its plugin can be installed)`));
821
872
  else if (h.status === 'alias') effects.out(warn(`'${h.command ?? h.name}' → ${h.detail} (I won't call it — manual steps below)`));
@@ -825,7 +876,7 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
825
876
  if (detected.every((h) => h.status === 'absent')) {
826
877
  effects.out(info('No Claude Code, Codex or Hermes found — install one and re-run to wire it up.'));
827
878
  effects.out(info('Your daemon is unaffected; nothing else in this run depends on a harness.'));
828
- return [];
879
+ return args.clientIntegrations ? detected.map(h => ({ key: h.name, state: 'skipped', note: 'not installed' })) : [];
829
880
  }
830
881
 
831
882
  const plans = planHarnessPlugins({
@@ -850,7 +901,7 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
850
901
  if (plan.action === 'manual' && exactSuite?.localPackages?.[plan.name]) {
851
902
  const root = await effects.prepareClientMarketplace(plan.name, exactSuite.localPackages[plan.name]);
852
903
  effects.out(warn(`${plan.label} requires manual registration of exact local marketplace ${root}; ${target.managed ? 'the saved client default is retained' : `keep OURS_CONFIG=${target.configPath}`}.`));
853
- rows.push({ ...row, state: 'failed', note: 'native executable unavailable' });
904
+ rows.push({ ...row, state: 'failed', note: plan.reason, manual: true });
854
905
  continue;
855
906
  }
856
907
  if (plan.action === 'manual') {
@@ -872,7 +923,7 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
872
923
  effects.out(warn(`${plan.label} — ${plan.reason}; install it yourself with:`));
873
924
  for (const step of manual) effects.out(info(` ${step}`));
874
925
  if (plan.envLine) effects.out(info(`Before later native launches, set: ${plan.envLine}`));
875
- rows.push({ ...row, state: 'skipped', note: plan.reason });
926
+ rows.push({ ...row, state: 'skipped', note: plan.reason, manual: true });
876
927
  continue;
877
928
  }
878
929
 
@@ -883,13 +934,16 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
883
934
  const steps = plan.name === 'codex'
884
935
  ? [['codex', 'plugin', 'marketplace', 'add', registration], ['codex', 'plugin', 'add', 'ours@ours-codex-marketplace']]
885
936
  : [['claude', 'plugin', 'marketplace', 'add', registration], ['claude', 'plugin', await effects.hasClaudePlugin() ? 'update' : 'install', 'ours@ours.network']];
886
- let failed = false;
937
+ let failure = null;
887
938
  for (const step of steps) {
888
- const outcome = await attempt(effects, false, step.join(' '), () => effects.run(step[0], step.slice(1), { env: profileEnv(target) }));
889
- if (!outcome.ok) { failed = true; break; }
939
+ const outcome = await attempt(effects, false, step.join(' '), () => effects.run(step[0], step.slice(1), { env: profileEnv(target) }), clientDiagnostic);
940
+ if (!outcome.ok) {
941
+ failure = { failedCommand: step, failedStep: step[2] === 'marketplace' ? `Register ${plan.label} marketplace` : `Install ${plan.label} plugin`, detail: clientDiagnostic(outcome.error) };
942
+ break;
943
+ }
890
944
  }
891
945
  effects.out(info(target.managed ? `Native ${plan.name} launches use the saved client default.` : `Native ${plan.name} launches must retain OURS_CONFIG=${target.configPath}.`));
892
- rows.push({ ...row, state: failed ? 'failed' : 'installed' });
946
+ rows.push({ ...row, state: failure ? 'failed' : 'installed', ...(failure ?? {}) });
893
947
  continue;
894
948
  }
895
949
 
@@ -1250,7 +1304,9 @@ export async function runInstall(argv, effects) {
1250
1304
 
1251
1305
  export async function runServerCommand(args, effects) {
1252
1306
  if (args.operation === 'status') return executeServerCommand(args, effects);
1253
- return effects.withInstallationLock(args.stateDir, () => executeServerCommand(args, effects));
1307
+ return effects.withInstallationLock(args.stateDir, () => args.migrateFrom
1308
+ ? executeLegacyMigration(args, effects, executeServerCommand)
1309
+ : executeServerCommand(args, effects));
1254
1310
  }
1255
1311
 
1256
1312
  async function executeServerCommand(args, effects) {
@@ -1266,6 +1322,7 @@ async function executeServerCommand(args, effects) {
1266
1322
  } else {
1267
1323
  if (args.operation !== 'install' || !args.mode) throw new Error('First server install requires --mode');
1268
1324
  record = effects.newInstallation(args.stateDir, args.mode);
1325
+ for (const key of ['port', 'coworkPort', 'messengerPort']) if (args[key] !== undefined) record[key] = args[key];
1269
1326
  }
1270
1327
  if (record.layoutConversion && !['install', 'start', 'stop', 'status'].includes(args.operation)) {
1271
1328
  throw new Error('Layout conversion is incomplete; resume with server install or server start before changing state or authority');
@@ -1273,14 +1330,32 @@ async function executeServerCommand(args, effects) {
1273
1330
  if (record.buildTransition && !['stop', 'status', record.buildTransition.operation].includes(args.operation)) {
1274
1331
  throw new Error(`Server build activation is incomplete; repeat server ${record.buildTransition.operation} before other mutations`);
1275
1332
  }
1333
+ const showInstallProgress = args.operation === 'install' && !(existing && (record.schema === 1 || record.layoutConversion));
1334
+ const installStageCount = (existing ? 7 : 9) + (args.identityName ? 2 : 0);
1335
+ let completedInstallStages = 0;
1336
+ const installStage = async (label, explanation, action) => {
1337
+ if (!showInstallProgress) return action();
1338
+ effects.out(progress(completedInstallStages, installStageCount, label, explanation));
1339
+ try {
1340
+ const result = await action();
1341
+ completedInstallStages += 1;
1342
+ effects.out(ok(`${label} complete`));
1343
+ return result;
1344
+ } catch (error) {
1345
+ effects.out(warn(`Server installation stopped during ${label.toLowerCase()}.`));
1346
+ throw error;
1347
+ }
1348
+ };
1276
1349
  if ((!existing || args.operation === 'update') && !record.buildTransition) {
1277
- const policy = args.sources ? effects.readJson(args.sources) : effects.packagedSourcePolicy();
1278
- args.resolvedSources = await effects.resolveSourcePolicy(policy, 'server');
1350
+ args.resolvedSources = await installStage('Package selection', 'Resolve the selected server packages before installation.', async () => {
1351
+ const policy = args.sourcePolicy ?? (args.sources ? effects.readJson(args.sources) : effects.packagedSourcePolicy());
1352
+ return effects.resolveSourcePolicy(policy, 'server');
1353
+ });
1279
1354
  }
1280
1355
  if (!existing && args.sources) record.sourcePolicyHash = effects.sourcePolicyHash(args.sources);
1281
- await effects.serverPreflight(record, args.operation, {
1282
- existing, sourcePath: args.sources ?? record.sourcesPath, sourceManifest: args.resolvedSources,
1283
- });
1356
+ await installStage('Prerequisite checks', record.mode === 'docker' ? 'Check Docker Engine and Docker Compose.' : 'Check native tools and the user service manager.', () => effects.serverPreflight(record, args.operation, {
1357
+ existing, sourcePath: args.sources ?? record.sourcesPath, sourceManifest: args.resolvedSources, identityName: args.identityName,
1358
+ }));
1284
1359
  if (existing && (record.schema === 1 || record.layoutConversion)
1285
1360
  && ['install', 'start', 'restart', 'update', 'rebuild'].includes(args.operation)) {
1286
1361
  record = record.mode === 'docker'
@@ -1295,16 +1370,27 @@ async function executeServerCommand(args, effects) {
1295
1370
  await effects.stopPendingConversion(record);
1296
1371
  } else if (args.operation === 'install') {
1297
1372
  if (!existing) {
1298
- await effects.initializeSelection(record, args.resolvedSources);
1299
- effects.writeJson(recordPath, JSON.stringify(record, null, 2) + '\n');
1373
+ await installStage('Installation setup', 'Save the selected packages and installation settings.', async () => {
1374
+ await effects.initializeSelection(record, args.resolvedSources);
1375
+ effects.writeJson(recordPath, JSON.stringify(record, null, 2) + '\n');
1376
+ });
1300
1377
  }
1301
- await effects.prepareInstallation(record);
1378
+ await installStage('Runtime preparation', record.mode === 'docker' ? 'Prepare the Docker runtime. Downloads and builds may take several minutes.' : 'Download and prepare the native runtime packages. This may take several minutes.', () => effects.prepareInstallation(record));
1302
1379
  // A repeated setup repairs delivery with the retained master and exact packages.
1303
- await effects.serverLifecycle(record, 'stop');
1304
- await effects.serverAccess(record, 'access-init', { migrate: !!args.migrate });
1305
- await effects.serverAccess(record, 'access-issue');
1306
- await effects.recordInstallationBuild(record);
1307
- await effects.serverLifecycle(record, 'start');
1380
+ await installStage('Service shutdown', 'Stop managed services before configuring access.', () => effects.serverLifecycle(record, 'stop'));
1381
+ await installStage('Credential initialization', 'Initialize or retain the installation credentials.', () => effects.serverAccess(record, 'access-init', { migrate: !!args.migrate }));
1382
+ await installStage('Credential delivery', 'Prepare access for the selected services.', () => effects.serverAccess(record, 'access-issue'));
1383
+ await installStage('Build verification', 'Record the installed runtime and selected package versions.', () => effects.recordInstallationBuild(record));
1384
+ if (args.identityName) {
1385
+ await installStage('Daemon startup', 'Start the daemon and restore its retained identities.', () => effects.serverLifecycle(record, 'start', ['daemon']));
1386
+ const identity = await installStage('Human identity', 'Keep the existing Human identity, or create it on a fresh daemon.', () => effects.serverEnsureIdentity(record, args.identityName));
1387
+ record.messengerIdentity = identity.name;
1388
+ effects.writeJson(recordPath, JSON.stringify(record, null, 2) + '\n');
1389
+ await installStage('Application startup', 'Start the selected applications and check readiness.', () => effects.serverLifecycle(record, 'start', record.services.filter(name => name !== 'daemon')));
1390
+ } else {
1391
+ await installStage('Service startup', 'Start the daemon and selected services, then check readiness.', () => effects.serverLifecycle(record, 'start'));
1392
+ }
1393
+ if (showInstallProgress) effects.out(progress(installStageCount, installStageCount, 'Installation complete', 'The selected services are ready.'));
1308
1394
  } else if (['backup', 'restore', 'reset'].includes(args.operation)) {
1309
1395
  await effects.serverMaintenance(record, args);
1310
1396
  } else if (['update', 'rebuild'].includes(args.operation)) {
@@ -1336,24 +1422,25 @@ async function executeServerCommand(args, effects) {
1336
1422
  layoutConversion: record.schema === 1 ? 'preparation-pending' : 'activation-pending',
1337
1423
  } : {}),
1338
1424
  }));
1425
+ if (args.operation === 'status') return EXIT_OK;
1339
1426
  }
1340
1427
  effects.out(ok(`Server ${args.operation} completed for ${record.root}`));
1341
1428
  return EXIT_OK;
1342
1429
  }
1343
1430
 
1344
- async function runClientCommand(command, effects) {
1431
+ export async function runClientCommand(command, effects) {
1345
1432
  const managedPath = join(effects.home, '.ours-client', 'profile.json');
1346
1433
  const saved = effects.readManagedClientProfile();
1347
1434
  let configPath = command.config;
1348
1435
  if (!configPath && saved) {
1349
- if (effects.interactive && !await effects.ask(`Reuse saved client server ${saved.endpoint}?`, true))
1436
+ if (!command.preset && effects.interactive && !await effects.ask(`Reuse saved client server ${saved.endpoint}?`, true))
1350
1437
  throw new InstallUsageError('Saved client default retained; use an explicit prepared profile to validate replacement input');
1351
1438
  configPath = managedPath;
1352
1439
  }
1353
1440
  let profile;
1354
1441
  if (configPath) profile = validateHostProfile(effects.readProfile(configPath));
1355
- else if (effects.interactive) {
1356
- const endpoint = await effects.askLine('Server HTTP endpoint: ', 'http://127.0.0.1:3050');
1442
+ else if (!command.preset && effects.interactive) {
1443
+ const endpoint = await effects.askLine('Server HTTP or HTTPS endpoint: ', 'http://127.0.0.1:3050');
1357
1444
  const credentialPath = await effects.askLine('Private issued-token file: ', '');
1358
1445
  if (!credentialPath) throw new InstallUsageError('Client setup requires an issued-token file');
1359
1446
  profile = await effects.discoverClientProfile(endpoint, credentialPath);
@@ -1366,46 +1453,63 @@ async function runClientCommand(command, effects) {
1366
1453
  }
1367
1454
  effects.out(info(`Selected server ${profile.endpoint} (instance ${profile.expectedInstanceId}).`));
1368
1455
  await effects.verifyHostProfile(configPath || profile);
1369
- await effects.verifyPackagedMcp(configPath || profile);
1370
1456
  const settings = saved?.installer ?? (configPath ? effects.readJson(configPath)?.installer : undefined);
1371
1457
  const settingsBase = saved ? dirname(managedPath) : configPath ? dirname(configPath) : process.cwd();
1372
- let integrations = settings?.integrations;
1373
- if (!integrations && effects.interactive) {
1458
+ let integrations = command.integrations ?? settings?.integrations;
1459
+ if (!integrations && !command.preset && effects.interactive) {
1374
1460
  integrations = [];
1375
1461
  for (const name of ['codex', 'claude-code', 'fleet']) if (await effects.ask(`Install ${name}?`, name !== 'fleet')) integrations.push(name);
1376
1462
  }
1377
- if (!Array.isArray(integrations) || !integrations.length || integrations.some(name => !['codex', 'claude-code', 'fleet'].includes(name)) || new Set(integrations).size !== integrations.length) throw new InstallUsageError('installer.integrations must select codex, claude-code and/or fleet');
1378
- let fleetSettingsPath = settings?.fleetSettingsPath;
1463
+ if (!Array.isArray(integrations) || integrations.some(name => !['codex', 'claude-code', 'fleet'].includes(name)) || new Set(integrations).size !== integrations.length) throw new InstallUsageError('installer.integrations must be an array of codex, claude-code and/or fleet; use an empty array for CLI only');
1464
+ let fleetSettingsPath = command.preset ? command.fleetSettingsPath : settings?.fleetSettingsPath;
1465
+ if (command.nonInteractive && integrations.includes('fleet') && !fleetSettingsPath) throw new InstallUsageError('Fleet in CLI mode requires --fleet-settings; no interactive wizard will be opened');
1379
1466
  if (fleetSettingsPath !== undefined && (typeof fleetSettingsPath !== 'string' || !fleetSettingsPath))
1380
1467
  throw new InstallUsageError('installer.fleetSettingsPath must be a non-empty path when supplied');
1381
1468
  if (fleetSettingsPath) fleetSettingsPath = resolve(settingsBase, fleetSettingsPath);
1382
- const selectedClients = [...new Set(['sdk', ...(integrations.includes('fleet') ? ['cli'] : []), ...integrations])];
1469
+ const selectedClients = clientPackageNames(integrations);
1383
1470
  let sourcesPath = settings?.sourcesPath;
1384
1471
  let resolvedSources;
1385
- if (saved) sourcesPath = resolve(settingsBase, sourcesPath);
1472
+ if (command.sourcePolicy) {
1473
+ resolvedSources = await effects.resolveSourcePolicy(command.sourcePolicy, 'client', selectedClients);
1474
+ sourcesPath = undefined;
1475
+ } else if (saved) sourcesPath = resolve(settingsBase, sourcesPath);
1386
1476
  else {
1387
1477
  if (sourcesPath) sourcesPath = resolve(settingsBase, sourcesPath);
1388
1478
  const policy = sourcesPath ? effects.readJson(sourcesPath) : effects.packagedSourcePolicy();
1389
1479
  resolvedSources = await effects.resolveSourcePolicy(policy, 'client', selectedClients);
1390
1480
  }
1391
- const imported = effects.importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath });
1481
+ effects.out(progress(0, 4, 'Client configuration', 'Prepare the selected integrations and private connection profile.'));
1482
+ const imported = effects.importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh: !!command.preset });
1483
+ let phase = 'Validate saved server connection';
1392
1484
  try {
1393
1485
  await effects.verifyHostProfile(imported.configPath);
1394
- const exactSuite = await effects.acquireClientPackages(imported.configPath, imported.settings.sourcesPath, integrations);
1486
+ effects.out(progress(1, 4, 'Client packages', 'Acquire and verify the selected client package versions.'));
1487
+ phase = 'Acquire client packages';
1488
+ const exactSuite = await effects.acquireClientPackages(imported.configPath, imported.settings.sourcesPath, integrations, { refresh: !!command.preset });
1395
1489
  const args = { assumeYes: true, dryRun: false, channel: 'latest', clientIntegrations: integrations,
1396
1490
  acquiredFleet: exactSuite.fleetBin, fleetSettingsPath: imported.settings.fleetSettingsPath };
1397
1491
  const target = { mode: 'host-profile', managed: true, configPath: imported.configPath, profile: imported.profile, endpoint: imported.profile.endpoint };
1492
+ effects.out(progress(2, 4, 'Client integrations', 'Register the selected agent integrations.'));
1493
+ phase = 'Register client integrations';
1398
1494
  const summary = await runHarnessPhase(args, effects, { target, isDefaultStateDir: false, exactSuite });
1399
- if (integrations.includes('fleet')) summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir: false }));
1495
+ if (integrations.includes('fleet')) {
1496
+ effects.out(progress(3, 4, 'Fleet configuration', 'Apply prepared settings or open the selected Fleet wizard.'));
1497
+ phase = 'Configure Fleet';
1498
+ summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir: false }));
1499
+ }
1400
1500
  const incomplete = integrations.filter(name => !summary.some(row => row.key === name && row.state === 'installed'));
1401
1501
  if (effects.env.OURS_CONFIG && resolve(effects.env.OURS_CONFIG) !== imported.configPath)
1402
1502
  effects.out(warn('This shell has an explicit OURS_CONFIG override. Unset it for new clients to use the saved default; installer did not edit your shell.'));
1403
- effects.out(incomplete.length
1404
- ? warn(`Client setup incomplete (${incomplete.join(', ')}); saved profile and settings retained. Re-run ours-install client install.`)
1405
- : ok(`Client setup complete. New clients discover ${imported.configPath}; no OURS_CONFIG export is required.`));
1503
+ if (incomplete.length) {
1504
+ effects.out(warn(`Client setup incomplete (${incomplete.join(', ')}). The selected client integrations need attention:`));
1505
+ for (const name of incomplete) explainClientFailure(effects, name, summary.find(row => row.key === name));
1506
+ clientRetry(effects, imported, integrations);
1507
+ } else effects.out(ok(`Client setup complete. New clients discover ${imported.configPath}; no OURS_CONFIG export is required.`));
1508
+ if (!incomplete.length) effects.out(progress(4, 4, 'Client setup complete', 'All selected integrations are configured.'));
1406
1509
  return incomplete.length ? EXIT_REFUSED : EXIT_OK;
1407
1510
  } catch (error) {
1408
- effects.out(warn(`Client setup incomplete: ${reason(error)}. Saved profile and settings retained; re-run ours-install client install.`));
1511
+ effects.out(warn(`Client setup incomplete: ${phase} failed — ${clientDiagnostic(error)}. Fix this error before retrying.`));
1512
+ clientRetry(effects, imported, integrations);
1409
1513
  return EXIT_REFUSED;
1410
1514
  }
1411
1515
  }
package/lib/plan.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { join, resolve, basename, dirname } from 'node:path';
8
8
  import { valid, validRange, satisfies } from 'semver';
9
+ import { releaseBinding } from '../assets/scripts/maintenance/release-graph.mjs';
9
10
 
10
11
  export const CLI_UNIT_MARKER = '# Managed by @ours.network/cli';
11
12
  export const SYSTEMD_USER_DIR = ['.config', 'systemd', 'user'];
@@ -96,7 +97,7 @@ export function launchdLabelForStateDir(stateDir) {
96
97
  export function classifyUnit(text) {
97
98
  if (text === null || text === undefined) return { kind: 'absent' };
98
99
  const s = String(text);
99
- if (s.startsWith(CLI_UNIT_MARKER)) return { kind: 'cli-managed' };
100
+ if ((s.startsWith(CLI_UNIT_MARKER) || s.startsWith('# Managed by @ours.network/daemon'))) return { kind: 'cli-managed' };
100
101
  const looksLikeOursMcp = /ExecStart=.*\bours-mcp\b/.test(s)
101
102
  || /^Description=ours MCP daemon\b/m.test(s)
102
103
  || (/^Environment=OURS_STATE_DIR=/m.test(s) && /^Environment=OURS_TRANSPORT=http$/m.test(s));
@@ -137,7 +138,7 @@ export function planServiceInstall({ stateDir, home, readText, platform = 'linux
137
138
  platform,
138
139
  reason: 'no-service-manager',
139
140
  message: `installing a boot service is not available on ${platform} — the ours CLI supports Linux user systemd and macOS launchd`,
140
- manual: ['ours', 'daemon', 'serve', '--config'],
141
+ manual: ['ours-daemon', 'serve', '--config'],
141
142
  };
142
143
  }
143
144
 
@@ -223,7 +224,7 @@ export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
223
224
  // --json so the caller can read back whether the unit actually CHANGED rather
224
225
  // than assuming it did. --force is reachable only through the explicit argument
225
226
  // above, and the CLI refuses to overwrite a unit it did not write.
226
- const cmd = ['ours', 'daemon', 'install-service', '--yes', '--json', '--state-dir', dir, '--config', join(dir, 'config.json')];
227
+ const cmd = ['ours-daemon', 'install-service', '--yes', '--json', '--state-dir', dir, '--config', join(dir, 'config.json')];
227
228
  if (adoptLegacyUnit) cmd.push('--force');
228
229
  return cmd;
229
230
  }
@@ -264,20 +265,20 @@ export function planDaemonSteps(target, { cliVersionChanged = false, cliStartedI
264
265
  const steps = [{ id: 'cli', label: 'install the ours-sdk CLI', command: ['npm', 'i', '-g', '@ours.network/cli'] }];
265
266
  steps.push({ id: 'config', label: `write ${join(dir, 'config.json')}`, port: target.port });
266
267
  if (target.action === 'create') {
267
- steps.push({ id: 'start', label: `start the daemon on port ${target.port}`, command: ['ours', 'daemon', 'start', '--config', join(dir, 'config.json')] });
268
+ steps.push({ id: 'start', label: `start the daemon on port ${target.port}`, command: ['ours-daemon', 'start', '--config', join(dir, 'config.json')] });
268
269
  } else if (cliVersionChanged) {
269
270
  // `ours daemon stop` refuses to signal a daemon it did not start, so a
270
271
  // daemon under another launcher is left running and the caller says which
271
272
  // launcher must be restarted instead.
272
273
  steps.push(cliStartedIt
273
- ? { id: 'restart', label: 'restart the daemon (package version changed)', command: ['ours', 'daemon', 'restart', '--config', join(dir, 'config.json')] }
274
+ ? { id: 'restart', label: 'restart the daemon (package version changed)', command: ['ours-daemon', 'restart', '--config', join(dir, 'config.json')] }
274
275
  : { id: 'restart-external', label: 'daemon was not started by the CLI — restart it with its own launcher', command: null });
275
276
  }
276
277
  steps.push({ id: 'service', label: 'install the boot service', command: serviceInstallCommand({ stateDir: dir }) });
277
278
  return steps;
278
279
  }
279
280
 
280
- export const SERVER_PACKAGES = ['sdk', 'cli', 'mcp', 'tg-connector', 'cowork', 'messenger-server'].map(n => `@ours.network/${n}`);
281
+ export const SERVER_PACKAGES = ['sdk', 'cli', 'daemon', 'tg-connector', 'cowork', 'messenger-server'].map(n => `@ours.network/${n}`);
281
282
  export const SERVER_DEPENDENCIES = {
282
283
  daemon: [], telegram: ['daemon'], cowork: ['daemon'], messenger: ['daemon'],
283
284
  };
@@ -310,6 +311,7 @@ export function selectSourcePackages(manifest, role, clients = []) {
310
311
 
311
312
  /** Resolve a packaged compatibility policy into a role-filtered exact selection. */
312
313
  export async function resolveSourcePolicy(manifest, role, clients = [], resolveNpm) {
314
+ const release = releaseBinding(manifest);
313
315
  const names = role === 'server' ? SERVER_PACKAGES : clients.map(name => `@ours.network/${name}`);
314
316
  const packages = {};
315
317
  const sourceNames = new Set();
@@ -332,7 +334,7 @@ export async function resolveSourcePolicy(manifest, role, clients = [], resolveN
332
334
  } else throw new Error(`Missing source policy for ${name}`);
333
335
  }
334
336
  const sources = Object.fromEntries([...sourceNames].map(name => [name, manifest.sources[name]]));
335
- const exact = { ...(sourceNames.size ? { sources } : {}), packages };
337
+ const exact = { ...(sourceNames.size ? { sources } : {}), packages, ...(release ? { release } : {}) };
336
338
  selectSourcePackages(exact, role, clients);
337
339
  return exact;
338
340
  }
@@ -468,3 +470,7 @@ export function consumerServiceState(text, service, platform) {
468
470
  }
469
471
  return matches === 1 ? selected : undefined;
470
472
  }
473
+
474
+ export function clientPackageNames(integrations) {
475
+ return [...new Set(['sdk', 'cli', ...(integrations.some(name => ['codex', 'claude-code'].includes(name)) ? ['mcp'] : []), ...integrations])];
476
+ }