@ours.network/install 1.2.1-nightly.3 → 1.2.1-nightly.31

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 (49) hide show
  1. package/GATEWAY_SETUP.md +178 -0
  2. package/README.md +353 -9
  3. package/ROOTLESS_PODMAN_VALIDATION.md +210 -0
  4. package/assets/Dockerfile +17 -13
  5. package/assets/Dockerfile.gateway +5 -0
  6. package/assets/docker-compose.yaml +4 -1
  7. package/assets/fleet-settings.example.json +48 -0
  8. package/assets/gateway-entrypoint.sh +37 -0
  9. package/assets/release-lock.json +1167 -4499
  10. package/assets/release.json +37 -19
  11. package/assets/scripts/build/build-common.mjs +2 -1
  12. package/assets/scripts/build/build-sdk.mjs +6 -1
  13. package/assets/scripts/maintenance/build-context.mjs +1 -1
  14. package/assets/scripts/maintenance/daemon-owner.mjs +15 -0
  15. package/assets/scripts/maintenance/docker-layout-conversion.mjs +7 -3
  16. package/assets/scripts/maintenance/release-graph.mjs +18 -1
  17. package/assets/scripts/maintenance/state-operation.mjs +4 -2
  18. package/assets/scripts/runtime/client-setup.mjs +80 -6
  19. package/assets/scripts/runtime/diagnostics.mjs +72 -0
  20. package/assets/scripts/runtime/entrypoint.sh +1 -1
  21. package/assets/scripts/runtime/health-messenger.mjs +7 -6
  22. package/assets/scripts/runtime/notifications-gateway.mjs +82 -0
  23. package/assets/scripts/runtime/start-messenger.sh +25 -4
  24. package/assets/scripts/runtime/supervise.mjs +35 -0
  25. package/assets/sources.json +54 -28
  26. package/install.mjs +7 -2
  27. package/lib/build-transition.mjs +3 -0
  28. package/lib/client-cli.mjs +127 -0
  29. package/lib/container-engine.mjs +127 -0
  30. package/lib/diagnostics.mjs +1 -0
  31. package/lib/docker-conversion-runtime.mjs +3 -2
  32. package/lib/docker-layout-installation.mjs +2 -1
  33. package/lib/docker-runtime-repair.mjs +160 -0
  34. package/lib/effects.mjs +298 -132
  35. package/lib/gateway-transition.mjs +87 -0
  36. package/lib/gateway.mjs +158 -0
  37. package/lib/legacy-migration.mjs +13 -2
  38. package/lib/orchestrate.mjs +135 -43
  39. package/lib/plan.mjs +27 -11
  40. package/lib/podman-preflight.mjs +40 -0
  41. package/lib/server-onboarding.mjs +164 -16
  42. package/lib/server-ports.mjs +17 -0
  43. package/lib/setup-options.mjs +88 -27
  44. package/lib/setup.mjs +35 -18
  45. package/lib/target.mjs +24 -8
  46. package/lib/uninstall.mjs +2 -2
  47. package/lib/usage.mjs +19 -6
  48. package/lib/workspace-setup.mjs +83 -0
  49. package/package.json +4 -2
@@ -1,3 +1,5 @@
1
+ import { validateGatewayClientProfile } from './target.mjs';
2
+ import { enableGateway } from './gateway-transition.mjs';
1
3
  import { executeLegacyMigration } from './legacy-migration.mjs';
2
4
  // ours-install v3 — the orchestrator.
3
5
  //
@@ -26,7 +28,8 @@ import { executeLegacyMigration } from './legacy-migration.mjs';
26
28
 
27
29
  import { basename, dirname, join, resolve } from 'node:path';
28
30
  import { parseNetworkArgs, validateHostProfile, parseInstallArgs, resolveTarget, resolveProfileSelection, profileEnv, InstallUsageError } from './target.mjs';
