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

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
@@ -13,17 +13,19 @@
13
13
 
14
14
  import { spawnSync, execFileSync } from 'node:child_process';
15
15
  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';
16
+ import { homedir, userInfo, platform as osPlatform, release as osRelease, arch as osArch } from 'node:os';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { randomUUID, createHash } from 'node:crypto';
19
19
  import { dirname, join, resolve } from 'node:path';
20
20
  import { maintenanceServices, installationPaths, validateInstallation, consumerServiceState, unitNameForStateDir, launchdLabelForStateDir, messengerServicePlan, selectSourcePackages, resolveSourcePolicy, SERVER_SERVICES } from './plan.mjs';
21
21
  import { validateHostProfile } from './target.mjs';
22
+ import { createServerOnboarding } from './server-onboarding.mjs';
22
23
  import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
23
24
  import { askYesNo, askLine as askLineOnTty } from './prompt.mjs';
24
25
  import { classifyHarnessProbe } from './logic.mjs';
25
26
  import { classifyStateDir } from './detect.mjs';
26
27
  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';
27
29
 
28
30
  /** GET http://127.0.0.1:<port>/state-dir — the unauthenticated identity probe. */
29
31
  async function probePort(port, { timeoutMs = 1500 } = {}) {
@@ -332,7 +334,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
332
334
  version,
333
335
  interactive: ttyFd != null,
334
336
  // Preflight reads the machine rather than asking the orchestrator to.
335
- platform: { platform: osPlatform(), release: osRelease() },
337
+ platform: { platform: osPlatform(), release: osRelease(), arch: osArch() },
336
338
  nodeVersion: process.versions.node,
337
339
  exists: (path) => existsSync(path),
338
340
  knownStateDirs: () => knownStateDirsIn(home),
@@ -412,7 +414,12 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
412
414
  stdio: [...(stream ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe']), ...(installationLockFd === null ? [] : [installationLockFd])],
413
415
  env: childEnv,
414
416
  });
415
- if (r.error || (r.status !== 0 && !allowCodes.includes(r.status))) {
417
+ if (r.error) {
418
+ const error = new Error(`${executable} could not start (${r.error.code ?? 'launch error'})`, { cause: r.error });
419
+ error.code = r.error.code;
420
+ throw error;
421
+ }
422
+ if (r.status !== 0 && !allowCodes.includes(r.status)) {
416
423
  const detail = sensitive ? '' : (r.stderr || r.stdout || '').trim().split('\n').slice(-3).join('; ');
417
424
  throw new Error(`${executable} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
418
425
  }
@@ -532,12 +539,30 @@ export function networkEffects(effects) {
532
539
  OURS_MESSENGER_PORT: String(record.messengerPort ?? 8420),
533
540
  OURS_MESSENGER_IDENTITY: record.messengerIdentity ?? '',
534
541
  });
535
- const compose = (record, args, options = {}) => effects.run('docker', [
542
+ const composeArgs = (record, args) => [
536
543
  'compose', '--project-directory', record.workDir, '--file', join(record.workDir,
537
544
  record.schema === 1 && existsSync(join(record.workDir, 'docker-compose.legacy.yaml'))
538
545
  ? 'docker-compose.legacy.yaml' : 'docker-compose.yaml'),
539
546
  '--project-name', record.project, ...args,
540
- ], { ...options, env: { ...baseEnv(record), ...options.env } });
547
+ ];
548
+ const compose = (record, args, options = {}) => effects.run('docker', composeArgs(record, args),
549
+ { ...options, env: { ...baseEnv(record), ...options.env } });
550
+ const dockerStartupError = async (record, service, cause) => {
551
+ const args = ['logs', '--no-color', '--tail', '50', '--timestamps', service];
552
+ const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
553
+ const command = `OURS_DAEMON_ID=${quote(record.instanceId)} docker ${composeArgs(record, args).map(quote).join(' ')}`;
554
+ let detail;
555
+ try {
556
+ const logs = await compose(record, args);
557
+ // Limit terminal diagnostics; container output must not inject terminal controls.
558
+ detail = (logs.stdout ?? '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
559
+ .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '').trim().slice(-6000);
560
+ detail = detail ? `Recent ${service} logs:\n${detail}` : 'The container produced no readable logs.';
561
+ } catch {
562
+ detail = 'Container logs could not be read.';
563
+ }
564
+ return new Error(`Docker service "${service}" failed to start or become healthy.\n${detail}\nStartup error: ${cause.message}\nInspect logs: ${command}`, { cause });
565
+ };
541
566
  const bin = (record, name) => join(record.workDir, 'node_modules', '.bin', name);
542
567
  const localEnv = (record, service = 'daemon') => {
543
568
  const paths = installationPaths(record);
@@ -565,11 +590,20 @@ export function networkEffects(effects) {
565
590
  }
566
591
  };
567
592
  return {
593
+ ...createServerOnboarding(effects, { compose, localEnv, bin }),
568
594
  sourcePolicyHash(path) {
569
595
  return createHash('sha256').update(readFileSync(path)).digest('hex');
570
596
  },
571
597
  packagedSourcePolicy() {
572
- return JSON.parse(readFileSync(PACKAGED_SOURCE_POLICY, 'utf8'));
598
+ const policy = JSON.parse(readFileSync(PACKAGED_SOURCE_POLICY, 'utf8'));
599
+ const release = releaseBinding(policy);
600
+ if (release) {
601
+ const embedded = JSON.parse(readFileSync(join(INSTALLER_ASSETS, 'release.json'), 'utf8'));
602
+ if (JSON.stringify(release) !== JSON.stringify(embedded)) throw new Error('Packaged source policy differs from immutable release');
603
+ } else if (Object.values(policy.packages ?? {}).some(p => p.type === 'npm')) {
604
+ throw new Error('Packaged npm source policy is missing its release binding');
605
+ }
606
+ return policy;
573
607
  },
574
608
  async resolveSourcePolicy(policy, role, clients = []) {
575
609
  return resolveSourcePolicy(policy, role, clients, async (name, range) => {
@@ -592,7 +626,7 @@ export function networkEffects(effects) {
592
626
  const project = `ours-${createHash('sha256').update(root).digest('hex').slice(0, 16)}`;
593
627
  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
628
  },
595
- async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest } = {}) {
629
+ async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest, identityName } = {}) {
596
630
  if (existing) {
597
631
  privateDirectory(record.root);
598
632
  assertPrivateRegularFile(join(record.root, 'installation.json'), 'selection');
@@ -600,10 +634,34 @@ export function networkEffects(effects) {
600
634
  if (env.OURS_DAEMON_ID && env.OURS_DAEMON_ID !== record.instanceId) throw new Error('Conflicting instance ID');
601
635
  }
602
636
  if (record.mode === 'docker') {
603
- await effects.run('docker', ['info', '--format', '{{.ServerVersion}}']);
604
- const version = await effects.run('docker', ['compose', 'version', '--short']);
637
+ const nativeRoot = existing ? '/path/to/new-empty-directory' : record.root;
638
+ const quotedRoot = `'${String(nativeRoot).replaceAll("'", "'\\''")}'`;
639
+ const quotedName = `'${String(identityName ?? record.messengerIdentity ?? 'Your Name').replaceAll("'", "'\\''")}'`;
640
+ const recovery = [
641
+ 'Please install Docker Desktop on macOS/Windows, or Docker Engine with the Compose plugin on Linux, and start Docker before retrying.',
642
+ 'Docker is recommended for macOS and Windows.',
643
+ `Alternatively, use native installation: ours-install server install --mode packages --state-dir ${quotedRoot} --identity-name ${quotedName}`,
644
+ 'Native mode requires systemd user services on Linux/WSL or a launchd GUI session on macOS.',
645
+ ...(existing ? ['Keep this existing Docker installation in Docker mode; use a separate empty directory for a new native installation.'] : []),
646
+ ].join('\n');
647
+ try {
648
+ await effects.run('docker', ['info', '--format', '{{.ServerVersion}}']);
649
+ } catch (cause) {
650
+ const problem = cause.code === 'ENOENT'
651
+ ? 'Docker command was not found in PATH.'
652
+ : `Docker Engine is not reachable. Start Docker and check that your user can access it.\nDetails: ${cause.message}`;
653
+ throw new Error(`${problem}\n${recovery}`, { cause });
654
+ }
655
+ let version;
656
+ try {
657
+ version = await effects.run('docker', ['compose', 'version', '--short']);
658
+ } catch (cause) {
659
+ 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 });
660
+ }
605
661
  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');
662
+ if (!match || Number(match[1]) < 2 || (Number(match[1]) === 2 && Number(match[2]) < 35)) {
663
+ throw new Error(`Docker Compose 2.35 or newer is required. Update Docker Desktop or the Docker Compose plugin.\n${recovery}`);
664
+ }
607
665
  if (operation !== 'status') {
608
666
  // Compose clients can disappear while their Engine-owned command continues.
609
667
  const active = await effects.run('docker', ['ps', '--filter', `label=com.docker.compose.project=${record.project}`, '--filter', 'label=com.docker.compose.oneoff=True', '--format', '{{.ID}}']);
@@ -650,7 +708,7 @@ export function networkEffects(effects) {
650
708
  const { dependencies } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
651
709
  writeFileSync(join(record.workDir, 'scripts/maintenance/package.json'), JSON.stringify({ private: true, type: 'module', dependencies }, null, 2) + '\n', { mode: 0o600 });
652
710
  const image = await effects.run('docker', ['image', 'inspect', `${record.project}:runtime`], { allowCodes: [1] });
653
- if (image.code !== 0) await compose(record, ['build', 'daemon']);
711
+ if (image.code !== 0) await compose(record, ['build', 'daemon'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
654
712
  if (runtimeOnly) return;
655
713
  await compose(record, ['run', '--rm', '--no-deps', '-T', 'prepare', 'prepare']);
656
714
  } else {
@@ -658,8 +716,8 @@ export function networkEffects(effects) {
658
716
  const sourceRoot = join(record.root, `build-${randomUUID()}`);
659
717
  ensurePrivateDirectory(sourceRoot);
660
718
  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 });
719
+ 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 } });
720
+ await effects.run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { stream: true, cwd: record.workDir });
663
721
  await effects.run(process.execPath, [join(record.workDir, 'scripts/build/record-build.mjs')], { cwd: record.workDir, env: { OURS_BUILD_ROOT: record.workDir } });
664
722
  writePrivateNew(join(record.workDir, '.packages-ready'), 'ready\n');
665
723
  } finally { rmSync(sourceRoot, { recursive: true, force: true }); }
@@ -867,6 +925,7 @@ export function networkEffects(effects) {
867
925
  },
868
926
  async recordRuntimeBuild(record) {
869
927
  if (record.mode === 'docker') return; // Image preparation records its build.
928
+ verifyRuntimeRelease(record.workDir);
870
929
  const tree = join(record.workDir, 'dependency-tree.json');
871
930
  if (!existsSync(tree)) {
872
931
  const result = await effects.run('npm', ['ls', '--omit=dev', '--all', '--json'], { cwd: record.workDir });
@@ -1051,13 +1110,19 @@ export function networkEffects(effects) {
1051
1110
  if ((await effects.serverLifecycle(record, 'status', selected)).length) throw new Error('Writers did not stop');
1052
1111
  return;
1053
1112
  }
1054
- if (selected.includes('daemon')) await compose(record, ['up', '-d', '--no-build', '--no-deps', '--wait', 'daemon']);
1113
+ const start = async service => {
1114
+ effects.out(`Starting ${service}; waiting for readiness...`);
1115
+ try { await compose(record, ['up', '-d', '--no-build', '--no-deps', '--wait', service]); }
1116
+ catch (cause) { throw await dockerStartupError(record, service, cause); }
1117
+ effects.out(`${service} is ready.`);
1118
+ };
1119
+ if (selected.includes('daemon')) await start('daemon');
1055
1120
  const failures = [];
1056
1121
  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); }
1122
+ try { await start(service); }
1123
+ catch (error) { failures.push({ service, error }); }
1059
1124
  }
1060
- if (failures.length) throw new Error(`Application readiness failed: ${failures.join(', ')}. Check owning prerequisites; no identities were created.`);
1125
+ 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
1126
  return;
1062
1127
  }
1063
1128
  return nativeLifecycle(record, operation, selected, { effects, localEnv, ownerCommand, bin });
@@ -1080,7 +1145,7 @@ export function networkEffects(effects) {
1080
1145
  // Full metadata and authenticated capability validation follows before publication.
1081
1146
  return validateHostProfile({ endpoint: url.origin, expectedInstanceId: selection.instanceId, credentialPath: resolve(credentialPath) });
1082
1147
  },
1083
- importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath }) {
1148
+ importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh = false }) {
1084
1149
  const root = join(home, '.ours-client');
1085
1150
  const configPath = join(root, 'profile.json');
1086
1151
  const credentialPath = join(root, 'credential');
@@ -1091,13 +1156,13 @@ export function networkEffects(effects) {
1091
1156
  const credential = readFileSync(profile.credentialPath, 'utf8');
1092
1157
  if (!credential.trim()) throw new Error('Client credential is empty');
1093
1158
  // Read every supplied input before any publication. Existing setup settings win on retry.
1094
- const sources = current ? null : resolvedSources
1159
+ const sources = current && !refresh ? null : resolvedSources
1095
1160
  ? Buffer.from(`${JSON.stringify(resolvedSources, null, 2)}\n`)
1096
1161
  : readFileSync(sourcesPath);
1097
- const fleetSettings = !current && fleetSettingsPath ? readFileSync(fleetSettingsPath) : null;
1162
+ const fleetSettings = (!current || refresh) && fleetSettingsPath ? readFileSync(fleetSettingsPath) : null;
1098
1163
  if (fleetSettings) JSON.parse(fleetSettings.toString());
1099
1164
  ensurePrivateDirectory(root);
1100
- if (current) {
1165
+ if (current && !refresh) {
1101
1166
  assertPrivateRegularFile(credentialPath, 'managed credential');
1102
1167
  if (readFileSync(credentialPath, 'utf8') !== credential) atomicWriteConfig(credentialPath, credential);
1103
1168
  return { configPath, profile: validateHostProfile(current), settings: current.installer };
@@ -1112,12 +1177,13 @@ export function networkEffects(effects) {
1112
1177
  atomicWriteConfig(configPath, JSON.stringify(saved, null, 2) + '\n');
1113
1178
  return { configPath, profile: validateHostProfile(saved), settings };
1114
1179
  },
1115
- async acquireClientPackages(configPath, sourcesPath, integrations) {
1180
+ async acquireClientPackages(configPath, sourcesPath, integrations, { refresh = false } = {}) {
1116
1181
  const manifest = JSON.parse(readFileSync(sourcesPath, 'utf8'));
1117
1182
  // Public SDK client APIs are actual integration dependencies; Fleet also owns CLI usage.
1118
1183
  const selected = [...new Set(['sdk', ...(integrations.includes('fleet') ? ['cli'] : []), ...integrations])];
1119
1184
  const packages = selectSourcePackages(manifest, 'client', selected);
1120
- const root = join(home, '.ours-client-install', createHash('sha256').update(configPath).digest('hex').slice(0, 16));
1185
+ const selectionKey = refresh ? JSON.stringify([configPath, manifest, integrations]) : configPath;
1186
+ const root = join(home, '.ours-client-install', createHash('sha256').update(selectionKey).digest('hex').slice(0, 16));
1121
1187
  const hasGit = Object.values(packages).some(selection => selection.source);
1122
1188
  await effects.run('npm', ['--version']);
1123
1189
  if (hasGit) {
@@ -1137,14 +1203,16 @@ export function networkEffects(effects) {
1137
1203
  const sourceRoot = join(root, `build-${randomUUID()}`);
1138
1204
  ensurePrivateDirectory(sourceRoot);
1139
1205
  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(',') } });
1206
+ 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
1207
  } finally { rmSync(sourceRoot, { recursive: true, force: true }); }
1142
1208
  } else {
1143
1209
  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
1210
  }
1145
- await effects.run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { cwd: root });
1211
+ await effects.run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { stream: true, cwd: root });
1212
+ verifyReleaseGraph(root, manifest, { requiredPackages: Object.keys(packages) });
1146
1213
  writePrivateNew(join(root, '.packages-ready'), 'ready\n');
1147
1214
  }
1215
+ verifyReleaseGraph(root, manifest, { requiredPackages: Object.keys(packages) });
1148
1216
  // Local acquisition alone does not publish native commands. Use the user's
1149
1217
  // configured npm prefix and retained dependency closure, including on retry.
1150
1218
  for (const name of integrations.filter(name => name === 'fleet' || name === 'codex')) {
@@ -1154,22 +1222,34 @@ export function networkEffects(effects) {
1154
1222
  return { localPackages, packages: {}, fleetBin: integrations.includes('fleet') ? join(root, 'node_modules/.bin/ours-fleet') : null };
1155
1223
  },
1156
1224
  async prepareClientMarketplace(name, packagePath) {
1157
- const root = join(dirname(dirname(dirname(packagePath))), 'marketplaces', name);
1225
+ const acquisitionRoot = dirname(dirname(dirname(packagePath)));
1226
+ const sourcePath = join(acquisitionRoot, 'sources.json');
1227
+ const policy = existsSync(sourcePath) ? JSON.parse(readFileSync(sourcePath, 'utf8')) : {}; // Retained pre-release client acquisitions.
1228
+ const release = releaseBinding(policy);
1229
+ const integrationsPath = join(acquisitionRoot, 'integrations.json');
1230
+ const integrations = existsSync(integrationsPath) ? JSON.parse(readFileSync(integrationsPath, 'utf8')) : null;
1231
+ const requiredPackages = integrations ? [...new Set(['sdk', ...(integrations.includes('fleet') ? ['cli'] : []), ...integrations])].map(name => '@ours.network/' + name) : Object.keys(policy.packages ?? {});
1232
+ verifyReleaseGraph(acquisitionRoot, policy, { requiredPackages });
1233
+ const root = join(acquisitionRoot, 'marketplaces', name);
1158
1234
  const plugin = join(root, 'plugins', 'ours');
1159
1235
  if (!existsSync(plugin)) {
1160
1236
  mkdirSync(dirname(plugin), { recursive: true, mode: 0o700 });
1161
1237
  cpSync(packagePath, plugin, { recursive: true });
1162
1238
  const manifestPath = join(plugin, 'package.json');
1163
1239
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
1164
- for (const dependency of ['sdk', 'cli']) {
1240
+ for (const dependency of release ? [] : ['sdk', 'cli']) {
1165
1241
  const name = `@ours.network/${dependency}`;
1166
1242
  if (manifest.dependencies?.[name]) manifest.dependencies[name] = `file:${join(dirname(packagePath), dependency)}`;
1167
1243
  }
1168
1244
  writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
1169
1245
  }
1246
+ if (release && JSON.stringify(JSON.parse(readFileSync(join(plugin, 'package.json'), 'utf8'))) !== JSON.stringify(JSON.parse(readFileSync(join(packagePath, 'package.json'), 'utf8')))) {
1247
+ throw new Error('Marketplace package differs from verified release acquisition');
1248
+ }
1170
1249
  // Native caches copy plugin contents; local SDK/CLI dependencies must not
1171
1250
  // remain links to acquisition paths. Repeating setup also repairs an interrupted install.
1172
1251
  await effects.run('npm', ['install', '--install-links', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: plugin });
1252
+ verifyReleaseGraph(plugin, policy);
1173
1253
  const value = name === 'codex'
1174
1254
  ? { name: 'ours-codex-marketplace', plugins: [{ name: 'ours', source: { source: 'local', path: './plugins/ours' }, policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' }, category: 'Productivity' }] }
1175
1255
  : { name: 'ours.network', owner: { name: 'Adapt Toolkit' }, plugins: [{ name: 'ours', source: './plugins/ours' }] };
@@ -1341,5 +1421,5 @@ async function nativeLifecycle(record, operation, selected, { effects, localEnv,
1341
1421
  failures.push(service);
1342
1422
  }
1343
1423
  }
1344
- if (failures.length) throw new Error(`Application readiness failed: ${failures.join(', ')}. Check owning prerequisites; no identities were created.`);
1424
+ if (failures.length) throw new Error(`Application readiness failed: ${failures.join(', ')}. Check the selected application prerequisites.`);
1345
1425
  }
@@ -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
+ }
@@ -1266,6 +1266,7 @@ async function executeServerCommand(args, effects) {
1266
1266
  } else {
1267
1267
  if (args.operation !== 'install' || !args.mode) throw new Error('First server install requires --mode');
1268
1268
  record = effects.newInstallation(args.stateDir, args.mode);
1269
+ for (const key of ['port', 'coworkPort', 'messengerPort']) if (args[key] !== undefined) record[key] = args[key];
1269
1270
  }
1270
1271
  if (record.layoutConversion && !['install', 'start', 'stop', 'status'].includes(args.operation)) {
1271
1272
  throw new Error('Layout conversion is incomplete; resume with server install or server start before changing state or authority');
@@ -1273,14 +1274,32 @@ async function executeServerCommand(args, effects) {
1273
1274
  if (record.buildTransition && !['stop', 'status', record.buildTransition.operation].includes(args.operation)) {
1274
1275
  throw new Error(`Server build activation is incomplete; repeat server ${record.buildTransition.operation} before other mutations`);
1275
1276
  }
1277
+ const showInstallProgress = args.operation === 'install' && !(existing && (record.schema === 1 || record.layoutConversion));
1278
+ const installStageCount = (existing ? 7 : 9) + (args.identityName ? 2 : 0);
1279
+ let completedInstallStages = 0;
1280
+ const installStage = async (label, explanation, action) => {
1281
+ if (!showInstallProgress) return action();
1282
+ effects.out(progress(completedInstallStages, installStageCount, label, explanation));
1283
+ try {
1284
+ const result = await action();
1285
+ completedInstallStages += 1;
1286
+ effects.out(ok(`${label} complete`));
1287
+ return result;
1288
+ } catch (error) {
1289
+ effects.out(warn(`Server installation stopped during ${label.toLowerCase()}.`));
1290
+ throw error;
1291
+ }
1292
+ };
1276
1293
  if ((!existing || args.operation === 'update') && !record.buildTransition) {
1277
- const policy = args.sources ? effects.readJson(args.sources) : effects.packagedSourcePolicy();
1278
- args.resolvedSources = await effects.resolveSourcePolicy(policy, 'server');
1294
+ args.resolvedSources = await installStage('Package selection', 'Resolve the selected server packages before installation.', async () => {
1295
+ const policy = args.sourcePolicy ?? (args.sources ? effects.readJson(args.sources) : effects.packagedSourcePolicy());
1296
+ return effects.resolveSourcePolicy(policy, 'server');
1297
+ });
1279
1298
  }
1280
1299
  if (!existing && args.sources) record.sourcePolicyHash = effects.sourcePolicyHash(args.sources);
1281
- await effects.serverPreflight(record, args.operation, {
1282
- existing, sourcePath: args.sources ?? record.sourcesPath, sourceManifest: args.resolvedSources,
1283
- });
1300
+ await installStage('Prerequisite checks', record.mode === 'docker' ? 'Check Docker Engine and Docker Compose.' : 'Check native tools and the user service manager.', () => effects.serverPreflight(record, args.operation, {
1301
+ existing, sourcePath: args.sources ?? record.sourcesPath, sourceManifest: args.resolvedSources, identityName: args.identityName,
1302
+ }));
1284
1303
  if (existing && (record.schema === 1 || record.layoutConversion)
1285
1304
  && ['install', 'start', 'restart', 'update', 'rebuild'].includes(args.operation)) {
1286
1305
  record = record.mode === 'docker'
@@ -1295,16 +1314,27 @@ async function executeServerCommand(args, effects) {
1295
1314
  await effects.stopPendingConversion(record);
1296
1315
  } else if (args.operation === 'install') {
1297
1316
  if (!existing) {
1298
- await effects.initializeSelection(record, args.resolvedSources);
1299
- effects.writeJson(recordPath, JSON.stringify(record, null, 2) + '\n');
1317
+ await installStage('Installation setup', 'Save the selected packages and installation settings.', async () => {
1318
+ await effects.initializeSelection(record, args.resolvedSources);
1319
+ effects.writeJson(recordPath, JSON.stringify(record, null, 2) + '\n');
1320
+ });
1300
1321
  }
1301
- await effects.prepareInstallation(record);
1322
+ await installStage('Runtime preparation', record.mode === 'docker' ? 'Prepare the Docker runtime. Downloads and builds may take several minutes.' : 'Download and prepare the native runtime packages. This may take several minutes.', () => effects.prepareInstallation(record));
1302
1323
  // A repeated setup repairs delivery with the retained master and exact packages.
1303
- await effects.serverLifecycle(record, 'stop');
1304
- await effects.serverAccess(record, 'access-init', { migrate: !!args.migrate });
1305
- await effects.serverAccess(record, 'access-issue');
1306
- await effects.recordInstallationBuild(record);
1307
- await effects.serverLifecycle(record, 'start');
1324
+ await installStage('Service shutdown', 'Stop managed services before configuring access.', () => effects.serverLifecycle(record, 'stop'));
1325
+ await installStage('Credential initialization', 'Initialize or retain the installation credentials.', () => effects.serverAccess(record, 'access-init', { migrate: !!args.migrate }));
1326
+ await installStage('Credential delivery', 'Prepare access for the selected services.', () => effects.serverAccess(record, 'access-issue'));
1327
+ await installStage('Build verification', 'Record the installed runtime and selected package versions.', () => effects.recordInstallationBuild(record));
1328
+ if (args.identityName) {
1329
+ await installStage('Daemon startup', 'Start the daemon and restore its retained identities.', () => effects.serverLifecycle(record, 'start', ['daemon']));
1330
+ const identity = await installStage('Human identity', 'Keep the existing Human identity, or create it on a fresh daemon.', () => effects.serverEnsureIdentity(record, args.identityName));
1331
+ record.messengerIdentity = identity.name;
1332
+ effects.writeJson(recordPath, JSON.stringify(record, null, 2) + '\n');
1333
+ await installStage('Application startup', 'Start the selected applications and check readiness.', () => effects.serverLifecycle(record, 'start', record.services.filter(name => name !== 'daemon')));
1334
+ } else {
1335
+ await installStage('Service startup', 'Start the daemon and selected services, then check readiness.', () => effects.serverLifecycle(record, 'start'));
1336
+ }
1337
+ if (showInstallProgress) effects.out(progress(installStageCount, installStageCount, 'Installation complete', 'The selected services are ready.'));
1308
1338
  } else if (['backup', 'restore', 'reset'].includes(args.operation)) {
1309
1339
  await effects.serverMaintenance(record, args);
1310
1340
  } else if (['update', 'rebuild'].includes(args.operation)) {
@@ -1341,18 +1371,18 @@ async function executeServerCommand(args, effects) {
1341
1371
  return EXIT_OK;
1342
1372
  }
1343
1373
 
1344
- async function runClientCommand(command, effects) {
1374
+ export async function runClientCommand(command, effects) {
1345
1375
  const managedPath = join(effects.home, '.ours-client', 'profile.json');
1346
1376
  const saved = effects.readManagedClientProfile();
1347
1377
  let configPath = command.config;
1348
1378
  if (!configPath && saved) {
1349
- if (effects.interactive && !await effects.ask(`Reuse saved client server ${saved.endpoint}?`, true))
1379
+ if (!command.preset && effects.interactive && !await effects.ask(`Reuse saved client server ${saved.endpoint}?`, true))
1350
1380
  throw new InstallUsageError('Saved client default retained; use an explicit prepared profile to validate replacement input');
1351
1381
  configPath = managedPath;
1352
1382
  }
1353
1383
  let profile;
1354
1384
  if (configPath) profile = validateHostProfile(effects.readProfile(configPath));
1355
- else if (effects.interactive) {
1385
+ else if (!command.preset && effects.interactive) {
1356
1386
  const endpoint = await effects.askLine('Server HTTP endpoint: ', 'http://127.0.0.1:3050');
1357
1387
  const credentialPath = await effects.askLine('Private issued-token file: ', '');
1358
1388
  if (!credentialPath) throw new InstallUsageError('Client setup requires an issued-token file');
@@ -1369,40 +1399,51 @@ async function runClientCommand(command, effects) {
1369
1399
  await effects.verifyPackagedMcp(configPath || profile);
1370
1400
  const settings = saved?.installer ?? (configPath ? effects.readJson(configPath)?.installer : undefined);
1371
1401
  const settingsBase = saved ? dirname(managedPath) : configPath ? dirname(configPath) : process.cwd();
1372
- let integrations = settings?.integrations;
1373
- if (!integrations && effects.interactive) {
1402
+ let integrations = command.integrations ?? settings?.integrations;
1403
+ if (!integrations && !command.preset && effects.interactive) {
1374
1404
  integrations = [];
1375
1405
  for (const name of ['codex', 'claude-code', 'fleet']) if (await effects.ask(`Install ${name}?`, name !== 'fleet')) integrations.push(name);
1376
1406
  }
1377
1407
  if (!Array.isArray(integrations) || !integrations.length || integrations.some(name => !['codex', 'claude-code', 'fleet'].includes(name)) || new Set(integrations).size !== integrations.length) throw new InstallUsageError('installer.integrations must select codex, claude-code and/or fleet');
1378
- let fleetSettingsPath = settings?.fleetSettingsPath;
1408
+ let fleetSettingsPath = command.preset ? command.fleetSettingsPath : settings?.fleetSettingsPath;
1409
+ if (command.nonInteractive && integrations.includes('fleet') && !fleetSettingsPath) throw new InstallUsageError('Fleet in CLI mode requires --fleet-settings; no interactive wizard will be opened');
1379
1410
  if (fleetSettingsPath !== undefined && (typeof fleetSettingsPath !== 'string' || !fleetSettingsPath))
1380
1411
  throw new InstallUsageError('installer.fleetSettingsPath must be a non-empty path when supplied');
1381
1412
  if (fleetSettingsPath) fleetSettingsPath = resolve(settingsBase, fleetSettingsPath);
1382
1413
  const selectedClients = [...new Set(['sdk', ...(integrations.includes('fleet') ? ['cli'] : []), ...integrations])];
1383
1414
  let sourcesPath = settings?.sourcesPath;
1384
1415
  let resolvedSources;
1385
- if (saved) sourcesPath = resolve(settingsBase, sourcesPath);
1416
+ if (command.sourcePolicy) {
1417
+ resolvedSources = await effects.resolveSourcePolicy(command.sourcePolicy, 'client', selectedClients);
1418
+ sourcesPath = undefined;
1419
+ } else if (saved) sourcesPath = resolve(settingsBase, sourcesPath);
1386
1420
  else {
1387
1421
  if (sourcesPath) sourcesPath = resolve(settingsBase, sourcesPath);
1388
1422
  const policy = sourcesPath ? effects.readJson(sourcesPath) : effects.packagedSourcePolicy();
1389
1423
  resolvedSources = await effects.resolveSourcePolicy(policy, 'client', selectedClients);
1390
1424
  }
1391
- const imported = effects.importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath });
1425
+ effects.out(progress(0, 4, 'Client configuration', 'Prepare the selected integrations and private connection profile.'));
1426
+ const imported = effects.importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh: !!command.preset });
1392
1427
  try {
1393
1428
  await effects.verifyHostProfile(imported.configPath);
1394
- const exactSuite = await effects.acquireClientPackages(imported.configPath, imported.settings.sourcesPath, integrations);
1429
+ effects.out(progress(1, 4, 'Client packages', 'Acquire and verify the selected client package versions.'));
1430
+ const exactSuite = await effects.acquireClientPackages(imported.configPath, imported.settings.sourcesPath, integrations, { refresh: !!command.preset });
1395
1431
  const args = { assumeYes: true, dryRun: false, channel: 'latest', clientIntegrations: integrations,
1396
1432
  acquiredFleet: exactSuite.fleetBin, fleetSettingsPath: imported.settings.fleetSettingsPath };
1397
1433
  const target = { mode: 'host-profile', managed: true, configPath: imported.configPath, profile: imported.profile, endpoint: imported.profile.endpoint };
1434
+ effects.out(progress(2, 4, 'Client integrations', 'Register the selected agent integrations.'));
1398
1435
  const summary = await runHarnessPhase(args, effects, { target, isDefaultStateDir: false, exactSuite });
1399
- if (integrations.includes('fleet')) summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir: false }));
1436
+ if (integrations.includes('fleet')) {
1437
+ effects.out(progress(3, 4, 'Fleet configuration', 'Apply prepared settings or open the selected Fleet wizard.'));
1438
+ summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir: false }));
1439
+ }
1400
1440
  const incomplete = integrations.filter(name => !summary.some(row => row.key === name && row.state === 'installed'));
1401
1441
  if (effects.env.OURS_CONFIG && resolve(effects.env.OURS_CONFIG) !== imported.configPath)
1402
1442
  effects.out(warn('This shell has an explicit OURS_CONFIG override. Unset it for new clients to use the saved default; installer did not edit your shell.'));
1403
1443
  effects.out(incomplete.length
1404
1444
  ? warn(`Client setup incomplete (${incomplete.join(', ')}); saved profile and settings retained. Re-run ours-install client install.`)
1405
1445
  : ok(`Client setup complete. New clients discover ${imported.configPath}; no OURS_CONFIG export is required.`));
1446
+ if (!incomplete.length) effects.out(progress(4, 4, 'Client setup complete', 'All selected integrations are configured.'));
1406
1447
  return incomplete.length ? EXIT_REFUSED : EXIT_OK;
1407
1448
  } catch (error) {
1408
1449
  effects.out(warn(`Client setup incomplete: ${reason(error)}. Saved profile and settings retained; re-run ours-install client install.`));
package/lib/plan.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { join, resolve, basename, dirname } from 'node:path';
8
8
  import { valid, validRange, satisfies } from 'semver';
9
+ import { releaseBinding } from '../assets/scripts/maintenance/release-graph.mjs';
9
10
 
10
11
  export const CLI_UNIT_MARKER = '# Managed by @ours.network/cli';
11
12
  export const SYSTEMD_USER_DIR = ['.config', 'systemd', 'user'];
@@ -310,6 +311,7 @@ export function selectSourcePackages(manifest, role, clients = []) {
310
311
 
311
312
  /** Resolve a packaged compatibility policy into a role-filtered exact selection. */
312
313
  export async function resolveSourcePolicy(manifest, role, clients = [], resolveNpm) {
314
+ const release = releaseBinding(manifest);
313
315
  const names = role === 'server' ? SERVER_PACKAGES : clients.map(name => `@ours.network/${name}`);
314
316
  const packages = {};
315
317
  const sourceNames = new Set();
@@ -332,7 +334,7 @@ export async function resolveSourcePolicy(manifest, role, clients = [], resolveN
332
334
  } else throw new Error(`Missing source policy for ${name}`);
333
335
  }
334
336
  const sources = Object.fromEntries([...sourceNames].map(name => [name, manifest.sources[name]]));
335
- const exact = { ...(sourceNames.size ? { sources } : {}), packages };
337
+ const exact = { ...(sourceNames.size ? { sources } : {}), packages, ...(release ? { release } : {}) };
336
338
  selectSourcePackages(exact, role, clients);
337
339
  return exact;
338
340
  }