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