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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +151 -131
  2. package/assets/Dockerfile +6 -3
  3. package/assets/docker-compose.yaml +21 -1
  4. package/assets/release-lock.json +7161 -0
  5. package/assets/release.json +57 -0
  6. package/assets/scripts/build/build-common.mjs +1 -1
  7. package/assets/scripts/build/build-sdk.mjs +6 -1
  8. package/assets/scripts/build/record-build.mjs +2 -0
  9. package/assets/scripts/maintenance/build-context.mjs +1 -1
  10. package/assets/scripts/maintenance/daemon-owner.mjs +15 -0
  11. package/assets/scripts/maintenance/docker-layout-conversion.mjs +3 -1
  12. package/assets/scripts/maintenance/release-graph.mjs +111 -0
  13. package/assets/scripts/maintenance/state-operation.mjs +4 -2
  14. package/assets/scripts/runtime/client-setup.mjs +3 -3
  15. package/assets/scripts/runtime/entrypoint.sh +1 -1
  16. package/assets/scripts/runtime/legacy-import.mjs +103 -0
  17. package/assets/scripts/runtime/runtime-common.mjs +2 -0
  18. package/assets/sources.json +96 -16
  19. package/install.mjs +2 -2
  20. package/install.sh +1 -1
  21. package/lib/build-transition.mjs +13 -7
  22. package/lib/client-cli.mjs +117 -0
  23. package/lib/docker-conversion-runtime.mjs +1 -0
  24. package/lib/docker-runtime-repair.mjs +125 -0
  25. package/lib/effects.mjs +163 -85
  26. package/lib/fleet-settings.mjs +43 -0
  27. package/lib/legacy-migration.mjs +202 -0
  28. package/lib/legacy-state.mjs +205 -0
  29. package/lib/managed-cli.mjs +164 -0
  30. package/lib/orchestrate.mjs +162 -58
  31. package/lib/plan.mjs +13 -7
  32. package/lib/prompt.mjs +113 -119
  33. package/lib/server-onboarding.mjs +114 -0
  34. package/lib/setup-options.mjs +253 -0
  35. package/lib/setup.mjs +154 -0
  36. package/lib/target.mjs +4 -4
  37. package/lib/uninstall.mjs +2 -2
  38. package/lib/usage.mjs +49 -44
  39. package/package.json +5 -4
package/lib/effects.mjs CHANGED
@@ -11,19 +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';
30
+ import { hostCliPolicy, releaseBinding, verifyReleaseGraph, verifyRuntimeRelease } from '../assets/scripts/maintenance/release-graph.mjs';
27
31
 
28
32
  /** GET http://127.0.0.1:<port>/state-dir — the unauthenticated identity probe. */
