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