@ours.network/install 1.2.1-nightly.4 → 1.2.1-nightly.41
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/GATEWAY_SETUP.md +178 -0
- package/README.md +471 -9
- package/ROOTLESS_PODMAN_VALIDATION.md +210 -0
- package/assets/Dockerfile +14 -12
- package/assets/Dockerfile.gateway +5 -0
- package/assets/docker-compose.yaml +8 -1
- package/assets/fleet-settings.example.json +48 -0
- package/assets/gateway-entrypoint.sh +37 -0
- package/assets/release-lock.json +1177 -4508
- package/assets/release.json +37 -19
- package/assets/scripts/build/build-common.mjs +2 -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 +7 -3
- package/assets/scripts/maintenance/recover-notifications.mjs +26 -0
- package/assets/scripts/maintenance/release-graph.mjs +18 -1
- package/assets/scripts/maintenance/state-operation.mjs +62 -5
- package/assets/scripts/runtime/README.md +7 -0
- package/assets/scripts/runtime/client-setup.mjs +80 -6
- package/assets/scripts/runtime/dependency-ready.mjs +13 -0
- package/assets/scripts/runtime/diagnostics.mjs +72 -0
- package/assets/scripts/runtime/entrypoint.sh +5 -1
- package/assets/scripts/runtime/health-messenger.mjs +7 -6
- package/assets/scripts/runtime/notifications-gateway.mjs +90 -0
- package/assets/scripts/runtime/recover.mjs +165 -0
- package/assets/scripts/runtime/start-cowork.sh +5 -0
- package/assets/scripts/runtime/start-messenger.sh +29 -4
- package/assets/scripts/runtime/start-telegram.sh +5 -0
- package/assets/scripts/runtime/supervise.mjs +35 -0
- package/assets/sources.json +54 -28
- package/install.mjs +7 -2
- package/lib/boot-readiness.mjs +43 -0
- package/lib/build-transition.mjs +16 -0
- package/lib/client-cli.mjs +127 -0
- package/lib/container-engine.mjs +127 -0
- package/lib/diagnostics.mjs +1 -0
- package/lib/docker-conversion-runtime.mjs +3 -2
- package/lib/docker-layout-installation.mjs +2 -1
- package/lib/docker-recovery.mjs +55 -0
- package/lib/docker-runtime-repair.mjs +160 -0
- package/lib/effects.mjs +357 -133
- package/lib/gateway-transition.mjs +87 -0
- package/lib/gateway.mjs +158 -0
- package/lib/legacy-migration.mjs +13 -2
- package/lib/orchestrate-uninstall.mjs +18 -6
- package/lib/orchestrate.mjs +193 -43
- package/lib/plan.mjs +27 -11
- package/lib/podman-preflight.mjs +40 -0
- package/lib/server-onboarding.mjs +164 -16
- package/lib/server-ports.mjs +17 -0
- package/lib/setup-options.mjs +99 -28
- package/lib/setup.mjs +35 -17
- package/lib/target.mjs +24 -8
- package/lib/uninstall.mjs +2 -2
- package/lib/usage.mjs +21 -7
- package/lib/workspace-setup.mjs +83 -0
- package/package.json +4 -2
package/lib/effects.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { selectServerPorts } from './server-ports.mjs';
|
|
2
|
+
import { qualifyPodman } from './podman-preflight.mjs';
|
|
3
|
+
import { engineName, bindPodman, runContainer, nativeBuildCommands } from './container-engine.mjs';
|
|
1
4
|
// ours-install v3 — the real side effects.
|
|
2
5
|
//
|
|
3
6
|
// Every mutation the installer can perform lives here and nowhere else, behind
|
|
@@ -11,21 +14,27 @@
|
|
|
11
14
|
// state-directory guard and the enable/reload. The installer never touches a
|
|
12
15
|
// unit file or the service manager directly.
|
|
13
16
|
|
|
17
|
+
import { publishClientCli } from './client-cli.mjs';
|
|
18
|
+
import { commandFailure } from './diagnostics.mjs';
|
|
14
19
|
import { spawnSync, execFileSync } from 'node:child_process';
|
|
15
20
|
import { chmodSync, closeSync, constants, cpSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
16
21
|
import { homedir, userInfo, platform as osPlatform, release as osRelease, arch as osArch } from 'node:os';
|
|
17
22
|
import { fileURLToPath } from 'node:url';
|
|
18
23
|
import { randomUUID, createHash } from 'node:crypto';
|
|
19
24
|
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 { validateHostProfile } from './target.mjs';
|
|
25
|
+
import { clientPackageNames, maintenanceServices, installationPaths, validateInstallation, consumerServiceState, unitNameForStateDir, launchdLabelForStateDir, messengerServicePlan, selectSourcePackages, resolveSourcePolicy, SERVER_SERVICES } from './plan.mjs';
|
|
26
|
+
import { validateHostProfile, validateGatewayClientProfile } from './target.mjs';
|
|
27
|
+
import { serverBase, validateGatewayDiscovery, gatewayCompose, gatewayNginx, gatewayAddress } from './gateway.mjs';
|
|
22
28
|
import { createServerOnboarding } from './server-onboarding.mjs';
|
|
23
29
|
import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
|
|
24
30
|
import { select as selectOnTty, multiselect as multiselectOnTty, askLine as askLineOnTty } from './prompt.mjs';
|
|
25
31
|
import { classifyHarnessProbe } from './logic.mjs';
|
|
32
|
+
import { qualifyDockerRuntime, refreshDockerPolicyCopy } from './docker-runtime-repair.mjs';
|
|
33
|
+
import { refreshDockerRecovery, recoveryCompose, reconcileDockerRecovery } from './docker-recovery.mjs';
|
|
34
|
+
import { reportBootReadiness } from './boot-readiness.mjs';
|
|
26
35
|
import { classifyStateDir } from './detect.mjs';
|
|
27
36
|
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';
|
|
37
|
+
import { hostCliPolicy, releaseBinding, verifyReleaseGraph, verifyRuntimeRelease } from '../assets/scripts/maintenance/release-graph.mjs';
|
|
29
38
|
|
|
30
39
|
/** GET http://127.0.0.1:<port>/state-dir — the unauthenticated identity probe. */
|
|
31
40
|
async function probePort(port, { timeoutMs = 1500 } = {}) {
|
|
@@ -399,7 +408,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
399
408
|
// invocation only and never to the installer's own process: a state
|
|
400
409
|
// directory selected by one run must not leak into anything the operator
|
|
401
410
|
// starts afterwards.
|
|
402
|
-
run: async (cmd, args, { env: extraEnv = null, stream = false, cwd, sensitive = false, allowCodes = [] } = {}) => {
|
|
411
|
+
run: async (cmd, args, { env: extraEnv = null, stream = false, cwd, sensitive = false, allowCodes = [], timeout, input } = {}) => {
|
|
403
412
|
// Always built from this layer's OWN env rather than left to spawnSync's
|
|
404
413
|
// implicit inheritance, so what a child receives is a property of the
|
|
405
414
|
// effects object a caller constructed and not of whatever ambient shell
|
|
@@ -410,18 +419,19 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
410
419
|
const executable = cmd === 'npm' ? npmBin : cmd;
|
|
411
420
|
const r = spawnSync(executable, args, {
|
|
412
421
|
cwd,
|
|
422
|
+
timeout,
|
|
413
423
|
encoding: 'utf8',
|
|
414
|
-
|
|
424
|
+
input,
|
|
425
|
+
stdio: [...(stream ? [input === undefined ? 'ignore' : 'pipe', 'inherit', 'inherit'] : [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe']), ...(installationLockFd === null ? [] : [installationLockFd])],
|
|
415
426
|
env: childEnv,
|
|
416
427
|
});
|
|
417
428
|
if (r.error) {
|
|
418
|
-
const error =
|
|
429
|
+
const error = commandFailure(executable, args, r, { sensitive, cwd, env: childEnv });
|
|
419
430
|
error.code = r.error.code;
|
|
420
431
|
throw error;
|
|
421
432
|
}
|
|
422
433
|
if (r.status !== 0 && !allowCodes.includes(r.status)) {
|
|
423
|
-
|
|
424
|
-
throw new Error(`${executable} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
|
|
434
|
+
throw commandFailure(executable, args, r, { sensitive, cwd, env: childEnv });
|
|
425
435
|
}
|
|
426
436
|
return { ok: true, code: r.status, stdout: r.stdout ?? '' };
|
|
427
437
|
},
|
|
@@ -435,7 +445,8 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
435
445
|
delete childEnv.OURS_INSTALLER_LOCK_FD;
|
|
436
446
|
if (installationLockFd !== null) childEnv.OURS_INSTALLER_LOCK_FD = '3';
|
|
437
447
|
const r = spawnSync(cmd, args, { stdio: ['inherit', 'inherit', 'inherit', ...(installationLockFd === null ? [] : [installationLockFd])], env: childEnv });
|
|
438
|
-
|
|
448
|
+
if (r.error || r.status !== 0) throw commandFailure(cmd, args, r, { env: childEnv });
|
|
449
|
+
return { ok: true, code: r.status };
|
|
439
450
|
},
|
|
440
451
|
installedVersion: (pkg) => installedVersionOf(pkg, npmBin),
|
|
441
452
|
packageDependencies: (spec) => packageDependenciesOf(spec, npmBin),
|
|
@@ -534,6 +545,7 @@ export function networkEffects(effects) {
|
|
|
534
545
|
const baseEnv = (record) => ({
|
|
535
546
|
OURS_DAEMON_ID: record.instanceId,
|
|
536
547
|
OURS_IMAGE: `${record.project}:runtime`,
|
|
548
|
+
OURS_GATEWAY_IMAGE: `${record.project}:gateway`,
|
|
537
549
|
OURS_MAINTENANCE_IMAGE: `${record.project}:maintenance`,
|
|
538
550
|
OURS_UID: String(record.uid ?? 1000), OURS_GID: String(record.gid ?? 1000),
|
|
539
551
|
OURS_HOST_PORT: String(record.port ?? 3050),
|
|
@@ -545,14 +557,56 @@ export function networkEffects(effects) {
|
|
|
545
557
|
'compose', '--project-directory', record.workDir, '--file', join(record.workDir,
|
|
546
558
|
record.schema === 1 && existsSync(join(record.workDir, 'docker-compose.legacy.yaml'))
|
|
547
559
|
? 'docker-compose.legacy.yaml' : 'docker-compose.yaml'),
|
|
560
|
+
...(record.gateway ? ['--file', join(record.workDir, 'docker-compose.gateway.yaml')] : []),
|
|
561
|
+
...(existsSync(join(record.workDir, 'compose.recovery.json')) ? ['--file', join(record.workDir, 'compose.recovery.json')] : []),
|
|
562
|
+
...(engineName(record) === 'podman' ? ['--file', join(record.workDir, 'compose.podman.json')] : []),
|
|
548
563
|
'--project-name', record.project, ...args,
|
|
549
564
|
];
|
|
550
|
-
const
|
|
551
|
-
|
|
565
|
+
const container = (record, args, options = {}) => runContainer(effects, record, args, options);
|
|
566
|
+
const compose = async (record, args, options = {}) => {
|
|
567
|
+
const selected = { ...options, env: { ...baseEnv(record), ...options.env } };
|
|
568
|
+
if (engineName(record) === 'podman' && args[0] === 'build') {
|
|
569
|
+
const result = await container(record, composeArgs(record, ['--profile', '*', 'config', '--format', 'json']), { env: selected.env });
|
|
570
|
+
for (const command of nativeBuildCommands(JSON.parse(result.stdout), args.slice(1), { ...env, ...selected.env })) await container(record, command, selected);
|
|
571
|
+
return { code: 0, stdout: '' };
|
|
572
|
+
}
|
|
573
|
+
if (engineName(record) === 'podman' && ['run', 'up'].includes(args[0])) {
|
|
574
|
+
const resolved = JSON.parse((await container(record, composeArgs(record, ['--profile', '*', 'config', '--format', 'json']), { env: selected.env })).stdout);
|
|
575
|
+
for (const [key, volume] of Object.entries(resolved.volumes ?? {})) {
|
|
576
|
+
if (volume.external) throw new Error('Unexpected external installation volume');
|
|
577
|
+
const name = volume.name ?? `${record.project}_${key}`;
|
|
578
|
+
const found = await container(record, ['volume', 'inspect', '--format', '{{json .}}', name], { allowCodes: [1] });
|
|
579
|
+
if (found.code === 1) continue;
|
|
580
|
+
const value = JSON.parse(found.stdout);
|
|
581
|
+
if (value.Name !== name || value.Labels?.['com.docker.compose.project'] !== record.project || value.Labels?.['com.docker.compose.volume'] !== key) throw new Error(`Refusing foreign volume: ${name}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// Compose run can implicitly build an absent administration image. Build
|
|
585
|
+
// its resolved target natively first, just like explicit build operations.
|
|
586
|
+
if (engineName(record) === 'podman' && args[0] === 'run') {
|
|
587
|
+
const resolved = JSON.parse((await container(record, composeArgs(record, ['--profile', '*', 'config', '--format', 'json']), { env: selected.env })).stdout);
|
|
588
|
+
const takesValue = new Set(['--name', '--entrypoint', '--env', '-e', '--user', '-u', '--workdir', '-w', '--volume', '-v', '--publish', '-p', '--label', '-l', '--pull']);
|
|
589
|
+
let name;
|
|
590
|
+
for (let i = 1; i < args.length; i++) {
|
|
591
|
+
if (takesValue.has(args[i])) { i++; continue; }
|
|
592
|
+
if (args[i].startsWith('-')) continue;
|
|
593
|
+
name = args[i]; break;
|
|
594
|
+
}
|
|
595
|
+
const service = resolved.services?.[name];
|
|
596
|
+
if (!service) throw new Error(`Unresolved Podman administration service: ${name}`);
|
|
597
|
+
if (service.build) {
|
|
598
|
+
const image = await container(record, ['image', 'inspect', service.image], { allowCodes: [1] });
|
|
599
|
+
if (image.code === 1) for (const command of nativeBuildCommands(resolved, [name], { ...env, ...selected.env })) await container(record, command, selected);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return container(record, composeArgs(record, args), selected);
|
|
603
|
+
};
|
|
552
604
|
const dockerStartupError = async (record, service, cause) => {
|
|
553
605
|
const args = ['logs', '--no-color', '--tail', '50', '--timestamps', service];
|
|
554
606
|
const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
555
|
-
const
|
|
607
|
+
const engine = engineName(record);
|
|
608
|
+
const prefix = engine === 'podman' ? ['--remote', '--url', `unix://${record.containerBinding.socket}`] : [];
|
|
609
|
+
const command = `OURS_DAEMON_ID=${quote(record.instanceId)} ${engine} ${[...prefix, ...composeArgs(record, args)].map(quote).join(' ')}`;
|
|
556
610
|
let detail;
|
|
557
611
|
try {
|
|
558
612
|
const logs = await compose(record, args);
|
|
@@ -563,7 +617,7 @@ export function networkEffects(effects) {
|
|
|
563
617
|
} catch {
|
|
564
618
|
detail = 'Container logs could not be read.';
|
|
565
619
|
}
|
|
566
|
-
return new Error(
|
|
620
|
+
return new Error(`${engine === 'podman' ? 'Podman' : 'Docker'} service "${service}" failed to start or become healthy.\n${detail}\nStartup error: ${cause.message}\nInspect logs: ${command}`, { cause });
|
|
567
621
|
};
|
|
568
622
|
const bin = (record, name) => join(record.workDir, 'node_modules', '.bin', name);
|
|
569
623
|
const localEnv = (record, service = 'daemon') => {
|
|
@@ -577,14 +631,15 @@ export function networkEffects(effects) {
|
|
|
577
631
|
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}` };
|
|
578
632
|
};
|
|
579
633
|
const ownerCommand = (record, service, op, options = {}) => {
|
|
580
|
-
const
|
|
581
|
-
const
|
|
634
|
+
const daemonBinary = existsSync(bin(record, 'ours-daemon')) ? 'ours-daemon' : 'ours';
|
|
635
|
+
const name = { daemon: daemonBinary, telegram: 'ours-tg-connector', cowork: 'ours-cowork' }[service];
|
|
636
|
+
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];
|
|
582
637
|
return effects.run(bin(record, name), args, { ...options, env: localEnv(record, service) });
|
|
583
638
|
};
|
|
584
639
|
const requireCleanContainerExit = async (record, selected) => {
|
|
585
640
|
const containers = await compose(record, ['ps', '-aq', ...selected]);
|
|
586
641
|
for (const id of containers.stdout.split(/\s+/).filter(Boolean)) {
|
|
587
|
-
const result = await
|
|
642
|
+
const result = await container(record, ['inspect', '--format', '{{json .State}}', id]);
|
|
588
643
|
const state = JSON.parse(result.stdout);
|
|
589
644
|
if (state.Status !== 'exited' || state.ExitCode !== 0 || state.OOMKilled !== false || state.Dead !== false) {
|
|
590
645
|
throw new Error('Selected source container did not stop cleanly; state operation refused');
|
|
@@ -618,7 +673,7 @@ export function networkEffects(effects) {
|
|
|
618
673
|
return version;
|
|
619
674
|
});
|
|
620
675
|
},
|
|
621
|
-
newInstallation(root, mode) {
|
|
676
|
+
newInstallation(root, mode, { containerEngine } = {}) {
|
|
622
677
|
if (existsSync(root)) {
|
|
623
678
|
privateDirectory(root);
|
|
624
679
|
if (readdirSync(root).some(name => name !== '.operation.lock')) throw new Error('Installation root is not empty and has no selection record');
|
|
@@ -626,9 +681,10 @@ export function networkEffects(effects) {
|
|
|
626
681
|
const instanceId = env.OURS_DAEMON_ID || randomUUID();
|
|
627
682
|
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(instanceId)) throw new Error('OURS_DAEMON_ID must be a lowercase UUID');
|
|
628
683
|
const project = `ours-${createHash('sha256').update(root).digest('hex').slice(0, 16)}`;
|
|
629
|
-
return { schema: 2, root, mode, instanceId, project, workDir: join(root, 'runtime'), configPath: installationPaths({ schema: 2, root }).config, sourcesPath: join(root, 'sources.json'), services: [...SERVER_SERVICES], port: 3050, coworkPort: 3052, messengerPort: 8420, messengerIdentity: env.OURS_MESSENGER_IDENTITY || null, uid: 1000, gid: 1000 };
|
|
684
|
+
return { schema: 2, root, mode, ...(containerEngine ? { containerEngine } : {}), instanceId, project, workDir: join(root, 'runtime'), configPath: installationPaths({ schema: 2, root }).config, sourcesPath: join(root, 'sources.json'), services: [...SERVER_SERVICES, ...(mode === 'docker' ? ['gateway'] : [])], ...(mode === 'docker' ? { gateway: { version: 1 } } : {}), port: 3050, coworkPort: 3052, messengerPort: 8420, messengerIdentity: env.OURS_MESSENGER_IDENTITY || null, uid: 1000, gid: 1000 };
|
|
630
685
|
},
|
|
631
|
-
async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest, identityName } = {}) {
|
|
686
|
+
async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest, identityName, explicitPorts } = {}) {
|
|
687
|
+
if (!existing && operation === 'install' && Number.isInteger(record.port)) selectServerPorts(record, portTakenSync, explicitPorts, effects.out);
|
|
632
688
|
if (existing) {
|
|
633
689
|
privateDirectory(record.root);
|
|
634
690
|
assertPrivateRegularFile(join(record.root, 'installation.json'), 'selection');
|
|
@@ -636,37 +692,55 @@ export function networkEffects(effects) {
|
|
|
636
692
|
if (env.OURS_DAEMON_ID && env.OURS_DAEMON_ID !== record.instanceId) throw new Error('Conflicting instance ID');
|
|
637
693
|
}
|
|
638
694
|
if (record.mode === 'docker') {
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
695
|
+
if (engineName(record) === 'podman') {
|
|
696
|
+
await bindPodman(effects, record);
|
|
697
|
+
if (['install', 'start', 'restart', 'update', 'rebuild', 'gateway-enable'].includes(operation)) {
|
|
698
|
+
const userHome = userInfo().homedir;
|
|
699
|
+
for (const [key, expected] of Object.entries({ HOME: userHome, XDG_CONFIG_HOME: join(userHome, '.config'), XDG_DATA_HOME: join(userHome, '.local/share'), XDG_RUNTIME_DIR: `/run/user/${process.getuid()}` })) {
|
|
700
|
+
if (env[key] && env[key] !== expected) throw new Error(`${key} selects a custom Podman environment that the standard boot service does not retain`);
|
|
701
|
+
}
|
|
702
|
+
if (record.containerBinding.socket !== `/run/user/${process.getuid()}/podman/podman.sock`) throw new Error('The standard user boot service requires the default local Podman socket');
|
|
703
|
+
await effects.run('systemctl', ['--user', 'is-enabled', 'podman.socket']);
|
|
704
|
+
await effects.run('systemctl', ['--user', 'is-enabled', 'podman-restart.service']);
|
|
705
|
+
const restart = await effects.run('systemctl', ['--user', 'show', 'podman-restart.service', '--property=ExecStart', '--value']);
|
|
706
|
+
if (!/--filter[ =]+should-start-on-boot=true(?:[ ;}]|$)/.test(restart.stdout)) throw new Error('Installed podman-restart.service cannot recover unless-stopped workloads; use a Podman release with should-start-on-boot support');
|
|
707
|
+
const linger = await effects.run('loginctl', ['show-user', String(process.getuid()), '--property=Linger', '--value']);
|
|
708
|
+
if (linger.stdout.trim() !== 'yes') throw new Error('Rootless server requires user linger for logout/reboot recovery; ask your administrator to enable it');
|
|
709
|
+
await qualifyPodman(effects, record);
|
|
710
|
+
}
|
|
711
|
+
} else {
|
|
712
|
+
const nativeRoot = existing ? '/path/to/new-empty-directory' : record.root;
|
|
713
|
+
const quotedRoot = `'${String(nativeRoot).replaceAll("'", "'\\''")}'`;
|
|
714
|
+
const quotedName = `'${String(identityName ?? record.messengerIdentity ?? 'Your Name').replaceAll("'", "'\\''")}'`;
|
|
715
|
+
const recovery = [
|
|
716
|
+
'Please install Docker Desktop on macOS/Windows, or Docker Engine with the Compose plugin on Linux, and start Docker before retrying.',
|
|
717
|
+
'Docker is recommended for macOS and Windows.',
|
|
718
|
+
`Alternatively, use native installation: ours-install server install --mode packages --state-dir ${quotedRoot} --identity-name ${quotedName}`,
|
|
719
|
+
'Native mode requires systemd user services on Linux/WSL or a launchd GUI session on macOS.',
|
|
720
|
+
...(existing ? ['Keep this existing Docker installation in Docker mode; use a separate empty directory for a new native installation.'] : []),
|
|
721
|
+
].join('\n');
|
|
722
|
+
try {
|
|
723
|
+
await container(record, ['info', '--format', '{{.ServerVersion}}']);
|
|
724
|
+
} catch (cause) {
|
|
725
|
+
const problem = cause.code === 'ENOENT'
|
|
726
|
+
? 'Docker command was not found in PATH.'
|
|
727
|
+
: `Docker Engine is not reachable. Start Docker and check that your user can access it.\nDetails: ${cause.message}`;
|
|
728
|
+
throw new Error(`${problem}\n${recovery}`, { cause });
|
|
729
|
+
}
|
|
730
|
+
let version;
|
|
731
|
+
try {
|
|
732
|
+
version = await container(record, ['compose', 'version', '--short']);
|
|
733
|
+
} catch (cause) {
|
|
734
|
+
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 });
|
|
735
|
+
}
|
|
736
|
+
const match = /^v?(\d+)\.(\d+)/.exec(version.stdout.trim());
|
|
737
|
+
if (!match || Number(match[1]) < 2 || (Number(match[1]) === 2 && Number(match[2]) < 35)) {
|
|
738
|
+
throw new Error(`Docker Compose 2.35 or newer is required. Update Docker Desktop or the Docker Compose plugin.\n${recovery}`);
|
|
739
|
+
}
|
|
666
740
|
}
|
|
667
741
|
if (operation !== 'status') {
|
|
668
742
|
// Compose clients can disappear while their Engine-owned command continues.
|
|
669
|
-
const active = await
|
|
743
|
+
const active = await container(record, ['ps', '--filter', `label=com.docker.compose.project=${record.project}`, '--filter', 'label=com.docker.compose.oneoff=True', '--format', '{{.ID}}']);
|
|
670
744
|
if (active.stdout.trim()) throw new Error('Another server installation operation is still running in Docker');
|
|
671
745
|
}
|
|
672
746
|
} else {
|
|
@@ -704,7 +778,57 @@ export function networkEffects(effects) {
|
|
|
704
778
|
else if (!readFileSync(record.sourcesPath).equals(bytes)) throw new Error('Migration source selection changed');
|
|
705
779
|
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');
|
|
706
780
|
},
|
|
781
|
+
async qualifyDockerRuntime(record) { return qualifyDockerRuntime(record, effects); },
|
|
782
|
+
async prepareGateway(record) {
|
|
783
|
+
atomicWriteConfig(join(record.workDir, 'docker-compose.gateway.yaml'), gatewayCompose(record));
|
|
784
|
+
atomicWriteConfig(join(record.workDir, 'nginx.conf'), gatewayNginx(record));
|
|
785
|
+
atomicWriteConfig(join(record.workDir, 'gateway-entrypoint.sh'), readFileSync(join(INSTALLER_ASSETS, 'gateway-entrypoint.sh'), 'utf8'));
|
|
786
|
+
atomicWriteConfig(join(record.workDir, 'Dockerfile.gateway'), readFileSync(join(INSTALLER_ASSETS, 'Dockerfile.gateway'), 'utf8'));
|
|
787
|
+
await compose(record, ['build', 'gateway'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
|
|
788
|
+
},
|
|
789
|
+
async removeGatewayContainer(record) { await compose(record, ['rm', '-f', 'gateway']); },
|
|
790
|
+
async verifyGateway(record) {
|
|
791
|
+
const { prefix } = gatewayAddress(record);
|
|
792
|
+
const script = `const fs=require('node:fs');let stage='selection';(async()=>{
|
|
793
|
+
const base=${JSON.stringify('http://gateway:8080' + prefix)};
|
|
794
|
+
const get=async(path,options={})=>{const r=await fetch(base+path,{...options,redirect:'error',signal:AbortSignal.timeout(5000)});if(!r.ok)throw Error('Gateway readiness HTTP '+r.status);return r.json();};
|
|
795
|
+
const selection=await get('/daemon/selection');
|
|
796
|
+
if(selection.instanceId!==${JSON.stringify(record.instanceId)})throw Error('Gateway daemon instance mismatch');
|
|
797
|
+
stage='unauthenticated-management';
|
|
798
|
+
const denied=await fetch(base+'/cowork/management/rpc',{method:'POST',redirect:'error',signal:AbortSignal.timeout(5000),headers:{'content-type':'application/json'},body:'{}'});
|
|
799
|
+
await denied.body?.cancel();if(denied.status!==401)throw Error('Gateway management does not reject unauthenticated requests');
|
|
800
|
+
stage='credential';
|
|
801
|
+
const token=fs.readFileSync('/var/lib/ours/daemon-token','utf8').trim();
|
|
802
|
+
const headers={'x-ours-api-token':token,'content-type':'application/json'};
|
|
803
|
+
stage='authenticated-daemon';
|
|
804
|
+
const identities=await get('/daemon/identities',{headers});
|
|
805
|
+
if(!Array.isArray(identities.identities))throw Error('Gateway daemon API invalid');
|
|
806
|
+
stage='authenticated-management';
|
|
807
|
+
const result=await get('/cowork/management/rpc',{method:'POST',headers,body:JSON.stringify({version:1,id:'installer-readiness',method:'room.list',params:{}})});
|
|
808
|
+
if(result.version!==1||result.id!=='installer-readiness'||!Array.isArray(result.result)||result.error)throw Error('Gateway Cowork management unavailable');
|
|
809
|
+
})().catch(error=>{const code=error.cause?.code;process.stdout.write('gateway-readiness:'+stage+':'+(['EAI_AGAIN','ENOTFOUND','ECONNREFUSED','ETIMEDOUT'].includes(code)?code:'CHECK_FAILED'));process.exitCode=1;});`;
|
|
810
|
+
const result = await compose(record, ['exec', '-T', 'daemon', 'node', '-e', script], { sensitive: true, allowCodes: [1] });
|
|
811
|
+
if (result.code === 1) {
|
|
812
|
+
const match = /^gateway-readiness:(selection|unauthenticated-management|credential|authenticated-daemon|authenticated-management):(EAI_AGAIN|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|CHECK_FAILED)$/.exec(result.stdout.trim());
|
|
813
|
+
const detail = match ? ` (${match[1]}, ${match[2]})` : '';
|
|
814
|
+
throw new Error(`Gateway readiness failed${detail}. Keep installation state intact; check the managed gateway startup and network connection, then retry setup.`);
|
|
815
|
+
}
|
|
816
|
+
},
|
|
817
|
+
async qualifyGatewayRuntime(record) {
|
|
818
|
+
for (const [name, args, capability] of [
|
|
819
|
+
['cowork', ['--json', 'capabilities'], 'cowork.http-management-v1'],
|
|
820
|
+
['messenger-server', ['capabilities'], 'messenger.gateway-prefix-v1'],
|
|
821
|
+
['tg-connector', ['capabilities'], 'telegram.gateway-listener-v1'],
|
|
822
|
+
]) {
|
|
823
|
+
const probe = await container(record, ['run', '--rm', '--network', 'none', '--read-only', '--entrypoint', 'node', `${record.project}:runtime`, `/opt/ours/node_modules/@ours.network/${name}/dist/cli.js`, ...args]);
|
|
824
|
+
let value; try { value = JSON.parse(probe.stdout); } catch {}
|
|
825
|
+
const capabilities = value?.result?.capabilities ?? value?.capabilities;
|
|
826
|
+
if (!Array.isArray(capabilities) || !capabilities.includes(capability))
|
|
827
|
+
throw new Error(`Selected ${name} artifact lacks ${capability}; select a compatible release before enabling the gateway`);
|
|
828
|
+
}
|
|
829
|
+
},
|
|
707
830
|
async prepareInstallation(record, { runtimeOnly = false } = {}) {
|
|
831
|
+
if (!runtimeOnly) await reportBootReadiness(effects, record);
|
|
708
832
|
let copied = false;
|
|
709
833
|
if (!existsSync(record.workDir)) {
|
|
710
834
|
cpSync(INSTALLER_ASSETS, record.workDir, { recursive: true, errorOnExist: true, force: false });
|
|
@@ -717,13 +841,29 @@ export function networkEffects(effects) {
|
|
|
717
841
|
if (!existsSync(materialized)) writePrivateNew(materialized, retained);
|
|
718
842
|
else if (!readFileSync(materialized).equals(retained)) throw new Error('Materialized sources differ from retained selection');
|
|
719
843
|
if (record.mode === 'docker') {
|
|
844
|
+
if (engineName(record) === 'podman') atomicWriteConfig(join(record.workDir, 'compose.podman.json'), JSON.stringify({ services: Object.fromEntries(record.services.map(name => [name, { restart: 'unless-stopped' }])) }));
|
|
845
|
+
const rebuildPrivateScripts = refreshDockerPolicyCopy(record);
|
|
846
|
+
const rebuildRecovery = refreshDockerRecovery(record);
|
|
847
|
+
atomicWriteConfig(join(record.workDir, 'compose.recovery.json'), JSON.stringify(recoveryCompose(record)));
|
|
848
|
+
if (record.gateway) {
|
|
849
|
+
atomicWriteConfig(join(record.workDir, 'docker-compose.gateway.yaml'), gatewayCompose(record));
|
|
850
|
+
atomicWriteConfig(join(record.workDir, 'nginx.conf'), gatewayNginx(record));
|
|
851
|
+
for (const file of ['Dockerfile.gateway', 'gateway-entrypoint.sh']) atomicWriteConfig(join(record.workDir, file), readFileSync(join(INSTALLER_ASSETS, file), 'utf8'));
|
|
852
|
+
}
|
|
720
853
|
// The installer owns these dependencies in both installation modes.
|
|
721
854
|
const { dependencies } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
722
855
|
writeFileSync(join(record.workDir, 'scripts/maintenance/package.json'), JSON.stringify({ private: true, type: 'module', dependencies }, null, 2) + '\n', { mode: 0o600 });
|
|
723
|
-
const image = await
|
|
724
|
-
if (image.code !== 0) await compose(record, ['build', 'daemon'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
|
|
856
|
+
const image = await container(record, ['image', 'inspect', `${record.project}:runtime`], { allowCodes: [1] });
|
|
857
|
+
if (image.code !== 0 || rebuildPrivateScripts || rebuildRecovery) await compose(record, ['build', 'daemon'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
|
|
858
|
+
await effects.qualifyDockerRuntime(record);
|
|
859
|
+
if (rebuildPrivateScripts) rmSync(join(record.workDir, '.script-permissions-rebuild'));
|
|
860
|
+
if (rebuildRecovery) rmSync(join(record.workDir, '.recovery-rebuild'));
|
|
861
|
+
if (record.gateway) {
|
|
862
|
+
await effects.qualifyGatewayRuntime(record);
|
|
863
|
+
await compose(record, ['build', 'gateway'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
|
|
864
|
+
}
|
|
725
865
|
if (runtimeOnly) return;
|
|
726
|
-
await
|
|
866
|
+
await effects.prepareServerVolumes(record);
|
|
727
867
|
} else {
|
|
728
868
|
if (!existsSync(join(record.workDir, '.packages-ready'))) {
|
|
729
869
|
const sourceRoot = join(record.root, `build-${randomUUID()}`);
|
|
@@ -744,12 +884,15 @@ export function networkEffects(effects) {
|
|
|
744
884
|
const profilePath = join(installationPaths(record).mcp, 'profile.json');
|
|
745
885
|
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') }));
|
|
746
886
|
const config = JSON.parse(readFileSync(record.configPath, 'utf8'));
|
|
747
|
-
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') };
|
|
748
887
|
effects.writeJson(record.configPath, JSON.stringify(config, null, 2) + '\n');
|
|
749
888
|
const cowork = join(installationPaths(record).cowork, 'config.json');
|
|
750
889
|
if (!existsSync(cowork)) writePrivateNew(cowork, JSON.stringify({ version: 1, stateDir: installationPaths(record).cowork, rest: { enabled: true, host: '127.0.0.1', port: record.coworkPort } }));
|
|
751
890
|
}
|
|
752
891
|
},
|
|
892
|
+
async prepareServerVolumes(record) {
|
|
893
|
+
if (record.mode !== 'docker') return;
|
|
894
|
+
await compose(record, ['run', '--rm', '--no-deps', '-T', 'prepare', 'prepare']);
|
|
895
|
+
},
|
|
753
896
|
async prepareServerBuild(record, args) {
|
|
754
897
|
validateInstallation(record, record.root);
|
|
755
898
|
if (record.schema !== 2 || record.layoutConversion || !['update', 'rebuild'].includes(args.operation)) {
|
|
@@ -792,17 +935,17 @@ export function networkEffects(effects) {
|
|
|
792
935
|
},
|
|
793
936
|
async copyDockerBuildRecords(record, directory) {
|
|
794
937
|
// Inspect immutable image metadata; never reinterpret a failed context copy as legacy.
|
|
795
|
-
const label = (await
|
|
938
|
+
const label = (await container(record, ['image', 'inspect', '--format', '{{ index .Config.Labels "network.ours.build-context" }}', `${record.project}:runtime`])).stdout.trim();
|
|
796
939
|
if (!['', '<no value>', '1'].includes(label)) throw new Error('Unsupported image build-context schema');
|
|
797
940
|
const names = [...BASE_RECORDS, ...(label === '1' ? [CONTEXT] : [])];
|
|
798
941
|
const stage = mkdtempSync(join(directory, '.records-'));
|
|
799
942
|
const name = `${record.project}-records`;
|
|
800
943
|
let created = false;
|
|
801
944
|
try {
|
|
802
|
-
await
|
|
945
|
+
await container(record, ['create', '--name', name, '--entrypoint', '/bin/true', `${record.project}:runtime`]);
|
|
803
946
|
created = true;
|
|
804
947
|
for (const file of names) {
|
|
805
|
-
await
|
|
948
|
+
await container(record, ['cp', `${name}:/opt/ours/${file}`, join(stage, file)]);
|
|
806
949
|
const st = lstatSync(join(stage, file));
|
|
807
950
|
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o7002)) throw new Error('Unsafe copied build record');
|
|
808
951
|
chmodSync(join(stage, file), 0o600);
|
|
@@ -812,7 +955,7 @@ export function networkEffects(effects) {
|
|
|
812
955
|
for (const file of names) renameSync(join(stage, file), join(directory, file));
|
|
813
956
|
if (label !== '1' && existsSync(join(directory, CONTEXT))) throw new Error('Legacy image conflicts with retained build context');
|
|
814
957
|
} finally {
|
|
815
|
-
try { if (created) await
|
|
958
|
+
try { if (created) await container(record, ['rm', name]); }
|
|
816
959
|
finally { rmSync(stage, { recursive: true, force: true }); }
|
|
817
960
|
}
|
|
818
961
|
},
|
|
@@ -860,15 +1003,47 @@ export function networkEffects(effects) {
|
|
|
860
1003
|
});
|
|
861
1004
|
}
|
|
862
1005
|
},
|
|
1006
|
+
async recoverServerBuildNotifications(record, candidate, phase) {
|
|
1007
|
+
const transition = record.buildTransition;
|
|
1008
|
+
if (record.mode !== 'docker' || transition?.operation !== 'update' || !transition.compatible
|
|
1009
|
+
|| !['prepared', 'state-updated', 'runtime-activated'].includes(phase)
|
|
1010
|
+
|| transition.phase !== phase
|
|
1011
|
+
|| transition.candidate.root !== candidate.root) throw new Error('Notifications recovery requires the retained compatible Docker update');
|
|
1012
|
+
const previous = join(candidate.root, 'previous-build');
|
|
1013
|
+
privateDirectory(previous);
|
|
1014
|
+
let active = phase === 'runtime-activated' ? record.workDir : candidate.workDir;
|
|
1015
|
+
// Publication can move both directories before its journal save. Resume
|
|
1016
|
+
// from the protected active copy only when the previous runtime was kept.
|
|
1017
|
+
if (phase === 'state-updated' && !existsSync(candidate.workDir)) {
|
|
1018
|
+
privateDirectory(join(candidate.root, 'previous-runtime'));
|
|
1019
|
+
active = record.workDir;
|
|
1020
|
+
}
|
|
1021
|
+
privateDirectory(active);
|
|
1022
|
+
const encode = directory => Object.fromEntries(Object.entries(readBuildRecords(directory, { privateFiles: true })).map(([name, bytes]) => [name, bytes.toString('base64')]));
|
|
1023
|
+
const input = JSON.stringify({ phase, previous: encode(previous), target: encode(active) });
|
|
1024
|
+
// Execute shipped maintenance against the retained image and its selected
|
|
1025
|
+
// volume/user contract. No image retag, build replacement or host bind mount.
|
|
1026
|
+
const source = readFileSync(join(INSTALLER_ASSETS, 'scripts/maintenance/state-operation.mjs'), 'utf8')
|
|
1027
|
+
.replaceAll("from './", "from 'file:///opt/ours/docker/");
|
|
1028
|
+
const module = `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`;
|
|
1029
|
+
const helper = readFileSync(join(INSTALLER_ASSETS, 'scripts/maintenance/recover-notifications.mjs'), 'utf8')
|
|
1030
|
+
.replace("'./state-operation.mjs'", JSON.stringify(module));
|
|
1031
|
+
const result = await compose({ ...record, workDir: active }, ['run', '--rm', '--no-deps', '-T', '--entrypoint', 'node', 'state-operation', '--input-type=module', '-e', helper], {
|
|
1032
|
+
input, env: { OURS_MAINTENANCE_IMAGE: `${candidate.project}:maintenance`, OURS_STATE_DOMAIN: 'server', OURS_LIVE_ROOT: '/storage/state' },
|
|
1033
|
+
});
|
|
1034
|
+
const recovered = JSON.parse(result.stdout);
|
|
1035
|
+
if (!recovered || Object.keys(recovered).join(',') !== 'stateUpdated' || typeof recovered.stateUpdated !== 'boolean') throw new Error('Invalid Notifications recovery result');
|
|
1036
|
+
return recovered.stateUpdated;
|
|
1037
|
+
},
|
|
863
1038
|
async publishServerBuild(record, candidate) {
|
|
864
1039
|
const previous = join(candidate.root, 'previous-runtime');
|
|
865
1040
|
if (record.mode === 'docker') {
|
|
866
|
-
for (const target of ['runtime', 'maintenance']) {
|
|
1041
|
+
for (const target of ['runtime', 'maintenance', ...(record.gateway ? ['gateway'] : [])]) {
|
|
867
1042
|
const retained = `${candidate.project}:previous-${target}`;
|
|
868
|
-
const found = await
|
|
1043
|
+
const found = await container(record, ['image', 'inspect', retained], { allowCodes: [1] });
|
|
869
1044
|
if (found.code !== 0) {
|
|
870
|
-
const current = await
|
|
871
|
-
if (current.code === 0) await
|
|
1045
|
+
const current = await container(record, ['image', 'inspect', `${record.project}:${target}`], { allowCodes: [1] });
|
|
1046
|
+
if (current.code === 0) await container(record, ['tag', `${record.project}:${target}`, retained]);
|
|
872
1047
|
}
|
|
873
1048
|
}
|
|
874
1049
|
}
|
|
@@ -884,8 +1059,8 @@ export function networkEffects(effects) {
|
|
|
884
1059
|
renameSync(candidate.workDir, record.workDir);
|
|
885
1060
|
} else privateDirectory(record.workDir);
|
|
886
1061
|
if (record.mode === 'docker') {
|
|
887
|
-
for (const target of ['runtime', 'maintenance']) {
|
|
888
|
-
await
|
|
1062
|
+
for (const target of ['runtime', 'maintenance', ...(record.gateway ? ['gateway'] : [])]) {
|
|
1063
|
+
await container(candidate, ['tag', `${candidate.project}:${target}`, `${record.project}:${target}`]);
|
|
889
1064
|
}
|
|
890
1065
|
}
|
|
891
1066
|
atomicWriteConfig(record.sourcesPath, readFileSync(candidate.sourcesPath));
|
|
@@ -899,10 +1074,10 @@ export function networkEffects(effects) {
|
|
|
899
1074
|
},
|
|
900
1075
|
async discardServerBuild(candidate) {
|
|
901
1076
|
if (candidate.mode === 'docker') {
|
|
902
|
-
for (const target of ['runtime', 'maintenance', 'previous-runtime', 'previous-maintenance']) {
|
|
1077
|
+
for (const target of ['runtime', 'maintenance', 'previous-runtime', 'previous-maintenance', ...(candidate.gateway ? ['gateway', 'previous-gateway'] : [])]) {
|
|
903
1078
|
const image = `${candidate.project}:${target}`;
|
|
904
|
-
const found = await
|
|
905
|
-
if (found.code === 0) await
|
|
1079
|
+
const found = await container(candidate, ['image', 'inspect', image], { allowCodes: [1] });
|
|
1080
|
+
if (found.code === 0) await container(candidate, ['image', 'rm', image]);
|
|
906
1081
|
}
|
|
907
1082
|
}
|
|
908
1083
|
privateDirectory(candidate.root);
|
|
@@ -910,7 +1085,15 @@ export function networkEffects(effects) {
|
|
|
910
1085
|
},
|
|
911
1086
|
async serverAccess(record, operation, { output, migrate = false } = {}) {
|
|
912
1087
|
if (record.mode === 'docker') {
|
|
913
|
-
|
|
1088
|
+
// Retained images contain the old helper that discarded CLI errors.
|
|
1089
|
+
// Execute the shipped installer helper against the SAME selected image
|
|
1090
|
+
// and Compose volume/user/environment contract, without retag/rebuild.
|
|
1091
|
+
const diagnosticsUrl = `data:text/javascript;base64,${readFileSync(join(INSTALLER_ASSETS, 'scripts/runtime/diagnostics.mjs')).toString('base64')}`;
|
|
1092
|
+
const helper = `process.argv.splice(1, 0, '/opt/ours/docker/client-setup.mjs');\n` + readFileSync(join(INSTALLER_ASSETS, 'scripts/runtime/client-setup.mjs'), 'utf8')
|
|
1093
|
+
.replace("'../maintenance/build-context.mjs'", "'/opt/ours/maintenance/build-context.mjs'")
|
|
1094
|
+
.replace("'./diagnostics.mjs'", JSON.stringify(diagnosticsUrl));
|
|
1095
|
+
const accessArgs = (...args) => ['--entrypoint', 'node', 'access', '--input-type=module', '-e', helper, '--', ...args];
|
|
1096
|
+
if (!output) return compose(record, ['run', '--rm', '--no-deps', '-T', ...accessArgs(operation)], { sensitive: true, env: { OURS_ACCESS_MIGRATE: migrate ? '1' : '0' } });
|
|
914
1097
|
privateDirectory(dirname(output));
|
|
915
1098
|
if (existsSync(output)) throw new Error('Credential output already exists');
|
|
916
1099
|
const name = `${record.project}-issue-${randomUUID()}`;
|
|
@@ -918,23 +1101,23 @@ export function networkEffects(effects) {
|
|
|
918
1101
|
const staging = join(dirname(output), `.ours-issued-${randomUUID()}`);
|
|
919
1102
|
ensurePrivateDirectory(staging);
|
|
920
1103
|
try {
|
|
921
|
-
await compose(record, ['run', '--name', name, '--no-deps', '-T', 'access
|
|
1104
|
+
await compose(record, ['run', '--name', name, '--no-deps', '-T', ...accessArgs('access-issue', issuedPath)], { sensitive: true });
|
|
922
1105
|
const file = join(staging, 'credential');
|
|
923
|
-
await
|
|
1106
|
+
await container(record, ['cp', `${name}:${issuedPath}`, file], { sensitive: true });
|
|
924
1107
|
assertPrivateRegularFile(file, 'issued credential');
|
|
925
1108
|
// Exclusive publication never overwrites an unrelated credential.
|
|
926
1109
|
writePrivateNew(output, readFileSync(file));
|
|
927
1110
|
} finally {
|
|
928
1111
|
await compose(record, ['run', '--rm', '--no-deps', '-T', '--entrypoint', 'rm', 'access', '-f', issuedPath], { sensitive: true });
|
|
929
|
-
await
|
|
1112
|
+
await container(record, ['rm', '-f', name], { sensitive: true });
|
|
930
1113
|
rmSync(staging, { recursive: true, force: true });
|
|
931
1114
|
}
|
|
932
1115
|
return;
|
|
933
1116
|
}
|
|
934
1117
|
const options = { env: localEnv(record), sensitive: true };
|
|
935
|
-
if (operation !== 'access-issue') return effects.run(bin(record, 'ours'), ['config', operation, '--config', record.configPath, ...(operation === 'access-replace' ? ['--confirm'] : migrate ? ['--migrate'] : []), '--json'], options);
|
|
1118
|
+
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);
|
|
936
1119
|
const outputs = output ? [output] : [join(installationPaths(record).daemon, 'daemon-token'), ...['telegram', 'cowork', 'messenger'].map(s => installationPaths(record).credentials[s])];
|
|
937
|
-
for (const path of outputs) await effects.run(bin(record, 'ours'), ['config', operation, '--config', record.configPath, '--output', path, ...(output ? [] : ['--replace']), '--json'], options);
|
|
1120
|
+
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);
|
|
938
1121
|
},
|
|
939
1122
|
async recordRuntimeBuild(record) {
|
|
940
1123
|
if (record.mode === 'docker') return; // Image preparation records its build.
|
|
@@ -978,7 +1161,7 @@ export function networkEffects(effects) {
|
|
|
978
1161
|
await effects.run(process.execPath, [join(INSTALLER_ASSETS, 'scripts/maintenance/state-operation.mjs'), ...command], {
|
|
979
1162
|
env: { ...localEnv(record), OURS_STATE_DOMAIN: args.domain,
|
|
980
1163
|
OURS_STATE_ROOT: join(record.root, 'storage'), OURS_LIVE_ROOT: ['server', 'daemon'].includes(args.domain) ? paths.state : paths[args.domain],
|
|
981
|
-
OURS_BUILD_ROOT: record.workDir, OURS_CLI_PATH: bin(record, 'ours'),
|
|
1164
|
+
OURS_BUILD_ROOT: record.workDir, OURS_CLI_PATH: bin(record, existsSync(bin(record, 'ours-daemon')) ? 'ours-daemon' : 'ours'),
|
|
982
1165
|
OURS_DAEMON_CONFIG: record.configPath, OURS_COWORK_CLI_PATH: bin(record, 'ours-cowork'),
|
|
983
1166
|
OURS_COWORK_CONFIG: join(paths.cowork, 'config.json'), OURS_COWORK_STATE_DIR: paths.cowork },
|
|
984
1167
|
});
|
|
@@ -996,12 +1179,12 @@ export function networkEffects(effects) {
|
|
|
996
1179
|
if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name)) {
|
|
997
1180
|
throw new Error('Invalid conversion volume name');
|
|
998
1181
|
}
|
|
999
|
-
const result = await
|
|
1182
|
+
const result = await container(record, ['volume', 'inspect', '--format', '{{json .}}', name], {
|
|
1000
1183
|
...(optional ? { allowCodes: [1] } : {}),
|
|
1001
1184
|
});
|
|
1002
1185
|
if (result.code !== 0) {
|
|
1003
1186
|
if (optional) {
|
|
1004
|
-
const inventory = await
|
|
1187
|
+
const inventory = await container(record, ['volume', 'ls', '--format', '{{.Name}}']);
|
|
1005
1188
|
if (inventory.code === 0 && !inventory.stdout.split(/\s+/).includes(name)) return false;
|
|
1006
1189
|
}
|
|
1007
1190
|
throw new Error(`Source volume is missing or unavailable: ${name}`);
|
|
@@ -1063,7 +1246,7 @@ export function networkEffects(effects) {
|
|
|
1063
1246
|
await ownerCommand(record, 'cowork', 'prepare-backup');
|
|
1064
1247
|
},
|
|
1065
1248
|
async retainConvertedPackageAuthority(record, daemon) {
|
|
1066
|
-
await effects.run(bin(record, 'ours'), [
|
|
1249
|
+
await effects.run(bin(record, existsSync(bin(record, 'ours-daemon')) ? 'ours-daemon' : 'ours'), [
|
|
1067
1250
|
'config', 'access-retain', '--config', record.configPath,
|
|
1068
1251
|
'--target-state-dir', daemon, '--json',
|
|
1069
1252
|
], { env: localEnv(record), sensitive: true });
|
|
@@ -1107,6 +1290,15 @@ export function networkEffects(effects) {
|
|
|
1107
1290
|
}
|
|
1108
1291
|
await nativeLifecycle(record, 'retire', record.services, { effects, localEnv, ownerCommand, bin });
|
|
1109
1292
|
},
|
|
1293
|
+
async serverUninstall(record) {
|
|
1294
|
+
if (record.schema !== 2 || record.layoutConversion || record.buildTransition) throw new Error('Complete retained installation transitions before uninstall');
|
|
1295
|
+
if (record.mode === 'docker') {
|
|
1296
|
+
await effects.serverLifecycle(record, 'stop');
|
|
1297
|
+
await requireCleanContainerExit(record, record.services);
|
|
1298
|
+
await compose(record, ['rm', '-f', ...record.services]);
|
|
1299
|
+
} else await nativeLifecycle(record, 'retire', record.services, { effects, localEnv, ownerCommand, bin });
|
|
1300
|
+
effects.out('Managed services removed; installation selection, stored identities, credentials and volumes retained.');
|
|
1301
|
+
},
|
|
1110
1302
|
async serverLifecycle(record, operation, selected = record.services) {
|
|
1111
1303
|
record = buildRuntime(record);
|
|
1112
1304
|
if (record.mode === 'docker') {
|
|
@@ -1123,9 +1315,13 @@ export function networkEffects(effects) {
|
|
|
1123
1315
|
if ((await effects.serverLifecycle(record, 'status', selected)).length) throw new Error('Writers did not stop');
|
|
1124
1316
|
return;
|
|
1125
1317
|
}
|
|
1318
|
+
if (existsSync(record.workDir)) atomicWriteConfig(join(record.workDir, 'compose.recovery.json'), JSON.stringify(recoveryCompose(record)));
|
|
1319
|
+
await reconcileDockerRecovery(record, effects);
|
|
1126
1320
|
const start = async service => {
|
|
1127
1321
|
effects.out(`Starting ${service}; waiting for readiness...`);
|
|
1128
|
-
|
|
1322
|
+
// Gateway has no persistent state. Recreate its network endpoint on each
|
|
1323
|
+
// managed start, including retries after an interrupted/failed bind.
|
|
1324
|
+
try { await compose(record, ['up', '-d', '--no-build', '--no-deps', ...(service === 'gateway' ? ['--force-recreate'] : []), '--wait', service]); }
|
|
1129
1325
|
catch (cause) { throw await dockerStartupError(record, service, cause); }
|
|
1130
1326
|
effects.out(`${service} is ready.`);
|
|
1131
1327
|
};
|
|
@@ -1135,6 +1331,7 @@ export function networkEffects(effects) {
|
|
|
1135
1331
|
try { await start(service); }
|
|
1136
1332
|
catch (error) { failures.push({ service, error }); }
|
|
1137
1333
|
}
|
|
1334
|
+
if (!failures.length && record.gateway && ['daemon', 'cowork', 'gateway'].every(service => selected.includes(service))) await effects.verifyGateway(record);
|
|
1138
1335
|
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')}`);
|
|
1139
1336
|
return;
|
|
1140
1337
|
}
|
|
@@ -1149,21 +1346,19 @@ export function networkEffects(effects) {
|
|
|
1149
1346
|
return JSON.parse(readFileSync(path, 'utf8'));
|
|
1150
1347
|
},
|
|
1151
1348
|
async discoverClientProfile(endpoint, credentialPath) {
|
|
1152
|
-
const
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
const selection = await response.json();
|
|
1158
|
-
// Full metadata and authenticated capability validation follows before publication.
|
|
1159
|
-
return validateHostProfile({ endpoint: url.origin, expectedInstanceId: selection.instanceId, credentialPath: resolve(credentialPath) });
|
|
1349
|
+
const base = serverBase(endpoint);
|
|
1350
|
+
const request = path => fetch(`${base}${path}`, { redirect: 'error', signal: AbortSignal.timeout(5000) });
|
|
1351
|
+
const discovery = await request('/.well-known/ours');
|
|
1352
|
+
if (discovery.ok) return validateGatewayDiscovery(base, await discovery.json(), resolve(credentialPath));
|
|
1353
|
+
throw new Error(`Gateway discovery answered HTTP ${discovery.status}; enable the gateway. Direct daemon fallback is not supported.`);
|
|
1160
1354
|
},
|
|
1161
|
-
importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh = false }) {
|
|
1355
|
+
importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, disableFleetAgentsSetup = false, refresh = false, notificationsProducerPath }) {
|
|
1356
|
+
profile = validateGatewayClientProfile(profile);
|
|
1162
1357
|
const root = join(home, '.ours-client');
|
|
1163
1358
|
const configPath = join(root, 'profile.json');
|
|
1164
1359
|
const credentialPath = join(root, 'credential');
|
|
1165
1360
|
const current = effects.readManagedClientProfile();
|
|
1166
|
-
if (current &&
|
|
1361
|
+
if (current && current.expectedInstanceId !== profile.expectedInstanceId)
|
|
1167
1362
|
throw new Error('Managed client already selects another server; existing default was not changed');
|
|
1168
1363
|
assertPrivateRegularFile(profile.credentialPath, 'credential');
|
|
1169
1364
|
const credential = readFileSync(profile.credentialPath, 'utf8');
|
|
@@ -1174,13 +1369,30 @@ export function networkEffects(effects) {
|
|
|
1174
1369
|
: readFileSync(sourcesPath);
|
|
1175
1370
|
const fleetSettings = (!current || refresh) && fleetSettingsPath ? readFileSync(fleetSettingsPath) : null;
|
|
1176
1371
|
if (fleetSettings) JSON.parse(fleetSettings.toString());
|
|
1372
|
+
let producer = null;
|
|
1373
|
+
if (notificationsProducerPath) {
|
|
1374
|
+
assertPrivateRegularFile(notificationsProducerPath, 'notification producer credential');
|
|
1375
|
+
producer = readFileSync(notificationsProducerPath, 'utf8');
|
|
1376
|
+
let bound; try { bound = JSON.parse(producer); } catch { bound = null; }
|
|
1377
|
+
if (bound?.schema !== 1 || bound.serverUrl !== profile.serverUrl || bound.expectedInstanceId !== profile.expectedInstanceId || !/^[A-Za-z0-9_-]{32,256}$/.test(bound.token ?? ''))
|
|
1378
|
+
throw new Error('Notification producer credential does not match the selected server');
|
|
1379
|
+
}
|
|
1177
1380
|
ensurePrivateDirectory(root);
|
|
1381
|
+
// Written before the profile it belongs to is published or changed.
|
|
1382
|
+
let notificationsProducerChanged = false;
|
|
1383
|
+
if (producer !== null) {
|
|
1384
|
+
const producerPath = join(root, 'notifications-producer.json');
|
|
1385
|
+
if (!existsSync(producerPath) || readFileSync(producerPath, 'utf8') !== producer) { atomicWriteConfig(producerPath, producer); notificationsProducerChanged = true; }
|
|
1386
|
+
}
|
|
1178
1387
|
if (current && !refresh) {
|
|
1179
1388
|
assertPrivateRegularFile(credentialPath, 'managed credential');
|
|
1180
1389
|
if (readFileSync(credentialPath, 'utf8') !== credential) atomicWriteConfig(credentialPath, credential);
|
|
1181
|
-
|
|
1390
|
+
const saved = { ...current, ...profile, credentialPath };
|
|
1391
|
+
if (current.endpoint !== saved.endpoint || current.serverUrl !== saved.serverUrl)
|
|
1392
|
+
atomicWriteConfig(configPath, JSON.stringify(saved, null, 2) + '\n');
|
|
1393
|
+
return { configPath, profile: validateGatewayClientProfile(saved), settings: current.installer, notificationsProducerChanged };
|
|
1182
1394
|
}
|
|
1183
|
-
const settings = { sourcesPath: join(root, 'sources.json'), integrations };
|
|
1395
|
+
const settings = { sourcesPath: join(root, 'sources.json'), integrations, ...(disableFleetAgentsSetup ? { disableFleetAgentsSetup: true } : {}) };
|
|
1184
1396
|
if (fleetSettings) settings.fleetSettingsPath = join(root, 'fleet-settings.json');
|
|
1185
1397
|
// Publish the profile last: clients cannot select incomplete imported inputs.
|
|
1186
1398
|
atomicWriteConfig(settings.sourcesPath, sources);
|
|
@@ -1188,20 +1400,41 @@ export function networkEffects(effects) {
|
|
|
1188
1400
|
atomicWriteConfig(credentialPath, credential);
|
|
1189
1401
|
const saved = { ...profile, credentialPath, installer: settings };
|
|
1190
1402
|
atomicWriteConfig(configPath, JSON.stringify(saved, null, 2) + '\n');
|
|
1191
|
-
return { configPath, profile: validateHostProfile(saved), settings };
|
|
1403
|
+
return { configPath, profile: validateHostProfile(saved), settings, notificationsProducerChanged };
|
|
1404
|
+
},
|
|
1405
|
+
async qualifyInstalledGatewayClient(profile) {
|
|
1406
|
+
if (!profile.installer?.integrations?.includes('fleet')) return;
|
|
1407
|
+
let info;
|
|
1408
|
+
try { info = JSON.parse((await effects.run('ours-fleet', ['version', '--json'])).stdout); } catch {}
|
|
1409
|
+
if (!Array.isArray(info?.capabilities) || !info.capabilities.includes('cowork.http-management-v1'))
|
|
1410
|
+
throw new Error('Installed Fleet does not support gateway HTTP management; upgrade Fleet before gateway-enable');
|
|
1411
|
+
},
|
|
1412
|
+
async qualifyGatewayClient({ profile, sourcesPath, sources, integrations, refresh = false }) {
|
|
1413
|
+
if (!profile.serverUrl || !integrations.includes('fleet')) return;
|
|
1414
|
+
// Acquire into the retained package cache without publishing a profile,
|
|
1415
|
+
// credential, native command, or integration. Normal acquisition rechecks it.
|
|
1416
|
+
const staging = mkdtempSync(join(home, '.ours-gateway-client-'));
|
|
1417
|
+
try {
|
|
1418
|
+
const stagedSources = join(staging, 'sources.json');
|
|
1419
|
+
writeFileSync(stagedSources, sources ? JSON.stringify(sources, null, 2) + '\n' : readFileSync(sourcesPath), { mode: 0o600 });
|
|
1420
|
+
await effects.acquireClientPackages(join(home, '.ours-client/profile.json'), stagedSources, integrations,
|
|
1421
|
+
{ refresh, gatewayServerUrl: profile.serverUrl, qualifyOnly: true });
|
|
1422
|
+
} finally { rmSync(staging, { recursive: true, force: true }); }
|
|
1192
1423
|
},
|
|
1193
|
-
async acquireClientPackages(configPath, sourcesPath, integrations, { refresh = false } = {}) {
|
|
1424
|
+
async acquireClientPackages(configPath, sourcesPath, integrations, { refresh = false, hostCliOnly = false, gatewayServerUrl, qualifyOnly = false } = {}) {
|
|
1194
1425
|
const manifest = JSON.parse(readFileSync(sourcesPath, 'utf8'));
|
|
1195
|
-
//
|
|
1196
|
-
const selected =
|
|
1426
|
+
// Every client installation includes the native CLI and its SDK dependency.
|
|
1427
|
+
const selected = clientPackageNames(integrations);
|
|
1197
1428
|
const packages = selectSourcePackages(manifest, 'client', selected);
|
|
1198
|
-
const selectionKey =
|
|
1429
|
+
const selectionKey = JSON.stringify([hostCliOnly ? 'host-cli-private-v1' : 'host-cli-v1', configPath, ...(hostCliOnly ? [manifest] : []), ...(refresh ? [manifest, integrations] : [])]);
|
|
1199
1430
|
const root = join(home, '.ours-client-install', createHash('sha256').update(selectionKey).digest('hex').slice(0, 16));
|
|
1200
1431
|
const hasGit = Object.values(packages).some(selection => selection.source);
|
|
1201
1432
|
await effects.run('npm', ['--version']);
|
|
1202
1433
|
if (hasGit) {
|
|
1203
1434
|
for (const command of ['python3', 'git', 'make', 'cc']) await effects.run(command, ['--version']);
|
|
1204
1435
|
}
|
|
1436
|
+
// This directory retains active executables, not disposable download cache.
|
|
1437
|
+
ensurePrivateDirectory(join(home, '.ours-client-install'));
|
|
1205
1438
|
ensurePrivateDirectory(root);
|
|
1206
1439
|
const retained = join(root, 'sources.json');
|
|
1207
1440
|
const bytes = readFileSync(sourcesPath);
|
|
@@ -1226,13 +1459,40 @@ export function networkEffects(effects) {
|
|
|
1226
1459
|
writePrivateNew(join(root, '.packages-ready'), 'ready\n');
|
|
1227
1460
|
}
|
|
1228
1461
|
verifyReleaseGraph(root, manifest, { requiredPackages: Object.keys(packages) });
|
|
1462
|
+
if (integrations.includes('fleet') && (gatewayServerUrl || readHostProfileFile(configPath)?.serverUrl)) {
|
|
1463
|
+
let info;
|
|
1464
|
+
try { info = JSON.parse(readFileSync(join(root, 'node_modules/@ours.network/fleet/dist/build-info.json'), 'utf8')); } catch {}
|
|
1465
|
+
if (!Array.isArray(info?.capabilities) || !info.capabilities.includes('cowork.http-management-v1'))
|
|
1466
|
+
throw new Error('Selected Fleet artifact does not support gateway HTTP management; select a compatible client release');
|
|
1467
|
+
}
|
|
1468
|
+
if (qualifyOnly) return;
|
|
1469
|
+
const cliPolicy = hostCliOnly ? null : hostCliPolicy(manifest);
|
|
1229
1470
|
// Local acquisition alone does not publish native commands. Use the user's
|
|
1230
1471
|
// configured npm prefix and retained dependency closure, including on retry.
|
|
1231
1472
|
for (const name of integrations.filter(name => name === 'fleet' || name === 'codex')) {
|
|
1232
1473
|
await effects.run('npm', ['install', '--global', '--install-links=false', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', join(root, 'node_modules', '@ours.network', name)]);
|
|
1233
1474
|
}
|
|
1475
|
+
let cliBin = join(root, 'node_modules/.bin/ours');
|
|
1476
|
+
if (cliPolicy) {
|
|
1477
|
+
const cliSources = join(root, 'host-cli-sources.json');
|
|
1478
|
+
const cliBytes = JSON.stringify(cliPolicy, null, 2) + '\n';
|
|
1479
|
+
if (!existsSync(cliSources)) writePrivateNew(cliSources, cliBytes);
|
|
1480
|
+
else if (readFileSync(cliSources, 'utf8') !== cliBytes) throw new Error('Retained host CLI source selection differs');
|
|
1481
|
+
const acquired = await effects.acquireClientPackages(configPath, cliSources, [], { refresh, hostCliOnly: true });
|
|
1482
|
+
cliBin = acquired.cliBin;
|
|
1483
|
+
} else {
|
|
1484
|
+
// Publish last so optional integration installation cannot downgrade ours.
|
|
1485
|
+
await publishClientCli(effects, join(root, 'node_modules', '@ours.network/cli'), { policy: manifest, isolated: hostCliOnly });
|
|
1486
|
+
}
|
|
1234
1487
|
const localPackages = Object.fromEntries(integrations.filter(n => n !== 'fleet').map(name => [name, join(root, 'node_modules', '@ours.network', name)]));
|
|
1235
|
-
return { localPackages, packages: {}, fleetBin: integrations.includes('fleet') ? join(root, 'node_modules/.bin/ours-fleet') : null };
|
|
1488
|
+
return { localPackages, packages: {}, cliBin, fleetBin: integrations.includes('fleet') ? join(root, 'node_modules/.bin/ours-fleet') : null };
|
|
1489
|
+
},
|
|
1490
|
+
/** Capability tokens the given Fleet executable reports for itself; [] when it reports none. */
|
|
1491
|
+
async fleetCapabilities(fleetBin, env = null) {
|
|
1492
|
+
try {
|
|
1493
|
+
const capabilities = JSON.parse((await effects.run(fleetBin, ['version', '--json'], { env })).stdout).capabilities;
|
|
1494
|
+
return Array.isArray(capabilities) ? capabilities.filter(item => typeof item === 'string') : [];
|
|
1495
|
+
} catch { return []; }
|
|
1236
1496
|
},
|
|
1237
1497
|
async prepareClientMarketplace(name, packagePath) {
|
|
1238
1498
|
const acquisitionRoot = dirname(dirname(dirname(packagePath)));
|
|
@@ -1241,7 +1501,7 @@ export function networkEffects(effects) {
|
|
|
1241
1501
|
const release = releaseBinding(policy);
|
|
1242
1502
|
const integrationsPath = join(acquisitionRoot, 'integrations.json');
|
|
1243
1503
|
const integrations = existsSync(integrationsPath) ? JSON.parse(readFileSync(integrationsPath, 'utf8')) : null;
|
|
1244
|
-
const requiredPackages = integrations ?
|
|
1504
|
+
const requiredPackages = integrations ? clientPackageNames(integrations).map(name => '@ours.network/' + name) : Object.keys(policy.packages ?? {});
|
|
1245
1505
|
verifyReleaseGraph(acquisitionRoot, policy, { requiredPackages });
|
|
1246
1506
|
const root = join(acquisitionRoot, 'marketplaces', name);
|
|
1247
1507
|
const plugin = join(root, 'plugins', 'ours');
|
|
@@ -1250,7 +1510,7 @@ export function networkEffects(effects) {
|
|
|
1250
1510
|
cpSync(packagePath, plugin, { recursive: true });
|
|
1251
1511
|
const manifestPath = join(plugin, 'package.json');
|
|
1252
1512
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
1253
|
-
for (const dependency of release ? [] : ['sdk', 'cli']) {
|
|
1513
|
+
for (const dependency of release ? [] : ['sdk', 'cli', 'mcp']) {
|
|
1254
1514
|
const name = `@ours.network/${dependency}`;
|
|
1255
1515
|
if (manifest.dependencies?.[name]) manifest.dependencies[name] = `file:${join(dirname(packagePath), dependency)}`;
|
|
1256
1516
|
}
|
|
@@ -1270,42 +1530,7 @@ export function networkEffects(effects) {
|
|
|
1270
1530
|
effects.writeJson(manifestPath, JSON.stringify(value, null, 2) + '\n');
|
|
1271
1531
|
return root;
|
|
1272
1532
|
},
|
|
1273
|
-
|
|
1274
|
-
const profile = typeof configPath === 'string' ? readHostProfileFile(configPath) : validateHostProfile(configPath);
|
|
1275
|
-
assertPrivateRegularFile(profile.credentialPath, 'credential');
|
|
1276
|
-
const token = readFileSync(profile.credentialPath, 'utf8').trim();
|
|
1277
|
-
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() };
|
|
1278
|
-
const request = async (method, params, id) => {
|
|
1279
|
-
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 }) });
|
|
1280
|
-
const session = response.headers.get('mcp-session-id');
|
|
1281
|
-
if (session) headers['mcp-session-id'] = session;
|
|
1282
|
-
if (!response.ok) throw new Error(`Packaged MCP verification failed: HTTP ${response.status}`);
|
|
1283
|
-
if (id === undefined) { await response.body?.cancel(); return; }
|
|
1284
|
-
const text = await response.text();
|
|
1285
|
-
const messages = response.headers.get('content-type')?.includes('text/event-stream')
|
|
1286
|
-
? text.split('\n').filter(line => line.startsWith('data:')).map(line => JSON.parse(line.slice(5)))
|
|
1287
|
-
: [JSON.parse(text)];
|
|
1288
|
-
const message = messages.find(value => value.id === id);
|
|
1289
|
-
if (!message || message.error || !message.result) throw new Error('Packaged MCP returned no successful protocol result');
|
|
1290
|
-
return message.result;
|
|
1291
|
-
};
|
|
1292
|
-
try {
|
|
1293
|
-
const initialized = await request('initialize', { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'ours-install', version: '1' } }, 1);
|
|
1294
|
-
if (initialized.serverInfo?.name !== 'ours' || !initialized.protocolVersion) throw new Error('Selected MCP is not the packaged OURS server');
|
|
1295
|
-
headers['mcp-protocol-version'] = initialized.protocolVersion;
|
|
1296
|
-
await request('notifications/initialized', {}, undefined);
|
|
1297
|
-
const resources = await request('resources/list', {}, 2);
|
|
1298
|
-
if (!resources.resources?.some(resource => resource.uri === 'ours://application-identities')) throw new Error('Packaged network MCP identity resource is absent');
|
|
1299
|
-
const toolList = await request('tools/list', {}, 3);
|
|
1300
|
-
if (!toolList.tools?.some(tool => tool.name === 'list_identities')) throw new Error('Packaged OURS identity tools are absent');
|
|
1301
|
-
} finally {
|
|
1302
|
-
if (headers['mcp-session-id']) {
|
|
1303
|
-
const response = await fetch(`${profile.endpoint}/mcp`, { method: 'DELETE', redirect: 'error', signal: AbortSignal.timeout(5000), headers });
|
|
1304
|
-
await response.body?.cancel();
|
|
1305
|
-
if (!response.ok) throw new Error('MCP verification session could not be closed');
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
},
|
|
1533
|
+
|
|
1309
1534
|
};
|
|
1310
1535
|
}
|
|
1311
1536
|
|
|
@@ -1352,7 +1577,6 @@ async function nativeLifecycle(record, operation, selected, { effects, localEnv,
|
|
|
1352
1577
|
if (service === 'daemon') {
|
|
1353
1578
|
const profilePath = join(installationPaths(record).mcp, 'profile.json');
|
|
1354
1579
|
await effects.verifyHostProfile(profilePath);
|
|
1355
|
-
await effects.verifyPackagedMcp(profilePath);
|
|
1356
1580
|
return;
|
|
1357
1581
|
}
|
|
1358
1582
|
if (service === 'cowork') { await ownerCommand(record, service, 'status'); return; }
|
|
@@ -1430,7 +1654,7 @@ async function nativeLifecycle(record, operation, selected, { effects, localEnv,
|
|
|
1430
1654
|
}
|
|
1431
1655
|
if (!ready) throw new Error('Application is not ready');
|
|
1432
1656
|
} catch {
|
|
1433
|
-
if (service === 'daemon') throw new Error('Daemon
|
|
1657
|
+
if (service === 'daemon') throw new Error('Daemon API is not ready; consumers were not started');
|
|
1434
1658
|
failures.push(service);
|
|
1435
1659
|
}
|
|
1436
1660
|
}
|