29
- import { selectSourcePackages, validateInstallation, SERVER_SERVICES, planDaemonConfig, planServiceInstall, serviceInstallCommand } from './plan.mjs';
31
+ import { NOTIFICATIONS_PRODUCER_FILE } from './server-onboarding.mjs';
32
+ import { clientPackageNames, selectSourcePackages, validateInstallation, SERVER_SERVICES, planDaemonConfig, planServiceInstall, serviceInstallCommand } from './plan.mjs';
30
33
  import {
31
34
  COMPONENTS,
32
35
  planComponentSelection, planMcpAttachment, planTgAttachment, planCoworkAttachment,
@@ -43,6 +46,7 @@ import {
43
46
  validateChannelVersion,
44
47
  } from './marketplace.mjs';
45
48
  import { USAGE } from './usage.mjs';
49
+ import { installerFailure } from './diagnostics.mjs';
46
50
  import { ok, info, warn, heading, banner, box, c, progress } from './ui.mjs';
47
51
 
48
52
  export const EXIT_OK = 0;
@@ -86,6 +90,37 @@ async function perform(effects, dryRun, label, thunk) {
86
90
 
87
91
  const reason = (error) => (error instanceof Error ? error.message : String(error));
88
92
 
93
+ function clientDiagnostic(error) {
94
+ return installerFailure(error);
95
+ }
96
+
97
+ function clientRetry(effects, imported, integrations) {
98
+ effects.out(info(`Saved server connection: ${imported.configPath}.`));
99
+ if (integrations.includes('fleet') && !imported.settings.disableFleetAgentsSetup && !imported.settings.fleetSettingsPath) {
100
+ 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.'));
101
+ return;
102
+ }
103
+ const args = ['client', 'install', '--config', imported.configPath, '--integrations', integrations.join(',') || 'none', '--sources', imported.settings.sourcesPath];
104
+ if (imported.settings.disableFleetAgentsSetup) args.push('--disable-fleet-agents-setup');
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
+
89
124
  function semverMajor(version) {
90
125
  const match = /^(?:[~^<>= ]*)(\d+)\./.exec(String(version ?? '').trim());
91
126
  return match ? Number(match[1]) : null;
@@ -110,6 +145,7 @@ function incompatibleUpgrade(target, cliDependencies) {
110
145
  async function prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatch) {
111
146
  const dir = target.stateDir;
112
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'];
113
149
  if (mismatch.unknown) {
114
150
  effects.out(warn(`ours: cannot verify whether ${cliPkg} can restore daemon v${mismatch.runningVersion}. Nothing was changed.`));
115
151
  effects.out(info('Check npm registry access and re-run; compatibility checks fail closed.'));
@@ -153,13 +189,13 @@ async function prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatc
153
189
  }
154
190
 
155
191
  await perform(effects, false, 'stop the incompatible daemon', () => effects.run(
156
- 'ours', ['daemon', 'stop', '--state-dir', dir, '--config', configPath], { stream: true },
192
+ owner[0], [...owner.slice(1), 'stop', '--state-dir', dir, '--config', configPath], { stream: true },
157
193
  ));
158
194
  try {
159
195
  await perform(effects, false, `back up complete daemon state to ${backupPath}`, () => effects.copyDir(dir, backupPath));
160
196
  } catch (error) {
161
197
  try {
162
- 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 });
163
199
  effects.out(ok('backup failed, but the old daemon was started again'));
164
200
  } catch {
165
201
  effects.out(warn(`backup failed and the old daemon did not restart; its state is still untouched at ${dir}`));
@@ -168,11 +204,11 @@ async function prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatc
168
204
  }
169
205
  try {
170
206
  await perform(effects, false, 'remove the incompatible daemon boot service', () => effects.run(
171
- '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 },
172
208
  ));
173
209
  } catch (error) {
174
210
  try {
175
- 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 });
176
212
  effects.out(ok(`service removal failed, but the old daemon was started again; backup retained at ${backupPath}`));
177
213
  } catch {
178
214
  effects.out(warn(`service removal failed and the old daemon did not restart; state remains at ${dir} and the backup is at ${backupPath}`));
@@ -193,11 +229,11 @@ async function prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatc
193
229
  * rather than one harness. So a failure here is one honest line plus whatever
194
230
  * the caller wants to say about retrying, and the walk continues.
195
231
  */
196
- async function attempt(effects, dryRun, label, thunk) {
232
+ async function attempt(effects, dryRun, label, thunk, formatReason = reason) {
197
233
  try {
198
234
  return { ok: true, ...(await perform(effects, dryRun, label, thunk)) };
199
235
  } catch (error) {
200
- effects.out(warn(`${label} — did not complete: ${reason(error)}`));
236
+ effects.out(warn(`${label} — did not complete: ${formatReason(error)}`));
201
237
  return { ok: false, error };
202
238
  }
203
239
  }
@@ -333,14 +369,13 @@ export async function runDaemonPhase(args, effects, exactSuite = null) {
333
369
  const mcpPkg = exactSuite?.packages?.mcp
334
370
  ? `@ours.network/mcp@${exactSuite.packages.mcp}`
335
371
  : componentSpec(componentByKey('mcp'), args.channel);
336
- // The CLI intentionally publishes only `latest`; unlike the lockstep MCP and
337
- // connector packages it has no nightly dist-tag. Keep this untagged on every
338
- // installer channel, and inspect that package's SDK dependency for the gate.
339
- 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';
340
375
  if (!creating && target.daemonVersion) {
341
- const mismatch = incompatibleUpgrade(target, effects.packageDependencies(cliPkg));
376
+ const mismatch = incompatibleUpgrade(target, effects.packageDependencies(daemonPkg));
342
377
  if (mismatch) {
343
- const prepared = await prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatch);
378
+ const prepared = await prepareIncompatibleUpgrade(args, effects, target, daemonPkg, mismatch);
344
379
  if (prepared.refused) return { target, refused: prepared.refused, steps };
345
380
  creating = prepared.purged === true;
346
381
  target.backupPath = prepared.backupPath;
@@ -349,6 +384,18 @@ export async function runDaemonPhase(args, effects, exactSuite = null) {
349
384
  }
350
385
  await perform(effects, args.dryRun, `MCP server installed (npm i -g ${mcpPkg})`, () => effects.run('npm', ['i', '-g', mcpPkg]));
351
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
+ }
352
399
  await perform(effects, args.dryRun, `ours CLI installed (npm i -g ${cliPkg})`, () => effects.run('npm', ['i', '-g', cliPkg]));
353
400
  steps.push({ id: 'cli', changed: true, packageRefresh: true });
354
401
 
@@ -383,14 +430,14 @@ export async function runDaemonPhase(args, effects, exactSuite = null) {
383
430
 
384
431
  try {
385
432
  if (creating) {
386
- 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 }));
387
434
  steps.push({ id: 'start', changed: true });
388
435
  } else {
389
- 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 }));
390
437
  steps.push({ id: 'restart', changed: true });
391
438
  }
392
439
 
393
- const service = await runServicePhase(args, effects, dir, target.port);
440
+ const service = migratedService ?? await runServicePhase(args, effects, dir, target.port);
394
441
  if (service.unsupported) serviceUnsupported = service.unsupported;
395
442
  if (service.refused) {
396
443
  // A REFUSAL IS A FAILURE TO REACH THE STATE, not a special case. An unknown
@@ -420,7 +467,7 @@ export async function runDaemonPhase(args, effects, exactSuite = null) {
420
467
  effects.out(recovery.recovered
421
468
  ? ok('your daemon is running again — nothing was committed, and the service is unchanged')
422
469
  : warn('and the daemon did NOT come back up — start it yourself before anything else: '
423
- + `ours daemon start --config ${configPath}`));
470
+ + `ours-daemon start --config ${configPath}`));
424
471
  }
425
472
  throw error;
426
473
  }
