@ours.network/install 1.2.1-nightly.3 → 1.2.1-nightly.30
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 +320 -9
- package/ROOTLESS_PODMAN_VALIDATION.md +210 -0
- package/assets/Dockerfile +17 -13
- package/assets/Dockerfile.gateway +5 -0
- package/assets/docker-compose.yaml +1 -1
- package/assets/fleet-settings.example.json +48 -0
- package/assets/gateway-entrypoint.sh +37 -0
- package/assets/release-lock.json +1122 -4471
- package/assets/release.json +33 -19
- package/assets/scripts/build/build-common.mjs +1 -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 +23 -5
- package/assets/scripts/runtime/diagnostics.mjs +72 -0
- package/assets/scripts/runtime/entrypoint.sh +1 -1
- package/assets/sources.json +46 -28
- package/install.mjs +7 -2
- package/lib/build-transition.mjs +1 -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 +278 -130
- package/lib/gateway-transition.mjs +87 -0
- package/lib/gateway.mjs +142 -0
- package/lib/legacy-migration.mjs +13 -2
- package/lib/orchestrate.mjs +129 -43
- package/lib/plan.mjs +22 -9
- package/lib/podman-preflight.mjs +40 -0
- package/lib/server-onboarding.mjs +154 -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/orchestrate.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { validateGatewayClientProfile } from './target.mjs';
|
|
2
|
+
import { enableGateway } from './gateway-transition.mjs';
|
|
1
3
|
import { executeLegacyMigration } from './legacy-migration.mjs';
|
|
2
4
|
// ours-install v3 — the orchestrator.
|
|
3
5
|
//
|
|
@@ -26,7 +28,7 @@ import { executeLegacyMigration } from './legacy-migration.mjs';
|
|
|
26
28
|
|
|
27
29
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
28
30
|
import { parseNetworkArgs, validateHostProfile, parseInstallArgs, resolveTarget, resolveProfileSelection, profileEnv, InstallUsageError } from './target.mjs';
|
|
29
|
-
import { selectSourcePackages, validateInstallation, SERVER_SERVICES, planDaemonConfig, planServiceInstall, serviceInstallCommand } from './plan.mjs';
|
|
31
|
+
import { clientPackageNames, selectSourcePackages, validateInstallation, SERVER_SERVICES, planDaemonConfig, planServiceInstall, serviceInstallCommand } from './plan.mjs';
|
|
30
32
|
import {
|
|
31
33
|
COMPONENTS,
|
|
32
34
|
planComponentSelection, planMcpAttachment, planTgAttachment, planCoworkAttachment,
|
|
@@ -43,6 +45,7 @@ import {
|
|
|
43
45
|
validateChannelVersion,
|
|
44
46
|
} from './marketplace.mjs';
|
|
45
47
|
import { USAGE } from './usage.mjs';
|
|
48
|
+
import { installerFailure } from './diagnostics.mjs';
|
|
46
49
|
import { ok, info, warn, heading, banner, box, c, progress } from './ui.mjs';
|
|
47
50
|
|
|
48
51
|
export const EXIT_OK = 0;
|
|
@@ -86,6 +89,37 @@ async function perform(effects, dryRun, label, thunk) {
|
|
|
86
89
|
|
|
87
90
|
const reason = (error) => (error instanceof Error ? error.message : String(error));
|
|
88
91
|
|
|
92
|
+
function clientDiagnostic(error) {
|
|
93
|
+
return installerFailure(error);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function clientRetry(effects, imported, integrations) {
|
|
97
|
+
effects.out(info(`Saved server connection: ${imported.configPath}.`));
|
|
98
|
+
if (integrations.includes('fleet') && !imported.settings.disableFleetAgentsSetup && !imported.settings.fleetSettingsPath) {
|
|
99
|
+
effects.out(info('Saved profile and settings retained. Run ours-install, choose Connect to an existing server, and reuse the saved profile to finish the interactive Fleet configuration.'));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const args = ['client', 'install', '--config', imported.configPath, '--integrations', integrations.join(',') || 'none', '--sources', imported.settings.sourcesPath];
|
|
103
|
+
if (imported.settings.disableFleetAgentsSetup) args.push('--disable-fleet-agents-setup');
|
|
104
|
+
if (imported.settings.fleetSettingsPath) args.push('--fleet-settings', imported.settings.fleetSettingsPath);
|
|
105
|
+
const command = `ours-install client install ${args.slice(2).map((value, i) => i % 2 === 0 ? value : shellQuote(value)).join(' ')}`;
|
|
106
|
+
effects.out(info(`Saved profile and settings retained; re-run ${command}`));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function explainClientFailure(effects, name, row) {
|
|
110
|
+
const label = { codex: 'Codex', 'claude-code': 'Claude Code', fleet: 'Fleet' }[name];
|
|
111
|
+
const command = name === 'claude-code' ? 'claude' : name;
|
|
112
|
+
if (row?.note === 'not installed') {
|
|
113
|
+
effects.out(warn(`${label}: executable not found on PATH. Install ${label} or make its executable available in this shell, then check ${command} --version before retrying.`));
|
|
114
|
+
} else if (row?.manual) {
|
|
115
|
+
effects.out(warn(`${label}: cannot register the plugin automatically — ${clientDiagnostic(row.note)}. Use the real executable (check ${command} --version), or register the displayed local marketplace manually.`));
|
|
116
|
+
} else {
|
|
117
|
+
effects.out(warn(`${label}: ${row?.failedStep ?? 'integration setup'} failed. ${clientDiagnostic(row?.detail ?? row?.note ?? 'The integration did not report successful completion. Review the messages above.')} Fix the reported command error before retrying.`));
|
|
118
|
+
if (row?.failedCommand) effects.out(info(`Failed command: ${row.failedCommand.map(shellQuote).join(' ')}`));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
89
123
|
function semverMajor(version) {
|
|
90
124
|
const match = /^(?:[~^<>= ]*)(\d+)\./.exec(String(version ?? '').trim());
|
|
91
125
|
return match ? Number(match[1]) : null;
|
|
@@ -110,6 +144,7 @@ function incompatibleUpgrade(target, cliDependencies) {
|
|
|
110
144
|
async function prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatch) {
|
|
111
145
|
const dir = target.stateDir;
|
|
112
146
|
const configPath = join(dir, 'config.json');
|
|
147
|
+
const owner = effects.readJson(join(dir, 'ours-cli-daemon.json'))?.owner === '@ours.network/daemon' ? ['ours-daemon'] : ['ours', 'daemon'];
|
|
113
148
|
if (mismatch.unknown) {
|
|
114
149
|
effects.out(warn(`ours: cannot verify whether ${cliPkg} can restore daemon v${mismatch.runningVersion}. Nothing was changed.`));
|
|
115
150
|
effects.out(info('Check npm registry access and re-run; compatibility checks fail closed.'));
|
|
@@ -153,13 +188,13 @@ async function prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatc
|
|
|
153
188
|
}
|
|
154
189
|
|
|
155
190
|
await perform(effects, false, 'stop the incompatible daemon', () => effects.run(
|
|
156
|
-
|
|
191
|
+
owner[0], [...owner.slice(1), 'stop', '--state-dir', dir, '--config', configPath], { stream: true },
|
|
157
192
|
));
|
|
158
193
|
try {
|
|
159
194
|
await perform(effects, false, `back up complete daemon state to ${backupPath}`, () => effects.copyDir(dir, backupPath));
|
|
160
195
|
} catch (error) {
|
|
161
196
|
try {
|
|
162
|
-
await effects.run(
|
|
197
|
+
await effects.run(owner[0], [...owner.slice(1), 'start', '--state-dir', dir, '--config', configPath], { stream: true });
|
|
163
198
|
effects.out(ok('backup failed, but the old daemon was started again'));
|
|
164
199
|
} catch {
|
|
165
200
|
effects.out(warn(`backup failed and the old daemon did not restart; its state is still untouched at ${dir}`));
|
|
@@ -168,11 +203,11 @@ async function prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatc
|
|
|
168
203
|
}
|
|
169
204
|
try {
|
|
170
205
|
await perform(effects, false, 'remove the incompatible daemon boot service', () => effects.run(
|
|
171
|
-
|
|
206
|
+
owner[0], [...owner.slice(1), 'uninstall-service', '--yes', '--state-dir', dir, '--config', configPath], { stream: true },
|
|
172
207
|
));
|
|
173
208
|
} catch (error) {
|
|
174
209
|
try {
|
|
175
|
-
await effects.run(
|
|
210
|
+
await effects.run(owner[0], [...owner.slice(1), 'start', '--state-dir', dir, '--config', configPath], { stream: true });
|
|
176
211
|
effects.out(ok(`service removal failed, but the old daemon was started again; backup retained at ${backupPath}`));
|
|
177
212
|
} catch {
|
|
178
213
|
effects.out(warn(`service removal failed and the old daemon did not restart; state remains at ${dir} and the backup is at ${backupPath}`));
|
|
@@ -193,11 +228,11 @@ async function prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatc
|
|
|
193
228
|
* rather than one harness. So a failure here is one honest line plus whatever
|
|
194
229
|
* the caller wants to say about retrying, and the walk continues.
|
|
195
230
|
*/
|
|
196
|
-
async function attempt(effects, dryRun, label, thunk) {
|
|
231
|
+
async function attempt(effects, dryRun, label, thunk, formatReason = reason) {
|
|
197
232
|
try {
|
|
198
233
|
return { ok: true, ...(await perform(effects, dryRun, label, thunk)) };
|
|
199
234
|
} catch (error) {
|
|
200
|
-
effects.out(warn(`${label} — did not complete: ${
|
|
235
|
+
effects.out(warn(`${label} — did not complete: ${formatReason(error)}`));
|
|
201
236
|
return { ok: false, error };
|
|
202
237
|
}
|
|
203
238
|
}
|
|
@@ -333,14 +368,13 @@ export async function runDaemonPhase(args, effects, exactSuite = null) {
|
|
|
333
368
|
const mcpPkg = exactSuite?.packages?.mcp
|
|
334
369
|
? `@ours.network/mcp@${exactSuite.packages.mcp}`
|
|
335
370
|
: componentSpec(componentByKey('mcp'), args.channel);
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const cliPkg = '@ours.network/cli';
|
|
371
|
+
// Inspect the runtime package SDK compatibility before replacing the owner.
|
|
372
|
+
const cliPkg = args.channel === 'nightly' ? '@ours.network/cli@nightly' : '@ours.network/cli';
|
|
373
|
+
const daemonPkg = args.channel === 'nightly' ? '@ours.network/daemon@nightly' : '@ours.network/daemon';
|
|
340
374
|
if (!creating && target.daemonVersion) {
|
|
341
|
-
const mismatch = incompatibleUpgrade(target, effects.packageDependencies(
|
|
375
|
+
const mismatch = incompatibleUpgrade(target, effects.packageDependencies(daemonPkg));
|
|
342
376
|
if (mismatch) {
|
|
343
|
-
const prepared = await prepareIncompatibleUpgrade(args, effects, target,
|
|
377
|
+
const prepared = await prepareIncompatibleUpgrade(args, effects, target, daemonPkg, mismatch);
|
|
344
378
|
if (prepared.refused) return { target, refused: prepared.refused, steps };
|
|
345
379
|
creating = prepared.purged === true;
|
|
346
380
|
target.backupPath = prepared.backupPath;
|
|
@@ -349,6 +383,18 @@ export async function runDaemonPhase(args, effects, exactSuite = null) {
|
|
|
349
383
|
}
|
|
350
384
|
await perform(effects, args.dryRun, `MCP server installed (npm i -g ${mcpPkg})`, () => effects.run('npm', ['i', '-g', mcpPkg]));
|
|
351
385
|
steps.push({ id: 'mcp-package', changed: true, packageRefresh: true });
|
|
386
|
+
await perform(effects, args.dryRun, 'daemon runtime installed', () => effects.run('npm', ['i', '-g', daemonPkg]));
|
|
387
|
+
// A retained global unit still points into the old CLI package. Rewrite and
|
|
388
|
+
// activate that owned unit before npm replaces its executable with thin CLI.
|
|
389
|
+
let migratedService;
|
|
390
|
+
if (!creating) {
|
|
391
|
+
const retained = planServiceInstall({ stateDir: dir, home: effects.home, readText: effects.readText, platform: effects.platform?.platform });
|
|
392
|
+
if (retained.unitPath && effects.readText(retained.unitPath) !== null) {
|
|
393
|
+
try { migratedService = await runServicePhase(args, effects, dir, target.port); }
|
|
394
|
+
catch (error) { await recoverDaemon(args, effects, dir, join(dir, 'config.json'), target.port); throw error; }
|
|
395
|
+
if (migratedService.refused) return { target, refused: migratedService.refused, steps };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
352
398
|
await perform(effects, args.dryRun, `ours CLI installed (npm i -g ${cliPkg})`, () => effects.run('npm', ['i', '-g', cliPkg]));
|
|
353
399
|
steps.push({ id: 'cli', changed: true, packageRefresh: true });
|
|
354
400
|
|
|
@@ -383,14 +429,14 @@ export async function runDaemonPhase(args, effects, exactSuite = null) {
|
|
|
383
429
|
|
|
384
430
|
try {
|
|
385
431
|
if (creating) {
|
|
386
|
-
await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours', [
|
|
432
|
+
await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours-daemon', [ 'start', '--config', configPath], { stream: true }));
|
|
387
433
|
steps.push({ id: 'start', changed: true });
|
|
388
434
|
} else {
|
|
389
|
-
await perform(effects, args.dryRun, `restart the daemon on port ${target.port}`, () => effects.run('ours', [
|
|
435
|
+
await perform(effects, args.dryRun, `restart the daemon on port ${target.port}`, () => effects.run('ours-daemon', [ 'restart', '--config', configPath], { stream: true }));
|
|
390
436
|
steps.push({ id: 'restart', changed: true });
|
|
391
437
|
}
|
|
392
438
|
|
|
393
|
-
const service = await runServicePhase(args, effects, dir, target.port);
|
|
439
|
+
const service = migratedService ?? await runServicePhase(args, effects, dir, target.port);
|
|
394
440
|
if (service.unsupported) serviceUnsupported = service.unsupported;
|
|
395
441
|
if (service.refused) {
|
|
396
442
|
// A REFUSAL IS A FAILURE TO REACH THE STATE, not a special case. An unknown
|
|
@@ -420,7 +466,7 @@ export async function runDaemonPhase(args, effects, exactSuite = null) {
|
|
|
420
466
|
effects.out(recovery.recovered
|
|
421
467
|
? ok('your daemon is running again — nothing was committed, and the service is unchanged')
|
|
422
468
|
: warn('and the daemon did NOT come back up — start it yourself before anything else: '
|
|
423
|
-
+ `ours
|
|
469
|
+
+ `ours-daemon start --config ${configPath}`));
|
|
424
470
|
}
|
|
425
471
|
throw error;
|
|
426
472
|
}
|
|
@@ -443,7 +489,7 @@ export async function runDaemonPhase(args, effects, exactSuite = null) {
|
|
|
443
489
|
async function recoverDaemon(args, effects, dir, configPath, port) {
|
|
444
490
|
if (args.dryRun) return null;
|
|
445
491
|
try {
|
|
446
|
-
await effects.run('ours', [
|
|
492
|
+
await effects.run('ours-daemon', [ 'start', '--config', configPath]);
|
|
447
493
|
return { recovered: true };
|
|
448
494
|
} catch (recoveryError) {
|
|
449
495
|
return { recovered: false, reason: reason(recoveryError) };
|
|
@@ -794,7 +840,7 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
|
|
|
794
840
|
}
|
|
795
841
|
if (/not running|not reachable|ECONNREFUSED|connect/i.test(text)) {
|
|
796
842
|
effects.out(warn("The daemon isn't reachable yet — couldn't create your human identity."));
|
|
797
|
-
effects.out(info(`Fix: run 'ours
|
|
843
|
+
effects.out(info(`Fix: run 'ours-daemon start --config ${env.OURS_CONFIG}', then 'ours identity create-root --config ${env.OURS_CONFIG} --name "${name}"'.`));
|
|
798
844
|
return { key: 'identity', label: 'Human identity', state: 'failed', note: 'daemon not reachable' };
|
|
799
845
|
}
|
|
800
846
|
effects.out(warn(`Couldn't create your human identity: ${text.split('\n')[0]}`));
|
|
@@ -817,6 +863,9 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
|
|
|
817
863
|
export async function runHarnessPhase(args, effects, { target, isDefaultStateDir, exactSuite = null }) {
|
|
818
864
|
effects.out(heading('Harness plugins'));
|
|
819
865
|
const detected = (await effects.detectHarnesses()).filter(h => !args.clientIntegrations || args.clientIntegrations.includes(h.name));
|
|
866
|
+
for (const name of args.clientIntegrations ?? []) {
|
|
867
|
+
if (name !== 'fleet' && !detected.some(h => h.name === name)) detected.push({ name, status: 'absent' });
|
|
868
|
+
}
|
|
820
869
|
for (const h of detected) {
|
|
821
870
|
if (h.status === 'ok') effects.out(ok(`'${h.command ?? h.name}' → ${h.detail ?? 'real program'} (its plugin can be installed)`));
|
|
822
871
|
else if (h.status === 'alias') effects.out(warn(`'${h.command ?? h.name}' → ${h.detail} (I won't call it — manual steps below)`));
|
|
@@ -826,7 +875,7 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
|
|
|
826
875
|
if (detected.every((h) => h.status === 'absent')) {
|
|
827
876
|
effects.out(info('No Claude Code, Codex or Hermes found — install one and re-run to wire it up.'));
|
|
828
877
|
effects.out(info('Your daemon is unaffected; nothing else in this run depends on a harness.'));
|
|
829
|
-
return [];
|
|
878
|
+
return args.clientIntegrations ? detected.map(h => ({ key: h.name, state: 'skipped', note: 'not installed' })) : [];
|
|
830
879
|
}
|
|
831
880
|
|
|
832
881
|
const plans = planHarnessPlugins({
|
|
@@ -851,7 +900,7 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
|
|
|
851
900
|
if (plan.action === 'manual' && exactSuite?.localPackages?.[plan.name]) {
|
|
852
901
|
const root = await effects.prepareClientMarketplace(plan.name, exactSuite.localPackages[plan.name]);
|
|
853
902
|
effects.out(warn(`${plan.label} requires manual registration of exact local marketplace ${root}; ${target.managed ? 'the saved client default is retained' : `keep OURS_CONFIG=${target.configPath}`}.`));
|
|
854
|
-
rows.push({ ...row, state: 'failed', note:
|
|
903
|
+
rows.push({ ...row, state: 'failed', note: plan.reason, manual: true });
|
|
855
904
|
continue;
|
|
856
905
|
}
|
|
857
906
|
if (plan.action === 'manual') {
|
|
@@ -873,7 +922,7 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
|
|
|
873
922
|
effects.out(warn(`${plan.label} — ${plan.reason}; install it yourself with:`));
|
|
874
923
|
for (const step of manual) effects.out(info(` ${step}`));
|
|
875
924
|
if (plan.envLine) effects.out(info(`Before later native launches, set: ${plan.envLine}`));
|
|
876
|
-
rows.push({ ...row, state: 'skipped', note: plan.reason });
|
|
925
|
+
rows.push({ ...row, state: 'skipped', note: plan.reason, manual: true });
|
|
877
926
|
continue;
|
|
878
927
|
}
|
|
879
928
|
|
|
@@ -884,13 +933,16 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
|
|
|
884
933
|
const steps = plan.name === 'codex'
|
|
885
934
|
? [['codex', 'plugin', 'marketplace', 'add', registration], ['codex', 'plugin', 'add', 'ours@ours-codex-marketplace']]
|
|
886
935
|
: [['claude', 'plugin', 'marketplace', 'add', registration], ['claude', 'plugin', await effects.hasClaudePlugin() ? 'update' : 'install', 'ours@ours.network']];
|
|
887
|
-
let
|
|
936
|
+
let failure = null;
|
|
888
937
|
for (const step of steps) {
|
|
889
|
-
const outcome = await attempt(effects, false, step.join(' '), () => effects.run(step[0], step.slice(1), { env: profileEnv(target) }));
|
|
890
|
-
if (!outcome.ok) {
|
|
938
|
+
const outcome = await attempt(effects, false, step.join(' '), () => effects.run(step[0], step.slice(1), { env: profileEnv(target) }), clientDiagnostic);
|
|
939
|
+
if (!outcome.ok) {
|
|
940
|
+
failure = { failedCommand: step, failedStep: step[2] === 'marketplace' ? `Register ${plan.label} marketplace` : `Install ${plan.label} plugin`, detail: clientDiagnostic(outcome.error) };
|
|
941
|
+
break;
|
|
942
|
+
}
|
|
891
943
|
}
|
|
892
944
|
effects.out(info(target.managed ? `Native ${plan.name} launches use the saved client default.` : `Native ${plan.name} launches must retain OURS_CONFIG=${target.configPath}.`));
|
|
893
|
-
rows.push({ ...row, state:
|
|
945
|
+
rows.push({ ...row, state: failure ? 'failed' : 'installed', ...(failure ?? {}) });
|
|
894
946
|
continue;
|
|
895
947
|
}
|
|
896
948
|
|
|
@@ -956,6 +1008,11 @@ export async function runFleetPhase(args, effects, { target, isDefaultStateDir }
|
|
|
956
1008
|
return { key: 'fleet', label: plan.label, state: 'skipped' };
|
|
957
1009
|
}
|
|
958
1010
|
const install = args.acquiredFleet ? { ok: true } : await attempt(effects, args.dryRun, plan.install.join(' '), () => effects.run(plan.install[0], plan.install.slice(1)));
|
|
1011
|
+
if (args.disableFleetAgentsSetup) {
|
|
1012
|
+
if (!install.ok) return { key: 'fleet', label: plan.label, state: 'failed', note: 'Fleet installation failed' };
|
|
1013
|
+
effects.out(ok('Fleet CLI installed; agent and model setup deferred to the App. Existing Fleet configuration was retained.'));
|
|
1014
|
+
return { key: 'fleet', label: plan.label, state: 'installed', note: 'agents setup deferred' };
|
|
1015
|
+
}
|
|
959
1016
|
// Fleet owns its v2 manifest, roles, models and permissions. With no prepared
|
|
960
1017
|
// settings, its wizard keeps the user's terminal while this invocation carries
|
|
961
1018
|
// the selected daemon; --settings makes the same call strictly noninteractive.
|
|
@@ -1262,15 +1319,22 @@ async function executeServerCommand(args, effects) {
|
|
|
1262
1319
|
const existing = record !== null;
|
|
1263
1320
|
if (existing) {
|
|
1264
1321
|
record = validateInstallation(record, args.stateDir);
|
|
1322
|
+
if (args.containerEngine && args.containerEngine !== (record.containerEngine ?? 'docker')) throw new Error('Conflicting container engine; explicit migration is required');
|
|
1265
1323
|
if (args.mode && args.mode !== record.mode) throw new Error('Conflicting runtime mode; retained installation was not changed');
|
|
1266
1324
|
if (args.operation !== 'update' && args.sources && (record.sourcePolicyHash
|
|
1267
1325
|
? effects.sourcePolicyHash(args.sources) !== record.sourcePolicyHash
|
|
1268
1326
|
: effects.readText(args.sources) !== effects.readText(record.sourcesPath))) throw new Error('Conflicting source selection; use explicit server update');
|
|
1269
1327
|
} else {
|
|
1270
1328
|
if (args.operation !== 'install' || !args.mode) throw new Error('First server install requires --mode');
|
|
1271
|
-
record = effects.newInstallation(args.stateDir, args.mode);
|
|
1329
|
+
record = effects.newInstallation(args.stateDir, args.mode, args);
|
|
1272
1330
|
for (const key of ['port', 'coworkPort', 'messengerPort']) if (args[key] !== undefined) record[key] = args[key];
|
|
1273
1331
|
}
|
|
1332
|
+
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');
|
|
1333
|
+
if (args.serverUrl && args.operation === 'install') {
|
|
1334
|
+
if (!record.gateway) throw new Error('Use server gateway-enable to migrate an existing Docker installation');
|
|
1335
|
+
if (existing && record.gateway.serverUrl !== args.serverUrl) throw new Error('Conflicting gateway server URL');
|
|
1336
|
+
record.gateway = { ...record.gateway, serverUrl: args.serverUrl };
|
|
1337
|
+
}
|
|
1274
1338
|
if (record.layoutConversion && !['install', 'start', 'stop', 'status'].includes(args.operation)) {
|
|
1275
1339
|
throw new Error('Layout conversion is incomplete; resume with server install or server start before changing state or authority');
|
|
1276
1340
|
}
|
|
@@ -1278,7 +1342,8 @@ async function executeServerCommand(args, effects) {
|
|
|
1278
1342
|
throw new Error(`Server build activation is incomplete; repeat server ${record.buildTransition.operation} before other mutations`);
|
|
1279
1343
|
}
|
|
1280
1344
|
const showInstallProgress = args.operation === 'install' && !(existing && (record.schema === 1 || record.layoutConversion));
|
|
1281
|
-
const installStageCount = (existing ? 7 : 9) + (args.identityName ? 2 : 0)
|
|
1345
|
+
const installStageCount = (existing ? 7 : 9) + (args.identityName ? 2 : 0)
|
|
1346
|
+
+ (args.identityName && args.name && args.surname && (!existing || [undefined, null, args.identityName, `${args.name} ${args.surname}`].includes(record.messengerIdentity)) ? 1 : 0);
|
|
1282
1347
|
let completedInstallStages = 0;
|
|
1283
1348
|
const installStage = async (label, explanation, action) => {
|
|
1284
1349
|
if (!showInstallProgress) return action();
|
|
@@ -1301,7 +1366,7 @@ async function executeServerCommand(args, effects) {
|
|
|
1301
1366
|
}
|
|
1302
1367
|
if (!existing && args.sources) record.sourcePolicyHash = effects.sourcePolicyHash(args.sources);
|
|
1303
1368
|
await installStage('Prerequisite checks', record.mode === 'docker' ? 'Check Docker Engine and Docker Compose.' : 'Check native tools and the user service manager.', () => effects.serverPreflight(record, args.operation, {
|
|
1304
|
-
existing, sourcePath: args.sources ?? record.sourcesPath, sourceManifest: args.resolvedSources, identityName: args.identityName,
|
|
1369
|
+
existing, sourcePath: args.sources ?? record.sourcesPath, sourceManifest: args.resolvedSources, identityName: args.identityName, explicitPorts: args.explicitPorts,
|
|
1305
1370
|
}));
|
|
1306
1371
|
if (existing && (record.schema === 1 || record.layoutConversion)
|
|
1307
1372
|
&& ['install', 'start', 'restart', 'update', 'rebuild'].includes(args.operation)) {
|
|
@@ -1331,13 +1396,26 @@ async function executeServerCommand(args, effects) {
|
|
|
1331
1396
|
if (args.identityName) {
|
|
1332
1397
|
await installStage('Daemon startup', 'Start the daemon and restore its retained identities.', () => effects.serverLifecycle(record, 'start', ['daemon']));
|
|
1333
1398
|
const identity = await installStage('Human identity', 'Keep the existing Human identity, or create it on a fresh daemon.', () => effects.serverEnsureIdentity(record, args.identityName));
|
|
1334
|
-
|
|
1399
|
+
// Messenger runs as the person's own identity under the Human identity when Name and Surname are known.
|
|
1400
|
+
// An installation whose Messenger already runs as some other identity under it keeps that identity.
|
|
1401
|
+
const person = args.name && args.surname ? `${args.name} ${args.surname}` : undefined;
|
|
1402
|
+
const selected = existing ? record.messengerIdentity : undefined;
|
|
1403
|
+
if (person && (!selected || selected === identity.name || selected === person)) {
|
|
1404
|
+
const own = await installStage('Messenger identity', 'Keep or create your own identity under the Human identity; Messenger runs as it.', () => effects.serverEnsureMessengerIdentity(record, person));
|
|
1405
|
+
record.messengerIdentity = own.name;
|
|
1406
|
+
} else if (selected && selected !== identity.name
|
|
1407
|
+
&& (await effects.serverListIdentities(record)).some(row => row.name === selected && row.kind === 'role' && row.temp === null)) {
|
|
1408
|
+
record.messengerIdentity = selected;
|
|
1409
|
+
} else record.messengerIdentity = identity.name;
|
|
1335
1410
|
effects.writeJson(recordPath, JSON.stringify(record, null, 2) + '\n');
|
|
1336
1411
|
await installStage('Application startup', 'Start the selected applications and check readiness.', () => effects.serverLifecycle(record, 'start', record.services.filter(name => name !== 'daemon')));
|
|
1337
1412
|
} else {
|
|
1338
1413
|
await installStage('Service startup', 'Start the daemon and selected services, then check readiness.', () => effects.serverLifecycle(record, 'start'));
|
|
1339
1414
|
}
|
|
1415
|
+
if (record.gateway) await effects.verifyGateway(record);
|
|
1340
1416
|
if (showInstallProgress) effects.out(progress(installStageCount, installStageCount, 'Installation complete', 'The selected services are ready.'));
|
|
1417
|
+
} else if (args.operation === 'gateway-enable') {
|
|
1418
|
+
await enableGateway(record, args, effects);
|
|
1341
1419
|
} else if (['backup', 'restore', 'reset'].includes(args.operation)) {
|
|
1342
1420
|
await effects.serverMaintenance(record, args);
|
|
1343
1421
|
} else if (['update', 'rebuild'].includes(args.operation)) {
|
|
@@ -1385,22 +1463,21 @@ export async function runClientCommand(command, effects) {
|
|
|
1385
1463
|
configPath = managedPath;
|
|
1386
1464
|
}
|
|
1387
1465
|
let profile;
|
|
1388
|
-
if (configPath) profile =
|
|
1466
|
+
if (configPath) profile = validateGatewayClientProfile(effects.readProfile(configPath));
|
|
1389
1467
|
else if (!command.preset && effects.interactive) {
|
|
1390
|
-
const endpoint = await effects.askLine('Server HTTP endpoint: ', 'http://127.0.0.1:3050');
|
|
1468
|
+
const endpoint = await effects.askLine('Server HTTP or HTTPS endpoint: ', 'http://127.0.0.1:3050');
|
|
1391
1469
|
const credentialPath = await effects.askLine('Private issued-token file: ', '');
|
|
1392
1470
|
if (!credentialPath) throw new InstallUsageError('Client setup requires an issued-token file');
|
|
1393
1471
|
profile = await effects.discoverClientProfile(endpoint, credentialPath);
|
|
1394
1472
|
}
|
|
1395
1473
|
if (!profile) throw new InstallUsageError('client install requires a complete network profile; supply --config or use the interactive client setup');
|
|
1396
|
-
if (saved &&
|
|
1474
|
+
if (saved && saved.expectedInstanceId !== profile.expectedInstanceId)
|
|
1397
1475
|
throw new InstallUsageError('Managed client already selects another server; existing default was not changed');
|
|
1398
1476
|
for (const name of ['OURS_API_TOKEN', 'OURS_PORT', 'OURS_STATE_DIR', 'OURS_DAEMON_ID']) {
|
|
1399
1477
|
if (effects.env[name]?.trim()) throw new InstallUsageError(`${name} conflicts with client profile selection`);
|
|
1400
1478
|
}
|
|
1401
1479
|
effects.out(info(`Selected server ${profile.endpoint} (instance ${profile.expectedInstanceId}).`));
|
|
1402
1480
|
await effects.verifyHostProfile(configPath || profile);
|
|
1403
|
-
await effects.verifyPackagedMcp(configPath || profile);
|
|
1404
1481
|
const settings = saved?.installer ?? (configPath ? effects.readJson(configPath)?.installer : undefined);
|
|
1405
1482
|
const settingsBase = saved ? dirname(managedPath) : configPath ? dirname(configPath) : process.cwd();
|
|
1406
1483
|
let integrations = command.integrations ?? settings?.integrations;
|
|
@@ -1408,13 +1485,14 @@ export async function runClientCommand(command, effects) {
|
|
|
1408
1485
|
integrations = [];
|
|
1409
1486
|
for (const name of ['codex', 'claude-code', 'fleet']) if (await effects.ask(`Install ${name}?`, name !== 'fleet')) integrations.push(name);
|
|
1410
1487
|
}
|
|
1411
|
-
if (!Array.isArray(integrations) ||
|
|
1488
|
+
if (!Array.isArray(integrations) || integrations.some(name => !['codex', 'claude-code', 'fleet'].includes(name)) || new Set(integrations).size !== integrations.length) throw new InstallUsageError('installer.integrations must be an array of codex, claude-code and/or fleet; use an empty array for CLI only');
|
|
1412
1489
|
let fleetSettingsPath = command.preset ? command.fleetSettingsPath : settings?.fleetSettingsPath;
|
|
1413
|
-
|
|
1490
|
+
const disableFleetAgentsSetup = command.disableFleetAgentsSetup ?? settings?.disableFleetAgentsSetup ?? false;
|
|
1491
|
+
if (command.nonInteractive && !disableFleetAgentsSetup && integrations.includes('fleet') && !fleetSettingsPath) throw new InstallUsageError('Fleet in CLI mode requires --fleet-settings; no interactive wizard will be opened');
|
|
1414
1492
|
if (fleetSettingsPath !== undefined && (typeof fleetSettingsPath !== 'string' || !fleetSettingsPath))
|
|
1415
1493
|
throw new InstallUsageError('installer.fleetSettingsPath must be a non-empty path when supplied');
|
|
1416
1494
|
if (fleetSettingsPath) fleetSettingsPath = resolve(settingsBase, fleetSettingsPath);
|
|
1417
|
-
const selectedClients =
|
|
1495
|
+
const selectedClients = clientPackageNames(integrations);
|
|
1418
1496
|
let sourcesPath = settings?.sourcesPath;
|
|
1419
1497
|
let resolvedSources;
|
|
1420
1498
|
if (command.sourcePolicy) {
|
|
@@ -1427,30 +1505,38 @@ export async function runClientCommand(command, effects) {
|
|
|
1427
1505
|
resolvedSources = await effects.resolveSourcePolicy(policy, 'client', selectedClients);
|
|
1428
1506
|
}
|
|
1429
1507
|
effects.out(progress(0, 4, 'Client configuration', 'Prepare the selected integrations and private connection profile.'));
|
|
1430
|
-
|
|
1508
|
+
if (profile.serverUrl && integrations.includes('fleet')) await effects.qualifyGatewayClient({ profile, sourcesPath, sources: resolvedSources, integrations, refresh: !!command.preset });
|
|
1509
|
+
const imported = effects.importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, disableFleetAgentsSetup, refresh: !!command.preset });
|
|
1510
|
+
let phase = 'Validate saved server connection';
|
|
1431
1511
|
try {
|
|
1432
1512
|
await effects.verifyHostProfile(imported.configPath);
|
|
1433
1513
|
effects.out(progress(1, 4, 'Client packages', 'Acquire and verify the selected client package versions.'));
|
|
1514
|
+
phase = 'Acquire client packages';
|
|
1434
1515
|
const exactSuite = await effects.acquireClientPackages(imported.configPath, imported.settings.sourcesPath, integrations, { refresh: !!command.preset });
|
|
1435
1516
|
const args = { assumeYes: true, dryRun: false, channel: 'latest', clientIntegrations: integrations,
|
|
1436
|
-
acquiredFleet: exactSuite.fleetBin, fleetSettingsPath: imported.settings.fleetSettingsPath };
|
|
1517
|
+
acquiredFleet: exactSuite.fleetBin, fleetSettingsPath: imported.settings.fleetSettingsPath, disableFleetAgentsSetup: imported.settings.disableFleetAgentsSetup };
|
|
1437
1518
|
const target = { mode: 'host-profile', managed: true, configPath: imported.configPath, profile: imported.profile, endpoint: imported.profile.endpoint };
|
|
1438
1519
|
effects.out(progress(2, 4, 'Client integrations', 'Register the selected agent integrations.'));
|
|
1520
|
+
phase = 'Register client integrations';
|
|
1439
1521
|
const summary = await runHarnessPhase(args, effects, { target, isDefaultStateDir: false, exactSuite });
|
|
1440
1522
|
if (integrations.includes('fleet')) {
|
|
1441
1523
|
effects.out(progress(3, 4, 'Fleet configuration', 'Apply prepared settings or open the selected Fleet wizard.'));
|
|
1524
|
+
phase = 'Configure Fleet';
|
|
1442
1525
|
summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir: false }));
|
|
1443
1526
|
}
|
|
1444
1527
|
const incomplete = integrations.filter(name => !summary.some(row => row.key === name && row.state === 'installed'));
|
|
1445
1528
|
if (effects.env.OURS_CONFIG && resolve(effects.env.OURS_CONFIG) !== imported.configPath)
|
|
1446
1529
|
effects.out(warn('This shell has an explicit OURS_CONFIG override. Unset it for new clients to use the saved default; installer did not edit your shell.'));
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1530
|
+
if (incomplete.length) {
|
|
1531
|
+
effects.out(warn(`Client setup incomplete (${incomplete.join(', ')}). The selected client integrations need attention:`));
|
|
1532
|
+
for (const name of incomplete) explainClientFailure(effects, name, summary.find(row => row.key === name));
|
|
1533
|
+
clientRetry(effects, imported, integrations);
|
|
1534
|
+
} else effects.out(ok(`Client setup complete. New clients discover ${imported.configPath}; no OURS_CONFIG export is required.`));
|
|
1450
1535
|
if (!incomplete.length) effects.out(progress(4, 4, 'Client setup complete', 'All selected integrations are configured.'));
|
|
1451
1536
|
return incomplete.length ? EXIT_REFUSED : EXIT_OK;
|
|
1452
1537
|
} catch (error) {
|
|
1453
|
-
effects.out(warn(`Client setup incomplete: ${
|
|
1538
|
+
effects.out(warn(`Client setup incomplete: ${phase} failed — ${clientDiagnostic(error)}. Fix this error before retrying.`));
|
|
1539
|
+
clientRetry(effects, imported, integrations);
|
|
1454
1540
|
return EXIT_REFUSED;
|
|
1455
1541
|
}
|
|
1456
1542
|
}
|
package/lib/plan.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { validateContainerEngine } from './container-engine.mjs';
|
|
2
|
+
import { gatewayAddress } from './gateway.mjs';
|
|
1
3
|
// ours-install v3 — daemon creation and boot-service installation.
|
|
2
4
|
//
|
|
3
5
|
// Pure planning code, like lib/target.mjs: the orchestrator
|
|
@@ -97,7 +99,7 @@ export function launchdLabelForStateDir(stateDir) {
|
|
|
97
99
|
export function classifyUnit(text) {
|
|
98
100
|
if (text === null || text === undefined) return { kind: 'absent' };
|
|
99
101
|
const s = String(text);
|
|
100
|
-
if (s.startsWith(CLI_UNIT_MARKER)) return { kind: 'cli-managed' };
|
|
102
|
+
if ((s.startsWith(CLI_UNIT_MARKER) || s.startsWith('# Managed by @ours.network/daemon'))) return { kind: 'cli-managed' };
|
|
101
103
|
const looksLikeOursMcp = /ExecStart=.*\bours-mcp\b/.test(s)
|
|
102
104
|
|| /^Description=ours MCP daemon\b/m.test(s)
|
|
103
105
|
|| (/^Environment=OURS_STATE_DIR=/m.test(s) && /^Environment=OURS_TRANSPORT=http$/m.test(s));
|
|
@@ -138,7 +140,7 @@ export function planServiceInstall({ stateDir, home, readText, platform = 'linux
|
|
|
138
140
|
platform,
|
|
139
141
|
reason: 'no-service-manager',
|
|
140
142
|
message: `installing a boot service is not available on ${platform} — the ours CLI supports Linux user systemd and macOS launchd`,
|
|
141
|
-
manual: ['ours
|
|
143
|
+
manual: ['ours-daemon', 'serve', '--config'],
|
|
142
144
|
};
|
|
143
145
|
}
|
|
144
146
|
|
|
@@ -224,7 +226,7 @@ export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
|
|
|
224
226
|
// --json so the caller can read back whether the unit actually CHANGED rather
|
|
225
227
|
// than assuming it did. --force is reachable only through the explicit argument
|
|
226
228
|
// above, and the CLI refuses to overwrite a unit it did not write.
|
|
227
|
-
const cmd = ['ours
|
|
229
|
+
const cmd = ['ours-daemon', 'install-service', '--yes', '--json', '--state-dir', dir, '--config', join(dir, 'config.json')];
|
|
228
230
|
if (adoptLegacyUnit) cmd.push('--force');
|
|
229
231
|
return cmd;
|
|
230
232
|
}
|
|
@@ -265,20 +267,20 @@ export function planDaemonSteps(target, { cliVersionChanged = false, cliStartedI
|
|
|
265
267
|
const steps = [{ id: 'cli', label: 'install the ours-sdk CLI', command: ['npm', 'i', '-g', '@ours.network/cli'] }];
|
|
266
268
|
steps.push({ id: 'config', label: `write ${join(dir, 'config.json')}`, port: target.port });
|
|
267
269
|
if (target.action === 'create') {
|
|
268
|
-
steps.push({ id: 'start', label: `start the daemon on port ${target.port}`, command: ['ours
|
|
270
|
+
steps.push({ id: 'start', label: `start the daemon on port ${target.port}`, command: ['ours-daemon', 'start', '--config', join(dir, 'config.json')] });
|
|
269
271
|
} else if (cliVersionChanged) {
|
|
270
272
|
// `ours daemon stop` refuses to signal a daemon it did not start, so a
|
|
271
273
|
// daemon under another launcher is left running and the caller says which
|
|
272
274
|
// launcher must be restarted instead.
|
|
273
275
|
steps.push(cliStartedIt
|
|
274
|
-
? { id: 'restart', label: 'restart the daemon (package version changed)', command: ['ours
|
|
276
|
+
? { id: 'restart', label: 'restart the daemon (package version changed)', command: ['ours-daemon', 'restart', '--config', join(dir, 'config.json')] }
|
|
275
277
|
: { id: 'restart-external', label: 'daemon was not started by the CLI — restart it with its own launcher', command: null });
|
|
276
278
|
}
|
|
277
279
|
steps.push({ id: 'service', label: 'install the boot service', command: serviceInstallCommand({ stateDir: dir }) });
|
|
278
280
|
return steps;
|
|
279
281
|
}
|
|
280
282
|
|
|
281
|
-
export const SERVER_PACKAGES = ['sdk', 'cli', '
|
|
283
|
+
export const SERVER_PACKAGES = ['sdk', 'cli', 'daemon', 'tg-connector', 'cowork', 'messenger-server'].map(n => `@ours.network/${n}`);
|
|
282
284
|
export const SERVER_DEPENDENCIES = {
|
|
283
285
|
daemon: [], telegram: ['daemon'], cowork: ['daemon'], messenger: ['daemon'],
|
|
284
286
|
};
|
|
@@ -356,15 +358,21 @@ export function installationPaths(record) {
|
|
|
356
358
|
}
|
|
357
359
|
|
|
358
360
|
export function validateInstallation(record, root) {
|
|
361
|
+
if (record) validateContainerEngine(record);
|
|
359
362
|
if (!record || ![1, 2].includes(record.schema) || !['docker', 'packages'].includes(record.mode)
|
|
360
363
|
|| record.root !== root || record.configPath !== installationPaths(record).config
|
|
361
364
|
|| record.sourcesPath !== join(root, 'sources.json') || record.workDir !== join(root, 'runtime')
|
|
362
365
|
|| !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(record.instanceId ?? '')
|
|
363
366
|
|| !/^ours-[a-z0-9]+$/.test(record.project ?? '')
|
|
364
367
|
|| (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))) {
|
|
368
|
+
|| !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
369
|
throw new Error('Invalid or conflicting installation selection');
|
|
367
370
|
}
|
|
371
|
+
if (record.gateway !== undefined && (record.schema !== 2 || record.mode !== 'docker' || record.gateway?.version !== 1
|
|
372
|
+
|| Object.keys(record.gateway).some(key => !['version', 'serverUrl'].includes(key)) || record.services.at(-1) !== 'gateway')) {
|
|
373
|
+
throw new Error('Invalid gateway installation selection');
|
|
374
|
+
}
|
|
375
|
+
if (record.gateway) gatewayAddress(record);
|
|
368
376
|
if (record.layoutConversion !== undefined) {
|
|
369
377
|
// DEPRECATED (introduced in 2.0): legacy managed-layout conversion only.
|
|
370
378
|
// Removal target: 3.0, after supported installs convert and upgrade inputs
|
|
@@ -380,7 +388,8 @@ export function validateInstallation(record, root) {
|
|
|
380
388
|
throw new Error('Invalid layout conversion record');
|
|
381
389
|
}
|
|
382
390
|
validateInstallation(source, root);
|
|
383
|
-
if (
|
|
391
|
+
if (JSON.stringify(source.containerBinding) !== JSON.stringify(record.containerBinding)) throw new Error('Conflicting conversion backend');
|
|
392
|
+
if (['mode', 'containerEngine', 'instanceId', 'project', 'sourcesPath', 'workDir'].some(key => source[key] !== record[key])
|
|
384
393
|
|| JSON.stringify(source.services) !== JSON.stringify(record.services)
|
|
385
394
|
|| conversion.runningServices.some(service => !source.services.includes(service))) {
|
|
386
395
|
throw new Error('Conflicting layout conversion source');
|
|
@@ -402,7 +411,7 @@ export function validateInstallation(record, root) {
|
|
|
402
411
|
throw new Error('Invalid server build transition');
|
|
403
412
|
}
|
|
404
413
|
validateInstallation(candidate, candidate.root);
|
|
405
|
-
for (const key of ['schema', 'mode', 'instanceId', 'services', 'port', 'coworkPort', 'messengerPort', 'messengerIdentity', 'uid', 'gid']) {
|
|
414
|
+
for (const key of ['schema', 'mode', 'containerEngine', 'containerBinding', 'instanceId', 'services', 'gateway', 'port', 'coworkPort', 'messengerPort', 'messengerIdentity', 'uid', 'gid']) {
|
|
406
415
|
if (JSON.stringify(candidate[key]) !== JSON.stringify(record[key])) throw new Error('Conflicting server build candidate');
|
|
407
416
|
}
|
|
408
417
|
}
|
|
@@ -470,3 +479,7 @@ export function consumerServiceState(text, service, platform) {
|
|
|
470
479
|
}
|
|
471
480
|
return matches === 1 ? selected : undefined;
|
|
472
481
|
}
|
|
482
|
+
|
|
483
|
+
export function clientPackageNames(integrations) {
|
|
484
|
+
return [...new Set(['sdk', 'cli', ...(integrations.some(name => ['codex', 'claude-code'].includes(name)) ? ['mcp'] : []), ...integrations])];
|
|
485
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { runContainer } from './container-engine.mjs';
|
|
6
|
+
|
|
7
|
+
export const PROBE_IMAGE = 'docker.io/library/nginx:1.28-alpine@sha256:a8b39bd9cf0f83869a2162827a0caf6137ddf759d50a171451b335cecc87d236';
|
|
8
|
+
/** Behavioral checks, in disposable resources, before touching installation storage. */
|
|
9
|
+
export async function qualifyPodman(effects, record) {
|
|
10
|
+
const root = mkdtempSync(join(tmpdir(), 'ours-podman-preflight-'));
|
|
11
|
+
const project = `ours-probe-${randomUUID()}`;
|
|
12
|
+
const file = join(root, 'compose.json'), overlay = join(root, 'reset.yaml');
|
|
13
|
+
const common = { image: PROBE_IMAGE, entrypoint: ['/bin/sh', '-ec'], read_only: true, cap_drop: ['ALL'], security_opt: ['no-new-privileges:true'] };
|
|
14
|
+
writeFileSync(file, JSON.stringify({ services: {
|
|
15
|
+
prepare: { ...common, user: '0:0', network_mode: 'none', cap_add: ['CHOWN', 'DAC_OVERRIDE'],
|
|
16
|
+
command: ['mkdir -p /storage/state/daemon /storage/state/sibling; chmod 700 /storage/state/daemon; chown 1000:1000 /storage/state/daemon'],
|
|
17
|
+
volumes: [{ type: 'volume', source: 'data', target: '/storage', volume: { nocopy: true } }] },
|
|
18
|
+
probe: { ...common, user: '1000:1000', command: ['test ! -e /data/sibling; test "$(stat -c %a /data)" = 700; nslookup -type=A probe.; echo ready > /data/probe; sleep 120'],
|
|
19
|
+
healthcheck: { test: ['CMD-SHELL', 'test -s /data/probe'], interval: '1s', timeout: '2s', retries: 10 },
|
|
20
|
+
volumes: [{ type: 'volume', source: 'data', target: '/data', volume: { nocopy: true, subpath: 'state/daemon' } }] },
|
|
21
|
+
}, volumes: { data: {} } }), { mode: 0o600 });
|
|
22
|
+
writeFileSync(overlay, 'services:\n probe:\n ports: !reset []\n', { mode: 0o600 });
|
|
23
|
+
const compose = args => runContainer(effects, record, ['compose', '-p', project, '-f', file, '-f', overlay, ...args], { timeout: 180000 });
|
|
24
|
+
try {
|
|
25
|
+
await compose(['config', '--format', 'json']);
|
|
26
|
+
await compose(['run', '--rm', '--no-deps', '-T', 'prepare']);
|
|
27
|
+
await compose(['up', '-d', '--no-build', '--wait', 'probe']);
|
|
28
|
+
const result = await compose(['ps', '--format', 'json']);
|
|
29
|
+
const rows = result.stdout.trim().startsWith('[') ? JSON.parse(result.stdout) : result.stdout.trim().split('\n').map(s => JSON.parse(s));
|
|
30
|
+
if (!rows.some(r => r.Service === 'probe' && r.State === 'running')) throw new Error('Compose JSON status is incompatible');
|
|
31
|
+
await compose(['exec', '-T', 'probe', '/bin/sh', '-ec', 'test -s /data/probe; test ! -e /data/state']);
|
|
32
|
+
} catch (cause) {
|
|
33
|
+
let logs = '';
|
|
34
|
+
try { logs = (await compose(['logs', '--no-color', '--tail', '30'])).stdout; } catch {}
|
|
35
|
+
throw new Error(`Podman capability check failed (Compose !reset/wait/status, DNS, rootless UID, volume subpath): ${cause.message}\n${logs}`, { cause });
|
|
36
|
+
} finally {
|
|
37
|
+
try { await compose(['down', '--volumes', '--remove-orphans']); }
|
|
38
|
+
finally { rmSync(root, { recursive: true, force: true }); }
|
|
39
|
+
}
|
|
40
|
+
}
|