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

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,14 +13,15 @@
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
- import { askYesNo, askLine as askLineOnTty } from './prompt.mjs';
24
+ import { select as selectOnTty, multiselect as multiselectOnTty, 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';
@@ -333,7 +334,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
333
334
  version,
334
335
  interactive: ttyFd != null,
335
336
  // Preflight reads the machine rather than asking the orchestrator to.
336
- platform: { platform: osPlatform(), release: osRelease() },
337
+ platform: { platform: osPlatform(), release: osRelease(), arch: osArch() },
337
338
  nodeVersion: process.versions.node,
338
339
  exists: (path) => existsSync(path),
339
340
  knownStateDirs: () => knownStateDirsIn(home),
@@ -413,7 +414,12 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
413
414
  stdio: [...(stream ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe']), ...(installationLockFd === null ? [] : [installationLockFd])],
414
415
  env: childEnv,
415
416
  });
416
- 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)) {
417
423
  const detail = sensitive ? '' : (r.stderr || r.stdout || '').trim().split('\n').slice(-3).join('; ');
418
424
  throw new Error(`${executable} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
419
425
  }
@@ -438,7 +444,9 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
438
444
  hasClaudePlugin,
439
445
  out: out ?? ((line) => process.stdout.write(`${line}\n`)),
440
446
  // Never called when assumeYes: the orchestrator takes the default itself.
441
- ask: async (prompt, def = false) => (ttyFd == null ? def : askYesNo(write, ttyFd, ` ${prompt} `, def)),
447
+ select: async (question, choices, def) => selectOnTty(write, ttyFd, question, choices, def),
448
+ multiselect: async (question, choices, defaults = []) => multiselectOnTty(write, ttyFd, question, choices, defaults),
449
+ ask: async (question, def = false) => selectOnTty(write, ttyFd, question, [{ value: true, label: 'Yes' }, { value: false, label: 'No' }], def),
442
450
  askLine: async (prompt, def = '') => (ttyFd == null ? def : askLineOnTty(write, ttyFd, ` ${prompt} `, def)),
443
451
  };
444
452
  return Object.assign(effects, networkEffects(effects));
@@ -533,12 +541,30 @@ export function networkEffects(effects) {
533
541
  OURS_MESSENGER_PORT: String(record.messengerPort ?? 8420),
534
542
  OURS_MESSENGER_IDENTITY: record.messengerIdentity ?? '',
535
543
  });
536
- const compose = (record, args, options = {}) => effects.run('docker', [
544
+ const composeArgs = (record, args) => [
537
545
  'compose', '--project-directory', record.workDir, '--file', join(record.workDir,
538
546
  record.schema === 1 && existsSync(join(record.workDir, 'docker-compose.legacy.yaml'))
539
547
  ? 'docker-compose.legacy.yaml' : 'docker-compose.yaml'),
540
548
  '--project-name', record.project, ...args,
541
- ], { ...options, env: { ...baseEnv(record), ...options.env } });
549
+ ];
550
+ const compose = (record, args, options = {}) => effects.run('docker', composeArgs(record, args),
551
+ { ...options, env: { ...baseEnv(record), ...options.env } });
552
+ const dockerStartupError = async (record, service, cause) => {
553
+ const args = ['logs', '--no-color', '--tail', '50', '--timestamps', service];
554
+ const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
555
+ const command = `OURS_DAEMON_ID=${quote(record.instanceId)} docker ${composeArgs(record, args).map(quote).join(' ')}`;
556
+ let detail;
557
+ try {
558
+ const logs = await compose(record, args);
559
+ // Limit terminal diagnostics; container output must not inject terminal controls.
560
+ detail = (logs.stdout ?? '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
561
+ .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '').trim().slice(-6000);
562
+ detail = detail ? `Recent ${service} logs:\n${detail}` : 'The container produced no readable logs.';
563
+ } catch {
564
+ detail = 'Container logs could not be read.';
565
+ }
566
+ return new Error(`Docker service "${service}" failed to start or become healthy.\n${detail}\nStartup error: ${cause.message}\nInspect logs: ${command}`, { cause });
567
+ };
542
568
  const bin = (record, name) => join(record.workDir, 'node_modules', '.bin', name);
543
569
  const localEnv = (record, service = 'daemon') => {
544
570
  const paths = installationPaths(record);
@@ -566,6 +592,7 @@ export function networkEffects(effects) {
566
592
  }
567
593
  };
568
594
  return {
595
+ ...createServerOnboarding(effects, { compose, localEnv, bin }),
569
596
  sourcePolicyHash(path) {
570
597
  return createHash('sha256').update(readFileSync(path)).digest('hex');
571
598
  },
@@ -601,7 +628,7 @@ export function networkEffects(effects) {
601
628
  const project = `ours-${createHash('sha256').update(root).digest('hex').slice(0, 16)}`;
602
629
  return { schema: 2, root, mode, instanceId, project, workDir: join(root, 'runtime'), configPath: installationPaths({ schema: 2, root }).config, sourcesPath: join(root, 'sources.json'), services: [...SERVER_SERVICES], port: 3050, coworkPort: 3052, messengerPort: 8420, messengerIdentity: env.OURS_MESSENGER_IDENTITY || null, uid: 1000, gid: 1000 };
603
630
  },
604
- async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest } = {}) {
631
+ async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest, identityName } = {}) {
605
632
  if (existing) {
606
633
  privateDirectory(record.root);
607
634
  assertPrivateRegularFile(join(record.root, 'installation.json'), 'selection');
@@ -609,10 +636,34 @@ export function networkEffects(effects) {
609
636
  if (env.OURS_DAEMON_ID && env.OURS_DAEMON_ID !== record.instanceId) throw new Error('Conflicting instance ID');
610
637
  }
611
638
  if (record.mode === 'docker') {
612
- await effects.run('docker', ['info', '--format', '{{.ServerVersion}}']);
613
- const version = await effects.run('docker', ['compose', 'version', '--short']);
639
+ const nativeRoot = existing ? '/path/to/new-empty-directory' : record.root;
640
+ const quotedRoot = `'${String(nativeRoot).replaceAll("'", "'\\''")}'`;
641
+ const quotedName = `'${String(identityName ?? record.messengerIdentity ?? 'Your Name').replaceAll("'", "'\\''")}'`;
642
+ const recovery = [
643
+ 'Please install Docker Desktop on macOS/Windows, or Docker Engine with the Compose plugin on Linux, and start Docker before retrying.',
644
+ 'Docker is recommended for macOS and Windows.',
645
+ `Alternatively, use native installation: ours-install server install --mode packages --state-dir ${quotedRoot} --identity-name ${quotedName}`,
646
+ 'Native mode requires systemd user services on Linux/WSL or a launchd GUI session on macOS.',
647
+ ...(existing ? ['Keep this existing Docker installation in Docker mode; use a separate empty directory for a new native installation.'] : []),
648
+ ].join('\n');
649
+ try {
650
+ await effects.run('docker', ['info', '--format', '{{.ServerVersion}}']);
651
+ } catch (cause) {
652
+ const problem = cause.code === 'ENOENT'
653
+ ? 'Docker command was not found in PATH.'
654
+ : `Docker Engine is not reachable. Start Docker and check that your user can access it.\nDetails: ${cause.message}`;
655
+ throw new Error(`${problem}\n${recovery}`, { cause });
656
+ }
657
+ let version;
658
+ try {
659
+ version = await effects.run('docker', ['compose', 'version', '--short']);
660
+ } catch (cause) {
661
+ throw new Error(`Docker Compose 2.35 or newer is required, but the Compose plugin could not run. Update Docker Desktop or install the Docker Compose plugin.\n${recovery}`, { cause });
662
+ }
614
663
  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');
664
+ if (!match || Number(match[1]) < 2 || (Number(match[1]) === 2 && Number(match[2]) < 35)) {
665
+ throw new Error(`Docker Compose 2.35 or newer is required. Update Docker Desktop or the Docker Compose plugin.\n${recovery}`);
666
+ }
616
667
  if (operation !== 'status') {
617
668
  // Compose clients can disappear while their Engine-owned command continues.
618
669
  const active = await effects.run('docker', ['ps', '--filter', `label=com.docker.compose.project=${record.project}`, '--filter', 'label=com.docker.compose.oneoff=True', '--format', '{{.ID}}']);
@@ -631,7 +682,17 @@ export function networkEffects(effects) {
631
682
  }
632
683
  }
633
684
  },
634
- async initializeSelection(record, manifest) {
685
+ async prepareLegacyDockerImport(record) {
686
+ if (record.mode === 'docker') await compose(record, ['build', 'legacy-import'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
687
+ },
688
+ async importLegacyDockerState(record) {
689
+ if (record.mode !== 'docker') return;
690
+ const source = join(record.root, 'storage', 'state');
691
+ if (source.includes(':')) throw new Error('Docker migration requires an installation path without colon characters');
692
+ await compose(record, ['run', '--rm', '--no-deps', '-T', '--volume', `${source}:/legacy-import:ro`,
693
+ '--env', `OURS_LEGACY_TARGET_ROOT=${record.root}`, 'legacy-import']);
694
+ },
695
+ async initializeSelection(record, manifest, { retainConfig = false } = {}) {
635
696
  if (typeof manifest === 'string') manifest = JSON.parse(readFileSync(manifest, 'utf8'));
636
697
  selectSourcePackages(manifest, 'server');
637
698
  const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
@@ -639,8 +700,9 @@ export function networkEffects(effects) {
639
700
  if (record.schema === 2) {
640
701
  for (const path of [join(record.root, 'storage'), installationPaths(record).state, installationPaths(record).daemon]) ensurePrivateDirectory(path);
641
702
  }
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');
703
+ if (!retainConfig || !existsSync(record.sourcesPath)) writePrivateNew(record.sourcesPath, bytes);
704
+ else if (!readFileSync(record.sourcesPath).equals(bytes)) throw new Error('Migration source selection changed');
705
+ 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
706
  },
645
707
  async prepareInstallation(record, { runtimeOnly = false } = {}) {
646
708
  let copied = false;
@@ -659,7 +721,7 @@ export function networkEffects(effects) {
659
721
  const { dependencies } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
660
722
  writeFileSync(join(record.workDir, 'scripts/maintenance/package.json'), JSON.stringify({ private: true, type: 'module', dependencies }, null, 2) + '\n', { mode: 0o600 });
661
723
  const image = await effects.run('docker', ['image', 'inspect', `${record.project}:runtime`], { allowCodes: [1] });
662
- if (image.code !== 0) await compose(record, ['build', 'daemon']);
724
+ if (image.code !== 0) await compose(record, ['build', 'daemon'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
663
725
  if (runtimeOnly) return;
664
726
  await compose(record, ['run', '--rm', '--no-deps', '-T', 'prepare', 'prepare']);
665
727
  } else {
@@ -667,8 +729,8 @@ export function networkEffects(effects) {
667
729
  const sourceRoot = join(record.root, `build-${randomUUID()}`);
668
730
  ensurePrivateDirectory(sourceRoot);
669
731
  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 });
732
+ 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 } });
733
+ await effects.run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { stream: true, cwd: record.workDir });
672
734
  await effects.run(process.execPath, [join(record.workDir, 'scripts/build/record-build.mjs')], { cwd: record.workDir, env: { OURS_BUILD_ROOT: record.workDir } });
673
735
  writePrivateNew(join(record.workDir, '.packages-ready'), 'ready\n');
674
736
  } finally { rmSync(sourceRoot, { recursive: true, force: true }); }
@@ -1061,13 +1123,19 @@ export function networkEffects(effects) {
1061
1123
  if ((await effects.serverLifecycle(record, 'status', selected)).length) throw new Error('Writers did not stop');
1062
1124
  return;
1063
1125
  }
1064
- if (selected.includes('daemon')) await compose(record, ['up', '-d', '--no-build', '--no-deps', '--wait', 'daemon']);
1126
+ const start = async service => {
1127
+ effects.out(`Starting ${service}; waiting for readiness...`);
1128
+ try { await compose(record, ['up', '-d', '--no-build', '--no-deps', '--wait', service]); }
1129
+ catch (cause) { throw await dockerStartupError(record, service, cause); }
1130
+ effects.out(`${service} is ready.`);
1131
+ };
1132
+ if (selected.includes('daemon')) await start('daemon');
1065
1133
  const failures = [];
1066
1134
  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); }
1135
+ try { await start(service); }
1136
+ catch (error) { failures.push({ service, error }); }
1069
1137
  }
1070
- if (failures.length) throw new Error(`Application readiness failed: ${failures.join(', ')}. Check owning prerequisites; no identities were created.`);
1138
+ 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
1139
  return;
1072
1140
  }
1073
1141
  return nativeLifecycle(record, operation, selected, { effects, localEnv, ownerCommand, bin });
@@ -1090,7 +1158,7 @@ export function networkEffects(effects) {
1090
1158
  // Full metadata and authenticated capability validation follows before publication.
1091
1159
  return validateHostProfile({ endpoint: url.origin, expectedInstanceId: selection.instanceId, credentialPath: resolve(credentialPath) });
1092
1160
  },
1093
- importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath }) {
1161
+ importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh = false }) {
1094
1162
  const root = join(home, '.ours-client');
1095
1163
  const configPath = join(root, 'profile.json');
1096
1164
  const credentialPath = join(root, 'credential');
@@ -1101,13 +1169,13 @@ export function networkEffects(effects) {
1101
1169
  const credential = readFileSync(profile.credentialPath, 'utf8');
1102
1170
  if (!credential.trim()) throw new Error('Client credential is empty');
1103
1171
  // Read every supplied input before any publication. Existing setup settings win on retry.
1104
- const sources = current ? null : resolvedSources
1172
+ const sources = current && !refresh ? null : resolvedSources
1105
1173
  ? Buffer.from(`${JSON.stringify(resolvedSources, null, 2)}\n`)
1106
1174
  : readFileSync(sourcesPath);
1107
- const fleetSettings = !current && fleetSettingsPath ? readFileSync(fleetSettingsPath) : null;
1175
+ const fleetSettings = (!current || refresh) && fleetSettingsPath ? readFileSync(fleetSettingsPath) : null;
1108
1176
  if (fleetSettings) JSON.parse(fleetSettings.toString());
1109
1177
  ensurePrivateDirectory(root);
1110
- if (current) {
1178
+ if (current && !refresh) {
1111
1179
  assertPrivateRegularFile(credentialPath, 'managed credential');
1112
1180
  if (readFileSync(credentialPath, 'utf8') !== credential) atomicWriteConfig(credentialPath, credential);
1113
1181
  return { configPath, profile: validateHostProfile(current), settings: current.installer };
@@ -1122,12 +1190,13 @@ export function networkEffects(effects) {
1122
1190
  atomicWriteConfig(configPath, JSON.stringify(saved, null, 2) + '\n');
1123
1191
  return { configPath, profile: validateHostProfile(saved), settings };
1124
1192
  },
1125
- async acquireClientPackages(configPath, sourcesPath, integrations) {
1193
+ async acquireClientPackages(configPath, sourcesPath, integrations, { refresh = false } = {}) {
1126
1194
  const manifest = JSON.parse(readFileSync(sourcesPath, 'utf8'));
1127
1195
  // Public SDK client APIs are actual integration dependencies; Fleet also owns CLI usage.
1128
1196
  const selected = [...new Set(['sdk', ...(integrations.includes('fleet') ? ['cli'] : []), ...integrations])];
1129
1197
  const packages = selectSourcePackages(manifest, 'client', selected);
1130
- const root = join(home, '.ours-client-install', createHash('sha256').update(configPath).digest('hex').slice(0, 16));
1198
+ const selectionKey = refresh ? JSON.stringify([configPath, manifest, integrations]) : configPath;
1199
+ const root = join(home, '.ours-client-install', createHash('sha256').update(selectionKey).digest('hex').slice(0, 16));
1131
1200
  const hasGit = Object.values(packages).some(selection => selection.source);
1132
1201
  await effects.run('npm', ['--version']);
1133
1202
  if (hasGit) {
@@ -1147,12 +1216,12 @@ export function networkEffects(effects) {
1147
1216
  const sourceRoot = join(root, `build-${randomUUID()}`);
1148
1217
  ensurePrivateDirectory(sourceRoot);
1149
1218
  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(',') } });
1219
+ 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
1220
  } finally { rmSync(sourceRoot, { recursive: true, force: true }); }
1152
1221
  } else {
1153
1222
  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
1223
  }
1155
- await effects.run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { cwd: root });
1224
+ await effects.run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { stream: true, cwd: root });
1156
1225
  verifyReleaseGraph(root, manifest, { requiredPackages: Object.keys(packages) });
1157
1226
  writePrivateNew(join(root, '.packages-ready'), 'ready\n');
1158
1227
  }
@@ -1365,5 +1434,5 @@ async function nativeLifecycle(record, operation, selected, { effects, localEnv,
1365
1434
  failures.push(service);
1366
1435
  }
1367
1436
  }
1368
- if (failures.length) throw new Error(`Application readiness failed: ${failures.join(', ')}. Check owning prerequisites; no identities were created.`);
1437
+ if (failures.length) throw new Error(`Application readiness failed: ${failures.join(', ')}. Check the selected application prerequisites.`);
1369
1438
  }
@@ -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
+ }
@@ -0,0 +1,202 @@
1
+ /** Local-only migration: preserve opaque daemon state and retire its old launcher. */
2
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
3
+ import { join, basename } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { inspectLegacyState, stageLegacyState, withLegacyStateLock, ensureLegacyLockSupport } from './legacy-state.mjs';
6
+ import { inspectManagedCli, installManagedCli } from './managed-cli.mjs';
7
+ import { atomicWriteConfig } from './config.mjs';
8
+ import { classifyUnit, unitPathForStateDir, validateInstallation } from './plan.mjs';
9
+
10
+ const journalPath = root => join(root, 'legacy-migration.json');
11
+ const parse = result => JSON.parse(result.stdout);
12
+ const phases = ['prepared', 'stopped', 'copied', 'activating', 'verified', 'complete'];
13
+ function readJournal(root) {
14
+ const path = journalPath(root);
15
+ if (!existsSync(path)) return null;
16
+ const j = JSON.parse(readFileSync(path));
17
+ if (j.schema !== 1 || j.targetRoot !== root || !phases.includes(j.phase) || !Array.isArray(j.identities)) throw new Error('Invalid legacy migration journal');
18
+ return j;
19
+ }
20
+ function selection(source) {
21
+ return { env: { OURS_CONFIG: source.configPath, OURS_STATE_DIR: source.stateDir, OURS_PORT: String(source.config.port ?? 3050), OURS_API_TOKEN: undefined, OURS_DAEMON_ID: undefined, OURS_DAEMON_URL: undefined, OURS_DAEMON_CREDENTIAL_PATH: undefined }, sensitive: true };
22
+ }
23
+ async function oldCommand(effects, source, args, options = {}) {
24
+ return effects.run(source.originalProgram ?? 'ours', [...args, '--config', source.configPath, '--state-dir', source.stateDir, '--json'], { ...selection(source), ...options });
25
+ }
26
+ function validateIdentities(rows, source) {
27
+ if (!Array.isArray(rows) || !rows.length || rows.some(r => !['root', 'role'].includes(r.kind) || typeof r.cid !== 'string' || !r.cid || typeof r.name !== 'string')) throw new Error('Legacy identities are not fully restored; migration requires a healthy source daemon');
28
+ const roots = rows.filter(r => r.kind === 'root');
29
+ if (roots.length !== 1 || roots[0].name !== source.rootName) throw new Error('Legacy Human identity does not match its stored root');
30
+ return rows.map(({ name, kind, cid }) => ({ name, kind, cid })).sort((a,b) => a.name.localeCompare(b.name));
31
+ }
32
+
33
+ export async function inspectLegacyMigration(options, effects, deps = {}) {
34
+ const inspect = deps.inspectLegacyState ?? inspectLegacyState;
35
+ await (deps.ensureLegacyLockSupport ?? ensureLegacyLockSupport)();
36
+ const cliPlan = options.dryRun ? null : await (deps.inspectManagedCli ?? inspectManagedCli)(effects, options.stateDir);
37
+ const journal = readJournal(options.stateDir);
38
+ if (journal) {
39
+ if (journal.sourceConfig !== options.migrateFrom) throw new Error('Another legacy source is already selected for this target');
40
+ return { journal, cliPlan, source: journal.phase === 'complete' ? null : { ...inspect(options.migrateFrom, options.stateDir), originalProgram: cliPlan?.originalProgram } };
41
+ }
42
+ const source = { ...inspect(options.migrateFrom, options.stateDir), originalProgram: cliPlan?.originalProgram };
43
+ if (options.mode === 'docker' && options.stateDir.includes(':')) throw new Error('Docker migration requires an installation path without colon characters');
44
+ if (effects.readJson(join(options.stateDir, 'installation.json'))) throw new Error('Legacy migration requires a new managed installation root');
45
+ if (existsSync(options.stateDir) && readdirSync(options.stateDir).length) throw new Error('Legacy migration target must be empty');
46
+ if (options.dryRun) return { source, journal: { schema: 1, sourceConfig: source.configPath, sourceStateDir: source.stateDir, targetRoot: options.stateDir, phase: 'prepared', identities: [], service: null } };
47
+ // The owning CLI verifies endpoint, process and state directory. No PID guessing.
48
+ const status = parse(await oldCommand(effects, source, ['daemon', 'status']));
49
+ if (status.state !== 'running' || status.stateDir !== source.stateDir) throw new Error('Start the legacy daemon before migration so its identities can be verified');
50
+ const identities = validateIdentities(parse(await oldCommand(effects, source, ['identity', 'list'])), source);
51
+ const service = parse(await oldCommand(effects, source, ['daemon', 'uninstall-service', '--dry-run']));
52
+ if (service.conflict) {
53
+ const legacy = effects.platform?.platform === 'linux' && source.stateDir === join(effects.home, '.ours')
54
+ ? unitPathForStateDir(source.stateDir, effects.home) : null;
55
+ if (!legacy?.ok || legacy.path !== service.serviceFile || classifyUnit(effects.readText(legacy.path)).kind !== 'legacy') {
56
+ throw new Error('Legacy boot service ownership could not be verified: ' + service.conflict.message);
57
+ }
58
+ service.legacyUnit = legacy.unit;
59
+ }
60
+ return { source, cliPlan, journal: { schema: 1, sourceConfig: source.configPath, sourceStateDir: source.stateDir, targetRoot: options.stateDir,
61
+ phase: 'prepared', identities, service, sourcePort: source.config.port ?? 3050 } };
62
+ }
63
+
64
+ export async function prepareMigrationCliRuntime(record, effects) {
65
+ const root = join(record.root, 'launcher-runtime');
66
+ const entry = join(root, 'node_modules', '@ours.network', 'install', 'install.mjs');
67
+ const ready = join(root, '.ready');
68
+ if (existsSync(ready) && existsSync(entry)) return entry;
69
+ mkdirSync(root, { recursive: true, mode: 0o700 });
70
+ effects.out('Preparing permanent management commands so ours will keep working after this installer exits.');
71
+ const packageRoot = fileURLToPath(new URL('..', import.meta.url));
72
+ const packed = parse(await effects.run('npm', ['pack', packageRoot, '--ignore-scripts', '--pack-destination', root, '--json']));
73
+ if (!Array.isArray(packed) || packed.length !== 1 || typeof packed[0].filename !== 'string' || basename(packed[0].filename) !== packed[0].filename) throw new Error('Cannot prepare the persistent installer package');
74
+ await effects.run('npm', ['install', '--prefix', root, '--prefer-offline', '--ignore-scripts', '--no-audit', '--no-fund', join(root, packed[0].filename)], { stream: true });
75
+ if (!existsSync(entry)) throw new Error('Persistent installer entry is missing');
76
+ await effects.run(process.execPath, [entry, '--help']);
77
+ atomicWriteConfig(ready, 'ready\n');
78
+ return entry;
79
+ }
80
+
81
+ /** Called under the normal installation lock; retries never overwrite imported state. */
82
+ export async function executeLegacyMigration(args, effects, install, deps = {}) {
83
+ const stage = deps.stageLegacyState ?? stageLegacyState;
84
+ const lock = deps.withLegacyStateLock ?? withLegacyStateLock;
85
+ const save = (root, value) => (deps.atomicWriteConfig ?? atomicWriteConfig)(journalPath(root), JSON.stringify(value, null, 2) + '\n');
86
+ const prepareCli = deps.prepareMigrationCliRuntime ?? prepareMigrationCliRuntime;
87
+ const inspected = args.legacyPlan ?? await inspectLegacyMigration(args, effects, deps);
88
+ let journal = readJournal(args.stateDir) ?? inspected.journal;
89
+ if (journal.phase === 'complete') {
90
+ const retained = effects.readJson(join(args.stateDir, 'installation.json'));
91
+ inspected.cliPlan.installerPath = await prepareCli(retained, effects);
92
+ await (deps.installManagedCli ?? installManagedCli)(retained, inspected.cliPlan, effects);
93
+ const rows = validateIdentities(parse(await effects.run('ours', ['identity', 'list', '--json'])), { rootName: journal.identities.find(row => row.kind === 'root')?.name });
94
+ if (JSON.stringify(rows) !== JSON.stringify(journal.identities)) throw new Error('Completed migration identity verification failed; no replacement Human was created');
95
+ effects.out('Legacy migration already completed; retained identities verified.');
96
+ return 0;
97
+ }
98
+ const source = inspected.source;
99
+ let record;
100
+ let sourceLock;
101
+ let activationAttempted = phases.indexOf(journal.phase) >= phases.indexOf('activating');
102
+ const wrapped = { ...effects,
103
+ newInstallation(root, mode) {
104
+ if (journal.record) {
105
+ if (journal.record.root !== root || journal.record.mode !== mode) throw new Error('Migration target selection changed');
106
+ return validateInstallation(journal.record, root);
107
+ }
108
+ return effects.newInstallation(root, mode);
109
+ },
110
+ async initializeSelection(selected, manifest) {
111
+ record = selected;
112
+ selected.legacyMigrationSource = args.migrateFrom;
113
+ journal.record = selected;
114
+ journal.sourcePolicy = args.sourcePolicy;
115
+ journal.sourceManifest = manifest;
116
+ save(selected.root, journal);
117
+ effects.writeJson(join(selected.root, 'installation.json'), JSON.stringify(selected, null, 2) + '\n');
118
+ await effects.initializeSelection(selected, manifest, { retainConfig: true });
119
+ },
120
+ async serverPreflight(selected, operation, options) {
121
+ if (journal.record && !existsSync(selected.sourcesPath)) {
122
+ if (!journal.sourceManifest) throw new Error('Migration journal lacks its retained package selection');
123
+ await effects.initializeSelection(selected, journal.sourceManifest, { retainConfig: true });
124
+ }
125
+ return effects.serverPreflight(selected, operation, options);
126
+ },
127
+ async prepareInstallation(selected) {
128
+ record = selected;
129
+ if (!existsSync(selected.sourcesPath)) {
130
+ const manifest = journal.sourceManifest ?? await effects.resolveSourcePolicy(args.sourcePolicy, 'server');
131
+ await effects.initializeSelection(selected, manifest, { retainConfig: true });
132
+ }
133
+ await effects.prepareInstallation(selected, { runtimeOnly: true });
134
+ if (selected.mode === 'docker') await effects.prepareLegacyDockerImport(selected);
135
+ inspected.cliPlan.installerPath = await prepareCli(selected, effects);
136
+ journal.cliInstallerPath = inspected.cliPlan.installerPath;
137
+ if (activationAttempted) await effects.serverLifecycle(selected, 'stop');
138
+ // Backups contain secrets; the enclosing installation is private.
139
+ const backup = join(selected.root, 'legacy-backup');
140
+ mkdirSync(backup, { mode: 0o700, recursive: true });
141
+ const configBackup = join(backup, 'config.json');
142
+ if (!existsSync(configBackup)) writeFileSync(configBackup, readFileSync(source.configPath), { flag: 'wx', mode: 0o600 });
143
+ if (journal.service.serviceFile && existsSync(journal.service.serviceFile) && !existsSync(join(backup, 'service'))) {
144
+ writeFileSync(join(backup, 'service'), readFileSync(journal.service.serviceFile), { flag: 'wx', mode: 0o600 });
145
+ }
146
+ save(selected.root, journal);
147
+ if (!activationAttempted) {
148
+ effects.out('Migration: retire the old boot service and stop its daemon.');
149
+ if (journal.service.legacyUnit) await effects.run('systemctl', ['--user', 'disable', '--now', journal.service.legacyUnit]);
150
+ else await oldCommand(effects, source, ['daemon', 'uninstall-service', '--yes']);
151
+ await oldCommand(effects, source, ['daemon', 'stop']);
152
+ journal.phase = 'stopped'; save(selected.root, journal);
153
+ }
154
+ // Hold the SDK-compatible owner lock until activation finishes, including failures.
155
+ sourceLock = await lock(source.stateDir);
156
+ effects.out('Migration: copy retained identities, keys, history and session state.');
157
+ stage(source, selected);
158
+ if (selected.mode === 'docker') await effects.importLegacyDockerState(selected);
159
+ if (!activationAttempted) { journal.phase = 'copied'; save(selected.root, journal); }
160
+ await effects.prepareInstallation(selected);
161
+ },
162
+ async serverLifecycle(selected, operation, services) {
163
+ if (operation === 'start') {
164
+ activationAttempted = true;
165
+ journal.phase = 'activating'; save(selected.root, journal);
166
+ }
167
+ return effects.serverLifecycle(selected, operation, services);
168
+ },
169
+ async serverEnsureIdentity(selected) {
170
+ const rows = await effects.serverListIdentities(selected);
171
+ const actual = validateIdentities(rows, source);
172
+ if (JSON.stringify(actual) !== JSON.stringify(journal.identities)) throw new Error('Migrated identities differ from the source; no replacement Human was created');
173
+ journal.phase = 'verified'; save(selected.root, journal);
174
+ const root = actual.find(r => r.kind === 'root');
175
+ return { ...root, created: false };
176
+ },
177
+ };
178
+ try {
179
+ const result = await install({ ...args, migrate: true }, wrapped);
180
+ if (result !== 0) throw new Error('Migrated server setup did not complete');
181
+ // Publish an explicit host profile; a daemon --config file is not a client profile.
182
+ const directory = join(record.root, 'legacy-client');
183
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
184
+ const credentialPath = join(directory, 'credential');
185
+ if (!existsSync(credentialPath)) await effects.serverAccess(record, 'access-issue', { output: credentialPath });
186
+ const profile = { endpoint: `http://127.0.0.1:${record.port}`, expectedInstanceId: record.instanceId, credentialPath };
187
+ atomicWriteConfig(join(directory, 'profile.json'), JSON.stringify(profile, null, 2) + '\n');
188
+ await (deps.installManagedCli ?? installManagedCli)(record, inspected.cliPlan, effects);
189
+ const defaultRows = validateIdentities(parse(await effects.run('ours', ['identity', 'list', '--json'])), source);
190
+ if (JSON.stringify(defaultRows) !== JSON.stringify(journal.identities)) throw new Error('Default ours command does not select the migrated identities');
191
+ journal.phase = 'complete'; save(record.root, journal);
192
+ effects.out(`Migration complete. Original state remains at ${source.stateDir}; original config/service are backed up in ${join(record.root, 'legacy-backup')}. Do not start the original state alongside the migrated daemon.`);
193
+ return 0;
194
+ } catch (error) {
195
+ if (record && activationAttempted) {
196
+ try { await effects.serverLifecycle(record, 'stop'); }
197
+ catch { throw new Error(`Migration activation failed and destination shutdown could not be confirmed. Keep the source stopped; repair this same target. ${error.message}`); }
198
+ throw new Error(`Migration paused after activation; source remains stopped to avoid diverging identity sessions. Repeat the same migration command to repair the retained target. ${error.message}`);
199
+ }
200
+ throw new Error(`Migration paused before activation; source data is retained. Repeat the same migration command. ${error.message}`);
201
+ } finally { await sourceLock?.close(); }
202
+ }