@@ -443,7 +490,7 @@ export async function runDaemonPhase(args, effects, exactSuite = null) {
443
490
  async function recoverDaemon(args, effects, dir, configPath, port) {
444
491
  if (args.dryRun) return null;
445
492
  try {
446
- await effects.run('ours', ['daemon', 'start', '--config', configPath]);
493
+ await effects.run('ours-daemon', [ 'start', '--config', configPath]);
447
494
  return { recovered: true };
448
495
  } catch (recoveryError) {
449
496
  return { recovered: false, reason: reason(recoveryError) };
@@ -794,7 +841,7 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
794
841
  }
795
842
  if (/not running|not reachable|ECONNREFUSED|connect/i.test(text)) {
796
843
  effects.out(warn("The daemon isn't reachable yet — couldn't create your human identity."));
797
- 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}"'.`));
798
845
  return { key: 'identity', label: 'Human identity', state: 'failed', note: 'daemon not reachable' };
799
846
  }
800
847
  effects.out(warn(`Couldn't create your human identity: ${text.split('\n')[0]}`));
@@ -817,6 +864,9 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
817
864
  export async function runHarnessPhase(args, effects, { target, isDefaultStateDir, exactSuite = null }) {
818
865
  effects.out(heading('Harness plugins'));
819
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
+ }
820
870
  for (const h of detected) {
821
871
  if (h.status === 'ok') effects.out(ok(`'${h.command ?? h.name}' → ${h.detail ?? 'real program'} (its plugin can be installed)`));
822
872
  else if (h.status === 'alias') effects.out(warn(`'${h.command ?? h.name}' → ${h.detail} (I won't call it — manual steps below)`));
@@ -826,7 +876,7 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
826
876
  if (detected.every((h) => h.status === 'absent')) {
827
877
  effects.out(info('No Claude Code, Codex or Hermes found — install one and re-run to wire it up.'));
828
878
  effects.out(info('Your daemon is unaffected; nothing else in this run depends on a harness.'));
829
- return [];
879
+ return args.clientIntegrations ? detected.map(h => ({ key: h.name, state: 'skipped', note: 'not installed' })) : [];
830
880
  }
831
881
 
832
882
  const plans = planHarnessPlugins({
@@ -851,7 +901,7 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
851
901
  if (plan.action === 'manual' && exactSuite?.localPackages?.[plan.name]) {
852
902
  const root = await effects.prepareClientMarketplace(plan.name, exactSuite.localPackages[plan.name]);
853
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}`}.`));
854
- rows.push({ ...row, state: 'failed', note: 'native executable unavailable' });
904
+ rows.push({ ...row, state: 'failed', note: plan.reason, manual: true });
855
905
  continue;
856
906
  }
857
907
  if (plan.action === 'manual') {
@@ -873,7 +923,7 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
873
923
  effects.out(warn(`${plan.label} — ${plan.reason}; install it yourself with:`));
874
924
  for (const step of manual) effects.out(info(` ${step}`));
875
925
  if (plan.envLine) effects.out(info(`Before later native launches, set: ${plan.envLine}`));
876
- rows.push({ ...row, state: 'skipped', note: plan.reason });
926
+ rows.push({ ...row, state: 'skipped', note: plan.reason, manual: true });
877
927
  continue;
878
928
  }
879
929
 
@@ -884,13 +934,16 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
884
934
  const steps = plan.name === 'codex'
885
935
  ? [['codex', 'plugin', 'marketplace', 'add', registration], ['codex', 'plugin', 'add', 'ours@ours-codex-marketplace']]
886
936
  : [['claude', 'plugin', 'marketplace', 'add', registration], ['claude', 'plugin', await effects.hasClaudePlugin() ? 'update' : 'install', 'ours@ours.network']];
887
- let failed = false;
937
+ let failure = null;
888
938
  for (const step of steps) {
889
- const outcome = await attempt(effects, false, step.join(' '), () => effects.run(step[0], step.slice(1), { env: profileEnv(target) }));
890
- 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
+ }
891
944
  }
