@ours.network/install 1.2.1-nightly.1 → 1.2.1-nightly.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/effects.mjs CHANGED
@@ -11,20 +11,23 @@
11
11
  // state-directory guard and the enable/reload. The installer never touches a
12
12
  // unit file or the service manager directly.
13
13
 
14
+ import { publishClientCli } from './client-cli.mjs';
14
15
  import { spawnSync, execFileSync } from 'node:child_process';
15
16
  import { chmodSync, closeSync, constants, cpSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
- import { homedir, userInfo, platform as osPlatform, release as osRelease } from 'node:os';
17
+ import { homedir, userInfo, platform as osPlatform, release as osRelease, arch as osArch } from 'node:os';
17
18
  import { fileURLToPath } from 'node:url';
18
19
  import { randomUUID, createHash } from 'node:crypto';
19
20
  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 { clientPackageNames, maintenanceServices, installationPaths, validateInstallation, consumerServiceState, unitNameForStateDir, launchdLabelForStateDir, messengerServicePlan, selectSourcePackages, resolveSourcePolicy, SERVER_SERVICES } from './plan.mjs';
21
22
  import { validateHostProfile } from './target.mjs';
23
+ import { createServerOnboarding } from './server-onboarding.mjs';
22
24
  import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
23
- import { askYesNo, askLine as askLineOnTty } from './prompt.mjs';
25
+ import { select as selectOnTty, multiselect as multiselectOnTty, askLine as askLineOnTty } from './prompt.mjs';
24
26
  import { classifyHarnessProbe } from './logic.mjs';
27
+ import { qualifyDockerRuntime, refreshDockerPolicyCopy } from './docker-runtime-repair.mjs';
25
28
  import { classifyStateDir } from './detect.mjs';
26
29
  import { BASE_RECORDS, CONTEXT, readBuildRecords, equalBuildRecords, initializeBuildMarker } from '../assets/scripts/maintenance/build-context.mjs';
27
- import { releaseBinding, verifyReleaseGraph, verifyRuntimeRelease } from '../assets/scripts/maintenance/release-graph.mjs';
30
+ import { hostCliPolicy, releaseBinding, verifyReleaseGraph, verifyRuntimeRelease } from '../assets/scripts/maintenance/release-graph.mjs';
28
31
 
29
32
  /** GET http://127.0.0.1:<port>/state-dir — the unauthenticated identity probe. */
30
33
  async function probePort(port, { timeoutMs = 1500 } = {}) {
@@ -333,7 +336,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
333
336
  version,
334
337
  interactive: ttyFd != null,
335
338
  // Preflight reads the machine rather than asking the orchestrator to.
336
- platform: { platform: osPlatform(), release: osRelease() },
339
+ platform: { platform: osPlatform(), release: osRelease(), arch: osArch() },
337
340
  nodeVersion: process.versions.node,
338
341
  exists: (path) => existsSync(path),
339
342
  knownStateDirs: () => knownStateDirsIn(home),
@@ -398,7 +401,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
398
401
  // invocation only and never to the installer's own process: a state
399
402
  // directory selected by one run must not leak into anything the operator
400
403
  // starts afterwards.
401
- run: async (cmd, args, { env: extraEnv = null, stream = false, cwd, sensitive = false, allowCodes = [] } = {}) => {
404
+ run: async (cmd, args, { env: extraEnv = null, stream = false, cwd, sensitive = false, allowCodes = [], timeout } = {}) => {
402
405
  // Always built from this layer's OWN env rather than left to spawnSync's
403
406
  // implicit inheritance, so what a child receives is a property of the
404
407
  // effects object a caller constructed and not of whatever ambient shell
@@ -409,11 +412,17 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
409
412
  const executable = cmd === 'npm' ? npmBin : cmd;
410
413
  const r = spawnSync(executable, args, {
411
414
  cwd,
415
+ timeout,
412
416
  encoding: 'utf8',
413
417
  stdio: [...(stream ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe']), ...(installationLockFd === null ? [] : [installationLockFd])],
414
418
  env: childEnv,
415
419
  });
416
- if (r.error || (r.status !== 0 && !allowCodes.includes(r.status))) {
420
+ if (r.error) {
421
+ const error = new Error(`${executable} could not start (${r.error.code ?? 'launch error'})`, { cause: r.error });
422
+ error.code = r.error.code;
423
+ throw error;
424
+ }
425
+ if (r.status !== 0 && !allowCodes.includes(r.status)) {
417
426
  const detail = sensitive ? '' : (r.stderr || r.stdout || '').trim().split('\n').slice(-3).join('; ');
418
427
  throw new Error(`${executable} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
419
428
  }
@@ -438,7 +447,9 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
438
447
  hasClaudePlugin,
439
448
  out: out ?? ((line) => process.stdout.write(`${line}\n`)),
440
449
  // Never called when assumeYes: the orchestrator takes the default itself.
441
- ask: async (prompt, def = false) => (ttyFd == null ? def : askYesNo(write, ttyFd, ` ${prompt} `, def)),
450
+ select: async (question, choices, def) => selectOnTty(write, ttyFd, question, choices, def),
451
+ multiselect: async (question, choices, defaults = []) => multiselectOnTty(write, ttyFd, question, choices, defaults),
452
+ ask: async (question, def = false) => selectOnTty(write, ttyFd, question, [{ value: true, label: 'Yes' }, { value: false, label: 'No' }], def),
442
453
  askLine: async (prompt, def = '') => (ttyFd == null ? def : askLineOnTty(write, ttyFd, ` ${prompt} `, def)),
443
454
  };
444
455
  return Object.assign(effects, networkEffects(effects));
@@ -533,12 +544,30 @@ export function networkEffects(effects) {
533
544
  OURS_MESSENGER_PORT: String(record.messengerPort ?? 8420),
534
545
  OURS_MESSENGER_IDENTITY: record.messengerIdentity ?? '',
535
546
  });
536
- const compose = (record, args, options = {}) => effects.run('docker', [
547
+ const composeArgs = (record, args) => [
537
548
  'compose', '--project-directory', record.workDir, '--file', join(record.workDir,
538
549
  record.schema === 1 && existsSync(join(record.workDir, 'docker-compose.legacy.yaml'))
539
550
  ? 'docker-compose.legacy.yaml' : 'docker-compose.yaml'),
540
551
  '--project-name', record.project, ...args,
541
- ], { ...options, env: { ...baseEnv(record), ...options.env } });
552
+ ];
553
+ const compose = (record, args, options = {}) => effects.run('docker', composeArgs(record, args),
554
+ { ...options, env: { ...baseEnv(record), ...options.env } });
555
+ const dockerStartupError = async (record, service, cause) => {
556
+ const args = ['logs', '--no-color', '--tail', '50', '--timestamps', service];
557
+ const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
558
+ const command = `OURS_DAEMON_ID=${quote(record.instanceId)} docker ${composeArgs(record, args).map(quote).join(' ')}`;
559
+ let detail;
560
+ try {
561
+ const logs = await compose(record, args);
562
+ // Limit terminal diagnostics; container output must not inject terminal controls.
563
+ detail = (logs.stdout ?? '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
564
+ .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '').trim().slice(-6000);
565
+ detail = detail ? `Recent ${service} logs:\n${detail}` : 'The container produced no readable logs.';
566
+ } catch {
567
+ detail = 'Container logs could not be read.';
568
+ }
569
+ return new Error(`Docker service "${service}" failed to start or become healthy.\n${detail}\nStartup error: ${cause.message}\nInspect logs: ${command}`, { cause });
570
+ };
542
571
  const bin = (record, name) => join(record.workDir, 'node_modules', '.bin', name);
543
572
  const localEnv = (record, service = 'daemon') => {
544
573
  const paths = installationPaths(record);
@@ -551,8 +580,9 @@ export function networkEffects(effects) {
551
580
  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}` };
552
581
  };
553
582
  const ownerCommand = (record, service, op, options = {}) => {
554
- const name = { daemon: 'ours', telegram: 'ours-tg-connector', cowork: 'ours-cowork' }[service];
555
- const args = service === 'daemon' ? ['daemon', op, ...(['install-service', 'uninstall-service'].includes(op) ? ['--yes'] : []), '--config', record.configPath, '--state-dir', installationPaths(record).daemon, '--json'] : [op];
583
+ const daemonBinary = existsSync(bin(record, 'ours-daemon')) ? 'ours-daemon' : 'ours';
584
+ const name = { daemon: daemonBinary, telegram: 'ours-tg-connector', cowork: 'ours-cowork' }[service];
585
+ 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];
556
586
  return effects.run(bin(record, name), args, { ...options, env: localEnv(record, service) });
557
587
  };
558
588
  const requireCleanContainerExit = async (record, selected) => {
@@ -566,6 +596,7 @@ export function networkEffects(effects) {
566
596
  }
567
597
  };
568
598
  return {
599
+ ...createServerOnboarding(effects, { compose, localEnv, bin }),
569
600
  sourcePolicyHash(path) {
570
601
  return createHash('sha256').update(readFileSync(path)).digest('hex');
571
602
  },
@@ -601,7 +632,7 @@ export function networkEffects(effects) {
601
632
  const project = `ours-${createHash('sha256').update(root).digest('hex').slice(0, 16)}`;
602
633
  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 };
603
634
  },
604
- async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest } = {}) {
635
+ async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest, identityName } = {}) {
605
636
  if (existing) {
606
637
  privateDirectory(record.root);
607
638
  assertPrivateRegularFile(join(record.root, 'installation.json'), 'selection');
@@ -609,10 +640,34 @@ export function networkEffects(effects) {
609
640
  if (env.OURS_DAEMON_ID && env.OURS_DAEMON_ID !== record.instanceId) throw new Error('Conflicting instance ID');
610
641
  }
611
642
  if (record.mode === 'docker') {
612
- await effects.run('docker', ['info', '--format', '{{.ServerVersion}}']);
613
- const version = await effects.run('docker', ['compose', 'version', '--short']);
643
+ const nativeRoot = existing ? '/path/to/new-empty-directory' : record.root;
644
+ const quotedRoot = `'${String(nativeRoot).replaceAll("'", "'\\''")}'`;
645
+ const quotedName = `'${String(identityName ?? record.messengerIdentity ?? 'Your Name').replaceAll("'", "'\\''")}'`;
646
+ const recovery = [
647
+ 'Please install Docker Desktop on macOS/Windows, or Docker Engine with the Compose plugin on Linux, and start Docker before retrying.',
648
+ 'Docker is recommended for macOS and Windows.',
649
+ `Alternatively, use native installation: ours-install server install --mode packages --state-dir ${quotedRoot} --identity-name ${quotedName}`,
650
+ 'Native mode requires systemd user services on Linux/WSL or a launchd GUI session on macOS.',
651
+ ...(existing ? ['Keep this existing Docker installation in Docker mode; use a separate empty directory for a new native installation.'] : []),
652
+ ].join('\n');
653
+ try {
654
+ await effects.run('docker', ['info', '--format', '{{.ServerVersion}}']);
655
+ } catch (cause) {
656
+ const problem = cause.code === 'ENOENT'
657
+ ? 'Docker command was not found in PATH.'
658
+ : `Docker Engine is not reachable. Start Docker and check that your user can access it.\nDetails: ${cause.message}`;
659
+ throw new Error(`${problem}\n${recovery}`, { cause });
660
+ }
661
+ let version;
662
+ try {
663
+ version = await effects.run('docker', ['compose', 'version', '--short']);
664
+ } catch (cause) {
665
+ 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 });
666
+ }
614
667
  const match = /^v?(\d+)\.(\d+)/.exec(version.stdout.trim());
615
- if (!match || Number(match[1]) < 2 || (Number(match[1]) === 2 && Number(match[2]) < 35)) throw new Error('Docker Compose 2.35 or newer is required');
668
+ if (!match || Number(match[1]) < 2 || (Number(match[1]) === 2 && Number(match[2]) < 35)) {
669
+ throw new Error(`Docker Compose 2.35 or newer is required. Update Docker Desktop or the Docker Compose plugin.\n${recovery}`);
670
+ }
616
671
  if (operation !== 'status') {
617
672
  // Compose clients can disappear while their Engine-owned command continues.
618
673
  const active = await effects.run('docker', ['ps', '--filter', `label=com.docker.compose.project=${record.project}`, '--filter', 'label=com.docker.compose.oneoff=True', '--format', '{{.ID}}']);
@@ -631,7 +686,17 @@ export function networkEffects(effects) {
631
686
  }
632
687
  }
633
688
  },
634
- async initializeSelection(record, manifest) {
689
+ async prepareLegacyDockerImport(record) {
690
+ if (record.mode === 'docker') await compose(record, ['build', 'legacy-import'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
691
+ },
692
+ async importLegacyDockerState(record) {
693
+ if (record.mode !== 'docker') return;
694
+ const source = join(record.root, 'storage', 'state');
695
+ if (source.includes(':')) throw new Error('Docker migration requires an installation path without colon characters');
696
+ await compose(record, ['run', '--rm', '--no-deps', '-T', '--volume', `${source}:/legacy-import:ro`,
697
+ '--env', `OURS_LEGACY_TARGET_ROOT=${record.root}`, 'legacy-import']);
698
+ },
699
+ async initializeSelection(record, manifest, { retainConfig = false } = {}) {
635
700
  if (typeof manifest === 'string') manifest = JSON.parse(readFileSync(manifest, 'utf8'));
636
701
  selectSourcePackages(manifest, 'server');
637
702
  const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
@@ -639,9 +704,11 @@ export function networkEffects(effects) {
639
704
  if (record.schema === 2) {
640
705
  for (const path of [join(record.root, 'storage'), installationPaths(record).state, installationPaths(record).daemon]) ensurePrivateDirectory(path);
641
706
  }
642
- writePrivateNew(record.sourcesPath, bytes);
643
- writePrivateNew(record.configPath, JSON.stringify({ stateDir: record.mode === 'docker' ? '/var/lib/ours' : installationPaths(record).daemon, port: record.port, apiVisibility: 'owner' }, null, 2) + '\n');
707
+ if (!retainConfig || !existsSync(record.sourcesPath)) writePrivateNew(record.sourcesPath, bytes);
708
+ else if (!readFileSync(record.sourcesPath).equals(bytes)) throw new Error('Migration source selection changed');
709
+ 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');
644
710
  },
711
+ async qualifyDockerRuntime(record) { return qualifyDockerRuntime(record, effects); },
645
712
  async prepareInstallation(record, { runtimeOnly = false } = {}) {
646
713
  let copied = false;
647
714
  if (!existsSync(record.workDir)) {
@@ -655,11 +722,13 @@ export function networkEffects(effects) {
655
722
  if (!existsSync(materialized)) writePrivateNew(materialized, retained);
656
723
  else if (!readFileSync(materialized).equals(retained)) throw new Error('Materialized sources differ from retained selection');
657
724
  if (record.mode === 'docker') {
725
+ refreshDockerPolicyCopy(record);
658
726
  // The installer owns these dependencies in both installation modes.
659
727
  const { dependencies } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
660
728
  writeFileSync(join(record.workDir, 'scripts/maintenance/package.json'), JSON.stringify({ private: true, type: 'module', dependencies }, null, 2) + '\n', { mode: 0o600 });
661
729
  const image = await effects.run('docker', ['image', 'inspect', `${record.project}:runtime`], { allowCodes: [1] });
662
- if (image.code !== 0) await compose(record, ['build', 'daemon']);
730
+ if (image.code !== 0) await compose(record, ['build', 'daemon'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
731
+ await effects.qualifyDockerRuntime(record);
663
732
  if (runtimeOnly) return;
664
733
  await compose(record, ['run', '--rm', '--no-deps', '-T', 'prepare', 'prepare']);
665
734
  } else {
@@ -667,8 +736,8 @@ export function networkEffects(effects) {
667
736
  const sourceRoot = join(record.root, `build-${randomUUID()}`);
668
737
  ensurePrivateDirectory(sourceRoot);
669
738
  try {
670
- await effects.run(process.execPath, [join(record.workDir, 'scripts/build/build.mjs')], { cwd: record.workDir, env: { OURS_BUILD_ROOT: record.workDir, OURS_SOURCE_ROOT: sourceRoot } });
671
- await effects.run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { cwd: record.workDir });
739
+ await effects.run(process.execPath, [join(record.workDir, 'scripts/build/build.mjs')], { stream: true, cwd: record.workDir, env: { OURS_BUILD_ROOT: record.workDir, OURS_SOURCE_ROOT: sourceRoot } });
740
+ await effects.run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { stream: true, cwd: record.workDir });
672
741
  await effects.run(process.execPath, [join(record.workDir, 'scripts/build/record-build.mjs')], { cwd: record.workDir, env: { OURS_BUILD_ROOT: record.workDir } });
673
742
  writePrivateNew(join(record.workDir, '.packages-ready'), 'ready\n');
674
743
  } finally { rmSync(sourceRoot, { recursive: true, force: true }); }
@@ -682,7 +751,6 @@ export function networkEffects(effects) {
682
751
  const profilePath = join(installationPaths(record).mcp, 'profile.json');
683
752
  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') }));
684
753
  const config = JSON.parse(readFileSync(record.configPath, 'utf8'));
685
- 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') };
686
754
  effects.writeJson(record.configPath, JSON.stringify(config, null, 2) + '\n');
687
755
  const cowork = join(installationPaths(record).cowork, 'config.json');
688
756
  if (!existsSync(cowork)) writePrivateNew(cowork, JSON.stringify({ version: 1, stateDir: installationPaths(record).cowork, rest: { enabled: true, host: '127.0.0.1', port: record.coworkPort } }));
@@ -870,9 +938,9 @@ export function networkEffects(effects) {
870
938
  return;
871
939
  }
872
940
  const options = { env: localEnv(record), sensitive: true };
873
- if (operation !== 'access-issue') return effects.run(bin(record, 'ours'), ['config', operation, '--config', record.configPath, ...(operation === 'access-replace' ? ['--confirm'] : migrate ? ['--migrate'] : []), '--json'], options);
941
+ 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);
874
942
  const outputs = output ? [output] : [join(installationPaths(record).daemon, 'daemon-token'), ...['telegram', 'cowork', 'messenger'].map(s => installationPaths(record).credentials[s])];
875
- for (const path of outputs) await effects.run(bin(record, 'ours'), ['config', operation, '--config', record.configPath, '--output', path, ...(output ? [] : ['--replace']), '--json'], options);
943
+ 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);
876
944
  },
877
945
  async recordRuntimeBuild(record) {
878
946
  if (record.mode === 'docker') return; // Image preparation records its build.
@@ -916,7 +984,7 @@ export function networkEffects(effects) {
916
984
  await effects.run(process.execPath, [join(INSTALLER_ASSETS, 'scripts/maintenance/state-operation.mjs'), ...command], {
917
985
  env: { ...localEnv(record), OURS_STATE_DOMAIN: args.domain,
918
986
  OURS_STATE_ROOT: join(record.root, 'storage'), OURS_LIVE_ROOT: ['server', 'daemon'].includes(args.domain) ? paths.state : paths[args.domain],
919
- OURS_BUILD_ROOT: record.workDir, OURS_CLI_PATH: bin(record, 'ours'),
987
+ OURS_BUILD_ROOT: record.workDir, OURS_CLI_PATH: bin(record, existsSync(bin(record, 'ours-daemon')) ? 'ours-daemon' : 'ours'),
920
988
  OURS_DAEMON_CONFIG: record.configPath, OURS_COWORK_CLI_PATH: bin(record, 'ours-cowork'),
921
989
  OURS_COWORK_CONFIG: join(paths.cowork, 'config.json'), OURS_COWORK_STATE_DIR: paths.cowork },
922
990
  });
@@ -1001,7 +1069,7 @@ export function networkEffects(effects) {
1001
1069
  await ownerCommand(record, 'cowork', 'prepare-backup');
1002
1070
  },
1003
1071
  async retainConvertedPackageAuthority(record, daemon) {
1004
- await effects.run(bin(record, 'ours'), [
1072
+ await effects.run(bin(record, existsSync(bin(record, 'ours-daemon')) ? 'ours-daemon' : 'ours'), [
1005
1073
  'config', 'access-retain', '--config', record.configPath,
1006
1074
  '--target-state-dir', daemon, '--json',
1007
1075
  ], { env: localEnv(record), sensitive: true });
@@ -1061,13 +1129,19 @@ export function networkEffects(effects) {
1061
1129
  if ((await effects.serverLifecycle(record, 'status', selected)).length) throw new Error('Writers did not stop');
1062
1130
  return;
1063
1131
  }
1064
- if (selected.includes('daemon')) await compose(record, ['up', '-d', '--no-build', '--no-deps', '--wait', 'daemon']);
1132
+ const start = async service => {
1133
+ effects.out(`Starting ${service}; waiting for readiness...`);
1134
+ try { await compose(record, ['up', '-d', '--no-build', '--no-deps', '--wait', service]); }
1135
+ catch (cause) { throw await dockerStartupError(record, service, cause); }
1136
+ effects.out(`${service} is ready.`);
1137
+ };
1138
+ if (selected.includes('daemon')) await start('daemon');
1065
1139
  const failures = [];
1066
1140
  for (const service of selected.filter(s => s !== 'daemon')) {
1067
- try { await compose(record, ['up', '-d', '--no-build', '--no-deps', '--wait', service]); }
1068
- catch { failures.push(service); }
1141
+ try { await start(service); }
1142
+ catch (error) { failures.push({ service, error }); }
1069
1143
  }
1070
- if (failures.length) throw new Error(`Application readiness failed: ${failures.join(', ')}. Check owning prerequisites; no identities were created.`);
1144
+ 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')}`);
1071
1145
  return;
1072
1146
  }
1073
1147
  return nativeLifecycle(record, operation, selected, { effects, localEnv, ownerCommand, bin });
@@ -1082,15 +1156,15 @@ export function networkEffects(effects) {
1082
1156
  },
1083
1157
  async discoverClientProfile(endpoint, credentialPath) {
1084
1158
  const url = new URL(endpoint);
1085
- if (url.protocol !== 'http:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash)
1086
- throw new Error('Client endpoint must be an HTTP origin');
1159
+ if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password || url.pathname !== '/' || url.search || url.hash)
1160
+ throw new Error('Client endpoint must be an HTTP or HTTPS origin');
1087
1161
  const response = await fetch(`${url.origin}/selection`, { redirect: 'error', signal: AbortSignal.timeout(5000) });
1088
1162
  if (!response.ok) throw new Error(`Daemon selection answered HTTP ${response.status}`);
1089
1163
  const selection = await response.json();
1090
1164
  // Full metadata and authenticated capability validation follows before publication.
1091
1165
  return validateHostProfile({ endpoint: url.origin, expectedInstanceId: selection.instanceId, credentialPath: resolve(credentialPath) });
1092
1166
  },
1093
- importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath }) {
1167
+ importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh = false }) {
1094
1168
  const root = join(home, '.ours-client');
1095
1169
  const configPath = join(root, 'profile.json');
1096
1170
  const credentialPath = join(root, 'credential');
@@ -1101,13 +1175,13 @@ export function networkEffects(effects) {
1101
1175
  const credential = readFileSync(profile.credentialPath, 'utf8');
1102
1176
  if (!credential.trim()) throw new Error('Client credential is empty');
1103
1177
  // Read every supplied input before any publication. Existing setup settings win on retry.
1104
- const sources = current ? null : resolvedSources
1178
+ const sources = current && !refresh ? null : resolvedSources
1105
1179
  ? Buffer.from(`${JSON.stringify(resolvedSources, null, 2)}\n`)
1106
1180
  : readFileSync(sourcesPath);
1107
- const fleetSettings = !current && fleetSettingsPath ? readFileSync(fleetSettingsPath) : null;
1181
+ const fleetSettings = (!current || refresh) && fleetSettingsPath ? readFileSync(fleetSettingsPath) : null;
1108
1182
  if (fleetSettings) JSON.parse(fleetSettings.toString());
1109
1183
  ensurePrivateDirectory(root);
1110
- if (current) {
1184
+ if (current && !refresh) {
1111
1185
  assertPrivateRegularFile(credentialPath, 'managed credential');
1112
1186
  if (readFileSync(credentialPath, 'utf8') !== credential) atomicWriteConfig(credentialPath, credential);
1113
1187
  return { configPath, profile: validateHostProfile(current), settings: current.installer };
@@ -1122,17 +1196,20 @@ export function networkEffects(effects) {
1122
1196
  atomicWriteConfig(configPath, JSON.stringify(saved, null, 2) + '\n');
1123
1197
  return { configPath, profile: validateHostProfile(saved), settings };
1124
1198
  },
1125
- async acquireClientPackages(configPath, sourcesPath, integrations) {
1199
+ async acquireClientPackages(configPath, sourcesPath, integrations, { refresh = false, hostCliOnly = false } = {}) {
1126
1200
  const manifest = JSON.parse(readFileSync(sourcesPath, 'utf8'));
1127
- // Public SDK client APIs are actual integration dependencies; Fleet also owns CLI usage.
1128
- const selected = [...new Set(['sdk', ...(integrations.includes('fleet') ? ['cli'] : []), ...integrations])];
1201
+ // Every client installation includes the native CLI and its SDK dependency.
1202
+ const selected = clientPackageNames(integrations);
1129
1203
  const packages = selectSourcePackages(manifest, 'client', selected);
1130
- const root = join(home, '.ours-client-install', createHash('sha256').update(configPath).digest('hex').slice(0, 16));
1204
+ const selectionKey = JSON.stringify([hostCliOnly ? 'host-cli-private-v1' : 'host-cli-v1', configPath, ...(hostCliOnly ? [manifest] : []), ...(refresh ? [manifest, integrations] : [])]);
1205
+ const root = join(home, '.ours-client-install', createHash('sha256').update(selectionKey).digest('hex').slice(0, 16));
1131
1206
  const hasGit = Object.values(packages).some(selection => selection.source);
1132
1207
  await effects.run('npm', ['--version']);
1133
1208
  if (hasGit) {
1134
1209
  for (const command of ['python3', 'git', 'make', 'cc']) await effects.run(command, ['--version']);
1135
1210
  }
1211
+ // This directory retains active executables, not disposable download cache.
1212
+ ensurePrivateDirectory(join(home, '.ours-client-install'));
1136
1213
  ensurePrivateDirectory(root);
1137
1214
  const retained = join(root, 'sources.json');
1138
1215
  const bytes = readFileSync(sourcesPath);
@@ -1147,23 +1224,36 @@ export function networkEffects(effects) {
1147
1224
  const sourceRoot = join(root, `build-${randomUUID()}`);
1148
1225
  ensurePrivateDirectory(sourceRoot);
1149
1226
  try {
1150
- await effects.run(process.execPath, [join(INSTALLER_ASSETS, 'scripts/build/build.mjs')], { cwd: root, env: { OURS_BUILD_ROOT: root, OURS_SOURCE_ROOT: sourceRoot, OURS_BUILD_PACKAGES: selected.join(',') } });
1227
+ await effects.run(process.execPath, [join(INSTALLER_ASSETS, 'scripts/build/build.mjs')], { stream: true, cwd: root, env: { OURS_BUILD_ROOT: root, OURS_SOURCE_ROOT: sourceRoot, OURS_BUILD_PACKAGES: selected.join(',') } });
1151
1228
  } finally { rmSync(sourceRoot, { recursive: true, force: true }); }
1152
1229
  } else {
1153
1230
  writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'ours-native-clients', private: true, dependencies: Object.fromEntries(Object.entries(packages).map(([name, selection]) => [name, selection.version])) }), { mode: 0o600 });
1154
1231
  }
1155
- await effects.run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { cwd: root });
1232
+ await effects.run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { stream: true, cwd: root });
1156
1233
  verifyReleaseGraph(root, manifest, { requiredPackages: Object.keys(packages) });
1157
1234
  writePrivateNew(join(root, '.packages-ready'), 'ready\n');
1158
1235
  }
1159
1236
  verifyReleaseGraph(root, manifest, { requiredPackages: Object.keys(packages) });
1237
+ const cliPolicy = hostCliOnly ? null : hostCliPolicy(manifest);
1160
1238
  // Local acquisition alone does not publish native commands. Use the user's
1161
1239
  // configured npm prefix and retained dependency closure, including on retry.
1162
1240
  for (const name of integrations.filter(name => name === 'fleet' || name === 'codex')) {
1163
1241
  await effects.run('npm', ['install', '--global', '--install-links=false', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', join(root, 'node_modules', '@ours.network', name)]);
1164
1242
  }
1243
+ let cliBin = join(root, 'node_modules/.bin/ours');
1244
+ if (cliPolicy) {
1245
+ const cliSources = join(root, 'host-cli-sources.json');
1246
+ const cliBytes = JSON.stringify(cliPolicy, null, 2) + '\n';
1247
+ if (!existsSync(cliSources)) writePrivateNew(cliSources, cliBytes);
1248
+ else if (readFileSync(cliSources, 'utf8') !== cliBytes) throw new Error('Retained host CLI source selection differs');
1249
+ const acquired = await effects.acquireClientPackages(configPath, cliSources, [], { refresh, hostCliOnly: true });
1250
+ cliBin = acquired.cliBin;
1251
+ } else {
1252
+ // Publish last so optional integration installation cannot downgrade ours.
1253
+ await publishClientCli(effects, join(root, 'node_modules', '@ours.network/cli'), { policy: manifest, isolated: hostCliOnly });
1254
+ }
1165
1255
  const localPackages = Object.fromEntries(integrations.filter(n => n !== 'fleet').map(name => [name, join(root, 'node_modules', '@ours.network', name)]));
1166
- return { localPackages, packages: {}, fleetBin: integrations.includes('fleet') ? join(root, 'node_modules/.bin/ours-fleet') : null };
1256
+ return { localPackages, packages: {}, cliBin, fleetBin: integrations.includes('fleet') ? join(root, 'node_modules/.bin/ours-fleet') : null };
1167
1257
  },
1168
1258
  async prepareClientMarketplace(name, packagePath) {
1169
1259
  const acquisitionRoot = dirname(dirname(dirname(packagePath)));
@@ -1172,7 +1262,7 @@ export function networkEffects(effects) {
1172
1262
  const release = releaseBinding(policy);
1173
1263
  const integrationsPath = join(acquisitionRoot, 'integrations.json');
1174
1264
  const integrations = existsSync(integrationsPath) ? JSON.parse(readFileSync(integrationsPath, 'utf8')) : null;
1175
- const requiredPackages = integrations ? [...new Set(['sdk', ...(integrations.includes('fleet') ? ['cli'] : []), ...integrations])].map(name => '@ours.network/' + name) : Object.keys(policy.packages ?? {});
1265
+ const requiredPackages = integrations ? clientPackageNames(integrations).map(name => '@ours.network/' + name) : Object.keys(policy.packages ?? {});
1176
1266
  verifyReleaseGraph(acquisitionRoot, policy, { requiredPackages });
1177
1267
  const root = join(acquisitionRoot, 'marketplaces', name);
1178
1268
  const plugin = join(root, 'plugins', 'ours');
@@ -1181,7 +1271,7 @@ export function networkEffects(effects) {
1181
1271
  cpSync(packagePath, plugin, { recursive: true });
1182
1272
  const manifestPath = join(plugin, 'package.json');
1183
1273
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
1184
- for (const dependency of release ? [] : ['sdk', 'cli']) {
1274
+ for (const dependency of release ? [] : ['sdk', 'cli', 'mcp']) {
1185
1275
  const name = `@ours.network/${dependency}`;
1186
1276
  if (manifest.dependencies?.[name]) manifest.dependencies[name] = `file:${join(dirname(packagePath), dependency)}`;
1187
1277
  }
@@ -1201,42 +1291,7 @@ export function networkEffects(effects) {
1201
1291
  effects.writeJson(manifestPath, JSON.stringify(value, null, 2) + '\n');
1202
1292
  return root;
1203
1293
  },
1204
- async verifyPackagedMcp(configPath) {
1205
- const profile = typeof configPath === 'string' ? readHostProfileFile(configPath) : validateHostProfile(configPath);
1206
- assertPrivateRegularFile(profile.credentialPath, 'credential');
1207
- const token = readFileSync(profile.credentialPath, 'utf8').trim();
1208
- 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() };
1209
- const request = async (method, params, id) => {
1210
- 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 }) });
1211
- const session = response.headers.get('mcp-session-id');
1212
- if (session) headers['mcp-session-id'] = session;
1213
- if (!response.ok) throw new Error(`Packaged MCP verification failed: HTTP ${response.status}`);
1214
- if (id === undefined) { await response.body?.cancel(); return; }
1215
- const text = await response.text();
1216
- const messages = response.headers.get('content-type')?.includes('text/event-stream')
1217
- ? text.split('\n').filter(line => line.startsWith('data:')).map(line => JSON.parse(line.slice(5)))
1218
- : [JSON.parse(text)];
1219
- const message = messages.find(value => value.id === id);
1220
- if (!message || message.error || !message.result) throw new Error('Packaged MCP returned no successful protocol result');
1221
- return message.result;
1222
- };
1223
- try {
1224
- const initialized = await request('initialize', { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'ours-install', version: '1' } }, 1);
1225
- if (initialized.serverInfo?.name !== 'ours' || !initialized.protocolVersion) throw new Error('Selected MCP is not the packaged OURS server');
1226
- headers['mcp-protocol-version'] = initialized.protocolVersion;
1227
- await request('notifications/initialized', {}, undefined);
1228
- const resources = await request('resources/list', {}, 2);
1229
- if (!resources.resources?.some(resource => resource.uri === 'ours://application-identities')) throw new Error('Packaged network MCP identity resource is absent');
1230
- const toolList = await request('tools/list', {}, 3);
1231
- if (!toolList.tools?.some(tool => tool.name === 'list_identities')) throw new Error('Packaged OURS identity tools are absent');
1232
- } finally {
1233
- if (headers['mcp-session-id']) {
1234
- const response = await fetch(`${profile.endpoint}/mcp`, { method: 'DELETE', redirect: 'error', signal: AbortSignal.timeout(5000), headers });
1235
- await response.body?.cancel();
1236
- if (!response.ok) throw new Error('MCP verification session could not be closed');
1237
- }
1238
- }
1239
- },
1294
+
1240
1295
  };
1241
1296
  }
1242
1297
 
@@ -1283,7 +1338,6 @@ async function nativeLifecycle(record, operation, selected, { effects, localEnv,
1283
1338
  if (service === 'daemon') {
1284
1339
  const profilePath = join(installationPaths(record).mcp, 'profile.json');
1285
1340
  await effects.verifyHostProfile(profilePath);
1286
- await effects.verifyPackagedMcp(profilePath);
1287
1341
  return;
1288
1342
  }
1289
1343
  if (service === 'cowork') { await ownerCommand(record, service, 'status'); return; }
@@ -1361,9 +1415,9 @@ async function nativeLifecycle(record, operation, selected, { effects, localEnv,
1361
1415
  }
1362
1416
  if (!ready) throw new Error('Application is not ready');
1363
1417
  } catch {
1364
- if (service === 'daemon') throw new Error('Daemon and packaged MCP are not ready; consumers were not started');
1418
+ if (service === 'daemon') throw new Error('Daemon API is not ready; consumers were not started');
1365
1419
  failures.push(service);
1366
1420
  }
1367
1421
  }
1368
- if (failures.length) throw new Error(`Application readiness failed: ${failures.join(', ')}. Check owning prerequisites; no identities were created.`);
1422
+ if (failures.length) throw new Error(`Application readiness failed: ${failures.join(', ')}. Check the selected application prerequisites.`);
1369
1423
  }
@@ -0,0 +1,43 @@
1
+ // Structural preflight adapted from @ours.network/fleet src/init-wizard.ts
2
+ // (readInitSettings/generateSetup), shipped in Fleet 1.2.0-nightly.1.
3
+ // This checks complete answers before installer effects, without duplicating
4
+ // Fleet's evolving model catalog. Fleet init remains authoritative for model
5
+ // membership, exact capability arrays, and its packaged preset validation.
6
+ const WORKS = ['development', 'review', 'coordination'];
7
+ const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
8
+ const REASONING = { quick: 'low', balanced: 'medium', thorough: 'high' };
9
+ const fail = message => { throw new Error(`Fleet settings ${message}`); };
10
+ function exactKeys(value, keys, label) {
11
+ if (!value || typeof value !== 'object' || Array.isArray(value)
12
+ || Object.keys(value).sort().join('\0') !== [...keys].sort().join('\0'))
13
+ fail(`${label} must contain exactly ${keys.join(', ')}`);
14
+ }
15
+ function uniqueSelection(value, choices, label) {
16
+ if (!Array.isArray(value) || value.length === 0 || new Set(value).size !== value.length
17
+ || value.some(item => !choices.includes(item)))
18
+ fail(`${label} must be a nonempty unique selection of ${choices.join(', ')}`);
19
+ }
20
+
21
+ export function validateFleetSettings(value) {
22
+ exactKeys(value, ['subscriptions', 'assignmentStrategy', 'models', 'reasoning'], 'answers');
23
+ uniqueSelection(value.subscriptions, ['codex', 'claude'], 'subscriptions');
24
+ if (!['one-model', 'per-job'].includes(value.assignmentStrategy)) fail('assignmentStrategy must be one-model or per-job');
25
+ if (typeof value.reasoning !== 'string' || !Object.hasOwn(REASONING, value.reasoning)) fail('reasoning must be quick, balanced, or thorough');
26
+ exactKeys(value.models, WORKS, 'models');
27
+ const tuples = new Set();
28
+ for (const work of WORKS) {
29
+ const model = value.models[work];
30
+ exactKeys(model, ['harness', 'session', 'model', 'efforts'], `${work} model`);
31
+ if (!['codex', 'claude-code'].includes(model.harness)) fail(`${work} harness must be codex or claude-code`);
32
+ if (model.session !== 'acp') fail(`${work} session must be acp`);
33
+ if (typeof model.model !== 'string' || !model.model.trim()) fail(`${work} model must be a nonempty string`);
34
+ uniqueSelection(model.efforts, EFFORTS, `${work} efforts`);
35
+ if (!model.efforts.includes(REASONING[value.reasoning])) fail(`${work} efforts do not support the selected reasoning`);
36
+ if (!value.subscriptions.includes(model.harness === 'codex' ? 'codex' : 'claude'))
37
+ fail(`${work} uses a harness outside the selected subscriptions`);
38
+ tuples.add(JSON.stringify([model.harness, model.session, model.model]));
39
+ }
40
+ if (value.assignmentStrategy === 'one-model' && tuples.size !== 1)
41
+ fail('one-model assignment requires the same model for development, review, and coordination');
42
+ return value;
43
+ }