29
33
  async function probePort(port, { timeoutMs = 1500 } = {}) {
@@ -332,7 +336,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
332
336
  version,
333
337
  interactive: ttyFd != null,
334
338
  // Preflight reads the machine rather than asking the orchestrator to.
335
- platform: { platform: osPlatform(), release: osRelease() },
339
+ platform: { platform: osPlatform(), release: osRelease(), arch: osArch() },
336
340
  nodeVersion: process.versions.node,
337
341
  exists: (path) => existsSync(path),
338
342
  knownStateDirs: () => knownStateDirsIn(home),
@@ -397,7 +401,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
397
401
  // invocation only and never to the installer's own process: a state
398
402
  // directory selected by one run must not leak into anything the operator
399
403
  // starts afterwards.
400
- 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 } = {}) => {
401
405
  // Always built from this layer's OWN env rather than left to spawnSync's
402
406
  // implicit inheritance, so what a child receives is a property of the
403
407
  // effects object a caller constructed and not of whatever ambient shell
@@ -408,11 +412,17 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
408
412
  const executable = cmd === 'npm' ? npmBin : cmd;
409
413
  const r = spawnSync(executable, args, {
410
414
  cwd,
415
+ timeout,
411
416
  encoding: 'utf8',
412
417
  stdio: [...(stream ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe']), ...(installationLockFd === null ? [] : [installationLockFd])],
413
418
  env: childEnv,
414
419
  });
415
- 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)) {
416
426
  const detail = sensitive ? '' : (r.stderr || r.stdout || '').trim().split('\n').slice(-3).join('; ');
417
427
  throw new Error(`${executable} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
418
428
  }
@@ -437,7 +447,9 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
437
447
  hasClaudePlugin,
438
448
  out: out ?? ((line) => process.stdout.write(`${line}\n`)),
439
449
  // Never called when assumeYes: the orchestrator takes the default itself.
440
- 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),
441
453
  askLine: async (prompt, def = '') => (ttyFd == null ? def : askLineOnTty(write, ttyFd, ` ${prompt} `, def)),
442
454
  };
443
455
  return Object.assign(effects, networkEffects(effects));
@@ -532,12 +544,30 @@ export function networkEffects(effects) {
532
544
  OURS_MESSENGER_PORT: String(record.messengerPort ?? 8420),
533
545
  OURS_MESSENGER_IDENTITY: record.messengerIdentity ?? '',
534
546
  });
535
- const compose = (record, args, options = {}) => effects.run('docker', [
547
+ const composeArgs = (record, args) => [
536
548
  'compose', '--project-directory', record.workDir, '--file', join(record.workDir,
537
549
  record.schema === 1 && existsSync(join(record.workDir, 'docker-compose.legacy.yaml'))
538
550
  ? 'docker-compose.legacy.yaml' : 'docker-compose.yaml'),
539
551
  '--project-name', record.project, ...args,
540
- ], { ...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
+ };
541
571
  const bin = (record, name) => join(record.workDir, 'node_modules', '.bin', name);
542
572
  const localEnv = (record, service = 'daemon') => {
543
573
  const paths = installationPaths(record);
@@ -550,8 +580,9 @@ export function networkEffects(effects) {
550
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}` };
551
581
  };
552
582
  const ownerCommand = (record, service, op, options = {}) => {
553
- const name = { daemon: 'ours', telegram: 'ours-tg-connector', cowork: 'ours-cowork' }[service];
554
- 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];
555
586
  return effects.run(bin(record, name), args, { ...options, env: localEnv(record, service) });
556
587
  };
557
588
  const requireCleanContainerExit = async (record, selected) => {
@@ -565,11 +596,20 @@ export function networkEffects(effects) {
565
596
  }
566
597
  };
567
598
  return {
599
+ ...createServerOnboarding(effects, { compose, localEnv, bin }),
568
600
  sourcePolicyHash(path) {
569
601
  return createHash('sha256').update(readFileSync(path)).digest('hex');
570
602
  },
571
603
  packagedSourcePolicy() {
572
- return JSON.parse(readFileSync(PACKAGED_SOURCE_POLICY, 'utf8'));
604
+ const policy = JSON.parse(readFileSync(PACKAGED_SOURCE_POLICY, 'utf8'));
605
+ const release = releaseBinding(policy);
606
+ if (release) {
607
+ const embedded = JSON.parse(readFileSync(join(INSTALLER_ASSETS, 'release.json'), 'utf8'));
608
+ if (JSON.stringify(release) !== JSON.stringify(embedded)) throw new Error('Packaged source policy differs from immutable release');
609
+ } else if (Object.values(policy.packages ?? {}).some(p => p.type === 'npm')) {
610
+ throw new Error('Packaged npm source policy is missing its release binding');
611
+ }
612
+ return policy;
573
613
  },
574
614
  async resolveSourcePolicy(policy, role, clients = []) {
575
615
  return resolveSourcePolicy(policy, role, clients, async (name, range) => {
@@ -592,7 +632,7 @@ export function networkEffects(effects) {
592
632
  const project = `ours-${createHash('sha256').update(root).digest('hex').slice(0, 16)}`;
593
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 };
594
634
  },
595
- async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest } = {}) {
635
+ async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest, identityName } = {}) {
596
636
  if (existing) {
597
637
  privateDirectory(record.root);
598
638
  assertPrivateRegularFile(join(record.root, 'installation.json'), 'selection');
@@ -600,10 +640,34 @@ export function networkEffects(effects) {
600
640
  if (env.OURS_DAEMON_ID && env.OURS_DAEMON_ID !== record.instanceId) throw new Error('Conflicting instance ID');
601
641
  }
602
642
  if (record.mode === 'docker') {
603
- await effects.run('docker', ['info', '--format', '{{.ServerVersion}}']);
604
- 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
+ }
605
667
  const match = /^v?(\d+)\.(\d+)/.exec(version.stdout.trim());
606
- 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
+ }
607
671
  if (operation !== 'status') {
608
672
  // Compose clients can disappear while their Engine-owned command continues.
609
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}}']);
@@ -622,7 +686,17 @@ export function networkEffects(effects) {
622
686
  }