892
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}.`));
893
- rows.push({ ...row, state: failed ? 'failed' : 'installed' });
946
+ rows.push({ ...row, state: failure ? 'failed' : 'installed', ...(failure ?? {}) });
894
947
  continue;
895
948
  }
896
949
 
@@ -956,6 +1009,11 @@ export async function runFleetPhase(args, effects, { target, isDefaultStateDir }
956
1009
  return { key: 'fleet', label: plan.label, state: 'skipped' };
957
1010
  }
958
1011
  const install = args.acquiredFleet ? { ok: true } : await attempt(effects, args.dryRun, plan.install.join(' '), () => effects.run(plan.install[0], plan.install.slice(1)));
1012
+ if (args.disableFleetAgentsSetup) {
1013
+ if (!install.ok) return { key: 'fleet', label: plan.label, state: 'failed', note: 'Fleet installation failed' };
1014
+ effects.out(ok('Fleet CLI installed; agent and model setup deferred to the App. Existing Fleet configuration was retained.'));
1015
+ return { key: 'fleet', label: plan.label, state: 'installed', note: 'agents setup deferred' };
1016
+ }
959
1017
  // Fleet owns its v2 manifest, roles, models and permissions. With no prepared
960
1018
  // settings, its wizard keeps the user's terminal while this invocation carries
961
1019
  // the selected daemon; --settings makes the same call strictly noninteractive.
@@ -1262,15 +1320,22 @@ async function executeServerCommand(args, effects) {
1262
1320
  const existing = record !== null;
1263
1321
  if (existing) {
1264
1322
  record = validateInstallation(record, args.stateDir);
1323
+ if (args.containerEngine && args.containerEngine !== (record.containerEngine ?? 'docker')) throw new Error('Conflicting container engine; explicit migration is required');
1265
1324
  if (args.mode && args.mode !== record.mode) throw new Error('Conflicting runtime mode; retained installation was not changed');
1266
1325
  if (args.operation !== 'update' && args.sources && (record.sourcePolicyHash
1267
1326
  ? effects.sourcePolicyHash(args.sources) !== record.sourcePolicyHash
1268
1327
  : effects.readText(args.sources) !== effects.readText(record.sourcesPath))) throw new Error('Conflicting source selection; use explicit server update');
1269
1328
  } else {
1270
1329
  if (args.operation !== 'install' || !args.mode) throw new Error('First server install requires --mode');
1271
- record = effects.newInstallation(args.stateDir, args.mode);
1330
+ record = effects.newInstallation(args.stateDir, args.mode, args);
1272
1331
  for (const key of ['port', 'coworkPort', 'messengerPort']) if (args[key] !== undefined) record[key] = args[key];
1273
1332
  }
1333
+ if (effects.readJson(join(record.root, 'gateway-transition.json')) && args.operation !== 'gateway-enable' && args.operation !== 'status') throw new Error('Interrupted gateway migration; run server gateway-enable before other changes');
1334
+ if (args.serverUrl && args.operation === 'install') {
1335
+ if (!record.gateway) throw new Error('Use server gateway-enable to migrate an existing Docker installation');
1336
+ if (existing && record.gateway.serverUrl !== args.serverUrl) throw new Error('Conflicting gateway server URL');
1337
+ record.gateway = { ...record.gateway, serverUrl: args.serverUrl };
1338
+ }
1274
1339
  if (record.layoutConversion && !['install', 'start', 'stop', 'status'].includes(args.operation)) {
1275
1340
  throw new Error('Layout conversion is incomplete; resume with server install or server start before changing state or authority');
1276
1341
  }
@@ -1278,7 +1343,8 @@ async function executeServerCommand(args, effects) {
1278
1343
  throw new Error(`Server build activation is incomplete; repeat server ${record.buildTransition.operation} before other mutations`);
1279
1344
  }
1280
1345
  const showInstallProgress = args.operation === 'install' && !(existing && (record.schema === 1 || record.layoutConversion));
1281
- const installStageCount = (existing ? 7 : 9) + (args.identityName ? 2 : 0);
1346
+ const installStageCount = (existing ? 7 : 9) + (args.identityName ? 2 : 0)
1347
+ + (args.identityName && args.name && args.surname && (!existing || [undefined, null, args.identityName, `${args.name} ${args.surname}`].includes(record.messengerIdentity)) ? 1 : 0);
1282
1348
  let completedInstallStages = 0;
1283
1349
  const installStage = async (label, explanation, action) => {
1284
1350
  if (!showInstallProgress) return action();
@@ -1301,7 +1367,7 @@ async function executeServerCommand(args, effects) {
1301
1367
  }
1302
1368
  if (!existing && args.sources) record.sourcePolicyHash = effects.sourcePolicyHash(args.sources);
1303
1369
  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, {
1304
- existing, sourcePath: args.sources ?? record.sourcesPath, sourceManifest: args.resolvedSources, identityName: args.identityName,
1370
+ existing, sourcePath: args.sources ?? record.sourcesPath, sourceManifest: args.resolvedSources, identityName: args.identityName, explicitPorts: args.explicitPorts,
1305
1371
  }));
1306
1372
  if (existing && (record.schema === 1 || record.layoutConversion)
1307
1373
  && ['install', 'start', 'restart', 'update', 'rebuild'].includes(args.operation)) {
@@ -1331,13 +1397,26 @@ async function executeServerCommand(args, effects) {
1331
1397
  if (args.identityName) {
1332
1398
  await installStage('Daemon startup', 'Start the daemon and restore its retained identities.', () => effects.serverLifecycle(record, 'start', ['daemon']));
1333
1399
  const identity = await installStage('Human identity', 'Keep the existing Human identity, or create it on a fresh daemon.', () => effects.serverEnsureIdentity(record, args.identityName));
1334
- record.messengerIdentity = identity.name;
1400
+ // Messenger runs as the person's own identity under the Human identity when Name and Surname are known.
1401
+ // An installation whose Messenger already runs as some other identity under it keeps that identity.
1402
+ const person = args.name && args.surname ? `${args.name} ${args.surname}` : undefined;
1403
+ const selected = existing ? record.messengerIdentity : undefined;
1404
+ if (person && (!selected || selected === identity.name || selected === person)) {
1405
+ const own = await installStage('Messenger identity', 'Keep or create your own identity under the Human identity; Messenger runs as it.', () => effects.serverEnsureMessengerIdentity(record, person));
1406
+ record.messengerIdentity = own.name;
1407
+ } else if (selected && selected !== identity.name
1408
+ && (await effects.serverListIdentities(record)).some(row => row.name === selected && row.kind === 'role' && row.temp === null)) {
1409
+ record.messengerIdentity = selected;
1410
+ } else record.messengerIdentity = identity.name;
1335
1411
  effects.writeJson(recordPath, JSON.stringify(record, null, 2) + '\n');
1336
1412
  await installStage('Application startup', 'Start the selected applications and check readiness.', () => effects.serverLifecycle(record, 'start', record.services.filter(name => name !== 'daemon')));
1337
1413
  } else {
1338
1414
  await installStage('Service startup', 'Start the daemon and selected services, then check readiness.', () => effects.serverLifecycle(record, 'start'));
1339
1415
  }
1416
+ if (record.gateway) await effects.verifyGateway(record);
1340
1417
  if (showInstallProgress) effects.out(progress(installStageCount, installStageCount, 'Installation complete', 'The selected services are ready.'));
1418
+ } else if (args.operation === 'gateway-enable') {
1419
+ await enableGateway(record, args, effects);
1341
1420
  } else if (['backup', 'restore', 'reset'].includes(args.operation)) {
1342
1421
  await effects.serverMaintenance(record, args);
1343
1422
  } else if (['update', 'rebuild'].includes(args.operation)) {
@@ -1385,22 +1464,21 @@ export async function runClientCommand(command, effects) {
1385
1464
  configPath = managedPath;
1386
1465
  }
1387
1466
  let profile;
1388
- if (configPath) profile = validateHostProfile(effects.readProfile(configPath));
1467
+ if (configPath) profile = validateGatewayClientProfile(effects.readProfile(configPath));
1389
1468
  else if (!command.preset && effects.interactive) {
1390
- const endpoint = await effects.askLine('Server HTTP endpoint: ', 'http://127.0.0.1:3050');
1469
+ const endpoint = await effects.askLine('Server HTTP or HTTPS endpoint: ', 'http://127.0.0.1:3050');
1391
1470
  const credentialPath = await effects.askLine('Private issued-token file: ', '');
1392
1471
  if (!credentialPath) throw new InstallUsageError('Client setup requires an issued-token file');
1393
1472
  profile = await effects.discoverClientProfile(endpoint, credentialPath);
1394
1473
  }
1395
1474
  if (!profile) throw new InstallUsageError('client install requires a complete network profile; supply --config or use the interactive client setup');
1396
- if (saved && (saved.endpoint !== profile.endpoint || saved.expectedInstanceId !== profile.expectedInstanceId))
1475
+ if (saved && saved.expectedInstanceId !== profile.expectedInstanceId)
1397
1476
  throw new InstallUsageError('Managed client already selects another server; existing default was not changed');
1398
1477
  for (const name of ['OURS_API_TOKEN', 'OURS_PORT', 'OURS_STATE_DIR', 'OURS_DAEMON_ID']) {
1399
1478
  if (effects.env[name]?.trim()) throw new InstallUsageError(`${name} conflicts with client profile selection`);
1400
1479
  }
1401
1480
  effects.out(info(`Selected server ${profile.endpoint} (instance ${profile.expectedInstanceId}).`));
1402
1481
  await effects.verifyHostProfile(configPath || profile);
1403
- await effects.verifyPackagedMcp(configPath || profile);
1404
1482
  const settings = saved?.installer ?? (configPath ? effects.readJson(configPath)?.installer : undefined);
1405
1483
  const settingsBase = saved ? dirname(managedPath) : configPath ? dirname(configPath) : process.cwd();
1406
1484
  let integrations = command.integrations ?? settings?.integrations;
@@ -1408,13 +1486,14 @@ export async function runClientCommand(command, effects) {
1408
1486
  integrations = [];
1409
1487
  for (const name of ['codex', 'claude-code', 'fleet']) if (await effects.ask(`Install ${name}?`, name !== 'fleet')) integrations.push(name);
1410
1488
  }
1411
- 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');
1489
+ 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');
1412
1490
  let fleetSettingsPath = command.preset ? command.fleetSettingsPath : settings?.fleetSettingsPath;
1413
- if (command.nonInteractive && integrations.includes('fleet') && !fleetSettingsPath) throw new InstallUsageError('Fleet in CLI mode requires --fleet-settings; no interactive wizard will be opened');
1491
+ const disableFleetAgentsSetup = command.disableFleetAgentsSetup ?? settings?.disableFleetAgentsSetup ?? false;
1492
+ if (command.nonInteractive && !disableFleetAgentsSetup && integrations.includes('fleet') && !fleetSettingsPath) throw new InstallUsageError('Fleet in CLI mode requires --fleet-settings; no interactive wizard will be opened');
1414
1493
  if (fleetSettingsPath !== undefined && (typeof fleetSettingsPath !== 'string' || !fleetSettingsPath))
1415
1494
  throw new InstallUsageError('installer.fleetSettingsPath must be a non-empty path when supplied');
1416
1495
  if (fleetSettingsPath) fleetSettingsPath = resolve(settingsBase, fleetSettingsPath);
1417
- const selectedClients = [...new Set(['sdk', ...(integrations.includes('fleet') ? ['cli'] : []), ...integrations])];
1496
+ const selectedClients = clientPackageNames(integrations);
1418
1497
  let sourcesPath = settings?.sourcesPath;
1419
1498
  let resolvedSources;
1420
1499
  if (command.sourcePolicy) {
@@ -1427,30 +1506,43 @@ export async function runClientCommand(command, effects) {
1427
1506
  resolvedSources = await effects.resolveSourcePolicy(policy, 'client', selectedClients);
1428
1507
  }
1429
1508
  effects.out(progress(0, 4, 'Client configuration', 'Prepare the selected integrations and private connection profile.'));
1430
- const imported = effects.importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh: !!command.preset });
1509
+ if (profile.serverUrl && integrations.includes('fleet')) await effects.qualifyGatewayClient({ profile, sourcesPath, sources: resolvedSources, integrations, refresh: !!command.preset });
1510
+ // A prepared local profile may carry the Fleet notification producer credential beside it.
1511
+ const notificationsProducerPath = configPath && configPath !== managedPath && effects.exists?.(join(dirname(configPath), NOTIFICATIONS_PRODUCER_FILE)) ? join(dirname(configPath), NOTIFICATIONS_PRODUCER_FILE) : undefined;
1512
+ const imported = effects.importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, disableFleetAgentsSetup, refresh: !!command.preset, notificationsProducerPath });
1513
+ let phase = 'Validate saved server connection';
1431
1514
  try {
1432
1515
  await effects.verifyHostProfile(imported.configPath);
1433
1516
  effects.out(progress(1, 4, 'Client packages', 'Acquire and verify the selected client package versions.'));
1517
+ phase = 'Acquire client packages';
1434
1518
  const exactSuite = await effects.acquireClientPackages(imported.configPath, imported.settings.sourcesPath, integrations, { refresh: !!command.preset });
1435
1519
  const args = { assumeYes: true, dryRun: false, channel: 'latest', clientIntegrations: integrations,
1436
- acquiredFleet: exactSuite.fleetBin, fleetSettingsPath: imported.settings.fleetSettingsPath };
1520
+ acquiredFleet: exactSuite.fleetBin, fleetSettingsPath: imported.settings.fleetSettingsPath, disableFleetAgentsSetup: imported.settings.disableFleetAgentsSetup };
1437
1521
  const target = { mode: 'host-profile', managed: true, configPath: imported.configPath, profile: imported.profile, endpoint: imported.profile.endpoint };
1438
1522
  effects.out(progress(2, 4, 'Client integrations', 'Register the selected agent integrations.'));
1523
+ phase = 'Register client integrations';
1439
1524
  const summary = await runHarnessPhase(args, effects, { target, isDefaultStateDir: false, exactSuite });
1440
1525
  if (integrations.includes('fleet')) {
1441
1526
  effects.out(progress(3, 4, 'Fleet configuration', 'Apply prepared settings or open the selected Fleet wizard.'));
1527
+ phase = 'Configure Fleet';
1442
1528
  summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir: false }));
1443
1529
  }
1444
1530
  const incomplete = integrations.filter(name => !summary.some(row => row.key === name && row.state === 'installed'));
1445
1531
  if (effects.env.OURS_CONFIG && resolve(effects.env.OURS_CONFIG) !== imported.configPath)
1446
1532
  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.'));
1447
- effects.out(incomplete.length
1448
- ? warn(`Client setup incomplete (${incomplete.join(', ')}); saved profile and settings retained. Re-run ours-install client install.`)
1449
- : ok(`Client setup complete. New clients discover ${imported.configPath}; no OURS_CONFIG export is required.`));
1533
+ if (incomplete.length) {
1534
+ effects.out(warn(`Client setup incomplete (${incomplete.join(', ')}). The selected client integrations need attention:`));
1535
+ for (const name of incomplete) explainClientFailure(effects, name, summary.find(row => row.key === name));
1536
+ clientRetry(effects, imported, integrations);
1537
+ } else effects.out(ok(`Client setup complete. New clients discover ${imported.configPath}; no OURS_CONFIG export is required.`));
1538
+ // Fleet's web service reads its producer credential when it starts; agents keep running across a web restart.
1539
+ if (imported.notificationsProducerChanged && integrations.includes('fleet'))
1540
+ effects.out(info('Notifications are ready for Fleet. Run `ours-fleet web restart` so a running Fleet web console starts producing them.'));
1450
1541
  if (!incomplete.length) effects.out(progress(4, 4, 'Client setup complete', 'All selected integrations are configured.'));
1451
1542
  return incomplete.length ? EXIT_REFUSED : EXIT_OK;
1452
1543
  } catch (error) {
1453
- effects.out(warn(`Client setup incomplete: ${reason(error)}. Saved profile and settings retained; re-run ours-install client install.`));
1544
+ effects.out(warn(`Client setup incomplete: ${phase} failed — ${clientDiagnostic(error)}. Fix this error before retrying.`));
1545
+ clientRetry(effects, imported, integrations);
1454
1546
  return EXIT_REFUSED;
1455
1547
  }
1456
1548
  }
package/lib/plan.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { validateContainerEngine } from './container-engine.mjs';
2
+ import { gatewayAddress } from './gateway.mjs';
1
3
  // ours-install v3 — daemon creation and boot-service installation.
2
4
  //
3
5
  // Pure planning code, like lib/target.mjs: the orchestrator
@@ -97,7 +99,7 @@ export function launchdLabelForStateDir(stateDir) {
97
99
  export function classifyUnit(text) {
98
100
  if (text === null || text === undefined) return { kind: 'absent' };
99
101
  const s = String(text);
100
- if (s.startsWith(CLI_UNIT_MARKER)) return { kind: 'cli-managed' };
102
+ if ((s.startsWith(CLI_UNIT_MARKER) || s.startsWith('# Managed by @ours.network/daemon'))) return { kind: 'cli-managed' };
101
103
  const looksLikeOursMcp = /ExecStart=.*\bours-mcp\b/.test(s)
102
104
  || /^Description=ours MCP daemon\b/m.test(s)
103
105
  || (/^Environment=OURS_STATE_DIR=/m.test(s) && /^Environment=OURS_TRANSPORT=http$/m.test(s));
@@ -138,7 +140,7 @@ export function planServiceInstall({ stateDir, home, readText, platform = 'linux
138
140
  platform,
139
141
  reason: 'no-service-manager',
140
142
  message: `installing a boot service is not available on ${platform} — the ours CLI supports Linux user systemd and macOS launchd`,
141
- manual: ['ours', 'daemon', 'serve', '--config'],
143
+ manual: ['ours-daemon', 'serve', '--config'],
142
144
  };
143
145
  }
144
146
 
@@ -224,7 +226,7 @@ export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
224
226
  // --json so the caller can read back whether the unit actually CHANGED rather
225
227
  // than assuming it did. --force is reachable only through the explicit argument
226
228
  // above, and the CLI refuses to overwrite a unit it did not write.
227
- const cmd = ['ours', 'daemon', 'install-service', '--yes', '--json', '--state-dir', dir, '--config', join(dir, 'config.json')];
229
+ const cmd = ['ours-daemon', 'install-service', '--yes', '--json', '--state-dir', dir, '--config', join(dir, 'config.json')];
228
230
  if (adoptLegacyUnit) cmd.push('--force');
229
231
  return cmd;
230
232
  }
@@ -265,20 +267,23 @@ export function planDaemonSteps(target, { cliVersionChanged = false, cliStartedI
265
267
  const steps = [{ id: 'cli', label: 'install the ours-sdk CLI', command: ['npm', 'i', '-g', '@ours.network/cli'] }];
266
268
  steps.push({ id: 'config', label: `write ${join(dir, 'config.json')}`, port: target.port });
267
269
  if (target.action === 'create') {
268
- steps.push({ id: 'start', label: `start the daemon on port ${target.port}`, command: ['ours', 'daemon', 'start', '--config', join(dir, 'config.json')] });
270
+ steps.push({ id: 'start', label: `start the daemon on port ${target.port}`, command: ['ours-daemon', 'start', '--config', join(dir, 'config.json')] });
269
271
  } else if (cliVersionChanged) {
270
272
  // `ours daemon stop` refuses to signal a daemon it did not start, so a
271
273
  // daemon under another launcher is left running and the caller says which
272
274
  // launcher must be restarted instead.
273
275
  steps.push(cliStartedIt
274
- ? { id: 'restart', label: 'restart the daemon (package version changed)', command: ['ours', 'daemon', 'restart', '--config', join(dir, 'config.json')] }
276
+ ? { id: 'restart', label: 'restart the daemon (package version changed)', command: ['ours-daemon', 'restart', '--config', join(dir, 'config.json')] }
275
277
  : { id: 'restart-external', label: 'daemon was not started by the CLI — restart it with its own launcher', command: null });
276
278
  }
277
279
  steps.push({ id: 'service', label: 'install the boot service', command: serviceInstallCommand({ stateDir: dir }) });
278
280
  return steps;
279
281
  }
280
282
 
281
- export const SERVER_PACKAGES = ['sdk', 'cli', 'mcp', 'tg-connector', 'cowork', 'messenger-server'].map(n => `@ours.network/${n}`);
283
+ export const SERVER_PACKAGES = ['sdk', 'cli', 'daemon', 'tg-connector', 'cowork', 'messenger-server'].map(n => `@ours.network/${n}`);
284
+ /** Selected when the policy names it; retained policies from before it existed stay valid. */
285
+ export const NOTIFICATIONS_PACKAGE = '@ours.network/notifications';
286
+ const serverPackages = manifest => [...SERVER_PACKAGES, ...(manifest?.packages?.[NOTIFICATIONS_PACKAGE] ? [NOTIFICATIONS_PACKAGE] : [])];
282
287
  export const SERVER_DEPENDENCIES = {
283
288
  daemon: [], telegram: ['daemon'], cowork: ['daemon'], messenger: ['daemon'],
284
289
  };
@@ -294,7 +299,7 @@ export function maintenanceServices(record, domain) {
294
299
 
295
300
  /** Validate exact supplied selections, without rewriting the source authority. */
296
301
  export function selectSourcePackages(manifest, role, clients = []) {
297
- const names = role === 'server' ? SERVER_PACKAGES : clients.map(n => `@ours.network/${n}`);
302
+ const names = role === 'server' ? serverPackages(manifest) : clients.map(n => `@ours.network/${n}`);
298
303
  const result = {};
299
304
  for (const name of names) {
300
305
  const selected = manifest?.packages?.[name];
@@ -312,7 +317,7 @@ export function selectSourcePackages(manifest, role, clients = []) {
312
317
  /** Resolve a packaged compatibility policy into a role-filtered exact selection. */
313
318
  export async function resolveSourcePolicy(manifest, role, clients = [], resolveNpm) {
314
319
  const release = releaseBinding(manifest);
315
- const names = role === 'server' ? SERVER_PACKAGES : clients.map(name => `@ours.network/${name}`);
320
+ const names = role === 'server' ? serverPackages(manifest) : clients.map(name => `@ours.network/${name}`);
316
321
  const packages = {};
317
322
  const sourceNames = new Set();
318
323
  for (const name of names) {
@@ -356,15 +361,21 @@ export function installationPaths(record) {
356
361
  }
357
362
 
358
363
  export function validateInstallation(record, root) {
364
+ if (record) validateContainerEngine(record);
359
365
  if (!record || ![1, 2].includes(record.schema) || !['docker', 'packages'].includes(record.mode)
360
366
  || record.root !== root || record.configPath !== installationPaths(record).config
361
367
  || record.sourcesPath !== join(root, 'sources.json') || record.workDir !== join(root, 'runtime')
362
368
  || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(record.instanceId ?? '')
363
369
  || !/^ours-[a-z0-9]+$/.test(record.project ?? '')
364
370
  || (record.sourcePolicyHash !== undefined && !/^[0-9a-f]{64}$/.test(record.sourcePolicyHash))
365
- || !Array.isArray(record.services) || record.services[0] !== 'daemon' || new Set(record.services).size !== record.services.length || record.services.some(s => !SERVER_SERVICES.includes(s))) {
371
+ || !Array.isArray(record.services) || record.services[0] !== 'daemon' || new Set(record.services).size !== record.services.length || record.services.some(s => !SERVER_SERVICES.includes(s) && !(s === 'gateway' && record.gateway?.version === 1))) {
366
372
  throw new Error('Invalid or conflicting installation selection');
367
373
  }
374
+ if (record.gateway !== undefined && (record.schema !== 2 || record.mode !== 'docker' || record.gateway?.version !== 1
375
+ || Object.keys(record.gateway).some(key => !['version', 'serverUrl'].includes(key)) || record.services.at(-1) !== 'gateway')) {
376
+ throw new Error('Invalid gateway installation selection');
377
+ }
378
+ if (record.gateway) gatewayAddress(record);
368
379
  if (record.layoutConversion !== undefined) {
369
380
  // DEPRECATED (introduced in 2.0): legacy managed-layout conversion only.
370
381
  // Removal target: 3.0, after supported installs convert and upgrade inputs
@@ -380,7 +391,8 @@ export function validateInstallation(record, root) {
380
391
  throw new Error('Invalid layout conversion record');
381
392
  }
382
393
  validateInstallation(source, root);
383
- if (['mode', 'instanceId', 'project', 'sourcesPath', 'workDir'].some(key => source[key] !== record[key])
394
+ if (JSON.stringify(source.containerBinding) !== JSON.stringify(record.containerBinding)) throw new Error('Conflicting conversion backend');
395
+ if (['mode', 'containerEngine', 'instanceId', 'project', 'sourcesPath', 'workDir'].some(key => source[key] !== record[key])
384
396
  || JSON.stringify(source.services) !== JSON.stringify(record.services)
385
397
  || conversion.runningServices.some(service => !source.services.includes(service))) {
386
398
  throw new Error('Conflicting layout conversion source');
@@ -402,7 +414,7 @@ export function validateInstallation(record, root) {
402
414
  throw new Error('Invalid server build transition');
403
415
  }
404
416
  validateInstallation(candidate, candidate.root);
405
- for (const key of ['schema', 'mode', 'instanceId', 'services', 'port', 'coworkPort', 'messengerPort', 'messengerIdentity', 'uid', 'gid']) {
417
+ for (const key of ['schema', 'mode', 'containerEngine', 'containerBinding', 'instanceId', 'services', 'gateway', 'port', 'coworkPort', 'messengerPort', 'messengerIdentity', 'uid', 'gid']) {
406
418
  if (JSON.stringify(candidate[key]) !== JSON.stringify(record[key])) throw new Error('Conflicting server build candidate');
407
419
  }
408
420
  }
@@ -470,3 +482,7 @@ export function consumerServiceState(text, service, platform) {
470
482
  }
471
483
  return matches === 1 ? selected : undefined;
472
484
  }
485
+
486
+ export function clientPackageNames(integrations) {
487
+ return [...new Set(['sdk', 'cli', ...(integrations.some(name => ['codex', 'claude-code'].includes(name)) ? ['mcp'] : []), ...integrations])];
488
+ }
@@ -0,0 +1,40 @@
1
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { runContainer } from './container-engine.mjs';
6
+
7
+ export const PROBE_IMAGE = 'docker.io/library/nginx:1.28-alpine@sha256:a8b39bd9cf0f83869a2162827a0caf6137ddf759d50a171451b335cecc87d236';
8
+ /** Behavioral checks, in disposable resources, before touching installation storage. */
9
+ export async function qualifyPodman(effects, record) {
10
+ const root = mkdtempSync(join(tmpdir(), 'ours-podman-preflight-'));
11
+ const project = `ours-probe-${randomUUID()}`;
12
+ const file = join(root, 'compose.json'), overlay = join(root, 'reset.yaml');
13
+ const common = { image: PROBE_IMAGE, entrypoint: ['/bin/sh', '-ec'], read_only: true, cap_drop: ['ALL'], security_opt: ['no-new-privileges:true'] };
14
+ writeFileSync(file, JSON.stringify({ services: {
15
+ prepare: { ...common, user: '0:0', network_mode: 'none', cap_add: ['CHOWN', 'DAC_OVERRIDE'],
16
+ command: ['mkdir -p /storage/state/daemon /storage/state/sibling; chmod 700 /storage/state/daemon; chown 1000:1000 /storage/state/daemon'],
17
+ volumes: [{ type: 'volume', source: 'data', target: '/storage', volume: { nocopy: true } }] },
18
+ probe: { ...common, user: '1000:1000', command: ['test ! -e /data/sibling; test "$(stat -c %a /data)" = 700; nslookup -type=A probe.; echo ready > /data/probe; sleep 120'],
19
+ healthcheck: { test: ['CMD-SHELL', 'test -s /data/probe'], interval: '1s', timeout: '2s', retries: 10 },
20
+ volumes: [{ type: 'volume', source: 'data', target: '/data', volume: { nocopy: true, subpath: 'state/daemon' } }] },
21
+ }, volumes: { data: {} } }), { mode: 0o600 });
22
+ writeFileSync(overlay, 'services:\n probe:\n ports: !reset []\n', { mode: 0o600 });
23
+ const compose = args => runContainer(effects, record, ['compose', '-p', project, '-f', file, '-f', overlay, ...args], { timeout: 180000 });
24
+ try {
25
+ await compose(['config', '--format', 'json']);
26
+ await compose(['run', '--rm', '--no-deps', '-T', 'prepare']);
27
+ await compose(['up', '-d', '--no-build', '--wait', 'probe']);
28
+ const result = await compose(['ps', '--format', 'json']);
29
+ const rows = result.stdout.trim().startsWith('[') ? JSON.parse(result.stdout) : result.stdout.trim().split('\n').map(s => JSON.parse(s));
30
+ if (!rows.some(r => r.Service === 'probe' && r.State === 'running')) throw new Error('Compose JSON status is incompatible');
31
+ await compose(['exec', '-T', 'probe', '/bin/sh', '-ec', 'test -s /data/probe; test ! -e /data/state']);
32
+ } catch (cause) {
33
+ let logs = '';
34
+ try { logs = (await compose(['logs', '--no-color', '--tail', '30'])).stdout; } catch {}
35
+ throw new Error(`Podman capability check failed (Compose !reset/wait/status, DNS, rootless UID, volume subpath): ${cause.message}\n${logs}`, { cause });
36
+ } finally {
37
+ try { await compose(['down', '--volumes', '--remove-orphans']); }
38
+ finally { rmSync(root, { recursive: true, force: true }); }
39
+ }
40
+ }