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