623
687
  }
624
688
  },
625
- 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 } = {}) {
626
700
  if (typeof manifest === 'string') manifest = JSON.parse(readFileSync(manifest, 'utf8'));
627
701
  selectSourcePackages(manifest, 'server');
628
702
  const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
@@ -630,9 +704,11 @@ export function networkEffects(effects) {
630
704
  if (record.schema === 2) {
631
705
  for (const path of [join(record.root, 'storage'), installationPaths(record).state, installationPaths(record).daemon]) ensurePrivateDirectory(path);
632
706
  }
633
- writePrivateNew(record.sourcesPath, bytes);
634
- 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');
635
710
  },
711
+ async qualifyDockerRuntime(record) { return qualifyDockerRuntime(record, effects); },
636
712
  async prepareInstallation(record, { runtimeOnly = false } = {}) {
637
713
  let copied = false;
638
714
  if (!existsSync(record.workDir)) {
@@ -646,11 +722,13 @@ export function networkEffects(effects) {
646
722
  if (!existsSync(materialized)) writePrivateNew(materialized, retained);
647
723
  else if (!readFileSync(materialized).equals(retained)) throw new Error('Materialized sources differ from retained selection');
648
724
  if (record.mode === 'docker') {
725
+ refreshDockerPolicyCopy(record);
649
726
  // The installer owns these dependencies in both installation modes.
650
727
  const { dependencies } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
651
728
  writeFileSync(join(record.workDir, 'scripts/maintenance/package.json'), JSON.stringify({ private: true, type: 'module', dependencies }, null, 2) + '\n', { mode: 0o600 });
652
729
  const image = await effects.run('docker', ['image', 'inspect', `${record.project}:runtime`], { allowCodes: [1] });
653
- 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);
654
732
  if (runtimeOnly) return;
655
733
  await compose(record, ['run', '--rm', '--no-deps', '-T', 'prepare', 'prepare']);
656
734
  } else {
@@ -658,8 +736,8 @@ export function networkEffects(effects) {
658
736
  const sourceRoot = join(record.root, `build-${randomUUID()}`);
659
737
  ensurePrivateDirectory(sourceRoot);
660
738
  try {
661
- 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 } });
662
- 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 });
663
741
  await effects.run(process.execPath, [join(record.workDir, 'scripts/build/record-build.mjs')], { cwd: record.workDir, env: { OURS_BUILD_ROOT: record.workDir } });
664
742
  writePrivateNew(join(record.workDir, '.packages-ready'), 'ready\n');
665
743
  } finally { rmSync(sourceRoot, { recursive: true, force: true }); }
