@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/README.md +94 -126
- package/assets/Dockerfile +1 -0
- package/assets/docker-compose.yaml +20 -0
- package/assets/release-lock.json +6 -6
- package/assets/release.json +1 -1
- package/assets/scripts/runtime/legacy-import.mjs +103 -0
- package/assets/sources.json +1 -1
- package/install.mjs +2 -2
- package/lib/build-transition.mjs +13 -7
- package/lib/effects.mjs +99 -30
- package/lib/fleet-settings.mjs +43 -0
- package/lib/legacy-migration.mjs +202 -0
- package/lib/legacy-state.mjs +205 -0
- package/lib/managed-cli.mjs +164 -0
- package/lib/orchestrate.mjs +69 -24
- package/lib/prompt.mjs +113 -119
- package/lib/server-onboarding.mjs +114 -0
- package/lib/setup-options.mjs +253 -0
- package/lib/setup.mjs +155 -0
- package/lib/usage.mjs +49 -44
- package/package.json +1 -1
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
]
|
|
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
|
-
|
|
613
|
-
const
|
|
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))
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|