@ours.network/install 1.2.1-nightly.4 → 1.2.1-nightly.40

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 (57) hide show
  1. package/GATEWAY_SETUP.md +178 -0
  2. package/README.md +453 -9
  3. package/ROOTLESS_PODMAN_VALIDATION.md +210 -0
  4. package/assets/Dockerfile +14 -12
  5. package/assets/Dockerfile.gateway +5 -0
  6. package/assets/docker-compose.yaml +8 -1
  7. package/assets/fleet-settings.example.json +48 -0
  8. package/assets/gateway-entrypoint.sh +37 -0
  9. package/assets/release-lock.json +1139 -4470
  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 +12 -2
  18. package/assets/scripts/runtime/README.md +7 -0
  19. package/assets/scripts/runtime/client-setup.mjs +80 -6
  20. package/assets/scripts/runtime/dependency-ready.mjs +13 -0
  21. package/assets/scripts/runtime/diagnostics.mjs +72 -0
  22. package/assets/scripts/runtime/entrypoint.sh +5 -1
  23. package/assets/scripts/runtime/health-messenger.mjs +7 -6
  24. package/assets/scripts/runtime/notifications-gateway.mjs +90 -0
  25. package/assets/scripts/runtime/recover.mjs +165 -0
  26. package/assets/scripts/runtime/start-cowork.sh +5 -0
  27. package/assets/scripts/runtime/start-messenger.sh +29 -4
  28. package/assets/scripts/runtime/start-telegram.sh +5 -0
  29. package/assets/scripts/runtime/supervise.mjs +35 -0
  30. package/assets/sources.json +54 -28
  31. package/install.mjs +7 -2
  32. package/lib/boot-readiness.mjs +43 -0
  33. package/lib/build-transition.mjs +3 -0
  34. package/lib/client-cli.mjs +127 -0
  35. package/lib/container-engine.mjs +127 -0
  36. package/lib/diagnostics.mjs +1 -0
  37. package/lib/docker-conversion-runtime.mjs +3 -2
  38. package/lib/docker-layout-installation.mjs +2 -1
  39. package/lib/docker-recovery.mjs +55 -0
  40. package/lib/docker-runtime-repair.mjs +160 -0
  41. package/lib/effects.mjs +323 -132
  42. package/lib/gateway-transition.mjs +87 -0
  43. package/lib/gateway.mjs +158 -0
  44. package/lib/legacy-migration.mjs +13 -2
  45. package/lib/orchestrate-uninstall.mjs +18 -6
  46. package/lib/orchestrate.mjs +193 -43
  47. package/lib/plan.mjs +27 -11
  48. package/lib/podman-preflight.mjs +40 -0
  49. package/lib/server-onboarding.mjs +164 -16
  50. package/lib/server-ports.mjs +17 -0
  51. package/lib/setup-options.mjs +99 -28
  52. package/lib/setup.mjs +35 -17
  53. package/lib/target.mjs +24 -8
  54. package/lib/uninstall.mjs +2 -2
  55. package/lib/usage.mjs +21 -7
  56. package/lib/workspace-setup.mjs +83 -0
  57. package/package.json +4 -2
package/lib/effects.mjs CHANGED
@@ -1,3 +1,6 @@
1
+ import { selectServerPorts } from './server-ports.mjs';
2
+ import { qualifyPodman } from './podman-preflight.mjs';
3
+ import { engineName, bindPodman, runContainer, nativeBuildCommands } from './container-engine.mjs';
1
4
  // ours-install v3 — the real side effects.
2
5
  //
3
6
  // Every mutation the installer can perform lives here and nowhere else, behind
@@ -11,21 +14,27 @@
11
14
  // state-directory guard and the enable/reload. The installer never touches a
12
15
  // unit file or the service manager directly.
13
16
 
17
+ import { publishClientCli } from './client-cli.mjs';
18
+ import { commandFailure } from './diagnostics.mjs';
14
19
  import { spawnSync, execFileSync } from 'node:child_process';