@@ -673,7 +751,6 @@ export function networkEffects(effects) {
673
751
  const profilePath = join(installationPaths(record).mcp, 'profile.json');
674
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') }));
675
753
  const config = JSON.parse(readFileSync(record.configPath, 'utf8'));
676
- 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') };
677
754
  effects.writeJson(record.configPath, JSON.stringify(config, null, 2) + '\n');
678
755
  const cowork = join(installationPaths(record).cowork, 'config.json');
679
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 } }));
@@ -861,12 +938,13 @@ export function networkEffects(effects) {
861
938
  return;
862
939
  }
863
940
  const options = { env: localEnv(record), sensitive: true };
864
- 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);
865
942
  const outputs = output ? [output] : [join(installationPaths(record).daemon, 'daemon-token'), ...['telegram', 'cowork', 'messenger'].map(s => installationPaths(record).credentials[s])];
866
- 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);
867
944
  },
868
945
  async recordRuntimeBuild(record) {
869
946
  if (record.mode === 'docker') return; // Image preparation records its build.
947
+ verifyRuntimeRelease(record.workDir);
870
948
  const tree = join(record.workDir, 'dependency-tree.json');
871
949
  if (!existsSync(tree)) {
872
950
  const result = await effects.run('npm', ['ls', '--omit=dev', '--all', '--json'], { cwd: record.workDir });
@@ -906,7 +984,7 @@ export function networkEffects(effects) {
906
984
  await effects.run(process.execPath, [join(INSTALLER_ASSETS, 'scripts/maintenance/state-operation.mjs'), ...command], {
907
985
  env: { ...localEnv(record), OURS_STATE_DOMAIN: args.domain,
908
986
  OURS_STATE_ROOT: join(record.root, 'storage'), OURS_LIVE_ROOT: ['server', 'daemon'].includes(args.domain) ? paths.state : paths[args.domain],
909
- 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'),
910
988
  OURS_DAEMON_CONFIG: record.configPath, OURS_COWORK_CLI_PATH: bin(record, 'ours-cowork'),
911
989
  OURS_COWORK_CONFIG: join(paths.cowork, 'config.json'), OURS_COWORK_STATE_DIR: paths.cowork },
912
990
  });
@@ -991,7 +1069,7 @@ export function networkEffects(effects) {
991
1069
  await ownerCommand(record, 'cowork', 'prepare-backup');
992
1070
  },
993
1071
  async retainConvertedPackageAuthority(record, daemon) {
994
- await effects.run(bin(record, 'ours'), [
1072
+ await effects.run(bin(record, existsSync(bin(record, 'ours-daemon')) ? 'ours-daemon' : 'ours'), [
995
1073
  'config', 'access-retain', '--config', record.configPath,
996
1074
  '--target-state-dir', daemon, '--json',
997
1075
  ], { env: localEnv(record), sensitive: true });
@@ -1051,13 +1129,19 @@ export function networkEffects(effects) {
1051
1129
  if ((await effects.serverLifecycle(record, 'status', selected)).length) throw new Error('Writers did not stop');
1052
1130
  return;
1053
1131
  }
1054
- 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');
1055
1139
  const failures = [];
1056
1140
  for (const service of selected.filter(s => s !== 'daemon')) {
1057
- try { await compose(record, ['up', '-d', '--no-build', '--no-deps', '--wait', service]); }
1058
- catch { failures.push(service); }
1141
+ try { await start(service); }
1142
+ catch (error) { failures.push({ service, error }); }
1059
1143
  }
1060
- 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')}`);
1061
1145
  return;
1062
1146
  }
1063
1147
  return nativeLifecycle(record, operation, selected, { effects, localEnv, ownerCommand, bin });
@@ -1072,15 +1156,15 @@ export function networkEffects(effects) {
1072
1156
  },
1073
1157
  async discoverClientProfile(endpoint, credentialPath) {
1074
1158
  const url = new URL(endpoint);
1075
- if (url.protocol !== 'http:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash)
1076
- 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');
1077
1161
  const response = await fetch(`${url.origin}/selection`, { redirect: 'error', signal: AbortSignal.timeout(5000) });
1078
1162
  if (!response.ok) throw new Error(`Daemon selection answered HTTP ${response.status}`);
1079
1163
  const selection = await response.json();
1080
1164
  // Full metadata and authenticated capability validation follows before publication.
1081
1165
  return validateHostProfile({ endpoint: url.origin, expectedInstanceId: selection.instanceId, credentialPath: resolve(credentialPath) });
1082
1166
  },
1083
- importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath }) {
1167
+ importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh = false }) {
1084
1168
  const root = join(home, '.ours-client');
1085
1169
  const configPath = join(root, 'profile.json');
1086
1170
  const credentialPath = join(root, 'credential');
@@ -1091,13 +1175,13 @@ export function networkEffects(effects) {
1091
1175
  const credential = readFileSync(profile.credentialPath, 'utf8');
1092
1176
  if (!credential.trim()) throw new Error('Client credential is empty');
1093
1177
  // Read every supplied input before any publication. Existing setup settings win on retry.
1094
- const sources = current ? null : resolvedSources
1178
+ const sources = current && !refresh ? null : resolvedSources
1095
1179
  ? Buffer.from(`${JSON.stringify(resolvedSources, null, 2)}\n`)
1096
1180
  : readFileSync(sourcesPath);
1097
- const fleetSettings = !current && fleetSettingsPath ? readFileSync(fleetSettingsPath) : null;
1181
+ const fleetSettings = (!current || refresh) && fleetSettingsPath ? readFileSync(fleetSettingsPath) : null;
1098
1182
  if (fleetSettings) JSON.parse(fleetSettings.toString());
1099
1183
  ensurePrivateDirectory(root);
1100
- if (current) {
1184
+ if (current && !refresh) {
1101
1185
  assertPrivateRegularFile(credentialPath, 'managed credential');
1102
1186
  if (readFileSync(credentialPath, 'utf8') !== credential) atomicWriteConfig(credentialPath, credential);
1103
1187
  return { configPath, profile: validateHostProfile(current), settings: current.installer };
@@ -1112,17 +1196,20 @@ export function networkEffects(effects) {
1112
1196
  atomicWriteConfig(configPath, JSON.stringify(saved, null, 2) + '\n');
1113
1197
  return { configPath, profile: validateHostProfile(saved), settings };
1114
1198
  },
1115
- async acquireClientPackages(configPath, sourcesPath, integrations) {
1199
+ async acquireClientPackages(configPath, sourcesPath, integrations, { refresh = false, hostCliOnly = false } = {}) {
1116
1200
  const manifest = JSON.parse(readFileSync(sourcesPath, 'utf8'));
1117
- // Public SDK client APIs are actual integration dependencies; Fleet also owns CLI usage.
1118
- 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);
1119
1203
  const packages = selectSourcePackages(manifest, 'client', selected);
1120
- 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));
1121
1206
  const hasGit = Object.values(packages).some(selection => selection.source);
1122
1207
  await effects.run('npm', ['--version']);
1123
1208
  if (hasGit) {
1124
1209
  for (const command of ['python3', 'git', 'make', 'cc']) await effects.run(command, ['--version']);
1125
1210
  }
1211
+ // This directory retains active executables, not disposable download cache.
1212
+ ensurePrivateDirectory(join(home, '.ours-client-install'));
1126
1213
  ensurePrivateDirectory(root);
1127
1214
  const retained = join(root, 'sources.json');
1128
1215
  const bytes = readFileSync(sourcesPath);
@@ -1137,39 +1224,66 @@ export function networkEffects(effects) {
1137
1224
  const sourceRoot = join(root, `build-${randomUUID()}`);
1138
1225
  ensurePrivateDirectory(sourceRoot);
1139
1226
  try {
1140
- 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(',') } });
1141
1228
  } finally { rmSync(sourceRoot, { recursive: true, force: true }); }
1142
1229
  } else {
1143
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 });
1144
1231
  }
1145
- 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 });
1233
+ verifyReleaseGraph(root, manifest, { requiredPackages: Object.keys(packages) });
1146
1234
  writePrivateNew(join(root, '.packages-ready'), 'ready\n');
