@ours.network/install 1.2.1-nightly.42 → 1.2.1-nightly.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -3
- package/assets/Dockerfile +1 -0
- package/assets/release-lock.json +120 -106
- package/assets/release.json +5 -5
- package/assets/scripts/build/build-common.mjs +9 -1
- package/assets/scripts/maintenance/docker-layout-conversion.mjs +15 -4
- package/assets/scripts/maintenance/state-operation.mjs +15 -4
- package/assets/scripts/runtime/client-setup.mjs +26 -15
- package/assets/scripts/runtime/daemon-client-prefix.mjs +115 -0
- package/assets/sources.json +7 -7
- package/lib/boot-readiness.mjs +67 -14
- package/lib/docker-conversion-runtime.mjs +4 -2
- package/lib/docker-recovery.mjs +6 -1
- package/lib/effects.mjs +136 -8
- package/lib/orchestrate-uninstall.mjs +6 -1
- package/lib/orchestrate.mjs +26 -1
- package/lib/ownership.mjs +63 -0
- package/lib/plan.mjs +14 -4
- package/lib/product.mjs +69 -0
- package/lib/removal-plan.mjs +437 -0
- package/lib/removal.mjs +749 -0
- package/lib/server-onboarding.mjs +22 -4
- package/lib/setup-options.mjs +20 -1
- package/lib/setup.mjs +52 -9
- package/lib/usage.mjs +14 -2
- package/lib/welcome.mjs +219 -0
- package/package.json +1 -1
package/lib/effects.mjs
CHANGED
|
@@ -17,15 +17,16 @@ import { engineName, bindPodman, runContainer, nativeBuildCommands } from './con
|
|
|
17
17
|
import { publishClientCli } from './client-cli.mjs';
|
|
18
18
|
import { commandFailure } from './diagnostics.mjs';
|
|
19
19
|
import { spawnSync, execFileSync } from 'node:child_process';
|
|
20
|
-
import { chmodSync, closeSync, constants, cpSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { chmodSync, closeSync, constants, cpSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, writeFileSync } from 'node:fs';
|
|
21
21
|
import { homedir, userInfo, platform as osPlatform, release as osRelease, arch as osArch } from 'node:os';
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
23
|
import { randomUUID, createHash } from 'node:crypto';
|
|
24
24
|
import { dirname, join, resolve } from 'node:path';
|
|
25
|
+
import { connect as netConnect } from 'node:net';
|
|
25
26
|
import { clientPackageNames, maintenanceServices, installationPaths, validateInstallation, consumerServiceState, unitNameForStateDir, launchdLabelForStateDir, messengerServicePlan, selectSourcePackages, resolveSourcePolicy, SERVER_SERVICES } from './plan.mjs';
|
|
26
27
|
import { validateHostProfile, validateGatewayClientProfile } from './target.mjs';
|
|
27
28
|
import { serverBase, validateGatewayDiscovery, gatewayCompose, gatewayNginx, gatewayAddress } from './gateway.mjs';
|
|
28
|
-
import { createServerOnboarding } from './server-onboarding.mjs';
|
|
29
|
+
import { createServerOnboarding, retainedCredentialRefused } from './server-onboarding.mjs';
|
|
29
30
|
import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
|
|
30
31
|
import { select as selectOnTty, multiselect as multiselectOnTty, askLine as askLineOnTty } from './prompt.mjs';
|
|
31
32
|
import { classifyHarnessProbe } from './logic.mjs';
|
|
@@ -33,10 +34,24 @@ import { qualifyDockerRuntime, refreshDockerPolicyCopy } from './docker-runtime-
|
|
|
33
34
|
import { refreshDockerRecovery, recoveryCompose, reconcileDockerRecovery } from './docker-recovery.mjs';
|
|
34
35
|
import { reportBootReadiness } from './boot-readiness.mjs';
|
|
35
36
|
import { classifyStateDir } from './detect.mjs';
|
|
37
|
+
import { isPluginOnly, serverRole, pluginOnlyCompose, PLUGIN_ONLY_COMPOSE } from './product.mjs';
|
|
38
|
+
import { OWNERS_FILE, OWNED_IMAGES_FILE, addOwner, ownedImagesRecord, changedImages } from './ownership.mjs';
|
|
39
|
+
import { IMAGE_TARGETS } from './removal-plan.mjs';
|
|
36
40
|
import { BASE_RECORDS, CONTEXT, readBuildRecords, equalBuildRecords, initializeBuildMarker } from '../assets/scripts/maintenance/build-context.mjs';
|
|
37
41
|
import { hostCliPolicy, releaseBinding, verifyReleaseGraph, verifyRuntimeRelease } from '../assets/scripts/maintenance/release-graph.mjs';
|
|
38
42
|
|
|
39
43
|
/** GET http://127.0.0.1:<port>/state-dir — the unauthenticated identity probe. */
|
|
44
|
+
/** Whether anything accepts TCP connections on a loopback port: 'refused', 'open', or 'unknown' (timeout or other error). */
|
|
45
|
+
function portState(port, { timeoutMs = 1500 } = {}) {
|
|
46
|
+
return new Promise(done => {
|
|
47
|
+
const socket = netConnect({ host: '127.0.0.1', port });
|
|
48
|
+
const finish = state => { socket.destroy(); done(state); };
|
|
49
|
+
socket.setTimeout(timeoutMs, () => finish('unknown'));
|
|
50
|
+
socket.once('connect', () => finish('open'));
|
|
51
|
+
socket.once('error', error => finish(error?.code === 'ECONNREFUSED' ? 'refused' : 'unknown'));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
async function probePort(port, { timeoutMs = 1500 } = {}) {
|
|
41
56
|
const controller = new AbortController();
|
|
42
57
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -338,6 +353,40 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
338
353
|
closeSync(fd);
|
|
339
354
|
}
|
|
340
355
|
},
|
|
356
|
+
/**
|
|
357
|
+
* Removal holds the installation lock of every root it removes for the
|
|
358
|
+
* whole run, so no installer operation can start inside a root being deleted.
|
|
359
|
+
*/
|
|
360
|
+
async holdInstallationLocks(roots) {
|
|
361
|
+
const { tryLock } = await import('../assets/scripts/maintenance/state-native.mjs');
|
|
362
|
+
const held = [];
|
|
363
|
+
try {
|
|
364
|
+
for (const root of roots) {
|
|
365
|
+
if (!existsSync(root)) continue;
|
|
366
|
+
privateDirectory(root);
|
|
367
|
+
const path = join(root, '.operation.lock');
|
|
368
|
+
const fd = openSync(path, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
|
|
369
|
+
held.push(fd);
|
|
370
|
+
const stat = fstatSync(fd);
|
|
371
|
+
if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o077) || stat.nlink !== 1) throw new Error(`Unsafe installation lock in ${root}`);
|
|
372
|
+
if (!tryLock(fd)) throw new Error(`Another installer operation is active in ${root}; try again when it has finished`);
|
|
373
|
+
}
|
|
374
|
+
} catch (error) {
|
|
375
|
+
for (const fd of held) closeSync(fd);
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
return () => { for (const fd of held) { try { closeSync(fd); } catch { /* released */ } } };
|
|
379
|
+
},
|
|
380
|
+
/** lstat as a plain record; null when absent. Never follows a link. */
|
|
381
|
+
stat: (path) => {
|
|
382
|
+
try {
|
|
383
|
+
const st = lstatSync(path, { bigint: true });
|
|
384
|
+
return { type: st.isSymbolicLink() ? 'symlink' : st.isDirectory() ? 'dir' : st.isFile() ? 'file' : 'other', uid: Number(st.uid), mode: Number(st.mode & 0o7777n), nlink: Number(st.nlink), size: Number(st.size), id: `${st.dev}:${st.ino}` };
|
|
385
|
+
} catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null; throw error; }
|
|
386
|
+
},
|
|
387
|
+
realpath: (path) => { try { return realpathSync(path); } catch { return null; } },
|
|
388
|
+
list: (path) => { try { return readdirSync(path); } catch { return null; } },
|
|
389
|
+
uid: process.getuid?.() ?? -1,
|
|
341
390
|
home,
|
|
342
391
|
env,
|
|
343
392
|
version,
|
|
@@ -352,6 +401,9 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
352
401
|
// pure planner already gated four ways, and this deletes exactly that.
|
|
353
402
|
removeDir: (path) => { rmSync(resolve(path), { recursive: true, force: true }); },
|
|
354
403
|
removeFile: (path) => { rmSync(resolve(path), { force: true }); },
|
|
404
|
+
removeEmptyDir: (path) => { rmdirSync(resolve(path)); },
|
|
405
|
+
renamePath: (from, to) => { renameSync(resolve(from), resolve(to)); },
|
|
406
|
+
sleep: (ms) => new Promise(done => setTimeout(done, ms)),
|
|
355
407
|
copyDir: (source, destination) => {
|
|
356
408
|
mkdirSync(dirname(resolve(destination)), { recursive: true, mode: 0o700 });
|
|
357
409
|
cpSync(resolve(source), resolve(destination), {
|
|
@@ -386,6 +438,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
386
438
|
brokerUrl: env.OURS_BROKER_URL ?? 'wss://broker1.ours.network',
|
|
387
439
|
now: () => Date.now(),
|
|
388
440
|
probe: (port) => probePort(port),
|
|
441
|
+
portState: (port) => portState(port),
|
|
389
442
|
isTaken: (port) => portTakenSync(port),
|
|
390
443
|
readJson: readJsonFile,
|
|
391
444
|
readProfile: readHostProfileFile,
|
|
@@ -463,7 +516,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
463
516
|
return Object.assign(effects, networkEffects(effects));
|
|
464
517
|
}
|
|
465
518
|
|
|
466
|
-
export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, readHostProfileFile, verifyHostProfile, installedVersionOf, packageDependenciesOf, resolvePackageVersion, codexMarketplace, hasClaudePlugin, knownStateDirsIn };
|
|
519
|
+
export const __testables = { probePort, portState, portTakenSync, readJsonFile, readTextFile, readHostProfileFile, verifyHostProfile, installedVersionOf, packageDependenciesOf, resolvePackageVersion, codexMarketplace, hasClaudePlugin, knownStateDirsIn };
|
|
467
520
|
|
|
468
521
|
// -----------------------------------------------------------------------------
|
|
469
522
|
// THE PAIR
|
|
@@ -560,6 +613,7 @@ export function networkEffects(effects) {
|
|
|
560
613
|
...(record.gateway ? ['--file', join(record.workDir, 'docker-compose.gateway.yaml')] : []),
|
|
561
614
|
...(existsSync(join(record.workDir, 'compose.recovery.json')) ? ['--file', join(record.workDir, 'compose.recovery.json')] : []),
|
|
562
615
|
...(engineName(record) === 'podman' ? ['--file', join(record.workDir, 'compose.podman.json')] : []),
|
|
616
|
+
...(isPluginOnly(record) ? ['--file', join(record.workDir, PLUGIN_ONLY_COMPOSE)] : []),
|
|
563
617
|
'--project-name', record.project, ...args,
|
|
564
618
|
];
|
|
565
619
|
const container = (record, args, options = {}) => runContainer(effects, record, args, options);
|
|
@@ -647,6 +701,7 @@ export function networkEffects(effects) {
|
|
|
647
701
|
}
|
|
648
702
|
};
|
|
649
703
|
return {
|
|
704
|
+
credentialRefused: profile => retainedCredentialRefused(profile),
|
|
650
705
|
...createServerOnboarding(effects, { compose, localEnv, bin }),
|
|
651
706
|
sourcePolicyHash(path) {
|
|
652
707
|
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
@@ -673,7 +728,7 @@ export function networkEffects(effects) {
|
|
|
673
728
|
return version;
|
|
674
729
|
});
|
|
675
730
|
},
|
|
676
|
-
newInstallation(root, mode, { containerEngine } = {}) {
|
|
731
|
+
newInstallation(root, mode, { containerEngine, product } = {}) {
|
|
677
732
|
if (existsSync(root)) {
|
|
678
733
|
privateDirectory(root);
|
|
679
734
|
if (readdirSync(root).some(name => name !== '.operation.lock')) throw new Error('Installation root is not empty and has no selection record');
|
|
@@ -681,7 +736,10 @@ export function networkEffects(effects) {
|
|
|
681
736
|
const instanceId = env.OURS_DAEMON_ID || randomUUID();
|
|
682
737
|
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');
|
|
683
738
|
const project = `ours-${createHash('sha256').update(root).digest('hex').slice(0, 16)}`;
|
|
684
|
-
|
|
739
|
+
if (isPluginOnly(product ?? 'workspace') && mode !== 'docker') throw new Error('Collaboration tools for existing agent apps run in Docker or Podman');
|
|
740
|
+
const services = isPluginOnly(product ?? 'workspace') ? ['daemon'] : [...SERVER_SERVICES, ...(mode === 'docker' ? ['gateway'] : [])];
|
|
741
|
+
const gateway = mode === 'docker' && !isPluginOnly(product ?? 'workspace');
|
|
742
|
+
return { schema: 2, root, mode, ...(isPluginOnly(product ?? 'workspace') ? { product } : {}), ...(containerEngine ? { containerEngine } : {}), instanceId, project, workDir: join(root, 'runtime'), configPath: installationPaths({ schema: 2, root }).config, sourcesPath: join(root, 'sources.json'), services, ...(gateway ? { gateway: { version: 1 } } : {}), port: 3050, coworkPort: 3052, messengerPort: 8420, messengerIdentity: env.OURS_MESSENGER_IDENTITY || null, uid: 1000, gid: 1000 };
|
|
685
743
|
},
|
|
686
744
|
async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest, identityName, explicitPorts } = {}) {
|
|
687
745
|
if (!existing && operation === 'install' && Number.isInteger(record.port)) selectServerPorts(record, portTakenSync, explicitPorts, effects.out);
|
|
@@ -748,7 +806,7 @@ export function networkEffects(effects) {
|
|
|
748
806
|
await effects.run(effects.platform.platform === 'linux' ? 'systemctl' : 'launchctl', effects.platform.platform === 'linux' ? ['--user', 'show-environment'] : ['print', `gui/${process.getuid()}`]);
|
|
749
807
|
if (operation === 'install') {
|
|
750
808
|
for (const command of ['node', 'npm']) await effects.run(command, ['--version']);
|
|
751
|
-
const packages = selectSourcePackages(sourceManifest ?? effects.readJson(existing ? record.sourcesPath : sourcePath),
|
|
809
|
+
const packages = selectSourcePackages(sourceManifest ?? effects.readJson(existing ? record.sourcesPath : sourcePath), serverRole(record));
|
|
752
810
|
if (Object.values(packages).some(selection => selection.source)) {
|
|
753
811
|
// Native dependencies in source builds still use their own toolchains.
|
|
754
812
|
for (const command of ['python3', 'git', 'make', 'cc']) await effects.run(command, ['--version']);
|
|
@@ -768,7 +826,7 @@ export function networkEffects(effects) {
|
|
|
768
826
|
},
|
|
769
827
|
async initializeSelection(record, manifest, { retainConfig = false } = {}) {
|
|
770
828
|
if (typeof manifest === 'string') manifest = JSON.parse(readFileSync(manifest, 'utf8'));
|
|
771
|
-
selectSourcePackages(manifest,
|
|
829
|
+
selectSourcePackages(manifest, serverRole(record));
|
|
772
830
|
const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
773
831
|
ensurePrivateDirectory(record.root);
|
|
774
832
|
if (record.schema === 2) {
|
|
@@ -841,6 +899,9 @@ export function networkEffects(effects) {
|
|
|
841
899
|
if (!existsSync(materialized)) writePrivateNew(materialized, retained);
|
|
842
900
|
else if (!readFileSync(materialized).equals(retained)) throw new Error('Materialized sources differ from retained selection');
|
|
843
901
|
if (record.mode === 'docker') {
|
|
902
|
+
// Image IDs before this run builds anything: only what it creates or changes becomes proof.
|
|
903
|
+
const imagesBefore = runtimeOnly ? null : await effects.imageIds(record);
|
|
904
|
+
if (isPluginOnly(record)) atomicWriteConfig(join(record.workDir, PLUGIN_ONLY_COMPOSE), pluginOnlyCompose());
|
|
844
905
|
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
906
|
const rebuildPrivateScripts = refreshDockerPolicyCopy(record);
|
|
846
907
|
const rebuildRecovery = refreshDockerRecovery(record);
|
|
@@ -864,6 +925,9 @@ export function networkEffects(effects) {
|
|
|
864
925
|
}
|
|
865
926
|
if (runtimeOnly) return;
|
|
866
927
|
await effects.prepareServerVolumes(record);
|
|
928
|
+
// Volume preparation may build the maintenance image; record after it.
|
|
929
|
+
const imagesAfter = await effects.imageIds(record);
|
|
930
|
+
await effects.recordOwnedImages(record, changedImages(imagesBefore, imagesAfter));
|
|
867
931
|
} else {
|
|
868
932
|
if (!existsSync(join(record.workDir, '.packages-ready'))) {
|
|
869
933
|
const sourceRoot = join(record.root, `build-${randomUUID()}`);
|
|
@@ -903,7 +967,7 @@ export function networkEffects(effects) {
|
|
|
903
967
|
? Buffer.from(`${JSON.stringify(args.resolvedSources, null, 2)}\n`)
|
|
904
968
|
: readFileSync(args.sources))
|
|
905
969
|
: readFileSync(record.sourcesPath);
|
|
906
|
-
selectSourcePackages(JSON.parse(sources),
|
|
970
|
+
selectSourcePackages(JSON.parse(sources), serverRole(record));
|
|
907
971
|
privateDirectory(record.root);
|
|
908
972
|
const root = mkdtempSync(join(record.root, '.build-'));
|
|
909
973
|
const candidate = { ...record, root, workDir: join(root, 'runtime'), sourcesPath: join(root, 'sources.json'),
|
|
@@ -915,6 +979,9 @@ export function networkEffects(effects) {
|
|
|
915
979
|
await effects.prepareInstallation(candidate, { runtimeOnly: true });
|
|
916
980
|
if (candidate.mode === 'docker') {
|
|
917
981
|
await compose(candidate, ['build', 'state-operation']);
|
|
982
|
+
// The candidate's project name is new for this build: every image under it was
|
|
983
|
+
// built now. Record them with the installation so removal can prove them.
|
|
984
|
+
await effects.recordOwnedImages(record, await effects.imageIds(candidate));
|
|
918
985
|
await effects.copyDockerBuildRecords(candidate, candidate.workDir);
|
|
919
986
|
}
|
|
920
987
|
// npm emits readable build records; maintenance consumes private copies.
|
|
@@ -941,6 +1008,15 @@ export function networkEffects(effects) {
|
|
|
941
1008
|
const stage = mkdtempSync(join(directory, '.records-'));
|
|
942
1009
|
const name = `${record.project}-records`;
|
|
943
1010
|
let created = false;
|
|
1011
|
+
// An interrupted earlier run can leave its stopped helper behind; clear only that.
|
|
1012
|
+
const stale = await container(record, ['container', 'inspect', name], { allowCodes: [1] });
|
|
1013
|
+
const [value] = stale.code === 0 ? (() => { try { return JSON.parse(stale.stdout); } catch { return []; } })() : [];
|
|
1014
|
+
if (value) {
|
|
1015
|
+
const helper = value?.State?.Running === false && JSON.stringify(value?.Config?.Entrypoint) === JSON.stringify(['/bin/true'])
|
|
1016
|
+
&& value?.Config?.Image === `${record.project}:runtime`;
|
|
1017
|
+
if (!helper) throw new Error(`A container named ${name} exists and is not this installer's stopped build-record helper; remove or rename it, then run the installer again`);
|
|
1018
|
+
await container(record, ['rm', name]);
|
|
1019
|
+
}
|
|
944
1020
|
try {
|
|
945
1021
|
await container(record, ['create', '--name', name, '--entrypoint', '/bin/true', `${record.project}:runtime`]);
|
|
946
1022
|
created = true;
|
|
@@ -1037,7 +1113,10 @@ export function networkEffects(effects) {
|
|
|
1037
1113
|
},
|
|
1038
1114
|
async publishServerBuild(record, candidate) {
|
|
1039
1115
|
const previous = join(candidate.root, 'previous-runtime');
|
|
1116
|
+
const built = {};
|
|
1040
1117
|
if (record.mode === 'docker') {
|
|
1118
|
+
const proven = effects.ownedImages(record);
|
|
1119
|
+
const before = await effects.imageIds(record);
|
|
1041
1120
|
for (const target of ['runtime', 'maintenance', ...(record.gateway ? ['gateway'] : [])]) {
|
|
1042
1121
|
const retained = `${candidate.project}:previous-${target}`;
|
|
1043
1122
|
const found = await container(record, ['image', 'inspect', retained], { allowCodes: [1] });
|
|
@@ -1045,7 +1124,14 @@ export function networkEffects(effects) {
|
|
|
1045
1124
|
const current = await container(record, ['image', 'inspect', `${record.project}:${target}`], { allowCodes: [1] });
|
|
1046
1125
|
if (current.code === 0) await container(record, ['tag', `${record.project}:${target}`, retained]);
|
|
1047
1126
|
}
|
|
1127
|
+
// A retained copy is proven only when the image it copies already was.
|
|
1128
|
+
const tag = `${record.project}:${target}`;
|
|
1129
|
+
if (before[tag] && proven[tag] === before[tag]) built[retained] = before[tag];
|
|
1048
1130
|
}
|
|
1131
|
+
// The candidate project name is a random per-build name kept in the private
|
|
1132
|
+
// record; its images were built by this update.
|
|
1133
|
+
Object.assign(built, Object.fromEntries(Object.entries(await effects.imageIds(candidate, ['runtime', 'maintenance', ...(record.gateway ? ['gateway'] : [])]))
|
|
1134
|
+
.map(([tag, id]) => [`${record.project}:${tag.slice(candidate.project.length + 1)}`, id])));
|
|
1049
1135
|
}
|
|
1050
1136
|
if (!existsSync(previous)) {
|
|
1051
1137
|
privateDirectory(record.workDir);
|
|
@@ -1064,6 +1150,27 @@ export function networkEffects(effects) {
|
|
|
1064
1150
|
}
|
|
1065
1151
|
}
|
|
1066
1152
|
atomicWriteConfig(record.sourcesPath, readFileSync(candidate.sourcesPath));
|
|
1153
|
+
if (record.mode === 'docker') await effects.recordOwnedImages(record, built);
|
|
1154
|
+
},
|
|
1155
|
+
/** Current image IDs of this project's tags (only those that exist). */
|
|
1156
|
+
async imageIds(record, targets = IMAGE_TARGETS) {
|
|
1157
|
+
const images = {};
|
|
1158
|
+
for (const target of targets) {
|
|
1159
|
+
const tag = `${record.project}:${target}`;
|
|
1160
|
+
const found = await container(record, ['image', 'inspect', '--format', '{{.Id}}', tag], { allowCodes: [1] });
|
|
1161
|
+
if (found.code === 0 && /^sha256:[0-9a-f]{64}$/.test(found.stdout.trim())) images[tag] = found.stdout.trim();
|
|
1162
|
+
}
|
|
1163
|
+
return images;
|
|
1164
|
+
},
|
|
1165
|
+
ownedImages(record) {
|
|
1166
|
+
const path = join(record.root, OWNED_IMAGES_FILE);
|
|
1167
|
+
try { const value = JSON.parse(readFileSync(path, 'utf8')); return value?.schema === 1 && value.images && typeof value.images === 'object' ? value.images : {}; } catch { return {}; }
|
|
1168
|
+
},
|
|
1169
|
+
/** Add the IDs of images this run built; never adopts a tag it did not produce. */
|
|
1170
|
+
async recordOwnedImages(record, built) {
|
|
1171
|
+
const path = join(record.root, OWNED_IMAGES_FILE);
|
|
1172
|
+
const next = ownedImagesRecord(existsSync(path) ? readFileSync(path, 'utf8') : null, built);
|
|
1173
|
+
if (next) atomicWriteConfig(path, next);
|
|
1067
1174
|
},
|
|
1068
1175
|
async validateServerBuildState(record) {
|
|
1069
1176
|
if (record.mode === 'docker') {
|
|
@@ -1299,6 +1406,19 @@ export function networkEffects(effects) {
|
|
|
1299
1406
|
} else await nativeLifecycle(record, 'retire', record.services, { effects, localEnv, ownerCommand, bin });
|
|
1300
1407
|
effects.out('Managed services removed; installation selection, stored identities, credentials and volumes retained.');
|
|
1301
1408
|
},
|
|
1409
|
+
/**
|
|
1410
|
+
* Stop a managed installation's services before its complete removal.
|
|
1411
|
+
* Docker writers stop through Compose; containers are removed by project
|
|
1412
|
+
* label afterwards. Native services are retired through their owners, which
|
|
1413
|
+
* also removes their boot registrations.
|
|
1414
|
+
*/
|
|
1415
|
+
async serverStopForRemoval(record) {
|
|
1416
|
+
if (record.mode === 'docker') {
|
|
1417
|
+
if (existsSync(record.workDir)) await effects.serverLifecycle(record, 'stop');
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
await nativeLifecycle(record, 'retire', record.services, { effects, localEnv, ownerCommand, bin });
|
|
1421
|
+
},
|
|
1302
1422
|
async serverLifecycle(record, operation, selected = record.services) {
|
|
1303
1423
|
record = buildRuntime(record);
|
|
1304
1424
|
if (record.mode === 'docker') {
|
|
@@ -1436,6 +1556,14 @@ export function networkEffects(effects) {
|
|
|
1436
1556
|
// This directory retains active executables, not disposable download cache.
|
|
1437
1557
|
ensurePrivateDirectory(join(home, '.ours-client-install'));
|
|
1438
1558
|
ensurePrivateDirectory(root);
|
|
1559
|
+
// Record which installation this acquisition serves, for complete removal.
|
|
1560
|
+
let owner = null;
|
|
1561
|
+
try { owner = readHostProfileFile(configPath)?.expectedInstanceId ?? null; } catch { /* not yet imported */ }
|
|
1562
|
+
const ownersPath = join(root, OWNERS_FILE);
|
|
1563
|
+
try {
|
|
1564
|
+
const ownerRecord = owner ? addOwner(existsSync(ownersPath) ? readFileSync(ownersPath, 'utf8') : null, owner) : null;
|
|
1565
|
+
if (ownerRecord) atomicWriteConfig(ownersPath, ownerRecord);
|
|
1566
|
+
} catch (error) { effects.out?.(`! ${error.message}; complete removal will leave ${root} in place and say so.`); }
|
|
1439
1567
|
const retained = join(root, 'sources.json');
|
|
1440
1568
|
const bytes = readFileSync(sourcesPath);
|
|
1441
1569
|
if (!existsSync(retained)) writePrivateNew(retained, bytes);
|
|
@@ -91,7 +91,12 @@ export async function runUninstall(argv, effects) {
|
|
|
91
91
|
effects.out(warn('Managed server uninstall does not support legacy component/profile selections. Nothing was changed; use an explicit server uninstall with those selectors cleared.'));
|
|
92
92
|
return EXIT_REFUSED;
|
|
93
93
|
}
|
|
94
|
-
|
|
94
|
+
// Complete removal, including identities and volumes, is the separate,
|
|
95
|
+
// confirmed `ours-install remove`; --purge selects exactly that for this root.
|
|
96
|
+
if (purge) {
|
|
97
|
+
const { runRemoval } = await import('./removal.mjs');
|
|
98
|
+
return runRemoval(['--state-dir', dir, ...(args.dryRun ? ['--dry-run'] : [])], effects);
|
|
99
|
+
}
|
|
95
100
|
if (args.dryRun) { effects.out(info('[dry-run] would stop and remove managed server services, retaining all stored data.')); return EXIT_OK; }
|
|
96
101
|
const { runServerCommand } = await import('./orchestrate.mjs');
|
|
97
102
|
return runServerCommand({ role: 'server', operation: 'uninstall', stateDir: dir }, effects);
|
package/lib/orchestrate.mjs
CHANGED
|
@@ -41,6 +41,7 @@ import { configJournal, reportRollback } from './journal.mjs';
|
|
|
41
41
|
import { detectDaemons, planDaemonSelection, resolveSelection } from './detect.mjs';
|
|
42
42
|
import { detectPlatform, resolveChannel } from './logic.mjs';
|
|
43
43
|
import { daemonEnv } from './effects.mjs';
|
|
44
|
+
import { productOf, serverRole, PRODUCT_LABELS } from './product.mjs';
|
|
44
45
|
import {
|
|
45
46
|
buildClaudeMarketplace, buildCodexMarketplace, marketplaceJson, marketplacePaths,
|
|
46
47
|
validateChannelVersion,
|
|
@@ -931,6 +932,29 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
|
|
|
931
932
|
let manual = plan.manual;
|
|
932
933
|
if (exactSuite?.localPackages?.[plan.name]) {
|
|
933
934
|
const registration = await effects.prepareClientMarketplace(plan.name, exactSuite.localPackages[plan.name]);
|
|
935
|
+
// Codex refuses to re-add a marketplace name from a new folder. An update
|
|
936
|
+
// moves it to a new download folder: replace only a registration that
|
|
937
|
+
// points into an earlier download folder of this installer.
|
|
938
|
+
if (plan.name === 'codex') {
|
|
939
|
+
const current = await effects.codexMarketplace?.();
|
|
940
|
+
const source = current?.marketplaceSource;
|
|
941
|
+
if (current && !(source?.sourceType === 'local' && source?.source === registration)) {
|
|
942
|
+
const earlier = source?.sourceType === 'local' && typeof source.source === 'string'
|
|
943
|
+
&& /^[0-9a-f]{16}\/marketplaces\/codex$/.test(source.source.slice(join(effects.home, '.ours-client-install').length + 1))
|
|
944
|
+
&& source.source.startsWith(join(effects.home, '.ours-client-install') + '/');
|
|
945
|
+
if (!earlier) {
|
|
946
|
+
const where = source?.source ?? 'another source';
|
|
947
|
+
effects.out(warn(`Codex already has a plugin source named ours-codex-marketplace from ${where}, which this installer did not create. It was left unchanged. Remove it with: codex plugin marketplace remove ours-codex-marketplace, then run the installer again.`));
|
|
948
|
+
rows.push({ ...row, state: 'failed', failedCommand: ['codex', 'plugin', 'marketplace', 'add', registration], failedStep: `Register ${plan.label} marketplace`, detail: `a different ours-codex-marketplace source (${where}) is registered` });
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
const moved = await attempt(effects, false, 'codex plugin marketplace remove ours-codex-marketplace (earlier download folder)', () => effects.run('codex', ['plugin', 'marketplace', 'remove', 'ours-codex-marketplace'], { env: profileEnv(target) }), clientDiagnostic);
|
|
952
|
+
if (!moved.ok) {
|
|
953
|
+
rows.push({ ...row, state: 'failed', failedCommand: ['codex', 'plugin', 'marketplace', 'remove', 'ours-codex-marketplace'], failedStep: `Move ${plan.label} marketplace`, detail: clientDiagnostic(moved.error) });
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
934
958
|
const steps = plan.name === 'codex'
|
|
935
959
|
? [['codex', 'plugin', 'marketplace', 'add', registration], ['codex', 'plugin', 'add', 'ours@ours-codex-marketplace']]
|
|
936
960
|
: [['claude', 'plugin', 'marketplace', 'add', registration], ['claude', 'plugin', await effects.hasClaudePlugin() ? 'update' : 'install', 'ours@ours.network']];
|
|
@@ -1377,6 +1401,7 @@ async function executeServerCommand(args, effects) {
|
|
|
1377
1401
|
record = validateInstallation(record, args.stateDir);
|
|
1378
1402
|
if (args.containerEngine && args.containerEngine !== (record.containerEngine ?? 'docker')) throw new Error('Conflicting container engine; explicit migration is required');
|
|
1379
1403
|
if (args.mode && args.mode !== record.mode) throw new Error('Conflicting runtime mode; retained installation was not changed');
|
|
1404
|
+
if (args.product && args.product !== productOf(record)) throw new Error(`This installation is ${PRODUCT_LABELS[productOf(record)].toLowerCase()}; it is kept as it is. Use a separate empty directory for the other kind of installation.`);
|
|
1380
1405
|
if (args.operation !== 'update' && args.sources && (record.sourcePolicyHash
|
|
1381
1406
|
? effects.sourcePolicyHash(args.sources) !== record.sourcePolicyHash
|
|
1382
1407
|
: effects.readText(args.sources) !== effects.readText(record.sourcesPath))) throw new Error('Conflicting source selection; use explicit server update');
|
|
@@ -1417,7 +1442,7 @@ async function executeServerCommand(args, effects) {
|
|
|
1417
1442
|
if ((!existing || args.operation === 'update') && !record.buildTransition) {
|
|
1418
1443
|
args.resolvedSources = await installStage('Package selection', 'Resolve the selected server packages before installation.', async () => {
|
|
1419
1444
|
const policy = args.sourcePolicy ?? (args.sources ? effects.readJson(args.sources) : effects.packagedSourcePolicy());
|
|
1420
|
-
return effects.resolveSourcePolicy(policy,
|
|
1445
|
+
return effects.resolveSourcePolicy(policy, serverRole(record));
|
|
1421
1446
|
});
|
|
1422
1447
|
}
|
|
1423
1448
|
if (!existing && args.sources) record.sourcePolicyHash = effects.sourcePolicyHash(args.sources);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Ownership records written at installation time, read by complete removal.
|
|
2
|
+
//
|
|
3
|
+
// A client generation under ~/.ours-client-install can serve more than one
|
|
4
|
+
// managed installation, and identical selections are not proof of who uses it.
|
|
5
|
+
// Each acquisition therefore records the installation instances it was acquired
|
|
6
|
+
// for. Server roots record the image IDs this installer built, so removal never
|
|
7
|
+
// relies on an image name alone.
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
export const OWNERS_FILE = 'owners.json';
|
|
11
|
+
export const OWNED_IMAGES_FILE = 'owned-images.json';
|
|
12
|
+
const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/;
|
|
13
|
+
|
|
14
|
+
const IMAGE_ID = /^sha256:[0-9a-f]{64}$/;
|
|
15
|
+
const IMAGE_TAG = /^ours-[a-z0-9]+:[a-z-]+$/;
|
|
16
|
+
|
|
17
|
+
/** A generation's owner record: absent, invalid (damaged or unknown format) or the owner IDs. */
|
|
18
|
+
export function readOwners(effects, generation) {
|
|
19
|
+
const text = effects.readText(join(generation, OWNERS_FILE));
|
|
20
|
+
if (text === null) return { state: 'absent' };
|
|
21
|
+
const instances = parseOwners(text);
|
|
22
|
+
return instances ? { state: 'valid', instances } : { state: 'invalid' };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseOwners(text) {
|
|
26
|
+
try {
|
|
27
|
+
const value = JSON.parse(text);
|
|
28
|
+
if (value?.schema !== 1 || !Array.isArray(value.instances) || value.instances.some(id => !UUID.test(id)) || Object.keys(value).length !== 2) return null;
|
|
29
|
+
return [...new Set(value.instances)];
|
|
30
|
+
} catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The next owner record for a generation, or null when nothing changes. A
|
|
35
|
+
* damaged record is never replaced: that would forget its earlier owners.
|
|
36
|
+
*/
|
|
37
|
+
export function addOwner(text, instanceId) {
|
|
38
|
+
if (!UUID.test(instanceId ?? '')) return null;
|
|
39
|
+
const instances = text === null || text === undefined ? [] : parseOwners(text);
|
|
40
|
+
if (instances === null) throw new Error('The owner record of the downloaded Ours programs is damaged; it was left unchanged');
|
|
41
|
+
if (instances.includes(instanceId)) return null;
|
|
42
|
+
return JSON.stringify({ schema: 1, instances: [...instances, instanceId] }, null, 2) + '\n';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The next owned-images record for a server root; a damaged record is left as it is. */
|
|
46
|
+
export function ownedImagesRecord(text, images) {
|
|
47
|
+
let current = {};
|
|
48
|
+
if (text !== null && text !== undefined) {
|
|
49
|
+
let value;
|
|
50
|
+
try { value = JSON.parse(text); } catch { return null; }
|
|
51
|
+
if (value?.schema !== 1 || !value.images || typeof value.images !== 'object' || Array.isArray(value.images)
|
|
52
|
+
|| Object.entries(value.images).some(([tag, id]) => !IMAGE_TAG.test(tag) || !IMAGE_ID.test(id))) return null;
|
|
53
|
+
current = value.images;
|
|
54
|
+
}
|
|
55
|
+
const added = Object.fromEntries(Object.entries(images ?? {}).filter(([tag, id]) => IMAGE_TAG.test(tag) && IMAGE_ID.test(id)));
|
|
56
|
+
const next = { ...current, ...added };
|
|
57
|
+
return JSON.stringify(current) === JSON.stringify(next) ? null : JSON.stringify({ schema: 1, images: next }, null, 2) + '\n';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Tags whose image this run created or replaced: a tag it found unchanged is never adopted. */
|
|
61
|
+
export function changedImages(before, after) {
|
|
62
|
+
return Object.fromEntries(Object.entries(after ?? {}).filter(([tag, id]) => (before ?? {})[tag] !== id));
|
|
63
|
+
}
|
package/lib/plan.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { validateContainerEngine } from './container-engine.mjs';
|
|
2
2
|
import { gatewayAddress } from './gateway.mjs';
|
|
3
|
+
import { PRODUCTS } from './product.mjs';
|
|
3
4
|
// ours-install v3 — daemon creation and boot-service installation.
|
|
4
5
|
//
|
|
5
6
|
// Pure planning code, like lib/target.mjs: the orchestrator
|
|
@@ -283,7 +284,11 @@ export function planDaemonSteps(target, { cliVersionChanged = false, cliStartedI
|
|
|
283
284
|
export const SERVER_PACKAGES = ['sdk', 'cli', 'daemon', 'tg-connector', 'cowork', 'messenger-server'].map(n => `@ours.network/${n}`);
|
|
284
285
|
/** Selected when the policy names it; retained policies from before it existed stay valid. */
|
|
285
286
|
export const NOTIFICATIONS_PACKAGE = '@ours.network/notifications';
|
|
286
|
-
|
|
287
|
+
/** Plugin-only installations select only these; no application packages. */
|
|
288
|
+
export const DAEMON_PACKAGES = ['sdk', 'cli', 'daemon'].map(n => `@ours.network/${n}`);
|
|
289
|
+
const serverPackages = (manifest, role = 'server') => role === 'daemon' ? DAEMON_PACKAGES
|
|
290
|
+
: [...SERVER_PACKAGES, ...(manifest?.packages?.[NOTIFICATIONS_PACKAGE] ? [NOTIFICATIONS_PACKAGE] : [])];
|
|
291
|
+
const serverRoles = ['server', 'daemon'];
|
|
287
292
|
export const SERVER_DEPENDENCIES = {
|
|
288
293
|
daemon: [], telegram: ['daemon'], cowork: ['daemon'], messenger: ['daemon'],
|
|
289
294
|
};
|
|
@@ -299,7 +304,7 @@ export function maintenanceServices(record, domain) {
|
|
|
299
304
|
|
|
300
305
|
/** Validate exact supplied selections, without rewriting the source authority. */
|
|
301
306
|
export function selectSourcePackages(manifest, role, clients = []) {
|
|
302
|
-
const names = role
|
|
307
|
+
const names = serverRoles.includes(role) ? serverPackages(manifest, role) : clients.map(n => `@ours.network/${n}`);
|
|
303
308
|
const result = {};
|
|
304
309
|
for (const name of names) {
|
|
305
310
|
const selected = manifest?.packages?.[name];
|
|
@@ -317,7 +322,7 @@ export function selectSourcePackages(manifest, role, clients = []) {
|
|
|
317
322
|
/** Resolve a packaged compatibility policy into a role-filtered exact selection. */
|
|
318
323
|
export async function resolveSourcePolicy(manifest, role, clients = [], resolveNpm) {
|
|
319
324
|
const release = releaseBinding(manifest);
|
|
320
|
-
const names = role
|
|
325
|
+
const names = serverRoles.includes(role) ? serverPackages(manifest, role) : clients.map(name => `@ours.network/${name}`);
|
|
321
326
|
const packages = {};
|
|
322
327
|
const sourceNames = new Set();
|
|
323
328
|
for (const name of names) {
|
|
@@ -371,6 +376,11 @@ export function validateInstallation(record, root) {
|
|
|
371
376
|
|| !Array.isArray(record.services) || record.services[0] !== 'daemon' || new Set(record.services).size !== record.services.length || record.services.some(s => !SERVER_SERVICES.includes(s) && !(s === 'gateway' && record.gateway?.version === 1))) {
|
|
372
377
|
throw new Error('Invalid or conflicting installation selection');
|
|
373
378
|
}
|
|
379
|
+
if (record.product !== undefined && (!PRODUCTS.includes(record.product) || record.product === 'workspace'
|
|
380
|
+
|| record.schema !== 2 || record.mode !== 'docker' || record.gateway !== undefined
|
|
381
|
+
|| JSON.stringify(record.services) !== JSON.stringify(['daemon']))) {
|
|
382
|
+
throw new Error('Invalid plugin-only installation selection');
|
|
383
|
+
}
|
|
374
384
|
if (record.gateway !== undefined && (record.schema !== 2 || record.mode !== 'docker' || record.gateway?.version !== 1
|
|
375
385
|
|| Object.keys(record.gateway).some(key => !['version', 'serverUrl'].includes(key)) || record.services.at(-1) !== 'gateway')) {
|
|
376
386
|
throw new Error('Invalid gateway installation selection');
|
|
@@ -414,7 +424,7 @@ export function validateInstallation(record, root) {
|
|
|
414
424
|
throw new Error('Invalid server build transition');
|
|
415
425
|
}
|
|
416
426
|
validateInstallation(candidate, candidate.root);
|
|
417
|
-
for (const key of ['schema', 'mode', 'containerEngine', 'containerBinding', 'instanceId', 'services', 'gateway', 'port', 'coworkPort', 'messengerPort', 'messengerIdentity', 'uid', 'gid']) {
|
|
427
|
+
for (const key of ['schema', 'mode', 'product', 'containerEngine', 'containerBinding', 'instanceId', 'services', 'gateway', 'port', 'coworkPort', 'messengerPort', 'messengerIdentity', 'uid', 'gid']) {
|
|
418
428
|
if (JSON.stringify(candidate[key]) !== JSON.stringify(record[key])) throw new Error('Conflicting server build candidate');
|
|
419
429
|
}
|
|
420
430
|
}
|
package/lib/product.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a managed installation is for. Recorded once in installation.json and
|
|
3
|
+
* retained by every later repair, update and removal: neither choice is ever
|
|
4
|
+
* widened or narrowed implicitly.
|
|
5
|
+
*
|
|
6
|
+
* workspace the complete Ours workspace: daemon, Telegram, Cowork,
|
|
7
|
+
* Messenger (with Notifications) and the one-URL gateway, plus
|
|
8
|
+
* Fleet on the host. Records without a product are workspaces.
|
|
9
|
+
* plugin-only collaboration tools for existing agent apps: only the daemon
|
|
10
|
+
* container, reached by Claude Code / Codex through the daemon's
|
|
11
|
+
* own client prefix. No gateway, Cowork, Messenger, Telegram,
|
|
12
|
+
* Notifications or Fleet.
|
|
13
|
+
*/
|
|
14
|
+
export const PRODUCTS = ['workspace', 'plugin-only'];
|
|
15
|
+
export const PLUGIN_ONLY = 'plugin-only';
|
|
16
|
+
export const PLUGIN_ONLY_INTEGRATIONS = ['claude-code', 'codex'];
|
|
17
|
+
/** Container port of the daemon's client prefix; the raw daemon port stays internal. */
|
|
18
|
+
export const CLIENT_PREFIX_PORT = 3049;
|
|
19
|
+
|
|
20
|
+
export const productOf = record => record?.product ?? 'workspace';
|
|
21
|
+
export const isPluginOnly = value => (typeof value === 'string' ? value : productOf(value)) === PLUGIN_ONLY;
|
|
22
|
+
/** Package selection role: plugin-only selects only the SDK, CLI and daemon. */
|
|
23
|
+
export const serverRole = value => isPluginOnly(value) ? 'daemon' : 'server';
|
|
24
|
+
/** Applications whose state, credentials and services the installation owns. */
|
|
25
|
+
export const applicationsOf = record => record.services.filter(service => service !== 'gateway');
|
|
26
|
+
|
|
27
|
+
export const PRODUCT_LABELS = {
|
|
28
|
+
'plugin-only': 'Collaboration tools for my existing agent apps',
|
|
29
|
+
workspace: 'The complete Ours workspace',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Compose overlay for a plugin-only installation. The daemon container serves
|
|
34
|
+
* the client prefix itself and publishes only that port, on loopback; the raw
|
|
35
|
+
* daemon API is not published. Administrative jobs see only daemon state, so no
|
|
36
|
+
* application state, configuration or credential is created for services this
|
|
37
|
+
* installation does not run.
|
|
38
|
+
*/
|
|
39
|
+
export function pluginOnlyCompose() {
|
|
40
|
+
const volume = (target, subpath) => ` - {type: volume, source: server-storage, target: ${target}, volume: {nocopy: true${subpath ? `, subpath: ${subpath}` : ''}}}`;
|
|
41
|
+
const locks = ' - {type: volume, source: owner-locks, target: "/tmp/ours-${OURS_UID:-1000}", volume: {nocopy: true}}';
|
|
42
|
+
return `services:
|
|
43
|
+
prepare:
|
|
44
|
+
environment:
|
|
45
|
+
OURS_SERVER_APPLICATIONS: daemon
|
|
46
|
+
daemon:
|
|
47
|
+
entrypoint: [node, /opt/ours/docker/daemon-client-prefix.mjs]
|
|
48
|
+
environment:
|
|
49
|
+
OURS_SERVER_APPLICATIONS: daemon
|
|
50
|
+
OURS_DAEMON_CLIENT_PREFIX_PORT: "${CLIENT_PREFIX_PORT}"
|
|
51
|
+
ports: !override
|
|
52
|
+
- {target: ${CLIENT_PREFIX_PORT}, published: "\${OURS_HOST_PORT:-3050}", host_ip: 127.0.0.1}
|
|
53
|
+
access:
|
|
54
|
+
environment:
|
|
55
|
+
OURS_SERVER_APPLICATIONS: daemon
|
|
56
|
+
volumes: !override
|
|
57
|
+
${volume('/var/lib/ours', 'state/daemon')}
|
|
58
|
+
${volume('/var/lib/ours-mcp', 'state/mcp')}
|
|
59
|
+
${locks}
|
|
60
|
+
state-operation:
|
|
61
|
+
environment:
|
|
62
|
+
OURS_SERVER_APPLICATIONS: daemon
|
|
63
|
+
volumes: !override
|
|
64
|
+
${volume('/storage')}
|
|
65
|
+
${volume('/var/lib/ours', 'state/daemon')}
|
|
66
|
+
${volume('/var/lib/ours-mcp', 'state/mcp')}
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
export const PLUGIN_ONLY_COMPOSE = 'compose.plugin-only.yaml';
|