@ours.network/install 1.2.1-nightly.3 → 1.2.1-nightly.31
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 +353 -9
- package/ROOTLESS_PODMAN_VALIDATION.md +210 -0
- package/assets/Dockerfile +17 -13
- package/assets/Dockerfile.gateway +5 -0
- package/assets/docker-compose.yaml +4 -1
- package/assets/fleet-settings.example.json +48 -0
- package/assets/gateway-entrypoint.sh +37 -0
- package/assets/release-lock.json +1167 -4499
- 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/release-graph.mjs +18 -1
- package/assets/scripts/maintenance/state-operation.mjs +4 -2
- package/assets/scripts/runtime/client-setup.mjs +80 -6
- package/assets/scripts/runtime/diagnostics.mjs +72 -0
- package/assets/scripts/runtime/entrypoint.sh +1 -1
- package/assets/scripts/runtime/health-messenger.mjs +7 -6
- package/assets/scripts/runtime/notifications-gateway.mjs +82 -0
- package/assets/scripts/runtime/start-messenger.sh +25 -4
- package/assets/scripts/runtime/supervise.mjs +35 -0
- package/assets/sources.json +54 -28
- package/install.mjs +7 -2
- package/lib/build-transition.mjs +3 -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-runtime-repair.mjs +160 -0
- package/lib/effects.mjs +298 -132
- package/lib/gateway-transition.mjs +87 -0
- package/lib/gateway.mjs +158 -0
- package/lib/legacy-migration.mjs +13 -2
- package/lib/orchestrate.mjs +135 -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 +88 -27
- package/lib/setup.mjs +35 -18
- package/lib/target.mjs +24 -8
- package/lib/uninstall.mjs +2 -2
- package/lib/usage.mjs +19 -6
- 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,25 @@
|
|
|
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';
|
|
26
33
|
import { classifyStateDir } from './detect.mjs';
|
|
27
34
|
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';
|
|
35
|
+
import { hostCliPolicy, releaseBinding, verifyReleaseGraph, verifyRuntimeRelease } from '../assets/scripts/maintenance/release-graph.mjs';
|
|
29
36
|
|
|
30
37
|
/** GET http://127.0.0.1:<port>/state-dir — the unauthenticated identity probe. */
|
|
31
38
|
async function probePort(port, { timeoutMs = 1500 } = {}) {
|
|
@@ -399,7 +406,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
399
406
|
// invocation only and never to the installer's own process: a state
|
|
400
407
|
// directory selected by one run must not leak into anything the operator
|
|
401
408
|
// starts afterwards.
|
|
402
|
-
run: async (cmd, args, { env: extraEnv = null, stream = false, cwd, sensitive = false, allowCodes = [] } = {}) => {
|
|
409
|
+
run: async (cmd, args, { env: extraEnv = null, stream = false, cwd, sensitive = false, allowCodes = [], timeout } = {}) => {
|
|
403
410
|
// Always built from this layer's OWN env rather than left to spawnSync's
|
|
404
411
|
// implicit inheritance, so what a child receives is a property of the
|
|
405
412
|
// effects object a caller constructed and not of whatever ambient shell
|
|
@@ -410,18 +417,18 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
410
417
|
const executable = cmd === 'npm' ? npmBin : cmd;
|
|
411
418
|
const r = spawnSync(executable, args, {
|
|
412
419
|
cwd,
|
|
420
|
+
timeout,
|
|
413
421
|
encoding: 'utf8',
|
|
414
422
|
stdio: [...(stream ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe']), ...(installationLockFd === null ? [] : [installationLockFd])],
|
|
415
423
|
env: childEnv,
|
|
416
424
|
});
|
|
417
425
|
if (r.error) {
|
|
418
|
-
const error =
|
|
426
|
+
const error = commandFailure(executable, args, r, { sensitive, cwd, env: childEnv });
|
|
419
427
|
error.code = r.error.code;
|
|
420
428
|
throw error;
|
|
421
429
|
}
|
|
422
430
|
if (r.status !== 0 && !allowCodes.includes(r.status)) {
|
|
423
|
-
|
|
424
|
-
throw new Error(`${executable} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
|
|
431
|
+
throw commandFailure(executable, args, r, { sensitive, cwd, env: childEnv });
|
|
425
432
|
}
|
|
426
433
|
return { ok: true, code: r.status, stdout: r.stdout ?? '' };
|
|
427
434
|
},
|
|
@@ -435,7 +442,8 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
435
442
|
delete childEnv.OURS_INSTALLER_LOCK_FD;
|
|
436
443
|
if (installationLockFd !== null) childEnv.OURS_INSTALLER_LOCK_FD = '3';
|
|
437
444
|
const r = spawnSync(cmd, args, { stdio: ['inherit', 'inherit', 'inherit', ...(installationLockFd === null ? [] : [installationLockFd])], env: childEnv });
|
|
438
|
-
|
|
445
|
+
if (r.error || r.status !== 0) throw commandFailure(cmd, args, r, { env: childEnv });
|
|
446
|
+
return { ok: true, code: r.status };
|
|
439
447
|
},
|
|
440
448
|
installedVersion: (pkg) => installedVersionOf(pkg, npmBin),
|
|
441
449
|
packageDependencies: (spec) => packageDependenciesOf(spec, npmBin),
|
|
@@ -534,6 +542,7 @@ export function networkEffects(effects) {
|
|
|
534
542
|
const baseEnv = (record) => ({
|
|
535
543
|
OURS_DAEMON_ID: record.instanceId,
|
|
536
544
|
OURS_IMAGE: `${record.project}:runtime`,
|
|
545
|
+
OURS_GATEWAY_IMAGE: `${record.project}:gateway`,
|
|
537
546
|
OURS_MAINTENANCE_IMAGE: `${record.project}:maintenance`,
|
|
538
547
|
OURS_UID: String(record.uid ?? 1000), OURS_GID: String(record.gid ?? 1000),
|
|
539
548
|
OURS_HOST_PORT: String(record.port ?? 3050),
|
|
@@ -545,14 +554,55 @@ export function networkEffects(effects) {
|
|
|
545
554
|
'compose', '--project-directory', record.workDir, '--file', join(record.workDir,
|
|
546
555
|
record.schema === 1 && existsSync(join(record.workDir, 'docker-compose.legacy.yaml'))
|
|
547
556
|
? 'docker-compose.legacy.yaml' : 'docker-compose.yaml'),
|
|
557
|
+
...(record.gateway ? ['--file', join(record.workDir, 'docker-compose.gateway.yaml')] : []),
|
|
558
|
+
...(engineName(record) === 'podman' ? ['--file', join(record.workDir, 'compose.podman.json')] : []),
|
|
548
559
|
'--project-name', record.project, ...args,
|
|
549
560
|
];
|
|
550
|
-
const
|
|
551
|
-
|
|
561
|
+
const container = (record, args, options = {}) => runContainer(effects, record, args, options);
|
|
562
|
+
const compose = async (record, args, options = {}) => {
|
|
563
|
+
const selected = { ...options, env: { ...baseEnv(record), ...options.env } };
|
|
564
|
+
if (engineName(record) === 'podman' && args[0] === 'build') {
|
|
565
|
+
const result = await container(record, composeArgs(record, ['--profile', '*', 'config', '--format', 'json']), { env: selected.env });
|
|
566
|
+
for (const command of nativeBuildCommands(JSON.parse(result.stdout), args.slice(1), { ...env, ...selected.env })) await container(record, command, selected);
|
|
567
|
+
return { code: 0, stdout: '' };
|
|
568
|
+
}
|
|
569
|
+
if (engineName(record) === 'podman' && ['run', 'up'].includes(args[0])) {
|
|
570
|
+
const resolved = JSON.parse((await container(record, composeArgs(record, ['--profile', '*', 'config', '--format', 'json']), { env: selected.env })).stdout);
|
|
571
|
+
for (const [key, volume] of Object.entries(resolved.volumes ?? {})) {
|
|
572
|
+
if (volume.external) throw new Error('Unexpected external installation volume');
|
|
573
|
+
const name = volume.name ?? `${record.project}_${key}`;
|
|
574
|
+
const found = await container(record, ['volume', 'inspect', '--format', '{{json .}}', name], { allowCodes: [1] });
|
|
575
|
+
if (found.code === 1) continue;
|
|
576
|
+
const value = JSON.parse(found.stdout);
|
|
577
|
+
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}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
// Compose run can implicitly build an absent administration image. Build
|
|
581
|
+
// its resolved target natively first, just like explicit build operations.
|
|
582
|
+
if (engineName(record) === 'podman' && args[0] === 'run') {
|
|
583
|
+
const resolved = JSON.parse((await container(record, composeArgs(record, ['--profile', '*', 'config', '--format', 'json']), { env: selected.env })).stdout);
|
|
584
|
+
const takesValue = new Set(['--name', '--entrypoint', '--env', '-e', '--user', '-u', '--workdir', '-w', '--volume', '-v', '--publish', '-p', '--label', '-l', '--pull']);
|
|
585
|
+
let name;
|
|
586
|
+
for (let i = 1; i < args.length; i++) {
|
|
587
|
+
if (takesValue.has(args[i])) { i++; continue; }
|
|
588
|
+
if (args[i].startsWith('-')) continue;
|
|
589
|
+
name = args[i]; break;
|
|
590
|
+
}
|
|
591
|
+
const service = resolved.services?.[name];
|
|
592
|
+
if (!service) throw new Error(`Unresolved Podman administration service: ${name}`);
|
|
593
|
+
if (service.build) {
|
|
594
|
+
const image = await container(record, ['image', 'inspect', service.image], { allowCodes: [1] });
|
|
595
|
+
if (image.code === 1) for (const command of nativeBuildCommands(resolved, [name], { ...env, ...selected.env })) await container(record, command, selected);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return container(record, composeArgs(record, args), selected);
|
|
599
|
+
};
|
|
552
600
|
const dockerStartupError = async (record, service, cause) => {
|
|
553
601
|
const args = ['logs', '--no-color', '--tail', '50', '--timestamps', service];
|
|
554
602
|
const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
555
|
-
const
|
|
603
|
+
const engine = engineName(record);
|
|
604
|
+
const prefix = engine === 'podman' ? ['--remote', '--url', `unix://${record.containerBinding.socket}`] : [];
|
|
605
|
+
const command = `OURS_DAEMON_ID=${quote(record.instanceId)} ${engine} ${[...prefix, ...composeArgs(record, args)].map(quote).join(' ')}`;
|
|
556
606
|
let detail;
|
|
557
607
|
try {
|
|
558
608
|
const logs = await compose(record, args);
|
|
@@ -563,7 +613,7 @@ export function networkEffects(effects) {
|
|
|
563
613
|
} catch {
|
|
564
614
|
detail = 'Container logs could not be read.';
|
|
565
615
|
}
|
|
566
|
-
return new Error(
|
|
616
|
+
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
617
|
};
|
|
568
618
|
const bin = (record, name) => join(record.workDir, 'node_modules', '.bin', name);
|
|
569
619
|
const localEnv = (record, service = 'daemon') => {
|
|
@@ -577,14 +627,15 @@ export function networkEffects(effects) {
|
|
|
577
627
|
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
628
|
};
|
|
579
629
|
const ownerCommand = (record, service, op, options = {}) => {
|
|
580
|
-
const
|
|
581
|
-
const
|
|
630
|
+
const daemonBinary = existsSync(bin(record, 'ours-daemon')) ? 'ours-daemon' : 'ours';
|
|
631
|
+
const name = { daemon: daemonBinary, telegram: 'ours-tg-connector', cowork: 'ours-cowork' }[service];
|
|
632
|
+
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
633
|
return effects.run(bin(record, name), args, { ...options, env: localEnv(record, service) });
|
|
583
634
|
};
|
|
584
635
|
const requireCleanContainerExit = async (record, selected) => {
|
|
585
636
|
const containers = await compose(record, ['ps', '-aq', ...selected]);
|
|
586
637
|
for (const id of containers.stdout.split(/\s+/).filter(Boolean)) {
|
|
587
|
-
const result = await
|
|
638
|
+
const result = await container(record, ['inspect', '--format', '{{json .State}}', id]);
|
|
588
639
|
const state = JSON.parse(result.stdout);
|
|
589
640
|
if (state.Status !== 'exited' || state.ExitCode !== 0 || state.OOMKilled !== false || state.Dead !== false) {
|
|
590
641
|
throw new Error('Selected source container did not stop cleanly; state operation refused');
|
|
@@ -618,7 +669,7 @@ export function networkEffects(effects) {
|
|
|
618
669
|
return version;
|
|
619
670
|
});
|
|
620
671
|
},
|
|
621
|
-
newInstallation(root, mode) {
|
|
672
|
+
newInstallation(root, mode, { containerEngine } = {}) {
|
|
622
673
|
if (existsSync(root)) {
|
|
623
674
|
privateDirectory(root);
|
|
624
675
|
if (readdirSync(root).some(name => name !== '.operation.lock')) throw new Error('Installation root is not empty and has no selection record');
|
|
@@ -626,9 +677,10 @@ export function networkEffects(effects) {
|
|
|
626
677
|
const instanceId = env.OURS_DAEMON_ID || randomUUID();
|
|
627
678
|
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
679
|
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 };
|
|
680
|
+
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
681
|
},
|
|
631
|
-
async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest, identityName } = {}) {
|
|
682
|
+
async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest, identityName, explicitPorts } = {}) {
|
|
683
|
+
if (!existing && operation === 'install' && Number.isInteger(record.port)) selectServerPorts(record, portTakenSync, explicitPorts, effects.out);
|
|
632
684
|
if (existing) {
|
|
633
685
|
privateDirectory(record.root);
|
|
634
686
|
assertPrivateRegularFile(join(record.root, 'installation.json'), 'selection');
|
|
@@ -636,37 +688,55 @@ export function networkEffects(effects) {
|
|
|
636
688
|
if (env.OURS_DAEMON_ID && env.OURS_DAEMON_ID !== record.instanceId) throw new Error('Conflicting instance ID');
|
|
637
689
|
}
|
|
638
690
|
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
|
-
|
|
691
|
+
if (engineName(record) === 'podman') {
|
|
692
|
+
await bindPodman(effects, record);
|
|
693
|
+
if (['install', 'start', 'restart', 'update', 'rebuild', 'gateway-enable'].includes(operation)) {
|
|
694
|
+
const userHome = userInfo().homedir;
|
|
695
|
+
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()}` })) {
|
|
696
|
+
if (env[key] && env[key] !== expected) throw new Error(`${key} selects a custom Podman environment that the standard boot service does not retain`);
|
|
697
|
+
}
|
|
698
|
+
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');
|
|
699
|
+
await effects.run('systemctl', ['--user', 'is-enabled', 'podman.socket']);
|
|
700
|
+
await effects.run('systemctl', ['--user', 'is-enabled', 'podman-restart.service']);
|
|
701
|
+
const restart = await effects.run('systemctl', ['--user', 'show', 'podman-restart.service', '--property=ExecStart', '--value']);
|
|
702
|
+
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');
|
|
703
|
+
const linger = await effects.run('loginctl', ['show-user', String(process.getuid()), '--property=Linger', '--value']);
|
|
704
|
+
if (linger.stdout.trim() !== 'yes') throw new Error('Rootless server requires user linger for logout/reboot recovery; ask your administrator to enable it');
|
|
705
|
+
await qualifyPodman(effects, record);
|
|
706
|
+
}
|
|
707
|
+
} else {
|
|
708
|
+
const nativeRoot = existing ? '/path/to/new-empty-directory' : record.root;
|
|
709
|
+
const quotedRoot = `'${String(nativeRoot).replaceAll("'", "'\\''")}'`;
|
|
710
|
+
const quotedName = `'${String(identityName ?? record.messengerIdentity ?? 'Your Name').replaceAll("'", "'\\''")}'`;
|
|
711
|
+
const recovery = [
|
|
712
|
+
'Please install Docker Desktop on macOS/Windows, or Docker Engine with the Compose plugin on Linux, and start Docker before retrying.',
|
|
713
|
+
'Docker is recommended for macOS and Windows.',
|
|
714
|
+
`Alternatively, use native installation: ours-install server install --mode packages --state-dir ${quotedRoot} --identity-name ${quotedName}`,
|
|
715
|
+
'Native mode requires systemd user services on Linux/WSL or a launchd GUI session on macOS.',
|
|
716
|
+
...(existing ? ['Keep this existing Docker installation in Docker mode; use a separate empty directory for a new native installation.'] : []),
|
|
717
|
+
].join('\n');
|
|
718
|
+
try {
|
|
719
|
+
await container(record, ['info', '--format', '{{.ServerVersion}}']);
|
|
720
|
+
} catch (cause) {
|
|
721
|
+
const problem = cause.code === 'ENOENT'
|
|
722
|
+
? 'Docker command was not found in PATH.'
|
|
723
|
+
: `Docker Engine is not reachable. Start Docker and check that your user can access it.\nDetails: ${cause.message}`;
|
|
724
|
+
throw new Error(`${problem}\n${recovery}`, { cause });
|
|
725
|
+
}
|
|
726
|
+
let version;
|
|
727
|
+
try {
|
|
728
|
+
version = await container(record, ['compose', 'version', '--short']);
|
|
729
|
+
} catch (cause) {
|
|
730
|
+
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 });
|
|
731
|
+
}
|
|
732
|
+
const match = /^v?(\d+)\.(\d+)/.exec(version.stdout.trim());
|
|
733
|
+
if (!match || Number(match[1]) < 2 || (Number(match[1]) === 2 && Number(match[2]) < 35)) {
|
|
734
|
+
throw new Error(`Docker Compose 2.35 or newer is required. Update Docker Desktop or the Docker Compose plugin.\n${recovery}`);
|
|
735
|
+
}
|
|
666
736
|
}
|
|
667
737
|
if (operation !== 'status') {
|
|
668
738
|
// Compose clients can disappear while their Engine-owned command continues.
|
|
669
|
-
const active = await
|
|
739
|
+
const active = await container(record, ['ps', '--filter', `label=com.docker.compose.project=${record.project}`, '--filter', 'label=com.docker.compose.oneoff=True', '--format', '{{.ID}}']);
|
|
670
740
|
if (active.stdout.trim()) throw new Error('Another server installation operation is still running in Docker');
|
|
671
741
|
}
|
|
672
742
|
} else {
|
|
@@ -704,6 +774,55 @@ export function networkEffects(effects) {
|
|
|
704
774
|
else if (!readFileSync(record.sourcesPath).equals(bytes)) throw new Error('Migration source selection changed');
|
|
705
775
|
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
776
|
},
|
|
777
|
+
async qualifyDockerRuntime(record) { return qualifyDockerRuntime(record, effects); },
|
|
778
|
+
async prepareGateway(record) {
|
|
779
|
+
atomicWriteConfig(join(record.workDir, 'docker-compose.gateway.yaml'), gatewayCompose(record));
|
|
780
|
+
atomicWriteConfig(join(record.workDir, 'nginx.conf'), gatewayNginx(record));
|
|
781
|
+
atomicWriteConfig(join(record.workDir, 'gateway-entrypoint.sh'), readFileSync(join(INSTALLER_ASSETS, 'gateway-entrypoint.sh'), 'utf8'));
|
|
782
|
+
atomicWriteConfig(join(record.workDir, 'Dockerfile.gateway'), readFileSync(join(INSTALLER_ASSETS, 'Dockerfile.gateway'), 'utf8'));
|
|
783
|
+
await compose(record, ['build', 'gateway'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
|
|
784
|
+
},
|
|
785
|
+
async removeGatewayContainer(record) { await compose(record, ['rm', '-f', 'gateway']); },
|
|
786
|
+
async verifyGateway(record) {
|
|
787
|
+
const { prefix } = gatewayAddress(record);
|
|
788
|
+
const script = `const fs=require('node:fs');let stage='selection';(async()=>{
|
|
789
|
+
const base=${JSON.stringify('http://gateway:8080' + prefix)};
|
|
790
|
+
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();};
|
|
791
|
+
const selection=await get('/daemon/selection');
|
|
792
|
+
if(selection.instanceId!==${JSON.stringify(record.instanceId)})throw Error('Gateway daemon instance mismatch');
|
|
793
|
+
stage='unauthenticated-management';
|
|
794
|
+
const denied=await fetch(base+'/cowork/management/rpc',{method:'POST',redirect:'error',signal:AbortSignal.timeout(5000),headers:{'content-type':'application/json'},body:'{}'});
|
|
795
|
+
await denied.body?.cancel();if(denied.status!==401)throw Error('Gateway management does not reject unauthenticated requests');
|
|
796
|
+
stage='credential';
|
|
797
|
+
const token=fs.readFileSync('/var/lib/ours/daemon-token','utf8').trim();
|
|
798
|
+
const headers={'x-ours-api-token':token,'content-type':'application/json'};
|
|
799
|
+
stage='authenticated-daemon';
|
|
800
|
+
const identities=await get('/daemon/identities',{headers});
|
|
801
|
+
if(!Array.isArray(identities.identities))throw Error('Gateway daemon API invalid');
|
|
802
|
+
stage='authenticated-management';
|
|
803
|
+
const result=await get('/cowork/management/rpc',{method:'POST',headers,body:JSON.stringify({version:1,id:'installer-readiness',method:'room.list',params:{}})});
|
|
804
|
+
if(result.version!==1||result.id!=='installer-readiness'||!Array.isArray(result.result)||result.error)throw Error('Gateway Cowork management unavailable');
|
|
805
|
+
})().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;});`;
|
|
806
|
+
const result = await compose(record, ['exec', '-T', 'daemon', 'node', '-e', script], { sensitive: true, allowCodes: [1] });
|
|
807
|
+
if (result.code === 1) {
|
|
808
|
+
const match = /^gateway-readiness:(selection|unauthenticated-management|credential|authenticated-daemon|authenticated-management):(EAI_AGAIN|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|CHECK_FAILED)$/.exec(result.stdout.trim());
|
|
809
|
+
const detail = match ? ` (${match[1]}, ${match[2]})` : '';
|
|
810
|
+
throw new Error(`Gateway readiness failed${detail}. Keep installation state intact; check the managed gateway startup and network connection, then retry setup.`);
|
|
811
|
+
}
|
|
812
|
+
},
|
|
813
|
+
async qualifyGatewayRuntime(record) {
|
|
814
|
+
for (const [name, args, capability] of [
|
|
815
|
+
['cowork', ['--json', 'capabilities'], 'cowork.http-management-v1'],
|
|
816
|
+
['messenger-server', ['capabilities'], 'messenger.gateway-prefix-v1'],
|
|
817
|
+
['tg-connector', ['capabilities'], 'telegram.gateway-listener-v1'],
|
|
818
|
+
]) {
|
|
819
|
+
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]);
|
|
820
|
+
let value; try { value = JSON.parse(probe.stdout); } catch {}
|
|
821
|
+
const capabilities = value?.result?.capabilities ?? value?.capabilities;
|
|
822
|
+
if (!Array.isArray(capabilities) || !capabilities.includes(capability))
|
|
823
|
+
throw new Error(`Selected ${name} artifact lacks ${capability}; select a compatible release before enabling the gateway`);
|
|
824
|
+
}
|
|
825
|
+
},
|
|
707
826
|
async prepareInstallation(record, { runtimeOnly = false } = {}) {
|
|
708
827
|
let copied = false;
|
|
709
828
|
if (!existsSync(record.workDir)) {
|
|
@@ -717,13 +836,26 @@ export function networkEffects(effects) {
|
|
|
717
836
|
if (!existsSync(materialized)) writePrivateNew(materialized, retained);
|
|
718
837
|
else if (!readFileSync(materialized).equals(retained)) throw new Error('Materialized sources differ from retained selection');
|
|
719
838
|
if (record.mode === 'docker') {
|
|
839
|
+
if (engineName(record) === 'podman') atomicWriteConfig(join(record.workDir, 'compose.podman.json'), JSON.stringify({ services: Object.fromEntries(record.services.map(name => [name, { restart: 'unless-stopped' }])) }));
|
|
840
|
+
const rebuildPrivateScripts = refreshDockerPolicyCopy(record);
|
|
841
|
+
if (record.gateway) {
|
|
842
|
+
atomicWriteConfig(join(record.workDir, 'docker-compose.gateway.yaml'), gatewayCompose(record));
|
|
843
|
+
atomicWriteConfig(join(record.workDir, 'nginx.conf'), gatewayNginx(record));
|
|
844
|
+
for (const file of ['Dockerfile.gateway', 'gateway-entrypoint.sh']) atomicWriteConfig(join(record.workDir, file), readFileSync(join(INSTALLER_ASSETS, file), 'utf8'));
|
|
845
|
+
}
|
|
720
846
|
// The installer owns these dependencies in both installation modes.
|
|
721
847
|
const { dependencies } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
722
848
|
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' } });
|
|
849
|
+
const image = await container(record, ['image', 'inspect', `${record.project}:runtime`], { allowCodes: [1] });
|
|
850
|
+
if (image.code !== 0 || rebuildPrivateScripts) await compose(record, ['build', 'daemon'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
|
|
851
|
+
await effects.qualifyDockerRuntime(record);
|
|
852
|
+
if (rebuildPrivateScripts) rmSync(join(record.workDir, '.script-permissions-rebuild'));
|
|
853
|
+
if (record.gateway) {
|
|
854
|
+
await effects.qualifyGatewayRuntime(record);
|
|
855
|
+
await compose(record, ['build', 'gateway'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
|
|
856
|
+
}
|
|
725
857
|
if (runtimeOnly) return;
|
|
726
|
-
await
|
|
858
|
+
await effects.prepareServerVolumes(record);
|
|
727
859
|
} else {
|
|
728
860
|
if (!existsSync(join(record.workDir, '.packages-ready'))) {
|
|
729
861
|
const sourceRoot = join(record.root, `build-${randomUUID()}`);
|
|
@@ -744,12 +876,15 @@ export function networkEffects(effects) {
|
|
|
744
876
|
const profilePath = join(installationPaths(record).mcp, 'profile.json');
|
|
745
877
|
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
878
|
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
879
|
effects.writeJson(record.configPath, JSON.stringify(config, null, 2) + '\n');
|
|
749
880
|
const cowork = join(installationPaths(record).cowork, 'config.json');
|
|
750
881
|
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
882
|
}
|
|
752
883
|
},
|
|
884
|
+
async prepareServerVolumes(record) {
|
|
885
|
+
if (record.mode !== 'docker') return;
|
|
886
|
+
await compose(record, ['run', '--rm', '--no-deps', '-T', 'prepare', 'prepare']);
|
|
887
|
+
},
|
|
753
888
|
async prepareServerBuild(record, args) {
|
|
754
889
|
validateInstallation(record, record.root);
|
|
755
890
|
if (record.schema !== 2 || record.layoutConversion || !['update', 'rebuild'].includes(args.operation)) {
|
|
@@ -792,17 +927,17 @@ export function networkEffects(effects) {
|
|
|
792
927
|
},
|
|
793
928
|
async copyDockerBuildRecords(record, directory) {
|
|
794
929
|
// Inspect immutable image metadata; never reinterpret a failed context copy as legacy.
|
|
795
|
-
const label = (await
|
|
930
|
+
const label = (await container(record, ['image', 'inspect', '--format', '{{ index .Config.Labels "network.ours.build-context" }}', `${record.project}:runtime`])).stdout.trim();
|
|
796
931
|
if (!['', '<no value>', '1'].includes(label)) throw new Error('Unsupported image build-context schema');
|
|
797
932
|
const names = [...BASE_RECORDS, ...(label === '1' ? [CONTEXT] : [])];
|
|
798
933
|
const stage = mkdtempSync(join(directory, '.records-'));
|
|
799
934
|
const name = `${record.project}-records`;
|
|
800
935
|
let created = false;
|
|
801
936
|
try {
|
|
802
|
-
await
|
|
937
|
+
await container(record, ['create', '--name', name, '--entrypoint', '/bin/true', `${record.project}:runtime`]);
|
|
803
938
|
created = true;
|
|
804
939
|
for (const file of names) {
|
|
805
|
-
await
|
|
940
|
+
await container(record, ['cp', `${name}:/opt/ours/${file}`, join(stage, file)]);
|
|
806
941
|
const st = lstatSync(join(stage, file));
|
|
807
942
|
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o7002)) throw new Error('Unsafe copied build record');
|
|
808
943
|
chmodSync(join(stage, file), 0o600);
|
|
@@ -812,7 +947,7 @@ export function networkEffects(effects) {
|
|
|
812
947
|
for (const file of names) renameSync(join(stage, file), join(directory, file));
|
|
813
948
|
if (label !== '1' && existsSync(join(directory, CONTEXT))) throw new Error('Legacy image conflicts with retained build context');
|
|
814
949
|
} finally {
|
|
815
|
-
try { if (created) await
|
|
950
|
+
try { if (created) await container(record, ['rm', name]); }
|
|
816
951
|
finally { rmSync(stage, { recursive: true, force: true }); }
|
|
817
952
|
}
|
|
818
953
|
},
|
|
@@ -863,12 +998,12 @@ export function networkEffects(effects) {
|
|
|
863
998
|
async publishServerBuild(record, candidate) {
|
|
864
999
|
const previous = join(candidate.root, 'previous-runtime');
|
|
865
1000
|
if (record.mode === 'docker') {
|
|
866
|
-
for (const target of ['runtime', 'maintenance']) {
|
|
1001
|
+
for (const target of ['runtime', 'maintenance', ...(record.gateway ? ['gateway'] : [])]) {
|
|
867
1002
|
const retained = `${candidate.project}:previous-${target}`;
|
|
868
|
-
const found = await
|
|
1003
|
+
const found = await container(record, ['image', 'inspect', retained], { allowCodes: [1] });
|
|
869
1004
|
if (found.code !== 0) {
|
|
870
|
-
const current = await
|
|
871
|
-
if (current.code === 0) await
|
|
1005
|
+
const current = await container(record, ['image', 'inspect', `${record.project}:${target}`], { allowCodes: [1] });
|
|
1006
|
+
if (current.code === 0) await container(record, ['tag', `${record.project}:${target}`, retained]);
|
|
872
1007
|
}
|
|
873
1008
|
}
|
|
874
1009
|
}
|
|
@@ -884,8 +1019,8 @@ export function networkEffects(effects) {
|
|
|
884
1019
|
renameSync(candidate.workDir, record.workDir);
|
|
885
1020
|
} else privateDirectory(record.workDir);
|
|
886
1021
|
if (record.mode === 'docker') {
|
|
887
|
-
for (const target of ['runtime', 'maintenance']) {
|
|
888
|
-
await
|
|
1022
|
+
for (const target of ['runtime', 'maintenance', ...(record.gateway ? ['gateway'] : [])]) {
|
|
1023
|
+
await container(candidate, ['tag', `${candidate.project}:${target}`, `${record.project}:${target}`]);
|
|
889
1024
|
}
|
|
890
1025
|
}
|
|
891
1026
|
atomicWriteConfig(record.sourcesPath, readFileSync(candidate.sourcesPath));
|
|
@@ -899,10 +1034,10 @@ export function networkEffects(effects) {
|
|
|
899
1034
|
},
|
|
900
1035
|
async discardServerBuild(candidate) {
|
|
901
1036
|
if (candidate.mode === 'docker') {
|
|
902
|
-
for (const target of ['runtime', 'maintenance', 'previous-runtime', 'previous-maintenance']) {
|
|
1037
|
+
for (const target of ['runtime', 'maintenance', 'previous-runtime', 'previous-maintenance', ...(candidate.gateway ? ['gateway', 'previous-gateway'] : [])]) {
|
|
903
1038
|
const image = `${candidate.project}:${target}`;
|
|
904
|
-
const found = await
|
|
905
|
-
if (found.code === 0) await
|
|
1039
|
+
const found = await container(candidate, ['image', 'inspect', image], { allowCodes: [1] });
|
|
1040
|
+
if (found.code === 0) await container(candidate, ['image', 'rm', image]);
|
|
906
1041
|
}
|
|
907
1042
|
}
|
|
908
1043
|
privateDirectory(candidate.root);
|
|
@@ -910,7 +1045,15 @@ export function networkEffects(effects) {
|
|
|
910
1045
|
},
|
|
911
1046
|
async serverAccess(record, operation, { output, migrate = false } = {}) {
|
|
912
1047
|
if (record.mode === 'docker') {
|
|
913
|
-
|
|
1048
|
+
// Retained images contain the old helper that discarded CLI errors.
|
|
1049
|
+
// Execute the shipped installer helper against the SAME selected image
|
|
1050
|
+
// and Compose volume/user/environment contract, without retag/rebuild.
|
|
1051
|
+
const diagnosticsUrl = `data:text/javascript;base64,${readFileSync(join(INSTALLER_ASSETS, 'scripts/runtime/diagnostics.mjs')).toString('base64')}`;
|
|
1052
|
+
const helper = `process.argv.splice(1, 0, '/opt/ours/docker/client-setup.mjs');\n` + readFileSync(join(INSTALLER_ASSETS, 'scripts/runtime/client-setup.mjs'), 'utf8')
|
|
1053
|
+
.replace("'../maintenance/build-context.mjs'", "'/opt/ours/maintenance/build-context.mjs'")
|
|
1054
|
+
.replace("'./diagnostics.mjs'", JSON.stringify(diagnosticsUrl));
|
|
1055
|
+
const accessArgs = (...args) => ['--entrypoint', 'node', 'access', '--input-type=module', '-e', helper, '--', ...args];
|
|
1056
|
+
if (!output) return compose(record, ['run', '--rm', '--no-deps', '-T', ...accessArgs(operation)], { sensitive: true, env: { OURS_ACCESS_MIGRATE: migrate ? '1' : '0' } });
|
|
914
1057
|
privateDirectory(dirname(output));
|
|
915
1058
|
if (existsSync(output)) throw new Error('Credential output already exists');
|
|
916
1059
|
const name = `${record.project}-issue-${randomUUID()}`;
|
|
@@ -918,23 +1061,23 @@ export function networkEffects(effects) {
|
|
|
918
1061
|
const staging = join(dirname(output), `.ours-issued-${randomUUID()}`);
|
|
919
1062
|
ensurePrivateDirectory(staging);
|
|
920
1063
|
try {
|
|
921
|
-
await compose(record, ['run', '--name', name, '--no-deps', '-T', 'access
|
|
1064
|
+
await compose(record, ['run', '--name', name, '--no-deps', '-T', ...accessArgs('access-issue', issuedPath)], { sensitive: true });
|
|
922
1065
|
const file = join(staging, 'credential');
|
|
923
|
-
await
|
|
1066
|
+
await container(record, ['cp', `${name}:${issuedPath}`, file], { sensitive: true });
|
|
924
1067
|
assertPrivateRegularFile(file, 'issued credential');
|
|
925
1068
|
// Exclusive publication never overwrites an unrelated credential.
|
|
926
1069
|
writePrivateNew(output, readFileSync(file));
|
|
927
1070
|
} finally {
|
|
928
1071
|
await compose(record, ['run', '--rm', '--no-deps', '-T', '--entrypoint', 'rm', 'access', '-f', issuedPath], { sensitive: true });
|
|
929
|
-
await
|
|
1072
|
+
await container(record, ['rm', '-f', name], { sensitive: true });
|
|
930
1073
|
rmSync(staging, { recursive: true, force: true });
|
|
931
1074
|
}
|
|
932
1075
|
return;
|
|
933
1076
|
}
|
|
934
1077
|
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);
|
|
1078
|
+
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
1079
|
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);
|
|
1080
|
+
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
1081
|
},
|
|
939
1082
|
async recordRuntimeBuild(record) {
|
|
940
1083
|
if (record.mode === 'docker') return; // Image preparation records its build.
|
|
@@ -978,7 +1121,7 @@ export function networkEffects(effects) {
|
|
|
978
1121
|
await effects.run(process.execPath, [join(INSTALLER_ASSETS, 'scripts/maintenance/state-operation.mjs'), ...command], {
|
|
979
1122
|
env: { ...localEnv(record), OURS_STATE_DOMAIN: args.domain,
|
|
980
1123
|
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'),
|
|
1124
|
+
OURS_BUILD_ROOT: record.workDir, OURS_CLI_PATH: bin(record, existsSync(bin(record, 'ours-daemon')) ? 'ours-daemon' : 'ours'),
|
|
982
1125
|
OURS_DAEMON_CONFIG: record.configPath, OURS_COWORK_CLI_PATH: bin(record, 'ours-cowork'),
|
|
983
1126
|
OURS_COWORK_CONFIG: join(paths.cowork, 'config.json'), OURS_COWORK_STATE_DIR: paths.cowork },
|
|
984
1127
|
});
|
|
@@ -996,12 +1139,12 @@ export function networkEffects(effects) {
|
|
|
996
1139
|
if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name)) {
|
|
997
1140
|
throw new Error('Invalid conversion volume name');
|
|
998
1141
|
}
|
|
999
|
-
const result = await
|
|
1142
|
+
const result = await container(record, ['volume', 'inspect', '--format', '{{json .}}', name], {
|
|
1000
1143
|
...(optional ? { allowCodes: [1] } : {}),
|
|
1001
1144
|
});
|
|
1002
1145
|
if (result.code !== 0) {
|
|
1003
1146
|
if (optional) {
|
|
1004
|
-
const inventory = await
|
|
1147
|
+
const inventory = await container(record, ['volume', 'ls', '--format', '{{.Name}}']);
|
|
1005
1148
|
if (inventory.code === 0 && !inventory.stdout.split(/\s+/).includes(name)) return false;
|
|
1006
1149
|
}
|
|
1007
1150
|
throw new Error(`Source volume is missing or unavailable: ${name}`);
|
|
@@ -1063,7 +1206,7 @@ export function networkEffects(effects) {
|
|
|
1063
1206
|
await ownerCommand(record, 'cowork', 'prepare-backup');
|
|
1064
1207
|
},
|
|
1065
1208
|
async retainConvertedPackageAuthority(record, daemon) {
|
|
1066
|
-
await effects.run(bin(record, 'ours'), [
|
|
1209
|
+
await effects.run(bin(record, existsSync(bin(record, 'ours-daemon')) ? 'ours-daemon' : 'ours'), [
|
|
1067
1210
|
'config', 'access-retain', '--config', record.configPath,
|
|
1068
1211
|
'--target-state-dir', daemon, '--json',
|
|
1069
1212
|
], { env: localEnv(record), sensitive: true });
|
|
@@ -1125,7 +1268,9 @@ export function networkEffects(effects) {
|
|
|
1125
1268
|
}
|
|
1126
1269
|
const start = async service => {
|
|
1127
1270
|
effects.out(`Starting ${service}; waiting for readiness...`);
|
|
1128
|
-
|
|
1271
|
+
// Gateway has no persistent state. Recreate its network endpoint on each
|
|
1272
|
+
// managed start, including retries after an interrupted/failed bind.
|
|
1273
|
+
try { await compose(record, ['up', '-d', '--no-build', '--no-deps', ...(service === 'gateway' ? ['--force-recreate'] : []), '--wait', service]); }
|
|
1129
1274
|
catch (cause) { throw await dockerStartupError(record, service, cause); }
|
|
1130
1275
|
effects.out(`${service} is ready.`);
|
|
1131
1276
|
};
|
|
@@ -1135,6 +1280,7 @@ export function networkEffects(effects) {
|
|
|
1135
1280
|
try { await start(service); }
|
|
1136
1281
|
catch (error) { failures.push({ service, error }); }
|
|
1137
1282
|
}
|
|
1283
|
+
if (!failures.length && record.gateway && ['daemon', 'cowork', 'gateway'].every(service => selected.includes(service))) await effects.verifyGateway(record);
|
|
1138
1284
|
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
1285
|
return;
|
|
1140
1286
|
}
|
|
@@ -1149,21 +1295,19 @@ export function networkEffects(effects) {
|
|
|
1149
1295
|
return JSON.parse(readFileSync(path, 'utf8'));
|
|
1150
1296
|
},
|
|
1151
1297
|
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) });
|
|
1298
|
+
const base = serverBase(endpoint);
|
|
1299
|
+
const request = path => fetch(`${base}${path}`, { redirect: 'error', signal: AbortSignal.timeout(5000) });
|
|
1300
|
+
const discovery = await request('/.well-known/ours');
|
|
1301
|
+
if (discovery.ok) return validateGatewayDiscovery(base, await discovery.json(), resolve(credentialPath));
|
|
1302
|
+
throw new Error(`Gateway discovery answered HTTP ${discovery.status}; enable the gateway. Direct daemon fallback is not supported.`);
|
|
1160
1303
|
},
|
|
1161
|
-
importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh = false }) {
|
|
1304
|
+
importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, disableFleetAgentsSetup = false, refresh = false, notificationsProducerPath }) {
|
|
1305
|
+
profile = validateGatewayClientProfile(profile);
|
|
1162
1306
|
const root = join(home, '.ours-client');
|
|
1163
1307
|
const configPath = join(root, 'profile.json');
|
|
1164
1308
|
const credentialPath = join(root, 'credential');
|
|
1165
1309
|
const current = effects.readManagedClientProfile();
|
|
1166
|
-
if (current &&
|
|
1310
|
+
if (current && current.expectedInstanceId !== profile.expectedInstanceId)
|
|
1167
1311
|
throw new Error('Managed client already selects another server; existing default was not changed');
|
|
1168
1312
|
assertPrivateRegularFile(profile.credentialPath, 'credential');
|
|
1169
1313
|
const credential = readFileSync(profile.credentialPath, 'utf8');
|
|
@@ -1174,13 +1318,30 @@ export function networkEffects(effects) {
|
|
|
1174
1318
|
: readFileSync(sourcesPath);
|
|
1175
1319
|
const fleetSettings = (!current || refresh) && fleetSettingsPath ? readFileSync(fleetSettingsPath) : null;
|
|
1176
1320
|
if (fleetSettings) JSON.parse(fleetSettings.toString());
|
|
1321
|
+
let producer = null;
|
|
1322
|
+
if (notificationsProducerPath) {
|
|
1323
|
+
assertPrivateRegularFile(notificationsProducerPath, 'notification producer credential');
|
|
1324
|
+
producer = readFileSync(notificationsProducerPath, 'utf8');
|
|
1325
|
+
let bound; try { bound = JSON.parse(producer); } catch { bound = null; }
|
|
1326
|
+
if (bound?.schema !== 1 || bound.serverUrl !== profile.serverUrl || bound.expectedInstanceId !== profile.expectedInstanceId || !/^[A-Za-z0-9_-]{32,256}$/.test(bound.token ?? ''))
|
|
1327
|
+
throw new Error('Notification producer credential does not match the selected server');
|
|
1328
|
+
}
|
|
1177
1329
|
ensurePrivateDirectory(root);
|
|
1330
|
+
// Written before the profile it belongs to is published or changed.
|
|
1331
|
+
let notificationsProducerChanged = false;
|
|
1332
|
+
if (producer !== null) {
|
|
1333
|
+
const producerPath = join(root, 'notifications-producer.json');
|
|
1334
|
+
if (!existsSync(producerPath) || readFileSync(producerPath, 'utf8') !== producer) { atomicWriteConfig(producerPath, producer); notificationsProducerChanged = true; }
|
|
1335
|
+
}
|
|
1178
1336
|
if (current && !refresh) {
|
|
1179
1337
|
assertPrivateRegularFile(credentialPath, 'managed credential');
|
|
1180
1338
|
if (readFileSync(credentialPath, 'utf8') !== credential) atomicWriteConfig(credentialPath, credential);
|
|
1181
|
-
|
|
1339
|
+
const saved = { ...current, ...profile, credentialPath };
|
|
1340
|
+
if (current.endpoint !== saved.endpoint || current.serverUrl !== saved.serverUrl)
|
|
1341
|
+
atomicWriteConfig(configPath, JSON.stringify(saved, null, 2) + '\n');
|
|
1342
|
+
return { configPath, profile: validateGatewayClientProfile(saved), settings: current.installer, notificationsProducerChanged };
|
|
1182
1343
|
}
|
|
1183
|
-
const settings = { sourcesPath: join(root, 'sources.json'), integrations };
|
|
1344
|
+
const settings = { sourcesPath: join(root, 'sources.json'), integrations, ...(disableFleetAgentsSetup ? { disableFleetAgentsSetup: true } : {}) };
|
|
1184
1345
|
if (fleetSettings) settings.fleetSettingsPath = join(root, 'fleet-settings.json');
|
|
1185
1346
|
// Publish the profile last: clients cannot select incomplete imported inputs.
|
|
1186
1347
|
atomicWriteConfig(settings.sourcesPath, sources);
|
|
@@ -1188,20 +1349,41 @@ export function networkEffects(effects) {
|
|
|
1188
1349
|
atomicWriteConfig(credentialPath, credential);
|
|
1189
1350
|
const saved = { ...profile, credentialPath, installer: settings };
|
|
1190
1351
|
atomicWriteConfig(configPath, JSON.stringify(saved, null, 2) + '\n');
|
|
1191
|
-
return { configPath, profile: validateHostProfile(saved), settings };
|
|
1352
|
+
return { configPath, profile: validateHostProfile(saved), settings, notificationsProducerChanged };
|
|
1353
|
+
},
|
|
1354
|
+
async qualifyInstalledGatewayClient(profile) {
|
|
1355
|
+
if (!profile.installer?.integrations?.includes('fleet')) return;
|
|
1356
|
+
let info;
|
|
1357
|
+
try { info = JSON.parse((await effects.run('ours-fleet', ['version', '--json'])).stdout); } catch {}
|
|
1358
|
+
if (!Array.isArray(info?.capabilities) || !info.capabilities.includes('cowork.http-management-v1'))
|
|
1359
|
+
throw new Error('Installed Fleet does not support gateway HTTP management; upgrade Fleet before gateway-enable');
|
|
1192
1360
|
},
|
|
1193
|
-
async
|
|
1361
|
+
async qualifyGatewayClient({ profile, sourcesPath, sources, integrations, refresh = false }) {
|
|
1362
|
+
if (!profile.serverUrl || !integrations.includes('fleet')) return;
|
|
1363
|
+
// Acquire into the retained package cache without publishing a profile,
|
|
1364
|
+
// credential, native command, or integration. Normal acquisition rechecks it.
|
|
1365
|
+
const staging = mkdtempSync(join(home, '.ours-gateway-client-'));
|
|
1366
|
+
try {
|
|
1367
|
+
const stagedSources = join(staging, 'sources.json');
|
|
1368
|
+
writeFileSync(stagedSources, sources ? JSON.stringify(sources, null, 2) + '\n' : readFileSync(sourcesPath), { mode: 0o600 });
|
|
1369
|
+
await effects.acquireClientPackages(join(home, '.ours-client/profile.json'), stagedSources, integrations,
|
|
1370
|
+
{ refresh, gatewayServerUrl: profile.serverUrl, qualifyOnly: true });
|
|
1371
|
+
} finally { rmSync(staging, { recursive: true, force: true }); }
|
|
1372
|
+
},
|
|
1373
|
+
async acquireClientPackages(configPath, sourcesPath, integrations, { refresh = false, hostCliOnly = false, gatewayServerUrl, qualifyOnly = false } = {}) {
|
|
1194
1374
|
const manifest = JSON.parse(readFileSync(sourcesPath, 'utf8'));
|
|
1195
|
-
//
|
|
1196
|
-
const selected =
|
|
1375
|
+
// Every client installation includes the native CLI and its SDK dependency.
|
|
1376
|
+
const selected = clientPackageNames(integrations);
|
|
1197
1377
|
const packages = selectSourcePackages(manifest, 'client', selected);
|
|
1198
|
-
const selectionKey =
|
|
1378
|
+
const selectionKey = JSON.stringify([hostCliOnly ? 'host-cli-private-v1' : 'host-cli-v1', configPath, ...(hostCliOnly ? [manifest] : []), ...(refresh ? [manifest, integrations] : [])]);
|
|
1199
1379
|
const root = join(home, '.ours-client-install', createHash('sha256').update(selectionKey).digest('hex').slice(0, 16));
|
|
1200
1380
|
const hasGit = Object.values(packages).some(selection => selection.source);
|
|
1201
1381
|
await effects.run('npm', ['--version']);
|
|
1202
1382
|
if (hasGit) {
|
|
1203
1383
|
for (const command of ['python3', 'git', 'make', 'cc']) await effects.run(command, ['--version']);
|
|
1204
1384
|
}
|
|
1385
|
+
// This directory retains active executables, not disposable download cache.
|
|
1386
|
+
ensurePrivateDirectory(join(home, '.ours-client-install'));
|
|
1205
1387
|
ensurePrivateDirectory(root);
|
|
1206
1388
|
const retained = join(root, 'sources.json');
|
|
1207
1389
|
const bytes = readFileSync(sourcesPath);
|
|
@@ -1226,13 +1408,33 @@ export function networkEffects(effects) {
|
|
|
1226
1408
|
writePrivateNew(join(root, '.packages-ready'), 'ready\n');
|
|
1227
1409
|
}
|
|
1228
1410
|
verifyReleaseGraph(root, manifest, { requiredPackages: Object.keys(packages) });
|
|
1411
|
+
if (integrations.includes('fleet') && (gatewayServerUrl || readHostProfileFile(configPath)?.serverUrl)) {
|
|
1412
|
+
let info;
|
|
1413
|
+
try { info = JSON.parse(readFileSync(join(root, 'node_modules/@ours.network/fleet/dist/build-info.json'), 'utf8')); } catch {}
|
|
1414
|
+
if (!Array.isArray(info?.capabilities) || !info.capabilities.includes('cowork.http-management-v1'))
|
|
1415
|
+
throw new Error('Selected Fleet artifact does not support gateway HTTP management; select a compatible client release');
|
|
1416
|
+
}
|
|
1417
|
+
if (qualifyOnly) return;
|
|
1418
|
+
const cliPolicy = hostCliOnly ? null : hostCliPolicy(manifest);
|
|
1229
1419
|
// Local acquisition alone does not publish native commands. Use the user's
|
|
1230
1420
|
// configured npm prefix and retained dependency closure, including on retry.
|
|
1231
1421
|
for (const name of integrations.filter(name => name === 'fleet' || name === 'codex')) {
|
|
1232
1422
|
await effects.run('npm', ['install', '--global', '--install-links=false', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', join(root, 'node_modules', '@ours.network', name)]);
|
|
1233
1423
|
}
|
|
1424
|
+
let cliBin = join(root, 'node_modules/.bin/ours');
|
|
1425
|
+
if (cliPolicy) {
|
|
1426
|
+
const cliSources = join(root, 'host-cli-sources.json');
|
|
1427
|
+
const cliBytes = JSON.stringify(cliPolicy, null, 2) + '\n';
|
|
1428
|
+
if (!existsSync(cliSources)) writePrivateNew(cliSources, cliBytes);
|
|
1429
|
+
else if (readFileSync(cliSources, 'utf8') !== cliBytes) throw new Error('Retained host CLI source selection differs');
|
|
1430
|
+
const acquired = await effects.acquireClientPackages(configPath, cliSources, [], { refresh, hostCliOnly: true });
|
|
1431
|
+
cliBin = acquired.cliBin;
|
|
1432
|
+
} else {
|
|
1433
|
+
// Publish last so optional integration installation cannot downgrade ours.
|
|
1434
|
+
await publishClientCli(effects, join(root, 'node_modules', '@ours.network/cli'), { policy: manifest, isolated: hostCliOnly });
|
|
1435
|
+
}
|
|
1234
1436
|
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 };
|
|
1437
|
+
return { localPackages, packages: {}, cliBin, fleetBin: integrations.includes('fleet') ? join(root, 'node_modules/.bin/ours-fleet') : null };
|
|
1236
1438
|
},
|
|
1237
1439
|
async prepareClientMarketplace(name, packagePath) {
|
|
1238
1440
|
const acquisitionRoot = dirname(dirname(dirname(packagePath)));
|
|
@@ -1241,7 +1443,7 @@ export function networkEffects(effects) {
|
|
|
1241
1443
|
const release = releaseBinding(policy);
|
|
1242
1444
|
const integrationsPath = join(acquisitionRoot, 'integrations.json');
|
|
1243
1445
|
const integrations = existsSync(integrationsPath) ? JSON.parse(readFileSync(integrationsPath, 'utf8')) : null;
|
|
1244
|
-
const requiredPackages = integrations ?
|
|
1446
|
+
const requiredPackages = integrations ? clientPackageNames(integrations).map(name => '@ours.network/' + name) : Object.keys(policy.packages ?? {});
|
|
1245
1447
|
verifyReleaseGraph(acquisitionRoot, policy, { requiredPackages });
|
|
1246
1448
|
const root = join(acquisitionRoot, 'marketplaces', name);
|
|
1247
1449
|
const plugin = join(root, 'plugins', 'ours');
|
|
@@ -1250,7 +1452,7 @@ export function networkEffects(effects) {
|
|
|
1250
1452
|
cpSync(packagePath, plugin, { recursive: true });
|
|
1251
1453
|
const manifestPath = join(plugin, 'package.json');
|
|
1252
1454
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
1253
|
-
for (const dependency of release ? [] : ['sdk', 'cli']) {
|
|
1455
|
+
for (const dependency of release ? [] : ['sdk', 'cli', 'mcp']) {
|
|
1254
1456
|
const name = `@ours.network/${dependency}`;
|
|
1255
1457
|
if (manifest.dependencies?.[name]) manifest.dependencies[name] = `file:${join(dirname(packagePath), dependency)}`;
|
|
1256
1458
|
}
|
|
@@ -1270,42 +1472,7 @@ export function networkEffects(effects) {
|
|
|
1270
1472
|
effects.writeJson(manifestPath, JSON.stringify(value, null, 2) + '\n');
|
|
1271
1473
|
return root;
|
|
1272
1474
|
},
|
|
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
|
-
},
|
|
1475
|
+
|
|
1309
1476
|
};
|
|
1310
1477
|
}
|
|
1311
1478
|
|
|
@@ -1352,7 +1519,6 @@ async function nativeLifecycle(record, operation, selected, { effects, localEnv,
|
|
|
1352
1519
|
if (service === 'daemon') {
|
|
1353
1520
|
const profilePath = join(installationPaths(record).mcp, 'profile.json');
|
|
1354
1521
|
await effects.verifyHostProfile(profilePath);
|
|
1355
|
-
await effects.verifyPackagedMcp(profilePath);
|
|
1356
1522
|
return;
|
|
1357
1523
|
}
|
|
1358
1524
|
if (service === 'cowork') { await ownerCommand(record, service, 'status'); return; }
|
|
@@ -1430,7 +1596,7 @@ async function nativeLifecycle(record, operation, selected, { effects, localEnv,
|
|
|
1430
1596
|
}
|
|
1431
1597
|
if (!ready) throw new Error('Application is not ready');
|
|
1432
1598
|
} catch {
|
|
1433
|
-
if (service === 'daemon') throw new Error('Daemon
|
|
1599
|
+
if (service === 'daemon') throw new Error('Daemon API is not ready; consumers were not started');
|
|
1434
1600
|
failures.push(service);
|
|
1435
1601
|
}
|
|
1436
1602
|
}
|