1147
1235
  }
1236
+ verifyReleaseGraph(root, manifest, { requiredPackages: Object.keys(packages) });
1237
+ const cliPolicy = hostCliOnly ? null : hostCliPolicy(manifest);
1148
1238
  // Local acquisition alone does not publish native commands. Use the user's
1149
1239
  // configured npm prefix and retained dependency closure, including on retry.
1150
1240
  for (const name of integrations.filter(name => name === 'fleet' || name === 'codex')) {
1151
1241
  await effects.run('npm', ['install', '--global', '--install-links=false', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', join(root, 'node_modules', '@ours.network', name)]);
1152
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
+ }
1153
1255
  const localPackages = Object.fromEntries(integrations.filter(n => n !== 'fleet').map(name => [name, join(root, 'node_modules', '@ours.network', name)]));
1154
- 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 };
1155
1257
  },
1156
1258
  async prepareClientMarketplace(name, packagePath) {
1157
- const root = join(dirname(dirname(dirname(packagePath))), 'marketplaces', name);
1259
+ const acquisitionRoot = dirname(dirname(dirname(packagePath)));
1260
+ const sourcePath = join(acquisitionRoot, 'sources.json');
1261
+ const policy = existsSync(sourcePath) ? JSON.parse(readFileSync(sourcePath, 'utf8')) : {}; // Retained pre-release client acquisitions.
1262
+ const release = releaseBinding(policy);
1263
+ const integrationsPath = join(acquisitionRoot, 'integrations.json');
1264
+ const integrations = existsSync(integrationsPath) ? JSON.parse(readFileSync(integrationsPath, 'utf8')) : null;
1265
+ const requiredPackages = integrations ? clientPackageNames(integrations).map(name => '@ours.network/' + name) : Object.keys(policy.packages ?? {});
1266
+ verifyReleaseGraph(acquisitionRoot, policy, { requiredPackages });
1267
+ const root = join(acquisitionRoot, 'marketplaces', name);
1158
1268
  const plugin = join(root, 'plugins', 'ours');
1159
1269
  if (!existsSync(plugin)) {
1160
1270
  mkdirSync(dirname(plugin), { recursive: true, mode: 0o700 });
1161
1271
  cpSync(packagePath, plugin, { recursive: true });
1162
1272
  const manifestPath = join(plugin, 'package.json');
1163
1273
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
1164
- for (const dependency of ['sdk', 'cli']) {
1274
+ for (const dependency of release ? [] : ['sdk', 'cli', 'mcp']) {
1165
1275
  const name = `@ours.network/${dependency}`;
1166
1276
  if (manifest.dependencies?.[name]) manifest.dependencies[name] = `file:${join(dirname(packagePath), dependency)}`;
1167
1277
  }
1168
1278
  writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
1169
1279
  }
1280
+ if (release && JSON.stringify(JSON.parse(readFileSync(join(plugin, 'package.json'), 'utf8'))) !== JSON.stringify(JSON.parse(readFileSync(join(packagePath, 'package.json'), 'utf8')))) {
1281
+ throw new Error('Marketplace package differs from verified release acquisition');
1282
+ }
1170
1283
  // Native caches copy plugin contents; local SDK/CLI dependencies must not
1171
1284
  // remain links to acquisition paths. Repeating setup also repairs an interrupted install.
1172
1285
  await effects.run('npm', ['install', '--install-links', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: plugin });
1286
+ verifyReleaseGraph(plugin, policy);
1173
1287
  const value = name === 'codex'
1174
1288
  ? { name: 'ours-codex-marketplace', plugins: [{ name: 'ours', source: { source: 'local', path: './plugins/ours' }, policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' }, category: 'Productivity' }] }
1175
1289
  : { name: 'ours.network', owner: { name: 'Adapt Toolkit' }, plugins: [{ name: 'ours', source: './plugins/ours' }] };
@@ -1177,42 +1291,7 @@ export function networkEffects(effects) {
1177
1291
  effects.writeJson(manifestPath, JSON.stringify(value, null, 2) + '\n');
1178
1292
  return root;
1179
1293
  },
1180
- async verifyPackagedMcp(configPath) {
1181
- const profile = typeof configPath === 'string' ? readHostProfileFile(configPath) : validateHostProfile(configPath);
1182
- assertPrivateRegularFile(profile.credentialPath, 'credential');
1183
- const token = readFileSync(profile.credentialPath, 'utf8').trim();
1184
- 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() };
1185
- const request = async (method, params, id) => {
1186
- 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 }) });
1187
- const session = response.headers.get('mcp-session-id');
1188
- if (session) headers['mcp-session-id'] = session;
1189
- if (!response.ok) throw new Error(`Packaged MCP verification failed: HTTP ${response.status}`);
1190
- if (id === undefined) { await response.body?.cancel(); return; }
1191
- const text = await response.text();
1192
- const messages = response.headers.get('content-type')?.includes('text/event-stream')
1193
- ? text.split('\n').filter(line => line.startsWith('data:')).map(line => JSON.parse(line.slice(5)))
1194
- : [JSON.parse(text)];
1195
- const message = messages.find(value => value.id === id);
1196
- if (!message || message.error || !message.result) throw new Error('Packaged MCP returned no successful protocol result');
1197
- return message.result;
1198
- };
1199
- try {
1200
- const initialized = await request('initialize', { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'ours-install', version: '1' } }, 1);
1201
- if (initialized.serverInfo?.name !== 'ours' || !initialized.protocolVersion) throw new Error('Selected MCP is not the packaged OURS server');
1202
- headers['mcp-protocol-version'] = initialized.protocolVersion;
1203
- await request('notifications/initialized', {}, undefined);
1204
- const resources = await request('resources/list', {}, 2);
1205
- if (!resources.resources?.some(resource => resource.uri === 'ours://application-identities')) throw new Error('Packaged network MCP identity resource is absent');
1206
- const toolList = await request('tools/list', {}, 3);
1207
- if (!toolList.tools?.some(tool => tool.name === 'list_identities')) throw new Error('Packaged OURS identity tools are absent');
1208
- } finally {
1209
- if (headers['mcp-session-id']) {
1210
- const response = await fetch(`${profile.endpoint}/mcp`, { method: 'DELETE', redirect: 'error', signal: AbortSignal.timeout(5000), headers });
1211
- await response.body?.cancel();
1212
- if (!response.ok) throw new Error('MCP verification session could not be closed');
1213
- }
1214
- }
1215
- },
1294
+
1216
1295
  };
1217
1296
  }
1218
1297
 
@@ -1259,7 +1338,6 @@ async function nativeLifecycle(record, operation, selected, { effects, localEnv,
1259
1338
  if (service === 'daemon') {
1260
1339
  const profilePath = join(installationPaths(record).mcp, 'profile.json');
1261
1340
  await effects.verifyHostProfile(profilePath);
1262
- await effects.verifyPackagedMcp(profilePath);
1263
1341
  return;
1264
1342
  }
1265
1343
  if (service === 'cowork') { await ownerCommand(record, service, 'status'); return; }
@@ -1337,9 +1415,9 @@ async function nativeLifecycle(record, operation, selected, { effects, localEnv,
1337
1415
  }
1338
1416
  if (!ready) throw new Error('Application is not ready');
1339
1417
  } catch {
1340
- 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');
1341
1419
  failures.push(service);
1342
1420
  }
1343
1421
  }
1344
- 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.`);
1345
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
+ }