15
20
  import { chmodSync, closeSync, constants, cpSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
21
  import { homedir, userInfo, platform as osPlatform, release as osRelease, arch as osArch } from 'node:os';
17
22
  import { fileURLToPath } from 'node:url';
18
23
  import { randomUUID, createHash } from 'node:crypto';
19
24
  import { dirname, join, resolve } from 'node:path';
20
- import { maintenanceServices, installationPaths, validateInstallation, consumerServiceState, unitNameForStateDir, launchdLabelForStateDir, messengerServicePlan, selectSourcePackages, resolveSourcePolicy, SERVER_SERVICES } from './plan.mjs';
21
- import { validateHostProfile } from './target.mjs';
25
+ import { clientPackageNames, maintenanceServices, installationPaths, validateInstallation, consumerServiceState, unitNameForStateDir, launchdLabelForStateDir, messengerServicePlan, selectSourcePackages, resolveSourcePolicy, SERVER_SERVICES } from './plan.mjs';
26
+ import { validateHostProfile, validateGatewayClientProfile } from './target.mjs';
27
+ import { serverBase, validateGatewayDiscovery, gatewayCompose, gatewayNginx, gatewayAddress } from './gateway.mjs';
22
28
  import { createServerOnboarding } from './server-onboarding.mjs';
23
29
  import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
24
30
  import { select as selectOnTty, multiselect as multiselectOnTty, askLine as askLineOnTty } from './prompt.mjs';
25
31
  import { classifyHarnessProbe } from './logic.mjs';
32
+ import { qualifyDockerRuntime, refreshDockerPolicyCopy } from './docker-runtime-repair.mjs';
33
+ import { refreshDockerRecovery, recoveryCompose, reconcileDockerRecovery } from './docker-recovery.mjs';
34
+ import { reportBootReadiness } from './boot-readiness.mjs';
26
35
  import { classifyStateDir } from './detect.mjs';
27
36
  import { BASE_RECORDS, CONTEXT, readBuildRecords, equalBuildRecords, initializeBuildMarker } from '../assets/scripts/maintenance/build-context.mjs';
28
- import { releaseBinding, verifyReleaseGraph, verifyRuntimeRelease } from '../assets/scripts/maintenance/release-graph.mjs';
37
+ import { hostCliPolicy, releaseBinding, verifyReleaseGraph, verifyRuntimeRelease } from '../assets/scripts/maintenance/release-graph.mjs';
29
38
 
30
39
  /** GET http://127.0.0.1:<port>/state-dir — the unauthenticated identity probe. */
31
40
  async function probePort(port, { timeoutMs = 1500 } = {}) {
@@ -399,7 +408,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
399
408
  // invocation only and never to the installer's own process: a state
400
409
  // directory selected by one run must not leak into anything the operator
401
410
  // starts afterwards.
402
- run: async (cmd, args, { env: extraEnv = null, stream = false, cwd, sensitive = false, allowCodes = [] } = {}) => {
411
+ run: async (cmd, args, { env: extraEnv = null, stream = false, cwd, sensitive = false, allowCodes = [], timeout } = {}) => {
403
412
  // Always built from this layer's OWN env rather than left to spawnSync's
404
413
  // implicit inheritance, so what a child receives is a property of the
405
414
  // effects object a caller constructed and not of whatever ambient shell
@@ -410,18 +419,18 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
410
419
  const executable = cmd === 'npm' ? npmBin : cmd;
411
420
  const r = spawnSync(executable, args, {
412
421
  cwd,
422
+ timeout,
413
423
  encoding: 'utf8',
414
424
  stdio: [...(stream ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe']), ...(installationLockFd === null ? [] : [installationLockFd])],
415
425
  env: childEnv,
416
426
  });
417
427
  if (r.error) {
418
- const error = new Error(`${executable} could not start (${r.error.code ?? 'launch error'})`, { cause: r.error });
428
+ const error = commandFailure(executable, args, r, { sensitive, cwd, env: childEnv });
419
429
  error.code = r.error.code;
420
430
  throw error;
421
431
  }
422
432
  if (r.status !== 0 && !allowCodes.includes(r.status)) {
423
- const detail = sensitive ? '' : (r.stderr || r.stdout || '').trim().split('\n').slice(-3).join('; ');
424
- throw new Error(`${executable} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
433
+ throw commandFailure(executable, args, r, { sensitive, cwd, env: childEnv });
425
434
  }
426
435
  return { ok: true, code: r.status, stdout: r.stdout ?? '' };
427
436
  },
@@ -435,7 +444,8 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
435
444
  delete childEnv.OURS_INSTALLER_LOCK_FD;
436
445
  if (installationLockFd !== null) childEnv.OURS_INSTALLER_LOCK_FD = '3';
437
446
  const r = spawnSync(cmd, args, { stdio: ['inherit', 'inherit', 'inherit', ...(installationLockFd === null ? [] : [installationLockFd])], env: childEnv });
438
- return { ok: !r.error && r.status === 0, code: r.status ?? -1 };
447
+ if (r.error || r.status !== 0) throw commandFailure(cmd, args, r, { env: childEnv });
448
+ return { ok: true, code: r.status };
439
449
  },
440
450
  installedVersion: (pkg) => installedVersionOf(pkg, npmBin),
441
451
  packageDependencies: (spec) => packageDependenciesOf(spec, npmBin),
@@ -534,6 +544,7 @@ export function networkEffects(effects) {
534
544
  const baseEnv = (record) => ({
535
545
  OURS_DAEMON_ID: record.instanceId,
536
546
  OURS_IMAGE: `${record.project}:runtime`,
547
+ OURS_GATEWAY_IMAGE: `${record.project}:gateway`,
537
548
  OURS_MAINTENANCE_IMAGE: `${record.project}:maintenance`,
538
549
  OURS_UID: String(record.uid ?? 1000), OURS_GID: String(record.gid ?? 1000),
539
550
  OURS_HOST_PORT: String(record.port ?? 3050),
@@ -545,14 +556,56 @@ export function networkEffects(effects) {
545
556
  'compose', '--project-directory', record.workDir, '--file', join(record.workDir,
546
557
  record.schema === 1 && existsSync(join(record.workDir, 'docker-compose.legacy.yaml'))
547
558
  ? 'docker-compose.legacy.yaml' : 'docker-compose.yaml'),
559
+ ...(record.gateway ? ['--file', join(record.workDir, 'docker-compose.gateway.yaml')] : []),
560
+ ...(existsSync(join(record.workDir, 'compose.recovery.json')) ? ['--file', join(record.workDir, 'compose.recovery.json')] : []),
561
+ ...(engineName(record) === 'podman' ? ['--file', join(record.workDir, 'compose.podman.json')] : []),
548
562
  '--project-name', record.project, ...args,
549
563
  ];
550
- const compose = (record, args, options = {}) => effects.run('docker', composeArgs(record, args),
551
- { ...options, env: { ...baseEnv(record), ...options.env } });
564
+ const container = (record, args, options = {}) => runContainer(effects, record, args, options);
565
+ const compose = async (record, args, options = {}) => {
566
+ const selected = { ...options, env: { ...baseEnv(record), ...options.env } };
567
+ if (engineName(record) === 'podman' && args[0] === 'build') {
568
+ const result = await container(record, composeArgs(record, ['--profile', '*', 'config', '--format', 'json']), { env: selected.env });
569
+ for (const command of nativeBuildCommands(JSON.parse(result.stdout), args.slice(1), { ...env, ...selected.env })) await container(record, command, selected);
570
+ return { code: 0, stdout: '' };
571
+ }
572
+ if (engineName(record) === 'podman' && ['run', 'up'].includes(args[0])) {
573
+ const resolved = JSON.parse((await container(record, composeArgs(record, ['--profile', '*', 'config', '--format', 'json']), { env: selected.env })).stdout);
574
+ for (const [key, volume] of Object.entries(resolved.volumes ?? {})) {
575
+ if (volume.external) throw new Error('Unexpected external installation volume');
576
+ const name = volume.name ?? `${record.project}_${key}`;
577
+ const found = await container(record, ['volume', 'inspect', '--format', '{{json .}}', name], { allowCodes: [1] });
578
+ if (found.code === 1) continue;
579
+ const value = JSON.parse(found.stdout);
580
+ if (value.Name !== name || value.Labels?.['com.docker.compose.project'] !== record.project || value.Labels?.['com.docker.compose.volume'] !== key) throw new Error(`Refusing foreign volume: ${name}`);
581
+ }
582
+ }
583
+ // Compose run can implicitly build an absent administration image. Build
584
+ // its resolved target natively first, just like explicit build operations.
585
+ if (engineName(record) === 'podman' && args[0] === 'run') {
586
+ const resolved = JSON.parse((await container(record, composeArgs(record, ['--profile', '*', 'config', '--format', 'json']), { env: selected.env })).stdout);
587
+ const takesValue = new Set(['--name', '--entrypoint', '--env', '-e', '--user', '-u', '--workdir', '-w', '--volume', '-v', '--publish', '-p', '--label', '-l', '--pull']);
588
+ let name;
589
+ for (let i = 1; i < args.length; i++) {
590
+ if (takesValue.has(args[i])) { i++; continue; }
591
+ if (args[i].startsWith('-')) continue;
592
+ name = args[i]; break;
593
+ }
594
+ const service = resolved.services?.[name];
595
+ if (!service) throw new Error(`Unresolved Podman administration service: ${name}`);
596
+ if (service.build) {
597
+ const image = await container(record, ['image', 'inspect', service.image], { allowCodes: [1] });
598
+ if (image.code === 1) for (const command of nativeBuildCommands(resolved, [name], { ...env, ...selected.env })) await container(record, command, selected);
599
+ }
600
+ }
601
+ return container(record, composeArgs(record, args), selected);
602
+ };
552
603
  const dockerStartupError = async (record, service, cause) => {
553
604
  const args = ['logs', '--no-color', '--tail', '50', '--timestamps', service];
554
605
  const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
555
- const command = `OURS_DAEMON_ID=${quote(record.instanceId)} docker ${composeArgs(record, args).map(quote).join(' ')}`;
606
+ const engine = engineName(record);
607
+ const prefix = engine === 'podman' ? ['--remote', '--url', `unix://${record.containerBinding.socket}`] : [];
608
+ const command = `OURS_DAEMON_ID=${quote(record.instanceId)} ${engine} ${[...prefix, ...composeArgs(record, args)].map(quote).join(' ')}`;
556
609
  let detail;
557
610
  try {
558
611
  const logs = await compose(record, args);
@@ -563,7 +616,7 @@ export function networkEffects(effects) {
563
616
  } catch {
564
617
  detail = 'Container logs could not be read.';
565
618
  }
566
- return new Error(`Docker service "${service}" failed to start or become healthy.\n${detail}\nStartup error: ${cause.message}\nInspect logs: ${command}`, { cause });
619
+ return new Error(`${engine === 'podman' ? 'Podman' : 'Docker'} service "${service}" failed to start or become healthy.\n${detail}\nStartup error: ${cause.message}\nInspect logs: ${command}`, { cause });
567
620
  };
568
621
  const bin = (record, name) => join(record.workDir, 'node_modules', '.bin', name);
569
622
  const localEnv = (record, service = 'daemon') => {
@@ -577,14 +630,15 @@ export function networkEffects(effects) {
577
630
  return { ...common, OURS_MESSENGER_STATE_DIR: state, ...(record.messengerIdentity ? { OURS_MESSENGER_IDENTITY: record.messengerIdentity } : {}), OURS_MESSENGER_PORT: String(record.messengerPort), OURS_MESSENGER_HOST: '127.0.0.1', OURS_MESSENGER_PUBLIC_ORIGIN: `http://127.0.0.1:${record.messengerPort}` };
578
631
  };
579
632
  const ownerCommand = (record, service, op, options = {}) => {
580
- const name = { daemon: 'ours', telegram: 'ours-tg-connector', cowork: 'ours-cowork' }[service];
581
- const args = service === 'daemon' ? ['daemon', op, ...(['install-service', 'uninstall-service'].includes(op) ? ['--yes'] : []), '--config', record.configPath, '--state-dir', installationPaths(record).daemon, '--json'] : [op];
633
+ const daemonBinary = existsSync(bin(record, 'ours-daemon')) ? 'ours-daemon' : 'ours';
634
+ const name = { daemon: daemonBinary, telegram: 'ours-tg-connector', cowork: 'ours-cowork' }[service];
635
+ const args = service === 'daemon' ? [...(daemonBinary === 'ours' ? ['daemon'] : []), op, ...(['install-service', 'uninstall-service'].includes(op) ? ['--yes'] : []), '--config', record.configPath, '--state-dir', installationPaths(record).daemon, '--json'] : [op];
582
636
  return effects.run(bin(record, name), args, { ...options, env: localEnv(record, service) });
583
637
  };
584
638
  const requireCleanContainerExit = async (record, selected) => {
585
639
  const containers = await compose(record, ['ps', '-aq', ...selected]);
586
640
  for (const id of containers.stdout.split(/\s+/).filter(Boolean)) {
587
- const result = await effects.run('docker', ['inspect', '--format', '{{json .State}}', id]);
641
+ const result = await container(record, ['inspect', '--format', '{{json .State}}', id]);
588
642
  const state = JSON.parse(result.stdout);
589
643
  if (state.Status !== 'exited' || state.ExitCode !== 0 || state.OOMKilled !== false || state.Dead !== false) {
590
644
  throw new Error('Selected source container did not stop cleanly; state operation refused');
@@ -618,7 +672,7 @@ export function networkEffects(effects) {
618
672
  return version;
619
673
  });
620
674
  },
621
- newInstallation(root, mode) {
675
+ newInstallation(root, mode, { containerEngine } = {}) {
622
676
  if (existsSync(root)) {
623
677
  privateDirectory(root);
624
678
  if (readdirSync(root).some(name => name !== '.operation.lock')) throw new Error('Installation root is not empty and has no selection record');
@@ -626,9 +680,10 @@ export function networkEffects(effects) {
626
680
  const instanceId = env.OURS_DAEMON_ID || randomUUID();
627
681
  if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(instanceId)) throw new Error('OURS_DAEMON_ID must be a lowercase UUID');
628
682
  const project = `ours-${createHash('sha256').update(root).digest('hex').slice(0, 16)}`;
629
- return { schema: 2, root, mode, instanceId, project, workDir: join(root, 'runtime'), configPath: installationPaths({ schema: 2, root }).config, sourcesPath: join(root, 'sources.json'), services: [...SERVER_SERVICES], port: 3050, coworkPort: 3052, messengerPort: 8420, messengerIdentity: env.OURS_MESSENGER_IDENTITY || null, uid: 1000, gid: 1000 };
683
+ return { schema: 2, root, mode, ...(containerEngine ? { containerEngine } : {}), instanceId, project, workDir: join(root, 'runtime'), configPath: installationPaths({ schema: 2, root }).config, sourcesPath: join(root, 'sources.json'), services: [...SERVER_SERVICES, ...(mode === 'docker' ? ['gateway'] : [])], ...(mode === 'docker' ? { gateway: { version: 1 } } : {}), port: 3050, coworkPort: 3052, messengerPort: 8420, messengerIdentity: env.OURS_MESSENGER_IDENTITY || null, uid: 1000, gid: 1000 };
630
684
  },
631
- async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest, identityName } = {}) {
685
+ async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest, identityName, explicitPorts } = {}) {
686
+ if (!existing && operation === 'install' && Number.isInteger(record.port)) selectServerPorts(record, portTakenSync, explicitPorts, effects.out);
632
687
  if (existing) {
633
688
  privateDirectory(record.root);
634
689
  assertPrivateRegularFile(join(record.root, 'installation.json'), 'selection');
@@ -636,37 +691,55 @@ export function networkEffects(effects) {
636
691
  if (env.OURS_DAEMON_ID && env.OURS_DAEMON_ID !== record.instanceId) throw new Error('Conflicting instance ID');
637
692
  }
638
693
  if (record.mode === 'docker') {
639
- const nativeRoot = existing ? '/path/to/new-empty-directory' : record.root;
640
- const quotedRoot = `'${String(nativeRoot).replaceAll("'", "'\\''")}'`;
641
- const quotedName = `'${String(identityName ?? record.messengerIdentity ?? 'Your Name').replaceAll("'", "'\\''")}'`;
642
- const recovery = [
643
- 'Please install Docker Desktop on macOS/Windows, or Docker Engine with the Compose plugin on Linux, and start Docker before retrying.',
644
- 'Docker is recommended for macOS and Windows.',
645
- `Alternatively, use native installation: ours-install server install --mode packages --state-dir ${quotedRoot} --identity-name ${quotedName}`,
646
- 'Native mode requires systemd user services on Linux/WSL or a launchd GUI session on macOS.',
647
- ...(existing ? ['Keep this existing Docker installation in Docker mode; use a separate empty directory for a new native installation.'] : []),
648
- ].join('\n');
649
- try {
650
- await effects.run('docker', ['info', '--format', '{{.ServerVersion}}']);
651
- } catch (cause) {
652
- const problem = cause.code === 'ENOENT'
653
- ? 'Docker command was not found in PATH.'
654
- : `Docker Engine is not reachable. Start Docker and check that your user can access it.\nDetails: ${cause.message}`;
655
- throw new Error(`${problem}\n${recovery}`, { cause });
656
- }
657
- let version;
658
- try {
659
- version = await effects.run('docker', ['compose', 'version', '--short']);
660
- } catch (cause) {
661
- throw new Error(`Docker Compose 2.35 or newer is required, but the Compose plugin could not run. Update Docker Desktop or install the Docker Compose plugin.\n${recovery}`, { cause });
662
- }
663
- const match = /^v?(\d+)\.(\d+)/.exec(version.stdout.trim());
664
- if (!match || Number(match[1]) < 2 || (Number(match[1]) === 2 && Number(match[2]) < 35)) {
665
- throw new Error(`Docker Compose 2.35 or newer is required. Update Docker Desktop or the Docker Compose plugin.\n${recovery}`);
694
+ if (engineName(record) === 'podman') {
695
+ await bindPodman(effects, record);
696
+ if (['install', 'start', 'restart', 'update', 'rebuild', 'gateway-enable'].includes(operation)) {
697
+ const userHome = userInfo().homedir;
698
+ for (const [key, expected] of Object.entries({ HOME: userHome, XDG_CONFIG_HOME: join(userHome, '.config'), XDG_DATA_HOME: join(userHome, '.local/share'), XDG_RUNTIME_DIR: `/run/user/${process.getuid()}` })) {
699
+ if (env[key] && env[key] !== expected) throw new Error(`${key} selects a custom Podman environment that the standard boot service does not retain`);
700
+ }
701
+ if (record.containerBinding.socket !== `/run/user/${process.getuid()}/podman/podman.sock`) throw new Error('The standard user boot service requires the default local Podman socket');
702
+ await effects.run('systemctl', ['--user', 'is-enabled', 'podman.socket']);
703
+ await effects.run('systemctl', ['--user', 'is-enabled', 'podman-restart.service']);
704
+ const restart = await effects.run('systemctl', ['--user', 'show', 'podman-restart.service', '--property=ExecStart', '--value']);
705
+ if (!/--filter[ =]+should-start-on-boot=true(?:[ ;}]|$)/.test(restart.stdout)) throw new Error('Installed podman-restart.service cannot recover unless-stopped workloads; use a Podman release with should-start-on-boot support');
706
+ const linger = await effects.run('loginctl', ['show-user', String(process.getuid()), '--property=Linger', '--value']);
707
+ if (linger.stdout.trim() !== 'yes') throw new Error('Rootless server requires user linger for logout/reboot recovery; ask your administrator to enable it');
708
+ await qualifyPodman(effects, record);
709
+ }
710
+ } else {
711
+ const nativeRoot = existing ? '/path/to/new-empty-directory' : record.root;
712
+ const quotedRoot = `'${String(nativeRoot).replaceAll("'", "'\\''")}'`;
713
+ const quotedName = `'${String(identityName ?? record.messengerIdentity ?? 'Your Name').replaceAll("'", "'\\''")}'`;
714
+ const recovery = [
715
+ 'Please install Docker Desktop on macOS/Windows, or Docker Engine with the Compose plugin on Linux, and start Docker before retrying.',
716
+ 'Docker is recommended for macOS and Windows.',
717
+ `Alternatively, use native installation: ours-install server install --mode packages --state-dir ${quotedRoot} --identity-name ${quotedName}`,
718
+ 'Native mode requires systemd user services on Linux/WSL or a launchd GUI session on macOS.',
719
+ ...(existing ? ['Keep this existing Docker installation in Docker mode; use a separate empty directory for a new native installation.'] : []),
720
+ ].join('\n');
721
+ try {
722
+ await container(record, ['info', '--format', '{{.ServerVersion}}']);
723
+ } catch (cause) {
724
+ const problem = cause.code === 'ENOENT'
725
+ ? 'Docker command was not found in PATH.'
726
+ : `Docker Engine is not reachable. Start Docker and check that your user can access it.\nDetails: ${cause.message}`;
727
+ throw new Error(`${problem}\n${recovery}`, { cause });
728
+ }
729
+ let version;
730
+ try {
731
+ version = await container(record, ['compose', 'version', '--short']);
732
+ } catch (cause) {
733
+ throw new Error(`Docker Compose 2.35 or newer is required, but the Compose plugin could not run. Update Docker Desktop or install the Docker Compose plugin.\n${recovery}`, { cause });
734
+ }
735
+ const match = /^v?(\d+)\.(\d+)/.exec(version.stdout.trim());
736
+ if (!match || Number(match[1]) < 2 || (Number(match[1]) === 2 && Number(match[2]) < 35)) {
737
+ throw new Error(`Docker Compose 2.35 or newer is required. Update Docker Desktop or the Docker Compose plugin.\n${recovery}`);
738
+ }
666
739
  }
667
740
  if (operation !== 'status') {
668
741
  // Compose clients can disappear while their Engine-owned command continues.
669
- const active = await effects.run('docker', ['ps', '--filter', `label=com.docker.compose.project=${record.project}`, '--filter', 'label=com.docker.compose.oneoff=True', '--format', '{{.ID}}']);
742
+ const active = await container(record, ['ps', '--filter', `label=com.docker.compose.project=${record.project}`, '--filter', 'label=com.docker.compose.oneoff=True', '--format', '{{.ID}}']);
670
743
  if (active.stdout.trim()) throw new Error('Another server installation operation is still running in Docker');
671
744
  }
672
745
  } else {
@@ -704,7 +777,57 @@ export function networkEffects(effects) {
704
777
  else if (!readFileSync(record.sourcesPath).equals(bytes)) throw new Error('Migration source selection changed');
705
778
  if (!retainConfig) writePrivateNew(record.configPath, JSON.stringify({ stateDir: record.mode === 'docker' ? '/var/lib/ours' : installationPaths(record).daemon, port: record.port, apiVisibility: 'owner' }, null, 2) + '\n');
706
779
  },
780
+ async qualifyDockerRuntime(record) { return qualifyDockerRuntime(record, effects); },
781
+ async prepareGateway(record) {
782
+ atomicWriteConfig(join(record.workDir, 'docker-compose.gateway.yaml'), gatewayCompose(record));
783
+ atomicWriteConfig(join(record.workDir, 'nginx.conf'), gatewayNginx(record));
784
+ atomicWriteConfig(join(record.workDir, 'gateway-entrypoint.sh'), readFileSync(join(INSTALLER_ASSETS, 'gateway-entrypoint.sh'), 'utf8'));
785
+ atomicWriteConfig(join(record.workDir, 'Dockerfile.gateway'), readFileSync(join(INSTALLER_ASSETS, 'Dockerfile.gateway'), 'utf8'));
786
+ await compose(record, ['build', 'gateway'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
787
+ },
788
+ async removeGatewayContainer(record) { await compose(record, ['rm', '-f', 'gateway']); },
789
+ async verifyGateway(record) {
790
+ const { prefix } = gatewayAddress(record);
791
+ const script = `const fs=require('node:fs');let stage='selection';(async()=>{
792
+ const base=${JSON.stringify('http://gateway:8080' + prefix)};
793
+ const get=async(path,options={})=>{const r=await fetch(base+path,{...options,redirect:'error',signal:AbortSignal.timeout(5000)});if(!r.ok)throw Error('Gateway readiness HTTP '+r.status);return r.json();};
794
+ const selection=await get('/daemon/selection');
795
+ if(selection.instanceId!==${JSON.stringify(record.instanceId)})throw Error('Gateway daemon instance mismatch');
796
+ stage='unauthenticated-management';
797
+ const denied=await fetch(base+'/cowork/management/rpc',{method:'POST',redirect:'error',signal:AbortSignal.timeout(5000),headers:{'content-type':'application/json'},body:'{}'});
798
+ await denied.body?.cancel();if(denied.status!==401)throw Error('Gateway management does not reject unauthenticated requests');
799
+ stage='credential';
800
+ const token=fs.readFileSync('/var/lib/ours/daemon-token','utf8').trim();
801
+ const headers={'x-ours-api-token':token,'content-type':'application/json'};
802
+ stage='authenticated-daemon';
803
+ const identities=await get('/daemon/identities',{headers});
804
+ if(!Array.isArray(identities.identities))throw Error('Gateway daemon API invalid');
805
+ stage='authenticated-management';
806
+ const result=await get('/cowork/management/rpc',{method:'POST',headers,body:JSON.stringify({version:1,id:'installer-readiness',method:'room.list',params:{}})});
807
+ if(result.version!==1||result.id!=='installer-readiness'||!Array.isArray(result.result)||result.error)throw Error('Gateway Cowork management unavailable');
808
+ })().catch(error=>{const code=error.cause?.code;process.stdout.write('gateway-readiness:'+stage+':'+(['EAI_AGAIN','ENOTFOUND','ECONNREFUSED','ETIMEDOUT'].includes(code)?code:'CHECK_FAILED'));process.exitCode=1;});`;
809
+ const result = await compose(record, ['exec', '-T', 'daemon', 'node', '-e', script], { sensitive: true, allowCodes: [1] });
810
+ if (result.code === 1) {
811
+ const match = /^gateway-readiness:(selection|unauthenticated-management|credential|authenticated-daemon|authenticated-management):(EAI_AGAIN|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|CHECK_FAILED)$/.exec(result.stdout.trim());
812
+ const detail = match ? ` (${match[1]}, ${match[2]})` : '';
813
+ throw new Error(`Gateway readiness failed${detail}. Keep installation state intact; check the managed gateway startup and network connection, then retry setup.`);
814
+ }
815
+ },
816
+ async qualifyGatewayRuntime(record) {
817
+ for (const [name, args, capability] of [
818
+ ['cowork', ['--json', 'capabilities'], 'cowork.http-management-v1'],
819
+ ['messenger-server', ['capabilities'], 'messenger.gateway-prefix-v1'],
820
+ ['tg-connector', ['capabilities'], 'telegram.gateway-listener-v1'],
821
+ ]) {
822
+ const probe = await container(record, ['run', '--rm', '--network', 'none', '--read-only', '--entrypoint', 'node', `${record.project}:runtime`, `/opt/ours/node_modules/@ours.network/${name}/dist/cli.js`, ...args]);
823
+ let value; try { value = JSON.parse(probe.stdout); } catch {}
824
+ const capabilities = value?.result?.capabilities ?? value?.capabilities;
825
+ if (!Array.isArray(capabilities) || !capabilities.includes(capability))
826
+ throw new Error(`Selected ${name} artifact lacks ${capability}; select a compatible release before enabling the gateway`);
827
+ }
828
+ },
707
829
  async prepareInstallation(record, { runtimeOnly = false } = {}) {
830
+ if (!runtimeOnly) await reportBootReadiness(effects, record);
708
831
  let copied = false;
709
832
  if (!existsSync(record.workDir)) {
710
833
  cpSync(INSTALLER_ASSETS, record.workDir, { recursive: true, errorOnExist: true, force: false });
@@ -717,13 +840,29 @@ export function networkEffects(effects) {
717
840
  if (!existsSync(materialized)) writePrivateNew(materialized, retained);
718
841
  else if (!readFileSync(materialized).equals(retained)) throw new Error('Materialized sources differ from retained selection');
719
842
  if (record.mode === 'docker') {
843
+ if (engineName(record) === 'podman') atomicWriteConfig(join(record.workDir, 'compose.podman.json'), JSON.stringify({ services: Object.fromEntries(record.services.map(name => [name, { restart: 'unless-stopped' }])) }));
844
+ const rebuildPrivateScripts = refreshDockerPolicyCopy(record);
845
+ const rebuildRecovery = refreshDockerRecovery(record);
846
+ atomicWriteConfig(join(record.workDir, 'compose.recovery.json'), JSON.stringify(recoveryCompose(record)));
847
+ if (record.gateway) {
848
+ atomicWriteConfig(join(record.workDir, 'docker-compose.gateway.yaml'), gatewayCompose(record));
849
+ atomicWriteConfig(join(record.workDir, 'nginx.conf'), gatewayNginx(record));
850
+ for (const file of ['Dockerfile.gateway', 'gateway-entrypoint.sh']) atomicWriteConfig(join(record.workDir, file), readFileSync(join(INSTALLER_ASSETS, file), 'utf8'));
851
+ }
720
852
  // The installer owns these dependencies in both installation modes.
721
853
  const { dependencies } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
722
854
  writeFileSync(join(record.workDir, 'scripts/maintenance/package.json'), JSON.stringify({ private: true, type: 'module', dependencies }, null, 2) + '\n', { mode: 0o600 });
723
- const image = await effects.run('docker', ['image', 'inspect', `${record.project}:runtime`], { allowCodes: [1] });
724
- if (image.code !== 0) await compose(record, ['build', 'daemon'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
855
+ const image = await container(record, ['image', 'inspect', `${record.project}:runtime`], { allowCodes: [1] });
856
+ if (image.code !== 0 || rebuildPrivateScripts || rebuildRecovery) await compose(record, ['build', 'daemon'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
857
+ await effects.qualifyDockerRuntime(record);
858
+ if (rebuildPrivateScripts) rmSync(join(record.workDir, '.script-permissions-rebuild'));
859
+ if (rebuildRecovery) rmSync(join(record.workDir, '.recovery-rebuild'));
860
+ if (record.gateway) {
861
+ await effects.qualifyGatewayRuntime(record);
862
+ await compose(record, ['build', 'gateway'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
863
+ }
725
864
  if (runtimeOnly) return;
726
- await compose(record, ['run', '--rm', '--no-deps', '-T', 'prepare', 'prepare']);
865
+ await effects.prepareServerVolumes(record);
727
866
  } else {
728
867
  if (!existsSync(join(record.workDir, '.packages-ready'))) {
729
868
  const sourceRoot = join(record.root, `build-${randomUUID()}`);
@@ -744,12 +883,15 @@ export function networkEffects(effects) {
744
883
  const profilePath = join(installationPaths(record).mcp, 'profile.json');
745
884
  if (!existsSync(profilePath)) writePrivateNew(profilePath, JSON.stringify({ endpoint: `http://127.0.0.1:${record.port}`, expectedInstanceId: record.instanceId, credentialPath: join(installationPaths(record).daemon, 'daemon-token') }));
746
885
  const config = JSON.parse(readFileSync(record.configPath, 'utf8'));
747
- config.networkMcp ??= { profile: { endpoint: `http://127.0.0.1:${record.port}`, expectedInstanceId: record.instanceId, credentialPath: join(installationPaths(record).daemon, 'daemon-token') }, applicationConfigPath: join(installationPaths(record).mcp, 'config.json') };
748
886
  effects.writeJson(record.configPath, JSON.stringify(config, null, 2) + '\n');
749
887
  const cowork = join(installationPaths(record).cowork, 'config.json');
750
888
  if (!existsSync(cowork)) writePrivateNew(cowork, JSON.stringify({ version: 1, stateDir: installationPaths(record).cowork, rest: { enabled: true, host: '127.0.0.1', port: record.coworkPort } }));
751
889
  }
752
890
  },
891
+ async prepareServerVolumes(record) {
892
+ if (record.mode !== 'docker') return;
893
+ await compose(record, ['run', '--rm', '--no-deps', '-T', 'prepare', 'prepare']);
894
+ },
753
895
  async prepareServerBuild(record, args) {
754
896
  validateInstallation(record, record.root);
755
897
  if (record.schema !== 2 || record.layoutConversion || !['update', 'rebuild'].includes(args.operation)) {
@@ -792,17 +934,17 @@ export function networkEffects(effects) {
792
934
  },
793
935
  async copyDockerBuildRecords(record, directory) {
794
936
  // Inspect immutable image metadata; never reinterpret a failed context copy as legacy.
795
- const label = (await effects.run('docker', ['image', 'inspect', '--format', '{{ index .Config.Labels "network.ours.build-context" }}', `${record.project}:runtime`])).stdout.trim();
937
+ const label = (await container(record, ['image', 'inspect', '--format', '{{ index .Config.Labels "network.ours.build-context" }}', `${record.project}:runtime`])).stdout.trim();
796
938
  if (!['', '<no value>', '1'].includes(label)) throw new Error('Unsupported image build-context schema');
797
939
  const names = [...BASE_RECORDS, ...(label === '1' ? [CONTEXT] : [])];
798
940
  const stage = mkdtempSync(join(directory, '.records-'));
799
941
  const name = `${record.project}-records`;
800
942
  let created = false;
801
943
  try {
802
- await effects.run('docker', ['create', '--name', name, '--entrypoint', '/bin/true', `${record.project}:runtime`]);
944
+ await container(record, ['create', '--name', name, '--entrypoint', '/bin/true', `${record.project}:runtime`]);
803
945
  created = true;
804
946
  for (const file of names) {
805
- await effects.run('docker', ['cp', `${name}:/opt/ours/${file}`, join(stage, file)]);
947
+ await container(record, ['cp', `${name}:/opt/ours/${file}`, join(stage, file)]);
806
948
  const st = lstatSync(join(stage, file));
807
949
  if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o7002)) throw new Error('Unsafe copied build record');
808
950
  chmodSync(join(stage, file), 0o600);
@@ -812,7 +954,7 @@ export function networkEffects(effects) {
812
954
  for (const file of names) renameSync(join(stage, file), join(directory, file));
813
955
  if (label !== '1' && existsSync(join(directory, CONTEXT))) throw new Error('Legacy image conflicts with retained build context');
814
956
  } finally {
815
- try { if (created) await effects.run('docker', ['rm', name]); }
957
+ try { if (created) await container(record, ['rm', name]); }
816
958
  finally { rmSync(stage, { recursive: true, force: true }); }
817
959
  }
818
960
  },
@@ -863,12 +1005,12 @@ export function networkEffects(effects) {
863
1005
  async publishServerBuild(record, candidate) {
864
1006
  const previous = join(candidate.root, 'previous-runtime');
865
1007
  if (record.mode === 'docker') {
866
- for (const target of ['runtime', 'maintenance']) {
1008
+ for (const target of ['runtime', 'maintenance', ...(record.gateway ? ['gateway'] : [])]) {
867
1009
  const retained = `${candidate.project}:previous-${target}`;
868
- const found = await effects.run('docker', ['image', 'inspect', retained], { allowCodes: [1] });
1010
+ const found = await container(record, ['image', 'inspect', retained], { allowCodes: [1] });
869
1011
  if (found.code !== 0) {
870
- const current = await effects.run('docker', ['image', 'inspect', `${record.project}:${target}`], { allowCodes: [1] });
871
- if (current.code === 0) await effects.run('docker', ['tag', `${record.project}:${target}`, retained]);
1012
+ const current = await container(record, ['image', 'inspect', `${record.project}:${target}`], { allowCodes: [1] });
1013
+ if (current.code === 0) await container(record, ['tag', `${record.project}:${target}`, retained]);
872
1014
  }
873
1015
  }
874
1016
  }
@@ -884,8 +1026,8 @@ export function networkEffects(effects) {
884
1026
  renameSync(candidate.workDir, record.workDir);
885
1027
  } else privateDirectory(record.workDir);
886
1028
  if (record.mode === 'docker') {
887
- for (const target of ['runtime', 'maintenance']) {
888
- await effects.run('docker', ['tag', `${candidate.project}:${target}`, `${record.project}:${target}`]);
1029
+ for (const target of ['runtime', 'maintenance', ...(record.gateway ? ['gateway'] : [])]) {
1030
+ await container(candidate, ['tag', `${candidate.project}:${target}`, `${record.project}:${target}`]);
889
1031
  }
890
1032
  }
891
1033
  atomicWriteConfig(record.sourcesPath, readFileSync(candidate.sourcesPath));
@@ -899,10 +1041,10 @@ export function networkEffects(effects) {
899
1041
  },
900
1042
  async discardServerBuild(candidate) {
901
1043
  if (candidate.mode === 'docker') {
902
- for (const target of ['runtime', 'maintenance', 'previous-runtime', 'previous-maintenance']) {
1044
+ for (const target of ['runtime', 'maintenance', 'previous-runtime', 'previous-maintenance', ...(candidate.gateway ? ['gateway', 'previous-gateway'] : [])]) {
903
1045
  const image = `${candidate.project}:${target}`;
904
- const found = await effects.run('docker', ['image', 'inspect', image], { allowCodes: [1] });
905
- if (found.code === 0) await effects.run('docker', ['image', 'rm', image]);
1046
+ const found = await container(candidate, ['image', 'inspect', image], { allowCodes: [1] });
1047
+ if (found.code === 0) await container(candidate, ['image', 'rm', image]);
906
1048
  }
907
1049
  }
908
1050
  privateDirectory(candidate.root);
@@ -910,7 +1052,15 @@ export function networkEffects(effects) {
910
1052
  },
911
1053
  async serverAccess(record, operation, { output, migrate = false } = {}) {
912
1054
  if (record.mode === 'docker') {
913
- if (!output) return compose(record, ['run', '--rm', '--no-deps', '-T', 'access', operation], { sensitive: true, env: { OURS_ACCESS_MIGRATE: migrate ? '1' : '0' } });
1055
+ // Retained images contain the old helper that discarded CLI errors.
1056
+ // Execute the shipped installer helper against the SAME selected image
1057
+ // and Compose volume/user/environment contract, without retag/rebuild.
1058
+ const diagnosticsUrl = `data:text/javascript;base64,${readFileSync(join(INSTALLER_ASSETS, 'scripts/runtime/diagnostics.mjs')).toString('base64')}`;
1059
+ const helper = `process.argv.splice(1, 0, '/opt/ours/docker/client-setup.mjs');\n` + readFileSync(join(INSTALLER_ASSETS, 'scripts/runtime/client-setup.mjs'), 'utf8')
1060
+ .replace("'../maintenance/build-context.mjs'", "'/opt/ours/maintenance/build-context.mjs'")
1061
+ .replace("'./diagnostics.mjs'", JSON.stringify(diagnosticsUrl));
1062
+ const accessArgs = (...args) => ['--entrypoint', 'node', 'access', '--input-type=module', '-e', helper, '--', ...args];
1063
+ if (!output) return compose(record, ['run', '--rm', '--no-deps', '-T', ...accessArgs(operation)], { sensitive: true, env: { OURS_ACCESS_MIGRATE: migrate ? '1' : '0' } });
914
1064
  privateDirectory(dirname(output));
915
1065
  if (existsSync(output)) throw new Error('Credential output already exists');
916
1066
  const name = `${record.project}-issue-${randomUUID()}`;
@@ -918,23 +1068,23 @@ export function networkEffects(effects) {
918
1068
  const staging = join(dirname(output), `.ours-issued-${randomUUID()}`);
919
1069
  ensurePrivateDirectory(staging);
920
1070
  try {
921
- await compose(record, ['run', '--name', name, '--no-deps', '-T', 'access', 'access-issue', issuedPath], { sensitive: true });
1071
+ await compose(record, ['run', '--name', name, '--no-deps', '-T', ...accessArgs('access-issue', issuedPath)], { sensitive: true });
922
1072
  const file = join(staging, 'credential');
923
- await effects.run('docker', ['cp', `${name}:${issuedPath}`, file], { sensitive: true });
1073
+ await container(record, ['cp', `${name}:${issuedPath}`, file], { sensitive: true });
924
1074
  assertPrivateRegularFile(file, 'issued credential');
925
1075
  // Exclusive publication never overwrites an unrelated credential.
926
1076
  writePrivateNew(output, readFileSync(file));
927
1077
  } finally {
928
1078
  await compose(record, ['run', '--rm', '--no-deps', '-T', '--entrypoint', 'rm', 'access', '-f', issuedPath], { sensitive: true });
929
- await effects.run('docker', ['rm', '-f', name], { sensitive: true });
1079
+ await container(record, ['rm', '-f', name], { sensitive: true });
930
1080
  rmSync(staging, { recursive: true, force: true });
931
1081
  }
932
1082
  return;
933
1083
  }
934
1084
  const options = { env: localEnv(record), sensitive: true };
935
- if (operation !== 'access-issue') return effects.run(bin(record, 'ours'), ['config', operation, '--config', record.configPath, ...(operation === 'access-replace' ? ['--confirm'] : migrate ? ['--migrate'] : []), '--json'], options);
1085
+ if (operation !== 'access-issue') return effects.run(bin(record, existsSync(bin(record, 'ours-daemon')) ? 'ours-daemon' : 'ours'), ['config', operation, '--config', record.configPath, ...(operation === 'access-replace' ? ['--confirm'] : migrate ? ['--migrate'] : []), '--json'], options);
936
1086
  const outputs = output ? [output] : [join(installationPaths(record).daemon, 'daemon-token'), ...['telegram', 'cowork', 'messenger'].map(s => installationPaths(record).credentials[s])];
937
- for (const path of outputs) await effects.run(bin(record, 'ours'), ['config', operation, '--config', record.configPath, '--output', path, ...(output ? [] : ['--replace']), '--json'], options);
1087
+ for (const path of outputs) await effects.run(bin(record, existsSync(bin(record, 'ours-daemon')) ? 'ours-daemon' : 'ours'), ['config', operation, '--config', record.configPath, '--output', path, ...(output ? [] : ['--replace']), '--json'], options);
938
1088
  },
939
1089
  async recordRuntimeBuild(record) {
940
1090
  if (record.mode === 'docker') return; // Image preparation records its build.
@@ -978,7 +1128,7 @@ export function networkEffects(effects) {
978
1128
  await effects.run(process.execPath, [join(INSTALLER_ASSETS, 'scripts/maintenance/state-operation.mjs'), ...command], {
979
1129
  env: { ...localEnv(record), OURS_STATE_DOMAIN: args.domain,
980
1130
  OURS_STATE_ROOT: join(record.root, 'storage'), OURS_LIVE_ROOT: ['server', 'daemon'].includes(args.domain) ? paths.state : paths[args.domain],
981
- OURS_BUILD_ROOT: record.workDir, OURS_CLI_PATH: bin(record, 'ours'),
1131
+ OURS_BUILD_ROOT: record.workDir, OURS_CLI_PATH: bin(record, existsSync(bin(record, 'ours-daemon')) ? 'ours-daemon' : 'ours'),
982
1132
  OURS_DAEMON_CONFIG: record.configPath, OURS_COWORK_CLI_PATH: bin(record, 'ours-cowork'),
983
1133
  OURS_COWORK_CONFIG: join(paths.cowork, 'config.json'), OURS_COWORK_STATE_DIR: paths.cowork },
984
1134
  });
@@ -996,12 +1146,12 @@ export function networkEffects(effects) {
996
1146
  if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name)) {
997
1147
  throw new Error('Invalid conversion volume name');
998
1148
  }
999
- const result = await effects.run('docker', ['volume', 'inspect', '--format', '{{json .}}', name], {
1149
+ const result = await container(record, ['volume', 'inspect', '--format', '{{json .}}', name], {
1000
1150
  ...(optional ? { allowCodes: [1] } : {}),
1001
1151
  });
1002
1152
  if (result.code !== 0) {
1003
1153
  if (optional) {
1004
- const inventory = await effects.run('docker', ['volume', 'ls', '--format', '{{.Name}}']);
1154
+ const inventory = await container(record, ['volume', 'ls', '--format', '{{.Name}}']);
1005
1155
  if (inventory.code === 0 && !inventory.stdout.split(/\s+/).includes(name)) return false;
1006
1156
  }
1007
1157
  throw new Error(`Source volume is missing or unavailable: ${name}`);
@@ -1063,7 +1213,7 @@ export function networkEffects(effects) {
1063
1213
  await ownerCommand(record, 'cowork', 'prepare-backup');
1064
1214
  },
1065
1215
  async retainConvertedPackageAuthority(record, daemon) {
1066
- await effects.run(bin(record, 'ours'), [
1216
+ await effects.run(bin(record, existsSync(bin(record, 'ours-daemon')) ? 'ours-daemon' : 'ours'), [
1067
1217
  'config', 'access-retain', '--config', record.configPath,
1068
1218
  '--target-state-dir', daemon, '--json',
1069
1219
  ], { env: localEnv(record), sensitive: true });
@@ -1107,6 +1257,15 @@ export function networkEffects(effects) {
1107
1257
  }
1108
1258
  await nativeLifecycle(record, 'retire', record.services, { effects, localEnv, ownerCommand, bin });
1109
1259
  },
1260
+ async serverUninstall(record) {
1261
+ if (record.schema !== 2 || record.layoutConversion || record.buildTransition) throw new Error('Complete retained installation transitions before uninstall');
1262
+ if (record.mode === 'docker') {
1263
+ await effects.serverLifecycle(record, 'stop');
1264
+ await requireCleanContainerExit(record, record.services);
1265
+ await compose(record, ['rm', '-f', ...record.services]);
1266
+ } else await nativeLifecycle(record, 'retire', record.services, { effects, localEnv, ownerCommand, bin });
1267
+ effects.out('Managed services removed; installation selection, stored identities, credentials and volumes retained.');
1268
+ },
1110
1269
  async serverLifecycle(record, operation, selected = record.services) {
1111
1270
  record = buildRuntime(record);
1112
1271
  if (record.mode === 'docker') {
@@ -1123,9 +1282,13 @@ export function networkEffects(effects) {
1123
1282
  if ((await effects.serverLifecycle(record, 'status', selected)).length) throw new Error('Writers did not stop');
1124
1283
  return;
1125
1284
  }
1285
+ if (existsSync(record.workDir)) atomicWriteConfig(join(record.workDir, 'compose.recovery.json'), JSON.stringify(recoveryCompose(record)));
1286
+ await reconcileDockerRecovery(record, effects);
1126
1287
  const start = async service => {
1127
1288
  effects.out(`Starting ${service}; waiting for readiness...`);
1128
- try { await compose(record, ['up', '-d', '--no-build', '--no-deps', '--wait', service]); }
1289
+ // Gateway has no persistent state. Recreate its network endpoint on each
1290
+ // managed start, including retries after an interrupted/failed bind.
1291
+ try { await compose(record, ['up', '-d', '--no-build', '--no-deps', ...(service === 'gateway' ? ['--force-recreate'] : []), '--wait', service]); }
1129
1292
  catch (cause) { throw await dockerStartupError(record, service, cause); }
1130
1293
  effects.out(`${service} is ready.`);
1131
1294
  };
@@ -1135,6 +1298,7 @@ export function networkEffects(effects) {
1135
1298
  try { await start(service); }
1136
1299
  catch (error) { failures.push({ service, error }); }
1137
1300
  }
1301
+ if (!failures.length && record.gateway && ['daemon', 'cowork', 'gateway'].every(service => selected.includes(service))) await effects.verifyGateway(record);
1138
1302
  if (failures.length) throw new Error(`Application readiness failed: ${failures.map(f => f.service).join(', ')}. Check the selected application prerequisites.\n${failures.map(f => f.error.message).join('\n\n')}`);
1139
1303
  return;
1140
1304
  }
@@ -1149,21 +1313,19 @@ export function networkEffects(effects) {
1149
1313
  return JSON.parse(readFileSync(path, 'utf8'));
1150
1314
  },
1151
1315
  async discoverClientProfile(endpoint, credentialPath) {
1152
- const url = new URL(endpoint);
1153
- if (url.protocol !== 'http:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash)
1154
- throw new Error('Client endpoint must be an HTTP origin');
1155
- const response = await fetch(`${url.origin}/selection`, { redirect: 'error', signal: AbortSignal.timeout(5000) });
1156
- if (!response.ok) throw new Error(`Daemon selection answered HTTP ${response.status}`);
1157
- const selection = await response.json();
1158
- // Full metadata and authenticated capability validation follows before publication.
1159
- return validateHostProfile({ endpoint: url.origin, expectedInstanceId: selection.instanceId, credentialPath: resolve(credentialPath) });
1316
+ const base = serverBase(endpoint);
1317
+ const request = path => fetch(`${base}${path}`, { redirect: 'error', signal: AbortSignal.timeout(5000) });
1318
+ const discovery = await request('/.well-known/ours');
1319
+ if (discovery.ok) return validateGatewayDiscovery(base, await discovery.json(), resolve(credentialPath));
1320
+ throw new Error(`Gateway discovery answered HTTP ${discovery.status}; enable the gateway. Direct daemon fallback is not supported.`);
1160
1321
  },
1161
- importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh = false }) {
1322
+ importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, disableFleetAgentsSetup = false, refresh = false, notificationsProducerPath }) {
1323
+ profile = validateGatewayClientProfile(profile);
1162
1324
  const root = join(home, '.ours-client');
1163
1325
  const configPath = join(root, 'profile.json');
1164
1326
  const credentialPath = join(root, 'credential');
1165
1327
  const current = effects.readManagedClientProfile();
1166
- if (current && (current.endpoint !== profile.endpoint || current.expectedInstanceId !== profile.expectedInstanceId))
1328
+ if (current && current.expectedInstanceId !== profile.expectedInstanceId)
1167
1329
  throw new Error('Managed client already selects another server; existing default was not changed');
1168
1330
  assertPrivateRegularFile(profile.credentialPath, 'credential');
1169
1331
  const credential = readFileSync(profile.credentialPath, 'utf8');
@@ -1174,13 +1336,30 @@ export function networkEffects(effects) {
1174
1336
  : readFileSync(sourcesPath);
1175
1337
  const fleetSettings = (!current || refresh) && fleetSettingsPath ? readFileSync(fleetSettingsPath) : null;
1176
1338
  if (fleetSettings) JSON.parse(fleetSettings.toString());
1339
+ let producer = null;
1340
+ if (notificationsProducerPath) {
1341
+ assertPrivateRegularFile(notificationsProducerPath, 'notification producer credential');
1342
+ producer = readFileSync(notificationsProducerPath, 'utf8');
1343
+ let bound; try { bound = JSON.parse(producer); } catch { bound = null; }
1344
+ if (bound?.schema !== 1 || bound.serverUrl !== profile.serverUrl || bound.expectedInstanceId !== profile.expectedInstanceId || !/^[A-Za-z0-9_-]{32,256}$/.test(bound.token ?? ''))
1345
+ throw new Error('Notification producer credential does not match the selected server');
1346
+ }
1177
1347
  ensurePrivateDirectory(root);
1348
+ // Written before the profile it belongs to is published or changed.
1349
+ let notificationsProducerChanged = false;
1350
+ if (producer !== null) {
1351
+ const producerPath = join(root, 'notifications-producer.json');
1352
+ if (!existsSync(producerPath) || readFileSync(producerPath, 'utf8') !== producer) { atomicWriteConfig(producerPath, producer); notificationsProducerChanged = true; }
1353
+ }
1178
1354
  if (current && !refresh) {
1179
1355
  assertPrivateRegularFile(credentialPath, 'managed credential');
1180
1356
  if (readFileSync(credentialPath, 'utf8') !== credential) atomicWriteConfig(credentialPath, credential);
1181
- return { configPath, profile: validateHostProfile(current), settings: current.installer };
1357
+ const saved = { ...current, ...profile, credentialPath };
1358
+ if (current.endpoint !== saved.endpoint || current.serverUrl !== saved.serverUrl)
1359
+ atomicWriteConfig(configPath, JSON.stringify(saved, null, 2) + '\n');
1360
+ return { configPath, profile: validateGatewayClientProfile(saved), settings: current.installer, notificationsProducerChanged };
1182
1361
  }
1183
- const settings = { sourcesPath: join(root, 'sources.json'), integrations };
1362
+ const settings = { sourcesPath: join(root, 'sources.json'), integrations, ...(disableFleetAgentsSetup ? { disableFleetAgentsSetup: true } : {}) };
1184
1363
  if (fleetSettings) settings.fleetSettingsPath = join(root, 'fleet-settings.json');
1185
1364
  // Publish the profile last: clients cannot select incomplete imported inputs.
1186
1365
  atomicWriteConfig(settings.sourcesPath, sources);
@@ -1188,20 +1367,41 @@ export function networkEffects(effects) {
1188
1367
  atomicWriteConfig(credentialPath, credential);
1189
1368
  const saved = { ...profile, credentialPath, installer: settings };
1190
1369
  atomicWriteConfig(configPath, JSON.stringify(saved, null, 2) + '\n');
1191
- return { configPath, profile: validateHostProfile(saved), settings };
1370
+ return { configPath, profile: validateHostProfile(saved), settings, notificationsProducerChanged };
1371
+ },
1372
+ async qualifyInstalledGatewayClient(profile) {
1373
+ if (!profile.installer?.integrations?.includes('fleet')) return;
1374
+ let info;
1375
+ try { info = JSON.parse((await effects.run('ours-fleet', ['version', '--json'])).stdout); } catch {}
1376
+ if (!Array.isArray(info?.capabilities) || !info.capabilities.includes('cowork.http-management-v1'))
1377
+ throw new Error('Installed Fleet does not support gateway HTTP management; upgrade Fleet before gateway-enable');
1192
1378
  },
1193
- async acquireClientPackages(configPath, sourcesPath, integrations, { refresh = false } = {}) {
1379
+ async qualifyGatewayClient({ profile, sourcesPath, sources, integrations, refresh = false }) {
1380
+ if (!profile.serverUrl || !integrations.includes('fleet')) return;
1381
+ // Acquire into the retained package cache without publishing a profile,
1382
+ // credential, native command, or integration. Normal acquisition rechecks it.
1383
+ const staging = mkdtempSync(join(home, '.ours-gateway-client-'));
1384
+ try {
1385
+ const stagedSources = join(staging, 'sources.json');
1386
+ writeFileSync(stagedSources, sources ? JSON.stringify(sources, null, 2) + '\n' : readFileSync(sourcesPath), { mode: 0o600 });
1387
+ await effects.acquireClientPackages(join(home, '.ours-client/profile.json'), stagedSources, integrations,
1388
+ { refresh, gatewayServerUrl: profile.serverUrl, qualifyOnly: true });
1389
+ } finally { rmSync(staging, { recursive: true, force: true }); }
1390
+ },
1391
+ async acquireClientPackages(configPath, sourcesPath, integrations, { refresh = false, hostCliOnly = false, gatewayServerUrl, qualifyOnly = false } = {}) {
1194
1392
  const manifest = JSON.parse(readFileSync(sourcesPath, 'utf8'));
1195
- // Public SDK client APIs are actual integration dependencies; Fleet also owns CLI usage.
1196
- const selected = [...new Set(['sdk', ...(integrations.includes('fleet') ? ['cli'] : []), ...integrations])];
1393
+ // Every client installation includes the native CLI and its SDK dependency.
1394
+ const selected = clientPackageNames(integrations);
1197
1395
  const packages = selectSourcePackages(manifest, 'client', selected);
1198
- const selectionKey = refresh ? JSON.stringify([configPath, manifest, integrations]) : configPath;
1396
+ const selectionKey = JSON.stringify([hostCliOnly ? 'host-cli-private-v1' : 'host-cli-v1', configPath, ...(hostCliOnly ? [manifest] : []), ...(refresh ? [manifest, integrations] : [])]);
1199
1397
  const root = join(home, '.ours-client-install', createHash('sha256').update(selectionKey).digest('hex').slice(0, 16));
1200
1398
  const hasGit = Object.values(packages).some(selection => selection.source);
1201
1399
  await effects.run('npm', ['--version']);
1202
1400
  if (hasGit) {
1203
1401
  for (const command of ['python3', 'git', 'make', 'cc']) await effects.run(command, ['--version']);
1204
1402
  }
1403
+ // This directory retains active executables, not disposable download cache.
1404
+ ensurePrivateDirectory(join(home, '.ours-client-install'));
1205
1405
  ensurePrivateDirectory(root);
1206
1406
  const retained = join(root, 'sources.json');
1207
1407
  const bytes = readFileSync(sourcesPath);
@@ -1226,13 +1426,40 @@ export function networkEffects(effects) {
1226
1426
  writePrivateNew(join(root, '.packages-ready'), 'ready\n');
1227
1427
  }
1228
1428
  verifyReleaseGraph(root, manifest, { requiredPackages: Object.keys(packages) });
1429
+ if (integrations.includes('fleet') && (gatewayServerUrl || readHostProfileFile(configPath)?.serverUrl)) {
1430
+ let info;
1431
+ try { info = JSON.parse(readFileSync(join(root, 'node_modules/@ours.network/fleet/dist/build-info.json'), 'utf8')); } catch {}
1432
+ if (!Array.isArray(info?.capabilities) || !info.capabilities.includes('cowork.http-management-v1'))
1433
+ throw new Error('Selected Fleet artifact does not support gateway HTTP management; select a compatible client release');
1434
+ }
1435
+ if (qualifyOnly) return;
1436
+ const cliPolicy = hostCliOnly ? null : hostCliPolicy(manifest);
1229
1437
  // Local acquisition alone does not publish native commands. Use the user's
1230
1438
  // configured npm prefix and retained dependency closure, including on retry.
1231
1439
  for (const name of integrations.filter(name => name === 'fleet' || name === 'codex')) {
1232
1440
  await effects.run('npm', ['install', '--global', '--install-links=false', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', join(root, 'node_modules', '@ours.network', name)]);
1233
1441
  }
1442
+ let cliBin = join(root, 'node_modules/.bin/ours');
1443
+ if (cliPolicy) {
1444
+ const cliSources = join(root, 'host-cli-sources.json');
1445
+ const cliBytes = JSON.stringify(cliPolicy, null, 2) + '\n';
1446
+ if (!existsSync(cliSources)) writePrivateNew(cliSources, cliBytes);
1447
+ else if (readFileSync(cliSources, 'utf8') !== cliBytes) throw new Error('Retained host CLI source selection differs');
1448
+ const acquired = await effects.acquireClientPackages(configPath, cliSources, [], { refresh, hostCliOnly: true });
1449
+ cliBin = acquired.cliBin;
1450
+ } else {
1451
+ // Publish last so optional integration installation cannot downgrade ours.
1452
+ await publishClientCli(effects, join(root, 'node_modules', '@ours.network/cli'), { policy: manifest, isolated: hostCliOnly });
1453
+ }
1234
1454
  const localPackages = Object.fromEntries(integrations.filter(n => n !== 'fleet').map(name => [name, join(root, 'node_modules', '@ours.network', name)]));
1235
- return { localPackages, packages: {}, fleetBin: integrations.includes('fleet') ? join(root, 'node_modules/.bin/ours-fleet') : null };
1455
+ return { localPackages, packages: {}, cliBin, fleetBin: integrations.includes('fleet') ? join(root, 'node_modules/.bin/ours-fleet') : null };
1456
+ },
1457
+ /** Capability tokens the given Fleet executable reports for itself; [] when it reports none. */
1458
+ async fleetCapabilities(fleetBin, env = null) {
1459
+ try {
1460
+ const capabilities = JSON.parse((await effects.run(fleetBin, ['version', '--json'], { env })).stdout).capabilities;
1461
+ return Array.isArray(capabilities) ? capabilities.filter(item => typeof item === 'string') : [];
1462
+ } catch { return []; }
1236
1463
  },
1237
1464
  async prepareClientMarketplace(name, packagePath) {
1238
1465
  const acquisitionRoot = dirname(dirname(dirname(packagePath)));
@@ -1241,7 +1468,7 @@ export function networkEffects(effects) {
1241
1468
  const release = releaseBinding(policy);
1242
1469
  const integrationsPath = join(acquisitionRoot, 'integrations.json');
1243
1470
  const integrations = existsSync(integrationsPath) ? JSON.parse(readFileSync(integrationsPath, 'utf8')) : null;
1244
- const requiredPackages = integrations ? [...new Set(['sdk', ...(integrations.includes('fleet') ? ['cli'] : []), ...integrations])].map(name => '@ours.network/' + name) : Object.keys(policy.packages ?? {});
1471
+ const requiredPackages = integrations ? clientPackageNames(integrations).map(name => '@ours.network/' + name) : Object.keys(policy.packages ?? {});
1245
1472
  verifyReleaseGraph(acquisitionRoot, policy, { requiredPackages });
1246
1473
  const root = join(acquisitionRoot, 'marketplaces', name);
1247
1474
  const plugin = join(root, 'plugins', 'ours');
@@ -1250,7 +1477,7 @@ export function networkEffects(effects) {
1250
1477
  cpSync(packagePath, plugin, { recursive: true });
1251
1478
  const manifestPath = join(plugin, 'package.json');
1252
1479
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
1253
- for (const dependency of release ? [] : ['sdk', 'cli']) {
1480
+ for (const dependency of release ? [] : ['sdk', 'cli', 'mcp']) {
1254
1481
  const name = `@ours.network/${dependency}`;
1255
1482
  if (manifest.dependencies?.[name]) manifest.dependencies[name] = `file:${join(dirname(packagePath), dependency)}`;
1256
1483
  }
@@ -1270,42 +1497,7 @@ export function networkEffects(effects) {
1270
1497
  effects.writeJson(manifestPath, JSON.stringify(value, null, 2) + '\n');
1271
1498
  return root;
1272
1499
  },
1273
- async verifyPackagedMcp(configPath) {
1274
- const profile = typeof configPath === 'string' ? readHostProfileFile(configPath) : validateHostProfile(configPath);
1275
- assertPrivateRegularFile(profile.credentialPath, 'credential');
1276
- const token = readFileSync(profile.credentialPath, 'utf8').trim();
1277
- const headers = { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'x-ours-api-token': token, 'x-ours-session-mode': 'external', 'x-ours-lease-token': randomUUID() };
1278
- const request = async (method, params, id) => {
1279
- const response = await fetch(`${profile.endpoint}/mcp`, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(5000), headers, body: JSON.stringify({ jsonrpc: '2.0', ...(id === undefined ? {} : { id }), method, params }) });
1280
- const session = response.headers.get('mcp-session-id');
1281
- if (session) headers['mcp-session-id'] = session;
1282
- if (!response.ok) throw new Error(`Packaged MCP verification failed: HTTP ${response.status}`);
1283
- if (id === undefined) { await response.body?.cancel(); return; }
1284
- const text = await response.text();
1285
- const messages = response.headers.get('content-type')?.includes('text/event-stream')
1286
- ? text.split('\n').filter(line => line.startsWith('data:')).map(line => JSON.parse(line.slice(5)))
1287
- : [JSON.parse(text)];
1288
- const message = messages.find(value => value.id === id);
1289
- if (!message || message.error || !message.result) throw new Error('Packaged MCP returned no successful protocol result');
1290
- return message.result;
1291
- };
1292
- try {
1293
- const initialized = await request('initialize', { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'ours-install', version: '1' } }, 1);
1294
- if (initialized.serverInfo?.name !== 'ours' || !initialized.protocolVersion) throw new Error('Selected MCP is not the packaged OURS server');
1295
- headers['mcp-protocol-version'] = initialized.protocolVersion;
1296
- await request('notifications/initialized', {}, undefined);
1297
- const resources = await request('resources/list', {}, 2);
1298
- if (!resources.resources?.some(resource => resource.uri === 'ours://application-identities')) throw new Error('Packaged network MCP identity resource is absent');
1299
- const toolList = await request('tools/list', {}, 3);
1300
- if (!toolList.tools?.some(tool => tool.name === 'list_identities')) throw new Error('Packaged OURS identity tools are absent');
1301
- } finally {
1302
- if (headers['mcp-session-id']) {
1303
- const response = await fetch(`${profile.endpoint}/mcp`, { method: 'DELETE', redirect: 'error', signal: AbortSignal.timeout(5000), headers });
1304
- await response.body?.cancel();
1305
- if (!response.ok) throw new Error('MCP verification session could not be closed');
1306
- }
1307
- }
1308
- },
1500
+
1309
1501
  };
1310
1502
  }
1311
1503
 
@@ -1352,7 +1544,6 @@ async function nativeLifecycle(record, operation, selected, { effects, localEnv,
1352
1544
  if (service === 'daemon') {
1353
1545
  const profilePath = join(installationPaths(record).mcp, 'profile.json');
1354
1546
  await effects.verifyHostProfile(profilePath);
1355
- await effects.verifyPackagedMcp(profilePath);
1356
1547
  return;
1357
1548
  }
1358
1549
  if (service === 'cowork') { await ownerCommand(record, service, 'status'); return; }
@@ -1430,7 +1621,7 @@ async function nativeLifecycle(record, operation, selected, { effects, localEnv,
1430
1621
  }
1431
1622
  if (!ready) throw new Error('Application is not ready');
1432
1623
  } catch {
1433
- if (service === 'daemon') throw new Error('Daemon and packaged MCP are not ready; consumers were not started');
1624
+ if (service === 'daemon') throw new Error('Daemon API is not ready; consumers were not started');
1434
1625
  failures.push(service);
1435
1626
  }
1436
1627
  }