@ours.network/install 1.2.1-nightly.11 → 1.2.1-nightly.13
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 +116 -6
- package/assets/Dockerfile.gateway +4 -0
- package/assets/release-lock.json +56 -56
- package/assets/release.json +25 -25
- package/assets/sources.json +35 -35
- package/lib/build-transition.mjs +1 -0
- package/lib/effects.mjs +89 -11
- package/lib/gateway-transition.mjs +87 -0
- package/lib/gateway.mjs +142 -0
- package/lib/orchestrate.mjs +11 -0
- package/lib/plan.mjs +8 -2
- package/lib/server-onboarding.mjs +12 -7
- package/lib/setup-options.mjs +6 -1
- package/lib/setup.mjs +3 -2
- package/lib/target.mjs +15 -7
- package/lib/usage.mjs +2 -0
- package/package.json +1 -1
package/lib/effects.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { randomUUID, createHash } from 'node:crypto';
|
|
|
20
20
|
import { dirname, join, resolve } from 'node:path';
|
|
21
21
|
import { clientPackageNames, maintenanceServices, installationPaths, validateInstallation, consumerServiceState, unitNameForStateDir, launchdLabelForStateDir, messengerServicePlan, selectSourcePackages, resolveSourcePolicy, SERVER_SERVICES } from './plan.mjs';
|
|
22
22
|
import { validateHostProfile } from './target.mjs';
|
|
23
|
+
import { serverBase, validateGatewayDiscovery, gatewayCompose, gatewayNginx, gatewayAddress } from './gateway.mjs';
|
|
23
24
|
import { createServerOnboarding } from './server-onboarding.mjs';
|
|
24
25
|
import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
|
|
25
26
|
import { select as selectOnTty, multiselect as multiselectOnTty, askLine as askLineOnTty } from './prompt.mjs';
|
|
@@ -537,6 +538,7 @@ export function networkEffects(effects) {
|
|
|
537
538
|
const baseEnv = (record) => ({
|
|
538
539
|
OURS_DAEMON_ID: record.instanceId,
|
|
539
540
|
OURS_IMAGE: `${record.project}:runtime`,
|
|
541
|
+
OURS_GATEWAY_IMAGE: `${record.project}:gateway`,
|
|
540
542
|
OURS_MAINTENANCE_IMAGE: `${record.project}:maintenance`,
|
|
541
543
|
OURS_UID: String(record.uid ?? 1000), OURS_GID: String(record.gid ?? 1000),
|
|
542
544
|
OURS_HOST_PORT: String(record.port ?? 3050),
|
|
@@ -548,6 +550,7 @@ export function networkEffects(effects) {
|
|
|
548
550
|
'compose', '--project-directory', record.workDir, '--file', join(record.workDir,
|
|
549
551
|
record.schema === 1 && existsSync(join(record.workDir, 'docker-compose.legacy.yaml'))
|
|
550
552
|
? 'docker-compose.legacy.yaml' : 'docker-compose.yaml'),
|
|
553
|
+
...(record.gateway ? ['--file', join(record.workDir, 'docker-compose.gateway.yaml')] : []),
|
|
551
554
|
'--project-name', record.project, ...args,
|
|
552
555
|
];
|
|
553
556
|
const compose = (record, args, options = {}) => effects.run('docker', composeArgs(record, args),
|
|
@@ -630,7 +633,7 @@ export function networkEffects(effects) {
|
|
|
630
633
|
const instanceId = env.OURS_DAEMON_ID || randomUUID();
|
|
631
634
|
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');
|
|
632
635
|
const project = `ours-${createHash('sha256').update(root).digest('hex').slice(0, 16)}`;
|
|
633
|
-
return { schema: 2, root, mode, instanceId, project, workDir: join(root, 'runtime'), configPath: installationPaths({ schema: 2, root }).config, sourcesPath: join(root, 'sources.json'), services: [...SERVER_SERVICES], port: 3050, coworkPort: 3052, messengerPort: 8420, messengerIdentity: env.OURS_MESSENGER_IDENTITY || null, uid: 1000, gid: 1000 };
|
|
636
|
+
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, ...(mode === 'docker' ? ['gateway'] : [])], ...(mode === 'docker' ? { gateway: { version: 1 } } : {}), port: 3050, coworkPort: 3052, messengerPort: 8420, messengerIdentity: env.OURS_MESSENGER_IDENTITY || null, uid: 1000, gid: 1000 };
|
|
634
637
|
},
|
|
635
638
|
async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest, identityName } = {}) {
|
|
636
639
|
if (existing) {
|
|
@@ -709,6 +712,44 @@ export function networkEffects(effects) {
|
|
|
709
712
|
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');
|
|
710
713
|
},
|
|
711
714
|
async qualifyDockerRuntime(record) { return qualifyDockerRuntime(record, effects); },
|
|
715
|
+
async prepareGateway(record) {
|
|
716
|
+
atomicWriteConfig(join(record.workDir, 'docker-compose.gateway.yaml'), gatewayCompose(record));
|
|
717
|
+
atomicWriteConfig(join(record.workDir, 'nginx.conf'), gatewayNginx(record));
|
|
718
|
+
atomicWriteConfig(join(record.workDir, 'Dockerfile.gateway'), readFileSync(join(INSTALLER_ASSETS, 'Dockerfile.gateway'), 'utf8'));
|
|
719
|
+
await compose(record, ['build', 'gateway'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
|
|
720
|
+
},
|
|
721
|
+
async removeGatewayContainer(record) { await compose(record, ['rm', '-f', 'gateway']); },
|
|
722
|
+
async verifyGateway(record) {
|
|
723
|
+
const { prefix } = gatewayAddress(record);
|
|
724
|
+
const script = `const fs=require('node:fs');(async()=>{
|
|
725
|
+
const base=${JSON.stringify('http://gateway:8080' + prefix)};
|
|
726
|
+
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();};
|
|
727
|
+
const selection=await get('/daemon/selection');
|
|
728
|
+
if(selection.instanceId!==${JSON.stringify(record.instanceId)})throw Error('Gateway daemon instance mismatch');
|
|
729
|
+
const denied=await fetch(base+'/cowork/management/rpc',{method:'POST',redirect:'error',signal:AbortSignal.timeout(5000),headers:{'content-type':'application/json'},body:'{}'});
|
|
730
|
+
await denied.body?.cancel();if(denied.status!==401)throw Error('Gateway management does not reject unauthenticated requests');
|
|
731
|
+
const token=fs.readFileSync('/var/lib/ours/daemon-token','utf8').trim();
|
|
732
|
+
const headers={'x-ours-api-token':token,'content-type':'application/json'};
|
|
733
|
+
const identities=await get('/daemon/identities',{headers});
|
|
734
|
+
if(!Array.isArray(identities.identities))throw Error('Gateway daemon API invalid');
|
|
735
|
+
const result=await get('/cowork/management/rpc',{method:'POST',headers,body:JSON.stringify({version:1,id:'installer-readiness',method:'room.list',params:{}})});
|
|
736
|
+
if(result.version!==1||result.id!=='installer-readiness'||!Array.isArray(result.result)||result.error)throw Error('Gateway Cowork management unavailable');
|
|
737
|
+
})().catch(()=>{console.error('Authenticated gateway readiness failed');process.exitCode=1;});`;
|
|
738
|
+
await compose(record, ['exec', '-T', 'daemon', 'node', '-e', script], { sensitive: true });
|
|
739
|
+
},
|
|
740
|
+
async qualifyGatewayRuntime(record) {
|
|
741
|
+
for (const [name, args, capability] of [
|
|
742
|
+
['cowork', ['--json', 'capabilities'], 'cowork.http-management-v1'],
|
|
743
|
+
['messenger-server', ['capabilities'], 'messenger.gateway-prefix-v1'],
|
|
744
|
+
['tg-connector', ['capabilities'], 'telegram.gateway-listener-v1'],
|
|
745
|
+
]) {
|
|
746
|
+
const probe = await effects.run('docker', ['run', '--rm', '--network', 'none', '--read-only', '--entrypoint', 'node', `${record.project}:runtime`, `/opt/ours/node_modules/@ours.network/${name}/dist/cli.js`, ...args]);
|
|
747
|
+
let value; try { value = JSON.parse(probe.stdout); } catch {}
|
|
748
|
+
const capabilities = value?.result?.capabilities ?? value?.capabilities;
|
|
749
|
+
if (!Array.isArray(capabilities) || !capabilities.includes(capability))
|
|
750
|
+
throw new Error(`Selected ${name} artifact lacks ${capability}; select a compatible release before enabling the gateway`);
|
|
751
|
+
}
|
|
752
|
+
},
|
|
712
753
|
async prepareInstallation(record, { runtimeOnly = false } = {}) {
|
|
713
754
|
let copied = false;
|
|
714
755
|
if (!existsSync(record.workDir)) {
|
|
@@ -723,12 +764,20 @@ export function networkEffects(effects) {
|
|
|
723
764
|
else if (!readFileSync(materialized).equals(retained)) throw new Error('Materialized sources differ from retained selection');
|
|
724
765
|
if (record.mode === 'docker') {
|
|
725
766
|
refreshDockerPolicyCopy(record);
|
|
767
|
+
if (record.gateway) {
|
|
768
|
+
atomicWriteConfig(join(record.workDir, 'docker-compose.gateway.yaml'), gatewayCompose(record));
|
|
769
|
+
atomicWriteConfig(join(record.workDir, 'nginx.conf'), gatewayNginx(record));
|
|
770
|
+
}
|
|
726
771
|
// The installer owns these dependencies in both installation modes.
|
|
727
772
|
const { dependencies } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
728
773
|
writeFileSync(join(record.workDir, 'scripts/maintenance/package.json'), JSON.stringify({ private: true, type: 'module', dependencies }, null, 2) + '\n', { mode: 0o600 });
|
|
729
774
|
const image = await effects.run('docker', ['image', 'inspect', `${record.project}:runtime`], { allowCodes: [1] });
|
|
730
775
|
if (image.code !== 0) await compose(record, ['build', 'daemon'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
|
|
731
776
|
await effects.qualifyDockerRuntime(record);
|
|
777
|
+
if (record.gateway) {
|
|
778
|
+
await effects.qualifyGatewayRuntime(record);
|
|
779
|
+
await compose(record, ['build', 'gateway'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
|
|
780
|
+
}
|
|
732
781
|
if (runtimeOnly) return;
|
|
733
782
|
await compose(record, ['run', '--rm', '--no-deps', '-T', 'prepare', 'prepare']);
|
|
734
783
|
} else {
|
|
@@ -869,7 +918,7 @@ export function networkEffects(effects) {
|
|
|
869
918
|
async publishServerBuild(record, candidate) {
|
|
870
919
|
const previous = join(candidate.root, 'previous-runtime');
|
|
871
920
|
if (record.mode === 'docker') {
|
|
872
|
-
for (const target of ['runtime', 'maintenance']) {
|
|
921
|
+
for (const target of ['runtime', 'maintenance', ...(record.gateway ? ['gateway'] : [])]) {
|
|
873
922
|
const retained = `${candidate.project}:previous-${target}`;
|
|
874
923
|
const found = await effects.run('docker', ['image', 'inspect', retained], { allowCodes: [1] });
|
|
875
924
|
if (found.code !== 0) {
|
|
@@ -890,7 +939,7 @@ export function networkEffects(effects) {
|
|
|
890
939
|
renameSync(candidate.workDir, record.workDir);
|
|
891
940
|
} else privateDirectory(record.workDir);
|
|
892
941
|
if (record.mode === 'docker') {
|
|
893
|
-
for (const target of ['runtime', 'maintenance']) {
|
|
942
|
+
for (const target of ['runtime', 'maintenance', ...(record.gateway ? ['gateway'] : [])]) {
|
|
894
943
|
await effects.run('docker', ['tag', `${candidate.project}:${target}`, `${record.project}:${target}`]);
|
|
895
944
|
}
|
|
896
945
|
}
|
|
@@ -905,7 +954,7 @@ export function networkEffects(effects) {
|
|
|
905
954
|
},
|
|
906
955
|
async discardServerBuild(candidate) {
|
|
907
956
|
if (candidate.mode === 'docker') {
|
|
908
|
-
for (const target of ['runtime', 'maintenance', 'previous-runtime', 'previous-maintenance']) {
|
|
957
|
+
for (const target of ['runtime', 'maintenance', 'previous-runtime', 'previous-maintenance', ...(candidate.gateway ? ['gateway', 'previous-gateway'] : [])]) {
|
|
909
958
|
const image = `${candidate.project}:${target}`;
|
|
910
959
|
const found = await effects.run('docker', ['image', 'inspect', image], { allowCodes: [1] });
|
|
911
960
|
if (found.code === 0) await effects.run('docker', ['image', 'rm', image]);
|
|
@@ -1155,14 +1204,17 @@ export function networkEffects(effects) {
|
|
|
1155
1204
|
return JSON.parse(readFileSync(path, 'utf8'));
|
|
1156
1205
|
},
|
|
1157
1206
|
async discoverClientProfile(endpoint, credentialPath) {
|
|
1158
|
-
const
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1207
|
+
const base = serverBase(endpoint);
|
|
1208
|
+
const request = path => fetch(`${base}${path}`, { redirect: 'error', signal: AbortSignal.timeout(5000) });
|
|
1209
|
+
const discovery = await request('/.well-known/ours');
|
|
1210
|
+
if (discovery.ok) return validateGatewayDiscovery(base, await discovery.json(), resolve(credentialPath));
|
|
1211
|
+
// Only an absent discovery resource denotes a legacy direct daemon. Never
|
|
1212
|
+
// reinterpret authentication, redirect, or malformed metadata as legacy.
|
|
1213
|
+
if (discovery.status !== 404) throw new Error(`Gateway discovery answered HTTP ${discovery.status}`);
|
|
1214
|
+
const response = await request('/selection');
|
|
1162
1215
|
if (!response.ok) throw new Error(`Daemon selection answered HTTP ${response.status}`);
|
|
1163
1216
|
const selection = await response.json();
|
|
1164
|
-
|
|
1165
|
-
return validateHostProfile({ endpoint: url.origin, expectedInstanceId: selection.instanceId, credentialPath: resolve(credentialPath) });
|
|
1217
|
+
return validateHostProfile({ endpoint: base, expectedInstanceId: selection.instanceId, credentialPath: resolve(credentialPath) });
|
|
1166
1218
|
},
|
|
1167
1219
|
importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh = false }) {
|
|
1168
1220
|
const root = join(home, '.ours-client');
|
|
@@ -1196,7 +1248,26 @@ export function networkEffects(effects) {
|
|
|
1196
1248
|
atomicWriteConfig(configPath, JSON.stringify(saved, null, 2) + '\n');
|
|
1197
1249
|
return { configPath, profile: validateHostProfile(saved), settings };
|
|
1198
1250
|
},
|
|
1199
|
-
async
|
|
1251
|
+
async qualifyInstalledGatewayClient(profile) {
|
|
1252
|
+
if (!profile.installer?.integrations?.includes('fleet')) return;
|
|
1253
|
+
let info;
|
|
1254
|
+
try { info = JSON.parse((await effects.run('ours-fleet', ['version', '--json'])).stdout); } catch {}
|
|
1255
|
+
if (!Array.isArray(info?.capabilities) || !info.capabilities.includes('cowork.http-management-v1'))
|
|
1256
|
+
throw new Error('Installed Fleet does not support gateway HTTP management; upgrade Fleet before gateway-enable');
|
|
1257
|
+
},
|
|
1258
|
+
async qualifyGatewayClient({ profile, sourcesPath, sources, integrations, refresh = false }) {
|
|
1259
|
+
if (!profile.serverUrl || !integrations.includes('fleet')) return;
|
|
1260
|
+
// Acquire into the retained package cache without publishing a profile,
|
|
1261
|
+
// credential, native command, or integration. Normal acquisition rechecks it.
|
|
1262
|
+
const staging = mkdtempSync(join(home, '.ours-gateway-client-'));
|
|
1263
|
+
try {
|
|
1264
|
+
const stagedSources = join(staging, 'sources.json');
|
|
1265
|
+
writeFileSync(stagedSources, sources ? JSON.stringify(sources, null, 2) + '\n' : readFileSync(sourcesPath), { mode: 0o600 });
|
|
1266
|
+
await effects.acquireClientPackages(join(home, '.ours-client/profile.json'), stagedSources, integrations,
|
|
1267
|
+
{ refresh, gatewayServerUrl: profile.serverUrl, qualifyOnly: true });
|
|
1268
|
+
} finally { rmSync(staging, { recursive: true, force: true }); }
|
|
1269
|
+
},
|
|
1270
|
+
async acquireClientPackages(configPath, sourcesPath, integrations, { refresh = false, hostCliOnly = false, gatewayServerUrl, qualifyOnly = false } = {}) {
|
|
1200
1271
|
const manifest = JSON.parse(readFileSync(sourcesPath, 'utf8'));
|
|
1201
1272
|
// Every client installation includes the native CLI and its SDK dependency.
|
|
1202
1273
|
const selected = clientPackageNames(integrations);
|
|
@@ -1234,6 +1305,13 @@ export function networkEffects(effects) {
|
|
|
1234
1305
|
writePrivateNew(join(root, '.packages-ready'), 'ready\n');
|
|
1235
1306
|
}
|
|
1236
1307
|
verifyReleaseGraph(root, manifest, { requiredPackages: Object.keys(packages) });
|
|
1308
|
+
if (integrations.includes('fleet') && (gatewayServerUrl || readHostProfileFile(configPath)?.serverUrl)) {
|
|
1309
|
+
let info;
|
|
1310
|
+
try { info = JSON.parse(readFileSync(join(root, 'node_modules/@ours.network/fleet/dist/build-info.json'), 'utf8')); } catch {}
|
|
1311
|
+
if (!Array.isArray(info?.capabilities) || !info.capabilities.includes('cowork.http-management-v1'))
|
|
1312
|
+
throw new Error('Selected Fleet artifact does not support gateway HTTP management; select a compatible client release');
|
|
1313
|
+
}
|
|
1314
|
+
if (qualifyOnly) return;
|
|
1237
1315
|
const cliPolicy = hostCliOnly ? null : hostCliPolicy(manifest);
|
|
1238
1316
|
// Local acquisition alone does not publish native commands. Use the user's
|
|
1239
1317
|
// configured npm prefix and retained dependency closure, including on retry.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { gatewayAddress } from './gateway.mjs';
|
|
4
|
+
import { validateInstallation } from './plan.mjs';
|
|
5
|
+
import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
|
|
6
|
+
|
|
7
|
+
// Called under the installation lock. No stored state or credentials are changed.
|
|
8
|
+
export async function enableGateway(record, args, effects) {
|
|
9
|
+
validateInstallation(record, record.root);
|
|
10
|
+
if (record.mode !== 'docker' || record.schema !== 2 || record.buildTransition || record.layoutConversion)
|
|
11
|
+
throw new Error('Gateway migration requires a settled schema-2 Docker installation');
|
|
12
|
+
const journal = join(record.root, 'gateway-transition.json');
|
|
13
|
+
const recordPath = join(record.root, 'installation.json');
|
|
14
|
+
const profilePath = join(effects.home, '.ours-client', 'profile.json');
|
|
15
|
+
const paths = [recordPath, ...['docker-compose.gateway.yaml', 'nginx.conf', 'Dockerfile.gateway'].map(name => join(record.workDir, name)), profilePath];
|
|
16
|
+
const snapshot = path => {
|
|
17
|
+
if (existsSync(path)) {
|
|
18
|
+
const stat = lstatSync(path);
|
|
19
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid() || (stat.mode & (path === profilePath || path === journal || path === recordPath ? 0o077 : 0o022)))
|
|
20
|
+
throw new Error('Gateway migration requires private owned configuration files');
|
|
21
|
+
}
|
|
22
|
+
return snapshotConfig(path);
|
|
23
|
+
};
|
|
24
|
+
async function rollback(saved) {
|
|
25
|
+
const phase = value => { saved.phase = value; atomicWriteConfig(journal, JSON.stringify(saved, null, 2) + '\n'); };
|
|
26
|
+
if (saved.phase === 'switching') phase('rollback-stopping');
|
|
27
|
+
if (saved.phase === 'rollback-stopping') {
|
|
28
|
+
await effects.serverLifecycle(saved.candidate, 'stop');
|
|
29
|
+
await effects.removeGatewayContainer(saved.candidate);
|
|
30
|
+
phase('rollback-restoring');
|
|
31
|
+
}
|
|
32
|
+
if (saved.phase === 'preparing') phase('rollback-restoring');
|
|
33
|
+
if (saved.phase === 'rollback-restoring') {
|
|
34
|
+
for (let i = 0; i < paths.length; i++) restoreConfig(paths[i], saved.snapshots[i]);
|
|
35
|
+
phase('rollback-restarting');
|
|
36
|
+
}
|
|
37
|
+
await effects.serverLifecycle(saved.original, 'start', saved.running);
|
|
38
|
+
unlinkSync(journal);
|
|
39
|
+
}
|
|
40
|
+
if (existsSync(journal)) {
|
|
41
|
+
snapshot(journal);
|
|
42
|
+
const saved = JSON.parse(readFileSync(journal, 'utf8'));
|
|
43
|
+
validateInstallation(saved.original, record.root);
|
|
44
|
+
validateInstallation(saved.candidate, record.root);
|
|
45
|
+
if (saved.schema !== 1 || !['preparing', 'switching', 'rollback-stopping', 'rollback-restoring', 'rollback-restarting'].includes(saved.phase) || !Array.isArray(saved.snapshots) || saved.snapshots.length !== paths.length
|
|
46
|
+
|| !Array.isArray(saved.running) || saved.running.some(name => !saved.original.services.includes(name)))
|
|
47
|
+
throw new Error('Invalid retained gateway transition; operator recovery required');
|
|
48
|
+
await rollback(saved);
|
|
49
|
+
throw new Error('Interrupted gateway migration rolled back; repeat gateway-enable to begin a new migration');
|
|
50
|
+
}
|
|
51
|
+
if (record.gateway) {
|
|
52
|
+
if (args.serverUrl && args.serverUrl !== gatewayAddress(record).base) throw new Error('Gateway URL is already configured; refusing implicit URL replacement');
|
|
53
|
+
await effects.verifyGateway(record);
|
|
54
|
+
return record;
|
|
55
|
+
}
|
|
56
|
+
const candidate = { ...record, gateway: { version: 1, ...(args.serverUrl ? { serverUrl: args.serverUrl } : {}) }, services: [...record.services, 'gateway'] };
|
|
57
|
+
validateInstallation(candidate, record.root);
|
|
58
|
+
const current = effects.readManagedClientProfile();
|
|
59
|
+
const selected = current?.expectedInstanceId === record.instanceId && current.endpoint === `http://127.0.0.1:${record.port}`;
|
|
60
|
+
const saved = { schema: 1, phase: 'preparing', original: record, candidate, snapshots: paths.map(snapshot), running: await effects.serverLifecycle(record, 'status') };
|
|
61
|
+
// Reject old service artifacts before stopping the working deployment.
|
|
62
|
+
await effects.qualifyGatewayRuntime(candidate);
|
|
63
|
+
if (selected) await effects.qualifyInstalledGatewayClient(current);
|
|
64
|
+
atomicWriteConfig(journal, JSON.stringify(saved, null, 2) + '\n');
|
|
65
|
+
try {
|
|
66
|
+
await effects.prepareGateway(candidate);
|
|
67
|
+
saved.phase = 'switching';
|
|
68
|
+
atomicWriteConfig(journal, JSON.stringify(saved, null, 2) + '\n');
|
|
69
|
+
await effects.serverLifecycle(record, 'stop');
|
|
70
|
+
await effects.serverLifecycle(candidate, 'start', [...new Set([...saved.running, 'daemon', 'cowork', 'gateway'])]);
|
|
71
|
+
await effects.verifyGateway(candidate);
|
|
72
|
+
if (selected) {
|
|
73
|
+
const serverUrl = gatewayAddress(candidate).base;
|
|
74
|
+
atomicWriteConfig(profilePath, JSON.stringify({ ...current, serverUrl, endpoint: serverUrl + '/daemon' }, null, 2) + '\n');
|
|
75
|
+
}
|
|
76
|
+
// Preserve the running/stopped selection; gateway follows a running daemon.
|
|
77
|
+
const keep = [...saved.running, ...(saved.running.includes('daemon') ? ['gateway'] : [])];
|
|
78
|
+
await effects.serverLifecycle(candidate, 'stop', candidate.services.filter(name => !keep.includes(name)));
|
|
79
|
+
atomicWriteConfig(recordPath, JSON.stringify(candidate, null, 2) + '\n');
|
|
80
|
+
unlinkSync(journal);
|
|
81
|
+
return candidate;
|
|
82
|
+
} catch (cause) {
|
|
83
|
+
try { await rollback(saved); }
|
|
84
|
+
catch (rollbackError) { throw new AggregateError([cause, rollbackError], 'Gateway migration and rollback failed; retained journal requires gateway-enable recovery'); }
|
|
85
|
+
throw new Error('Gateway migration failed; previous routing, profile and service selection restored', { cause });
|
|
86
|
+
}
|
|
87
|
+
}
|
package/lib/gateway.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/** One public URL; fixed same-origin service paths are never supplied by a peer. */
|
|
2
|
+
export const GATEWAY_SERVICES = Object.freeze({daemon:'/daemon',cowork:'/cowork',telegram:'/tg-connector',messenger:'/messenger'});
|
|
3
|
+
export function serverBase(value) {
|
|
4
|
+
if(typeof value!=='string'||/[\s\\?#]/.test(value)||!/^https?:\/\//.test(value))throw new Error('Server URL must be an HTTP or HTTPS base URL');
|
|
5
|
+
const url=new URL(value);
|
|
6
|
+
if(url.username||url.password)throw new Error('Server URL cannot contain credentials');
|
|
7
|
+
return url.origin+url.pathname.replace(/\/+$/,'');
|
|
8
|
+
}
|
|
9
|
+
export function gatewayDiscovery(record) {
|
|
10
|
+
return {schema:1,instanceId:record.instanceId,services:GATEWAY_SERVICES,capabilities:['ours.gateway-v1','cowork.http-management-v1']};
|
|
11
|
+
}
|
|
12
|
+
export function validateGatewayDiscovery(url,value,credentialPath) {
|
|
13
|
+
const base=serverBase(url);
|
|
14
|
+
if(value?.schema!==1||!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value.instanceId??'')
|
|
15
|
+
||!Array.isArray(value.capabilities)||!value.capabilities.includes('ours.gateway-v1')||!value.capabilities?.includes('cowork.http-management-v1')
|
|
16
|
+
||Object.keys(GATEWAY_SERVICES).some(key=>value.services?.[key]!==GATEWAY_SERVICES[key]))throw new Error('Incompatible server gateway discovery');
|
|
17
|
+
return {serverUrl:base,endpoint:base+'/daemon',expectedInstanceId:value.instanceId,credentialPath};
|
|
18
|
+
}
|
|
19
|
+
export function gatewayAddress(record) {
|
|
20
|
+
const base = serverBase(record.gateway?.serverUrl ?? `http://127.0.0.1:${record.port}`);
|
|
21
|
+
const url = new URL(base);
|
|
22
|
+
if (!/^\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]*$/.test(url.pathname)) throw new Error('Gateway base path must contain plain URL path segments');
|
|
23
|
+
return { base, origin: url.origin, prefix: url.pathname.replace(/\/$/, '') };
|
|
24
|
+
}
|
|
25
|
+
export function gatewayCompose(record) {
|
|
26
|
+
const { origin, prefix } = gatewayAddress(record);
|
|
27
|
+
return `services:
|
|
28
|
+
daemon:
|
|
29
|
+
ports: !reset []
|
|
30
|
+
telegram:
|
|
31
|
+
ports: !reset []
|
|
32
|
+
environment:
|
|
33
|
+
OURS_TG_CONTROL_HOST: "0.0.0.0"
|
|
34
|
+
cowork:
|
|
35
|
+
ports: !reset []
|
|
36
|
+
environment:
|
|
37
|
+
OURS_COWORK_HTTP_MANAGEMENT: "1"
|
|
38
|
+
OURS_COWORK_PUBLIC_ORIGIN: ${JSON.stringify(origin)}
|
|
39
|
+
messenger:
|
|
40
|
+
ports: !reset []
|
|
41
|
+
environment:
|
|
42
|
+
OURS_MESSENGER_PUBLIC_ORIGIN: ${JSON.stringify(origin)}
|
|
43
|
+
OURS_MESSENGER_BASE_PATH: ${JSON.stringify(prefix + "/messenger/")}
|
|
44
|
+
gateway:
|
|
45
|
+
image: "\${OURS_GATEWAY_IMAGE:-${record.project}:gateway}"
|
|
46
|
+
build:
|
|
47
|
+
context: .
|
|
48
|
+
dockerfile: Dockerfile.gateway
|
|
49
|
+
user: "101:101"
|
|
50
|
+
read_only: true
|
|
51
|
+
cap_drop: [ALL]
|
|
52
|
+
security_opt: [no-new-privileges:true]
|
|
53
|
+
tmpfs: ["/tmp:rw,nosuid,nodev,noexec,size=32m,mode=1777"]
|
|
54
|
+
networks: [ours]
|
|
55
|
+
ports:
|
|
56
|
+
- {target: 8080, published: "${record.port}", host_ip: "127.0.0.1"}
|
|
57
|
+
restart: unless-stopped
|
|
58
|
+
stop_grace_period: 10s
|
|
59
|
+
healthcheck:
|
|
60
|
+
test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1:8080/healthz]
|
|
61
|
+
interval: 5s
|
|
62
|
+
timeout: 3s
|
|
63
|
+
retries: 6
|
|
64
|
+
`;
|
|
65
|
+
}
|
|
66
|
+
export function gatewayNginx(record) {
|
|
67
|
+
const { prefix: basePath } = gatewayAddress(record);
|
|
68
|
+
const routes=[['daemon',3050],['cowork',record.coworkPort??3052],['telegram',3051],['messenger',8420]];
|
|
69
|
+
const upstream=Object.fromEntries(routes);
|
|
70
|
+
for(const port of Object.values(upstream))if(!Number.isInteger(port)||port<1||port>65535)throw new Error('Invalid gateway upstream port');
|
|
71
|
+
const route=(name,servicePath)=> { const prefix=basePath+servicePath; return`location ${prefix}/ {
|
|
72
|
+
set $upstream_${name} http://${name}:${upstream[name]};
|
|
73
|
+
rewrite ^${prefix}/(.*)$ /$1 break;
|
|
74
|
+
proxy_pass $upstream_${name};
|
|
75
|
+
proxy_set_header Host $http_host;
|
|
76
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
77
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
78
|
+
proxy_set_header Connection $connection_upgrade;
|
|
79
|
+
proxy_http_version 1.1;
|
|
80
|
+
proxy_buffering off;
|
|
81
|
+
proxy_read_timeout 300s;
|
|
82
|
+
}`; };
|
|
83
|
+
return `worker_processes 1;
|
|
84
|
+
pid /tmp/nginx.pid;
|
|
85
|
+
error_log /dev/stderr warn;
|
|
86
|
+
events { worker_connections 1024; }
|
|
87
|
+
http {
|
|
88
|
+
access_log off;
|
|
89
|
+
client_body_temp_path /tmp/client;
|
|
90
|
+
proxy_temp_path /tmp/proxy;
|
|
91
|
+
fastcgi_temp_path /tmp/fastcgi;
|
|
92
|
+
uwsgi_temp_path /tmp/uwsgi;
|
|
93
|
+
scgi_temp_path /tmp/scgi;
|
|
94
|
+
proxy_next_upstream off;
|
|
95
|
+
resolver 127.0.0.11 valid=5s ipv6=off;
|
|
96
|
+
map $http_upgrade $connection_upgrade { default upgrade; '' close; }
|
|
97
|
+
server {
|
|
98
|
+
listen 8080;
|
|
99
|
+
server_name _;
|
|
100
|
+
client_max_body_size 64m;
|
|
101
|
+
location = /healthz { return 200 'ok'; }
|
|
102
|
+
location = ${basePath}/.well-known/ours { default_type application/json; return 200 '${JSON.stringify(gatewayDiscovery(record))}'; }
|
|
103
|
+
location = / { return 302 ${basePath}/messenger/; }
|
|
104
|
+
location = ${basePath}/daemon { return 308 ${basePath}/daemon/; }
|
|
105
|
+
location = ${basePath}/cowork { return 308 ${basePath}/cowork/; }
|
|
106
|
+
location = ${basePath}/tg-connector { return 308 ${basePath}/tg-connector/; }
|
|
107
|
+
location = ${basePath}/messenger { return 308 ${basePath}/messenger/; }
|
|
108
|
+
# Never publish the unauthenticated loopback browser RPC. Management has its
|
|
109
|
+
# own application-level credential check and rejects browser-origin requests.
|
|
110
|
+
location = ${basePath}/cowork/rpc { return 403; }
|
|
111
|
+
location ${basePath}/cowork/management/ {
|
|
112
|
+
client_max_body_size 1m;
|
|
113
|
+
set $management http://cowork:${upstream.cowork};
|
|
114
|
+
rewrite ^${basePath}/cowork/(.*)$ /$1 break;
|
|
115
|
+
proxy_pass $management;
|
|
116
|
+
proxy_set_header Host $http_host;
|
|
117
|
+
proxy_http_version 1.1;
|
|
118
|
+
proxy_read_timeout 60s;
|
|
119
|
+
}
|
|
120
|
+
${route('daemon','/daemon')}
|
|
121
|
+
${route('cowork','/cowork')}
|
|
122
|
+
# Telegram's control API is local-only; no unauthenticated remote proxy.
|
|
123
|
+
location ${basePath}/tg-connector/ {
|
|
124
|
+
auth_request /_server_auth;
|
|
125
|
+
set $telegram http://telegram:3051;
|
|
126
|
+
rewrite ^${basePath}/tg-connector/(.*)$ /$1 break;
|
|
127
|
+
proxy_pass $telegram;
|
|
128
|
+
}
|
|
129
|
+
location = /_server_auth {
|
|
130
|
+
internal;
|
|
131
|
+
set $auth http://daemon:3050/identities;
|
|
132
|
+
proxy_pass $auth;
|
|
133
|
+
proxy_pass_request_body off;
|
|
134
|
+
proxy_set_header Content-Length "";
|
|
135
|
+
proxy_set_header X-Ours-Api-Token $http_x_ours_api_token;
|
|
136
|
+
}
|
|
137
|
+
${route('messenger','/messenger')}
|
|
138
|
+
location / { return 404; }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
`;
|
|
142
|
+
}
|
package/lib/orchestrate.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { enableGateway } from './gateway-transition.mjs';
|
|
1
2
|
import { executeLegacyMigration } from './legacy-migration.mjs';
|
|
2
3
|
// ours-install v3 — the orchestrator.
|
|
3
4
|
//
|
|
@@ -1324,6 +1325,12 @@ async function executeServerCommand(args, effects) {
|
|
|
1324
1325
|
record = effects.newInstallation(args.stateDir, args.mode);
|
|
1325
1326
|
for (const key of ['port', 'coworkPort', 'messengerPort']) if (args[key] !== undefined) record[key] = args[key];
|
|
1326
1327
|
}
|
|
1328
|
+
if (effects.readJson(join(record.root, 'gateway-transition.json')) && args.operation !== 'gateway-enable' && args.operation !== 'status') throw new Error('Interrupted gateway migration; run server gateway-enable before other changes');
|
|
1329
|
+
if (args.serverUrl && args.operation === 'install') {
|
|
1330
|
+
if (!record.gateway) throw new Error('Use server gateway-enable to migrate an existing Docker installation');
|
|
1331
|
+
if (existing && record.gateway.serverUrl !== args.serverUrl) throw new Error('Conflicting gateway server URL');
|
|
1332
|
+
record.gateway = { ...record.gateway, serverUrl: args.serverUrl };
|
|
1333
|
+
}
|
|
1327
1334
|
if (record.layoutConversion && !['install', 'start', 'stop', 'status'].includes(args.operation)) {
|
|
1328
1335
|
throw new Error('Layout conversion is incomplete; resume with server install or server start before changing state or authority');
|
|
1329
1336
|
}
|
|
@@ -1390,7 +1397,10 @@ async function executeServerCommand(args, effects) {
|
|
|
1390
1397
|
} else {
|
|
1391
1398
|
await installStage('Service startup', 'Start the daemon and selected services, then check readiness.', () => effects.serverLifecycle(record, 'start'));
|
|
1392
1399
|
}
|
|
1400
|
+
if (record.gateway) await effects.verifyGateway(record);
|
|
1393
1401
|
if (showInstallProgress) effects.out(progress(installStageCount, installStageCount, 'Installation complete', 'The selected services are ready.'));
|
|
1402
|
+
} else if (args.operation === 'gateway-enable') {
|
|
1403
|
+
await enableGateway(record, args, effects);
|
|
1394
1404
|
} else if (['backup', 'restore', 'reset'].includes(args.operation)) {
|
|
1395
1405
|
await effects.serverMaintenance(record, args);
|
|
1396
1406
|
} else if (['update', 'rebuild'].includes(args.operation)) {
|
|
@@ -1479,6 +1489,7 @@ export async function runClientCommand(command, effects) {
|
|
|
1479
1489
|
resolvedSources = await effects.resolveSourcePolicy(policy, 'client', selectedClients);
|
|
1480
1490
|
}
|
|
1481
1491
|
effects.out(progress(0, 4, 'Client configuration', 'Prepare the selected integrations and private connection profile.'));
|
|
1492
|
+
if (profile.serverUrl && integrations.includes('fleet')) await effects.qualifyGatewayClient({ profile, sourcesPath, sources: resolvedSources, integrations, refresh: !!command.preset });
|
|
1482
1493
|
const imported = effects.importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh: !!command.preset });
|
|
1483
1494
|
let phase = 'Validate saved server connection';
|
|
1484
1495
|
try {
|
package/lib/plan.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gatewayAddress } from './gateway.mjs';
|
|
1
2
|
// ours-install v3 — daemon creation and boot-service installation.
|
|
2
3
|
//
|
|
3
4
|
// Pure planning code, like lib/target.mjs: the orchestrator
|
|
@@ -362,9 +363,14 @@ export function validateInstallation(record, root) {
|
|
|
362
363
|
|| !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(record.instanceId ?? '')
|
|
363
364
|
|| !/^ours-[a-z0-9]+$/.test(record.project ?? '')
|
|
364
365
|
|| (record.sourcePolicyHash !== undefined && !/^[0-9a-f]{64}$/.test(record.sourcePolicyHash))
|
|
365
|
-
|| !Array.isArray(record.services) || record.services[0] !== 'daemon' || new Set(record.services).size !== record.services.length || record.services.some(s => !SERVER_SERVICES.includes(s))) {
|
|
366
|
+
|| !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))) {
|
|
366
367
|
throw new Error('Invalid or conflicting installation selection');
|
|
367
368
|
}
|
|
369
|
+
if (record.gateway !== undefined && (record.schema !== 2 || record.mode !== 'docker' || record.gateway?.version !== 1
|
|
370
|
+
|| Object.keys(record.gateway).some(key => !['version', 'serverUrl'].includes(key)) || record.services.at(-1) !== 'gateway')) {
|
|
371
|
+
throw new Error('Invalid gateway installation selection');
|
|
372
|
+
}
|
|
373
|
+
if (record.gateway) gatewayAddress(record);
|
|
368
374
|
if (record.layoutConversion !== undefined) {
|
|
369
375
|
// DEPRECATED (introduced in 2.0): legacy managed-layout conversion only.
|
|
370
376
|
// Removal target: 3.0, after supported installs convert and upgrade inputs
|
|
@@ -402,7 +408,7 @@ export function validateInstallation(record, root) {
|
|
|
402
408
|
throw new Error('Invalid server build transition');
|
|
403
409
|
}
|
|
404
410
|
validateInstallation(candidate, candidate.root);
|
|
405
|
-
for (const key of ['schema', 'mode', 'instanceId', 'services', 'port', 'coworkPort', 'messengerPort', 'messengerIdentity', 'uid', 'gid']) {
|
|
411
|
+
for (const key of ['schema', 'mode', 'instanceId', 'services', 'gateway', 'port', 'coworkPort', 'messengerPort', 'messengerIdentity', 'uid', 'gid']) {
|
|
406
412
|
if (JSON.stringify(candidate[key]) !== JSON.stringify(record[key])) throw new Error('Conflicting server build candidate');
|
|
407
413
|
}
|
|
408
414
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gatewayAddress } from './gateway.mjs';
|
|
1
2
|
/** Human bootstrap and local client handoff through the existing owner interfaces. */
|
|
2
3
|
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
4
|
import { join, resolve, isAbsolute } from 'node:path';
|
|
@@ -82,7 +83,8 @@ export function createServerOnboarding(effects, { compose, localEnv, bin }) {
|
|
|
82
83
|
const settings = JSON.parse(readFileSync(fleetSettingsPath, 'utf8'));
|
|
83
84
|
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) throw new Error('Fleet settings must be a JSON object');
|
|
84
85
|
}
|
|
85
|
-
const
|
|
86
|
+
const serverUrl = record.gateway ? gatewayAddress(record).base : undefined;
|
|
87
|
+
const endpoint = serverUrl ? `${serverUrl}/daemon` : `http://127.0.0.1:${record.port}`;
|
|
86
88
|
const current = effects.readManagedClientProfile();
|
|
87
89
|
if (current && (current.endpoint !== endpoint || current.expectedInstanceId !== record.instanceId)) throw new Error('Managed client already selects another server; no credential was issued');
|
|
88
90
|
privatePath(record.root, true);
|
|
@@ -93,13 +95,16 @@ export function createServerOnboarding(effects, { compose, localEnv, bin }) {
|
|
|
93
95
|
const published = join(root, 'issued-' + randomUUID());
|
|
94
96
|
const credential = join(stage, 'credential');
|
|
95
97
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
if (!current) {
|
|
99
|
+
effects.out?.('Issuing a separate local client credential with the retained server authority.');
|
|
100
|
+
try { await effects.serverAccess(record, 'access-issue', { output: credential }); }
|
|
101
|
+
catch { throw new Error('Client credential issuance failed; existing profiles were retained'); }
|
|
102
|
+
}
|
|
103
|
+
const retainedCredential = current?.credentialPath ?? credential;
|
|
104
|
+
const stat = privatePath(retainedCredential);
|
|
105
|
+
if (stat.size > 4096 || !readFileSync(retainedCredential, 'utf8').trim()) throw new Error('Issued client credential is empty or invalid');
|
|
101
106
|
const profile = {
|
|
102
|
-
...validateHostProfile({ endpoint, expectedInstanceId: record.instanceId, credentialPath: join(published, 'credential') }),
|
|
107
|
+
...validateHostProfile({ ...(serverUrl ? { serverUrl } : {}), endpoint, expectedInstanceId: record.instanceId, credentialPath: current?.credentialPath ?? join(published, 'credential') }),
|
|
103
108
|
installer: { integrations: [...integrations], ...(fleetSettingsPath !== undefined ? { fleetSettingsPath } : {}) },
|
|
104
109
|
};
|
|
105
110
|
writeFileSync(join(stage, 'profile.json'), JSON.stringify(profile, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
package/lib/setup-options.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { serverBase } from './gateway.mjs';
|
|
1
2
|
import { join, resolve, isAbsolute, dirname } from 'node:path';
|
|
2
3
|
|
|
3
4
|
const scopes = ['all', 'server', 'client'];
|
|
4
5
|
const integrations = ['codex', 'claude-code', 'fleet'];
|
|
5
6
|
const valueFlags = new Map(Object.entries({
|
|
6
|
-
'--scope': 'scope', '--action': 'operation', '--mode': 'mode', '--state-dir': 'stateDir',
|
|
7
|
+
'--server-url': 'serverUrl', '--scope': 'scope', '--action': 'operation', '--mode': 'mode', '--state-dir': 'stateDir',
|
|
7
8
|
'--identity-name': 'identityName', '--integrations': 'integrations', '--fleet-settings': 'fleetSettingsPath',
|
|
8
9
|
'--config': 'config', '--sources': 'sources', '--migrate-from': 'migrateFrom', '--port': 'port', '--cowork-port': 'coworkPort', '--messenger-port': 'messengerPort',
|
|
9
10
|
}));
|
|
@@ -40,6 +41,10 @@ export function validateSetupOptions(input, { interactive = input?.interactive =
|
|
|
40
41
|
if (missing.length) throw new Error(`Missing required setup options: ${missing.join(', ')}`);
|
|
41
42
|
if (options.mode === 'native') options.mode = 'packages';
|
|
42
43
|
if (server && !['packages', 'docker'].includes(options.mode)) throw new Error('Mode must be packages (or native) or docker');
|
|
44
|
+
if (options.serverUrl !== undefined) {
|
|
45
|
+
if (!server || options.mode !== 'docker' || options.operation !== 'install') throw new Error('--server-url requires Docker server installation');
|
|
46
|
+
options.serverUrl = serverBase(options.serverUrl);
|
|
47
|
+
}
|
|
43
48
|
if (server && options.config !== undefined) throw new Error('--config is only valid for client scope');
|
|
44
49
|
if (!server) for (const key of ['mode', 'stateDir', 'identityName', ...Object.keys(defaults), 'compatible', 'migrate']) {
|
|
45
50
|
if (options[key] !== undefined) throw new Error(`${key} is only valid for server or all scope`);
|
package/lib/setup.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gatewayAddress } from './gateway.mjs';
|
|
1
2
|
import { inspectLegacyMigration } from './legacy-migration.mjs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { parseSetupArgs, collectSetupOptions, validateSetupOptions } from './setup-options.mjs';
|
|
@@ -10,7 +11,7 @@ import { USAGE } from './usage.mjs';
|
|
|
10
11
|
import { validateIdentityName } from './server-onboarding.mjs';
|
|
11
12
|
import { validateFleetSettings } from './fleet-settings.mjs';
|
|
12
13
|
|
|
13
|
-
const maintenance = new Set(['status', 'start', 'stop', 'restart', 'rebuild', 'access-issue', 'access-replace', 'backup', 'restore', 'reset']);
|
|
14
|
+
const maintenance = new Set(['status', 'start', 'stop', 'restart', 'rebuild', 'gateway-enable', 'access-issue', 'access-replace', 'backup', 'restore', 'reset']);
|
|
14
15
|
|
|
15
16
|
export function completeReleasePolicy(retained, supplied) {
|
|
16
17
|
if (retained?.release) {
|
|
@@ -67,7 +68,7 @@ export async function prepareSetupPlan(options, effects) {
|
|
|
67
68
|
}
|
|
68
69
|
// Reject a local client already attached to another server before changing the server.
|
|
69
70
|
const saved = options.scope === 'all' ? effects.readManagedClientProfile() : null;
|
|
70
|
-
if (saved && (!plan.existing || saved.expectedInstanceId !== plan.existing.instanceId || saved.endpoint !== `http://127.0.0.1:${plan.port}`)) {
|
|
71
|
+
if (saved && (!plan.existing || saved.expectedInstanceId !== plan.existing.instanceId || saved.endpoint !== (plan.existing?.gateway ? gatewayAddress(plan.existing).base + '/daemon' : `http://127.0.0.1:${plan.port}`))) {
|
|
71
72
|
throw new InstallUsageError('This user already has clients attached to a different server; their saved connection was not changed');
|
|
72
73
|
}
|
|
73
74
|
} else {
|