@ours.network/install 1.2.1-nightly.1 → 1.2.1-nightly.2
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 +58 -129
- package/assets/release.json +1 -1
- package/assets/sources.json +1 -1
- package/install.mjs +2 -2
- package/lib/build-transition.mjs +13 -7
- package/lib/effects.mjs +81 -25
- package/lib/fleet-settings.mjs +43 -0
- package/lib/orchestrate.mjs +64 -23
- package/lib/server-onboarding.mjs +113 -0
- package/lib/setup-options.mjs +157 -0
- package/lib/setup.mjs +145 -0
- package/lib/usage.mjs +47 -44
- package/package.json +1 -1
package/lib/orchestrate.mjs
CHANGED
|
@@ -1266,6 +1266,7 @@ async function executeServerCommand(args, effects) {
|
|
|
1266
1266
|
} else {
|
|
1267
1267
|
if (args.operation !== 'install' || !args.mode) throw new Error('First server install requires --mode');
|
|
1268
1268
|
record = effects.newInstallation(args.stateDir, args.mode);
|
|
1269
|
+
for (const key of ['port', 'coworkPort', 'messengerPort']) if (args[key] !== undefined) record[key] = args[key];
|
|
1269
1270
|
}
|
|
1270
1271
|
if (record.layoutConversion && !['install', 'start', 'stop', 'status'].includes(args.operation)) {
|
|
1271
1272
|
throw new Error('Layout conversion is incomplete; resume with server install or server start before changing state or authority');
|
|
@@ -1273,14 +1274,32 @@ async function executeServerCommand(args, effects) {
|
|
|
1273
1274
|
if (record.buildTransition && !['stop', 'status', record.buildTransition.operation].includes(args.operation)) {
|
|
1274
1275
|
throw new Error(`Server build activation is incomplete; repeat server ${record.buildTransition.operation} before other mutations`);
|
|
1275
1276
|
}
|
|
1277
|
+
const showInstallProgress = args.operation === 'install' && !(existing && (record.schema === 1 || record.layoutConversion));
|
|
1278
|
+
const installStageCount = (existing ? 7 : 9) + (args.identityName ? 2 : 0);
|
|
1279
|
+
let completedInstallStages = 0;
|
|
1280
|
+
const installStage = async (label, explanation, action) => {
|
|
1281
|
+
if (!showInstallProgress) return action();
|
|
1282
|
+
effects.out(progress(completedInstallStages, installStageCount, label, explanation));
|
|
1283
|
+
try {
|
|
1284
|
+
const result = await action();
|
|
1285
|
+
completedInstallStages += 1;
|
|
1286
|
+
effects.out(ok(`${label} complete`));
|
|
1287
|
+
return result;
|
|
1288
|
+
} catch (error) {
|
|
1289
|
+
effects.out(warn(`Server installation stopped during ${label.toLowerCase()}.`));
|
|
1290
|
+
throw error;
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1276
1293
|
if ((!existing || args.operation === 'update') && !record.buildTransition) {
|
|
1277
|
-
|
|
1278
|
-
|
|
1294
|
+
args.resolvedSources = await installStage('Package selection', 'Resolve the selected server packages before installation.', async () => {
|
|
1295
|
+
const policy = args.sourcePolicy ?? (args.sources ? effects.readJson(args.sources) : effects.packagedSourcePolicy());
|
|
1296
|
+
return effects.resolveSourcePolicy(policy, 'server');
|
|
1297
|
+
});
|
|
1279
1298
|
}
|
|
1280
1299
|
if (!existing && args.sources) record.sourcePolicyHash = effects.sourcePolicyHash(args.sources);
|
|
1281
|
-
await effects.serverPreflight(record, args.operation, {
|
|
1282
|
-
existing, sourcePath: args.sources ?? record.sourcesPath, sourceManifest: args.resolvedSources,
|
|
1283
|
-
});
|
|
1300
|
+
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, {
|
|
1301
|
+
existing, sourcePath: args.sources ?? record.sourcesPath, sourceManifest: args.resolvedSources, identityName: args.identityName,
|
|
1302
|
+
}));
|
|
1284
1303
|
if (existing && (record.schema === 1 || record.layoutConversion)
|
|
1285
1304
|
&& ['install', 'start', 'restart', 'update', 'rebuild'].includes(args.operation)) {
|
|
1286
1305
|
record = record.mode === 'docker'
|
|
@@ -1295,16 +1314,27 @@ async function executeServerCommand(args, effects) {
|
|
|
1295
1314
|
await effects.stopPendingConversion(record);
|
|
1296
1315
|
} else if (args.operation === 'install') {
|
|
1297
1316
|
if (!existing) {
|
|
1298
|
-
await
|
|
1299
|
-
|
|
1317
|
+
await installStage('Installation setup', 'Save the selected packages and installation settings.', async () => {
|
|
1318
|
+
await effects.initializeSelection(record, args.resolvedSources);
|
|
1319
|
+
effects.writeJson(recordPath, JSON.stringify(record, null, 2) + '\n');
|
|
1320
|
+
});
|
|
1300
1321
|
}
|
|
1301
|
-
await effects.prepareInstallation(record);
|
|
1322
|
+
await installStage('Runtime preparation', record.mode === 'docker' ? 'Prepare the Docker runtime. Downloads and builds may take several minutes.' : 'Download and prepare the native runtime packages. This may take several minutes.', () => effects.prepareInstallation(record));
|
|
1302
1323
|
// A repeated setup repairs delivery with the retained master and exact packages.
|
|
1303
|
-
await effects.serverLifecycle(record, 'stop');
|
|
1304
|
-
await effects.serverAccess(record, 'access-init', { migrate: !!args.migrate });
|
|
1305
|
-
await effects.serverAccess(record, 'access-issue');
|
|
1306
|
-
await effects.recordInstallationBuild(record);
|
|
1307
|
-
|
|
1324
|
+
await installStage('Service shutdown', 'Stop managed services before configuring access.', () => effects.serverLifecycle(record, 'stop'));
|
|
1325
|
+
await installStage('Credential initialization', 'Initialize or retain the installation credentials.', () => effects.serverAccess(record, 'access-init', { migrate: !!args.migrate }));
|
|
1326
|
+
await installStage('Credential delivery', 'Prepare access for the selected services.', () => effects.serverAccess(record, 'access-issue'));
|
|
1327
|
+
await installStage('Build verification', 'Record the installed runtime and selected package versions.', () => effects.recordInstallationBuild(record));
|
|
1328
|
+
if (args.identityName) {
|
|
1329
|
+
await installStage('Daemon startup', 'Start the daemon and restore its retained identities.', () => effects.serverLifecycle(record, 'start', ['daemon']));
|
|
1330
|
+
const identity = await installStage('Human identity', 'Keep the existing Human identity, or create it on a fresh daemon.', () => effects.serverEnsureIdentity(record, args.identityName));
|
|
1331
|
+
record.messengerIdentity = identity.name;
|
|
1332
|
+
effects.writeJson(recordPath, JSON.stringify(record, null, 2) + '\n');
|
|
1333
|
+
await installStage('Application startup', 'Start the selected applications and check readiness.', () => effects.serverLifecycle(record, 'start', record.services.filter(name => name !== 'daemon')));
|
|
1334
|
+
} else {
|
|
1335
|
+
await installStage('Service startup', 'Start the daemon and selected services, then check readiness.', () => effects.serverLifecycle(record, 'start'));
|
|
1336
|
+
}
|
|
1337
|
+
if (showInstallProgress) effects.out(progress(installStageCount, installStageCount, 'Installation complete', 'The selected services are ready.'));
|
|
1308
1338
|
} else if (['backup', 'restore', 'reset'].includes(args.operation)) {
|
|
1309
1339
|
await effects.serverMaintenance(record, args);
|
|
1310
1340
|
} else if (['update', 'rebuild'].includes(args.operation)) {
|
|
@@ -1341,18 +1371,18 @@ async function executeServerCommand(args, effects) {
|
|
|
1341
1371
|
return EXIT_OK;
|
|
1342
1372
|
}
|
|
1343
1373
|
|
|
1344
|
-
async function runClientCommand(command, effects) {
|
|
1374
|
+
export async function runClientCommand(command, effects) {
|
|
1345
1375
|
const managedPath = join(effects.home, '.ours-client', 'profile.json');
|
|
1346
1376
|
const saved = effects.readManagedClientProfile();
|
|
1347
1377
|
let configPath = command.config;
|
|
1348
1378
|
if (!configPath && saved) {
|
|
1349
|
-
if (effects.interactive && !await effects.ask(`Reuse saved client server ${saved.endpoint}?`, true))
|
|
1379
|
+
if (!command.preset && effects.interactive && !await effects.ask(`Reuse saved client server ${saved.endpoint}?`, true))
|
|
1350
1380
|
throw new InstallUsageError('Saved client default retained; use an explicit prepared profile to validate replacement input');
|
|
1351
1381
|
configPath = managedPath;
|
|
1352
1382
|
}
|
|
1353
1383
|
let profile;
|
|
1354
1384
|
if (configPath) profile = validateHostProfile(effects.readProfile(configPath));
|
|
1355
|
-
else if (effects.interactive) {
|
|
1385
|
+
else if (!command.preset && effects.interactive) {
|
|
1356
1386
|
const endpoint = await effects.askLine('Server HTTP endpoint: ', 'http://127.0.0.1:3050');
|
|
1357
1387
|
const credentialPath = await effects.askLine('Private issued-token file: ', '');
|
|
1358
1388
|
if (!credentialPath) throw new InstallUsageError('Client setup requires an issued-token file');
|
|
@@ -1369,40 +1399,51 @@ async function runClientCommand(command, effects) {
|
|
|
1369
1399
|
await effects.verifyPackagedMcp(configPath || profile);
|
|
1370
1400
|
const settings = saved?.installer ?? (configPath ? effects.readJson(configPath)?.installer : undefined);
|
|
1371
1401
|
const settingsBase = saved ? dirname(managedPath) : configPath ? dirname(configPath) : process.cwd();
|
|
1372
|
-
let integrations = settings?.integrations;
|
|
1373
|
-
if (!integrations && effects.interactive) {
|
|
1402
|
+
let integrations = command.integrations ?? settings?.integrations;
|
|
1403
|
+
if (!integrations && !command.preset && effects.interactive) {
|
|
1374
1404
|
integrations = [];
|
|
1375
1405
|
for (const name of ['codex', 'claude-code', 'fleet']) if (await effects.ask(`Install ${name}?`, name !== 'fleet')) integrations.push(name);
|
|
1376
1406
|
}
|
|
1377
1407
|
if (!Array.isArray(integrations) || !integrations.length || integrations.some(name => !['codex', 'claude-code', 'fleet'].includes(name)) || new Set(integrations).size !== integrations.length) throw new InstallUsageError('installer.integrations must select codex, claude-code and/or fleet');
|
|
1378
|
-
let fleetSettingsPath = settings?.fleetSettingsPath;
|
|
1408
|
+
let fleetSettingsPath = command.preset ? command.fleetSettingsPath : settings?.fleetSettingsPath;
|
|
1409
|
+
if (command.nonInteractive && integrations.includes('fleet') && !fleetSettingsPath) throw new InstallUsageError('Fleet in CLI mode requires --fleet-settings; no interactive wizard will be opened');
|
|
1379
1410
|
if (fleetSettingsPath !== undefined && (typeof fleetSettingsPath !== 'string' || !fleetSettingsPath))
|
|
1380
1411
|
throw new InstallUsageError('installer.fleetSettingsPath must be a non-empty path when supplied');
|
|
1381
1412
|
if (fleetSettingsPath) fleetSettingsPath = resolve(settingsBase, fleetSettingsPath);
|
|
1382
1413
|
const selectedClients = [...new Set(['sdk', ...(integrations.includes('fleet') ? ['cli'] : []), ...integrations])];
|
|
1383
1414
|
let sourcesPath = settings?.sourcesPath;
|
|
1384
1415
|
let resolvedSources;
|
|
1385
|
-
if (
|
|
1416
|
+
if (command.sourcePolicy) {
|
|
1417
|
+
resolvedSources = await effects.resolveSourcePolicy(command.sourcePolicy, 'client', selectedClients);
|
|
1418
|
+
sourcesPath = undefined;
|
|
1419
|
+
} else if (saved) sourcesPath = resolve(settingsBase, sourcesPath);
|
|
1386
1420
|
else {
|
|
1387
1421
|
if (sourcesPath) sourcesPath = resolve(settingsBase, sourcesPath);
|
|
1388
1422
|
const policy = sourcesPath ? effects.readJson(sourcesPath) : effects.packagedSourcePolicy();
|
|
1389
1423
|
resolvedSources = await effects.resolveSourcePolicy(policy, 'client', selectedClients);
|
|
1390
1424
|
}
|
|
1391
|
-
|
|
1425
|
+
effects.out(progress(0, 4, 'Client configuration', 'Prepare the selected integrations and private connection profile.'));
|
|
1426
|
+
const imported = effects.importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh: !!command.preset });
|
|
1392
1427
|
try {
|
|
1393
1428
|
await effects.verifyHostProfile(imported.configPath);
|
|
1394
|
-
|
|
1429
|
+
effects.out(progress(1, 4, 'Client packages', 'Acquire and verify the selected client package versions.'));
|
|
1430
|
+
const exactSuite = await effects.acquireClientPackages(imported.configPath, imported.settings.sourcesPath, integrations, { refresh: !!command.preset });
|
|
1395
1431
|
const args = { assumeYes: true, dryRun: false, channel: 'latest', clientIntegrations: integrations,
|
|
1396
1432
|
acquiredFleet: exactSuite.fleetBin, fleetSettingsPath: imported.settings.fleetSettingsPath };
|
|
1397
1433
|
const target = { mode: 'host-profile', managed: true, configPath: imported.configPath, profile: imported.profile, endpoint: imported.profile.endpoint };
|
|
1434
|
+
effects.out(progress(2, 4, 'Client integrations', 'Register the selected agent integrations.'));
|
|
1398
1435
|
const summary = await runHarnessPhase(args, effects, { target, isDefaultStateDir: false, exactSuite });
|
|
1399
|
-
if (integrations.includes('fleet'))
|
|
1436
|
+
if (integrations.includes('fleet')) {
|
|
1437
|
+
effects.out(progress(3, 4, 'Fleet configuration', 'Apply prepared settings or open the selected Fleet wizard.'));
|
|
1438
|
+
summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir: false }));
|
|
1439
|
+
}
|
|
1400
1440
|
const incomplete = integrations.filter(name => !summary.some(row => row.key === name && row.state === 'installed'));
|
|
1401
1441
|
if (effects.env.OURS_CONFIG && resolve(effects.env.OURS_CONFIG) !== imported.configPath)
|
|
1402
1442
|
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.'));
|
|
1403
1443
|
effects.out(incomplete.length
|
|
1404
1444
|
? warn(`Client setup incomplete (${incomplete.join(', ')}); saved profile and settings retained. Re-run ours-install client install.`)
|
|
1405
1445
|
: ok(`Client setup complete. New clients discover ${imported.configPath}; no OURS_CONFIG export is required.`));
|
|
1446
|
+
if (!incomplete.length) effects.out(progress(4, 4, 'Client setup complete', 'All selected integrations are configured.'));
|
|
1406
1447
|
return incomplete.length ? EXIT_REFUSED : EXIT_OK;
|
|
1407
1448
|
} catch (error) {
|
|
1408
1449
|
effects.out(warn(`Client setup incomplete: ${reason(error)}. Saved profile and settings retained; re-run ours-install client install.`));
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** Human bootstrap and local client handoff through the existing owner interfaces. */
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join, resolve, isAbsolute } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { installationPaths } from './plan.mjs';
|
|
6
|
+
import { validateHostProfile } from './target.mjs';
|
|
7
|
+
|
|
8
|
+
export function validateIdentityName(name) {
|
|
9
|
+
if (typeof name !== 'string' || [...name].length < 1 || [...name].length > 64 || name !== name.normalize('NFC')
|
|
10
|
+
|| /[\\/\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u.test(name)
|
|
11
|
+
|| ['.', '..', 'contact-book', 'root.json', 'bindings.json'].includes(name)) {
|
|
12
|
+
throw new Error('Invalid Human identity name; use 1–64 NFC characters without reserved names or path/control characters');
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function privatePath(path, directory = false) {
|
|
16
|
+
const stat = lstatSync(path);
|
|
17
|
+
if (!(directory ? stat.isDirectory() : stat.isFile()) || realpathSync(path) !== path
|
|
18
|
+
|| stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0 || (!directory && stat.nlink !== 1)) {
|
|
19
|
+
throw new Error('Onboarding requires an owned private ' + (directory ? 'directory' : 'regular credential file'));
|
|
20
|
+
}
|
|
21
|
+
return stat;
|
|
22
|
+
}
|
|
23
|
+
function validateRecord(record) {
|
|
24
|
+
if (!record || !['packages', 'docker'].includes(record.mode) || typeof record.root !== 'string'
|
|
25
|
+
|| !isAbsolute(record.root) || resolve(record.root) !== record.root
|
|
26
|
+
|| !Number.isInteger(record.port) || record.port < 1 || record.port > 65535) throw new Error('Invalid local server selection');
|
|
27
|
+
validateHostProfile({ endpoint: `http://127.0.0.1:${record.port}`, expectedInstanceId: record.instanceId, credentialPath: join(record.root, 'client', 'credential') });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createServerOnboarding(effects, { compose, localEnv, bin }) {
|
|
31
|
+
async function identityCommand(record, args) {
|
|
32
|
+
const result = record.mode === 'docker'
|
|
33
|
+
? await compose(record, ['exec', '-T', 'daemon', 'node', '/opt/ours/node_modules/@ours.network/cli/dist/cli.js', ...args, '--config', '/var/lib/ours/config.json', '--state-dir', '/var/lib/ours', '--json'])
|
|
34
|
+
: await effects.run(bin(record, 'ours'), [...args, '--config', record.configPath, '--state-dir', installationPaths(record).daemon, '--json'], { env: localEnv(record) });
|
|
35
|
+
if (result.code !== undefined && result.code !== 0) throw new Error('Owner identity operation failed');
|
|
36
|
+
try { return JSON.parse(result.stdout); }
|
|
37
|
+
catch { throw new Error('Owner identity operation returned malformed JSON'); }
|
|
38
|
+
}
|
|
39
|
+
async function identities(record) {
|
|
40
|
+
const rows = await identityCommand(record, ['identity', 'list']);
|
|
41
|
+
if (!Array.isArray(rows) || rows.some(row => !row || typeof row.name !== 'string'
|
|
42
|
+
|| (!['root', 'role'].includes(row.kind) && !['reconciling', 'awaiting-root', 'migration-failed', 'refresh-failed'].includes(row.status))
|
|
43
|
+
|| (row.kind && (typeof row.cid !== 'string' || !row.cid)))) throw new Error('Owner identity list is malformed');
|
|
44
|
+
const roots = rows.filter(row => row.kind === 'root');
|
|
45
|
+
if (roots.length > 1) throw new Error('Owner identity list contains multiple Human roots');
|
|
46
|
+
return { rows, root: roots[0] };
|
|
47
|
+
}
|
|
48
|
+
function retained({ rows, root }) {
|
|
49
|
+
effects.out?.(`Retained ${rows.length} existing ${rows.length === 1 ? 'identity' : 'identities'}; Human identity: ${root.name}.`);
|
|
50
|
+
return { name: root.name, cid: root.cid, created: false };
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
async serverEnsureIdentity(record, name) {
|
|
54
|
+
validateRecord(record);
|
|
55
|
+
validateIdentityName(name);
|
|
56
|
+
const prior = await identities(record);
|
|
57
|
+
if (prior.root) return retained(prior);
|
|
58
|
+
if (prior.rows.some(row => row.name === name)) throw new Error('Requested Human identity name already exists; no identity was changed');
|
|
59
|
+
effects.out?.(`Creating Human identity ${name}; retaining ${prior.rows.length} existing identities.`);
|
|
60
|
+
try {
|
|
61
|
+
await identityCommand(record, ['identity', 'create-root', '--name', name, '--skip-if-root-exists', 'true']);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
// ROOT_EXISTS is currently a CLI error. A concurrent creator is safe only
|
|
64
|
+
// when the authoritative list now contains a root; other errors stay errors.
|
|
65
|
+
const after = await identities(record);
|
|
66
|
+
if (after.root) return retained(after);
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
const after = await identities(record);
|
|
70
|
+
if (!after.root) throw new Error('Human identity creation completed without a visible root');
|
|
71
|
+
if (after.root.name !== name) return retained(after);
|
|
72
|
+
effects.out?.(`Human identity ${after.root.name} is ready.`);
|
|
73
|
+
return { name: after.root.name, cid: after.root.cid, created: true };
|
|
74
|
+
},
|
|
75
|
+
async prepareLocalClient(record, integrations, fleetSettingsPath) {
|
|
76
|
+
validateRecord(record);
|
|
77
|
+
if (!Array.isArray(integrations) || !integrations.length || new Set(integrations).size !== integrations.length
|
|
78
|
+
|| integrations.some(name => !['codex', 'claude-code', 'fleet'].includes(name))) throw new Error('Client integrations must select codex, claude-code and/or fleet');
|
|
79
|
+
if (fleetSettingsPath !== undefined) {
|
|
80
|
+
if (typeof fleetSettingsPath !== 'string' || !isAbsolute(fleetSettingsPath) || resolve(fleetSettingsPath) !== fleetSettingsPath) throw new Error('Fleet settings path must be absolute');
|
|
81
|
+
const settings = JSON.parse(readFileSync(fleetSettingsPath, 'utf8'));
|
|
82
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) throw new Error('Fleet settings must be a JSON object');
|
|
83
|
+
}
|
|
84
|
+
const endpoint = `http://127.0.0.1:${record.port}`;
|
|
85
|
+
const current = effects.readManagedClientProfile();
|
|
86
|
+
if (current && (current.endpoint !== endpoint || current.expectedInstanceId !== record.instanceId)) throw new Error('Managed client already selects another server; no credential was issued');
|
|
87
|
+
privatePath(record.root, true);
|
|
88
|
+
const root = join(record.root, 'client');
|
|
89
|
+
if (!existsSync(root)) mkdirSync(root, { mode: 0o700 });
|
|
90
|
+
privatePath(root, true);
|
|
91
|
+
const stage = mkdtempSync(join(root, '.pending-'));
|
|
92
|
+
const published = join(root, 'issued-' + randomUUID());
|
|
93
|
+
const credential = join(stage, 'credential');
|
|
94
|
+
try {
|
|
95
|
+
effects.out?.('Issuing a separate local client credential with the retained server authority.');
|
|
96
|
+
try { await effects.serverAccess(record, 'access-issue', { output: credential }); }
|
|
97
|
+
catch { throw new Error('Client credential issuance failed; existing profiles were retained'); }
|
|
98
|
+
const stat = privatePath(credential);
|
|
99
|
+
if (stat.size > 4096 || !readFileSync(credential, 'utf8').trim()) throw new Error('Issued client credential is empty or invalid');
|
|
100
|
+
const profile = {
|
|
101
|
+
...validateHostProfile({ endpoint, expectedInstanceId: record.instanceId, credentialPath: join(published, 'credential') }),
|
|
102
|
+
installer: { integrations: [...integrations], ...(fleetSettingsPath !== undefined ? { fleetSettingsPath } : {}) },
|
|
103
|
+
};
|
|
104
|
+
writeFileSync(join(stage, 'profile.json'), JSON.stringify(profile, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
105
|
+
// Publish the complete pair in one rename. Managed-default activation and
|
|
106
|
+
// package/source selection remain the normal client installer's job.
|
|
107
|
+
renameSync(stage, published);
|
|
108
|
+
effects.out?.('Local client profile is ready for authenticated client setup.');
|
|
109
|
+
return { configPath: join(published, 'profile.json'), profile };
|
|
110
|
+
} finally { rmSync(stage, { recursive: true, force: true }); }
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { join, resolve } from 'node:path';
|
|
2
|
+
|
|
3
|
+
const scopes = ['all', 'server', 'client'];
|
|
4
|
+
const integrations = ['codex', 'claude-code', 'fleet'];
|
|
5
|
+
const valueFlags = new Map(Object.entries({
|
|
6
|
+
'--scope': 'scope', '--action': 'operation', '--mode': 'mode', '--state-dir': 'stateDir',
|
|
7
|
+
'--identity-name': 'identityName', '--integrations': 'integrations', '--fleet-settings': 'fleetSettingsPath',
|
|
8
|
+
'--config': 'config', '--sources': 'sources', '--port': 'port', '--cowork-port': 'coworkPort', '--messenger-port': 'messengerPort',
|
|
9
|
+
}));
|
|
10
|
+
const boolFlags = new Map([['--compatible', 'compatible'], ['--dry-run', 'dryRun'], ['--migrate', 'migrate']]);
|
|
11
|
+
const allowed = new Set([...valueFlags.values(), ...boolFlags.values(), 'interactive', 'explicitPorts']);
|
|
12
|
+
const defaults = { port: 3050, coworkPort: 3052, messengerPort: 8420 };
|
|
13
|
+
const paths = ['stateDir', 'config', 'sources', 'fleetSettingsPath'];
|
|
14
|
+
const nonempty = value => typeof value === 'string' && value.trim().length > 0 && !/[\x00-\x1f\x7f]/.test(value);
|
|
15
|
+
|
|
16
|
+
export function recommendedMode({ platform, arch, release = '' } = {}) {
|
|
17
|
+
if (platform === 'linux' && /microsoft|wsl/i.test(release)) return { mode: 'docker', reason: 'Docker is recommended on Windows and WSL.' };
|
|
18
|
+
if (platform === 'linux' && arch === 'x64') return { mode: 'packages', reason: 'Native packages are recommended on Linux x64 with a systemd user manager.' };
|
|
19
|
+
if (platform === 'darwin') return { mode: 'docker', reason: 'Docker is recommended on macOS.' };
|
|
20
|
+
if (platform === 'win32') return { mode: 'docker', reason: 'Docker is recommended on Windows; run the installer inside WSL with Docker Desktop.' };
|
|
21
|
+
return { mode: 'docker', reason: 'Docker is recommended for this platform.' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function validateSetupOptions(input, { interactive = input?.interactive === true } = {}) {
|
|
25
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Setup options must be an object');
|
|
26
|
+
for (const key of Object.keys(input)) if (!allowed.has(key)) throw new Error(`Unknown setup option: ${key}`);
|
|
27
|
+
const options = { ...input, scope: input.scope ?? 'all', operation: input.operation ?? 'install', interactive };
|
|
28
|
+
if (!scopes.includes(options.scope)) throw new Error('Scope must be all, server, or client');
|
|
29
|
+
if (!['install', 'update'].includes(options.operation)) throw new Error('Operation must be install or update');
|
|
30
|
+
const server = options.scope !== 'client';
|
|
31
|
+
const client = options.scope !== 'server';
|
|
32
|
+
const missing = [];
|
|
33
|
+
if (server) for (const [key, flag] of [['mode', '--mode'], ['stateDir', '--state-dir'], ['identityName', '--identity-name']]) if (!nonempty(options[key])) missing.push(flag);
|
|
34
|
+
if (client && options.integrations === undefined) missing.push('--integrations (use none to skip)');
|
|
35
|
+
if (!server && !nonempty(options.config)) missing.push('--config');
|
|
36
|
+
if (server && options.operation === 'update' && options.compatible !== true) missing.push('--compatible');
|
|
37
|
+
if (Array.isArray(options.integrations) && options.integrations.includes('fleet') && !interactive && !nonempty(options.fleetSettingsPath)) missing.push('--fleet-settings');
|
|
38
|
+
if (missing.length) throw new Error(`Missing required setup options: ${missing.join(', ')}`);
|
|
39
|
+
if (options.mode === 'native') options.mode = 'packages';
|
|
40
|
+
if (server && !['packages', 'docker'].includes(options.mode)) throw new Error('Mode must be packages (or native) or docker');
|
|
41
|
+
if (server && options.config !== undefined) throw new Error('--config is only valid for client scope');
|
|
42
|
+
if (!server) for (const key of ['mode', 'stateDir', 'identityName', ...Object.keys(defaults), 'compatible', 'migrate']) {
|
|
43
|
+
if (options[key] !== undefined) throw new Error(`${key} is only valid for server or all scope`);
|
|
44
|
+
}
|
|
45
|
+
if (!client && (options.integrations !== undefined || options.fleetSettingsPath !== undefined)) throw new Error('--integrations and --fleet-settings require all or client scope');
|
|
46
|
+
if (options.integrations !== undefined) {
|
|
47
|
+
if (!Array.isArray(options.integrations) || options.integrations.some(name => !integrations.includes(name)) || new Set(options.integrations).size !== options.integrations.length) throw new Error('Integrations must be unique codex, claude-code, fleet, or none');
|
|
48
|
+
options.integrations = [...options.integrations];
|
|
49
|
+
}
|
|
50
|
+
if (options.fleetSettingsPath !== undefined && !options.integrations?.includes('fleet')) throw new Error('--fleet-settings requires the fleet integration');
|
|
51
|
+
for (const key of paths) if (options[key] !== undefined && !nonempty(options[key])) throw new Error(`Invalid path for ${key}`);
|
|
52
|
+
for (const key of ['compatible', 'migrate']) if (options[key] !== undefined && typeof options[key] !== 'boolean') throw new Error(`${key} must be a boolean`);
|
|
53
|
+
if (server) {
|
|
54
|
+
options.identityName = options.identityName.trim();
|
|
55
|
+
for (const [key, fallback] of Object.entries(defaults)) {
|
|
56
|
+
options[key] ??= fallback;
|
|
57
|
+
if (!Number.isInteger(options[key]) || options[key] < 1 || options[key] > 65535) throw new Error(`${key} must be an integer port between 1 and 65535`);
|
|
58
|
+
}
|
|
59
|
+
if (new Set(Object.keys(defaults).map(key => options[key])).size !== 3) throw new Error('Server ports must be distinct');
|
|
60
|
+
}
|
|
61
|
+
options.explicitPorts ??= [];
|
|
62
|
+
if (!Array.isArray(options.explicitPorts) || options.explicitPorts.some(key => !Object.hasOwn(defaults, key)) || new Set(options.explicitPorts).size !== options.explicitPorts.length || (!server && options.explicitPorts.length)) throw new Error('Invalid explicitPorts selection');
|
|
63
|
+
options.explicitPorts = [...options.explicitPorts];
|
|
64
|
+
return options;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function expandPaths(options, home) {
|
|
68
|
+
const output = { ...options };
|
|
69
|
+
for (const key of paths) if (nonempty(output[key])) {
|
|
70
|
+
const value = output[key];
|
|
71
|
+
if (value === '~' || value.startsWith('~/')) {
|
|
72
|
+
if (!nonempty(home)) throw new Error('Home directory is required to expand ~/ paths');
|
|
73
|
+
output[key] = resolve(home, value === '~' ? '' : value.slice(2));
|
|
74
|
+
} else output[key] = resolve(value);
|
|
75
|
+
}
|
|
76
|
+
return output;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parseSetupArgs(argv, { home } = {}) {
|
|
80
|
+
if (!Array.isArray(argv) || !argv.every(arg => typeof arg === 'string')) throw new Error('Arguments must be strings');
|
|
81
|
+
const options = { interactive: false, explicitPorts: [] };
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
const positional = [];
|
|
84
|
+
const put = (key, value) => {
|
|
85
|
+
if (seen.has(key)) throw new Error(`Duplicate or conflicting setup option: ${key}`);
|
|
86
|
+
seen.add(key); options[key] = value;
|
|
87
|
+
};
|
|
88
|
+
for (let i = 0; i < argv.length; i++) {
|
|
89
|
+
const arg = argv[i];
|
|
90
|
+
if (!arg.startsWith('-')) { positional.push(arg); continue; }
|
|
91
|
+
const equal = arg.indexOf('=');
|
|
92
|
+
const flag = equal < 0 ? arg : arg.slice(0, equal);
|
|
93
|
+
if (boolFlags.has(flag)) {
|
|
94
|
+
if (equal >= 0) throw new Error(`${flag} takes no value`);
|
|
95
|
+
put(boolFlags.get(flag), true); continue;
|
|
96
|
+
}
|
|
97
|
+
if (!valueFlags.has(flag)) throw new Error(`Unknown setup flag: ${flag}`);
|
|
98
|
+
const value = equal < 0 ? argv[++i] : arg.slice(equal + 1);
|
|
99
|
+
if (!nonempty(value) || value.startsWith('--')) throw new Error(`${flag} requires a value`);
|
|
100
|
+
const key = valueFlags.get(flag);
|
|
101
|
+
if (key === 'integrations') put(key, value === 'none' ? [] : value.split(',').map(item => item.trim()));
|
|
102
|
+
else if (Object.hasOwn(defaults, key)) {
|
|
103
|
+
if (!/^[1-9]\d*$/.test(value)) throw new Error(`${flag} requires an integer port`);
|
|
104
|
+
put(key, Number(value)); options.explicitPorts.push(key);
|
|
105
|
+
} else put(key, value);
|
|
106
|
+
}
|
|
107
|
+
if (positional.length && scopes.includes(positional[0])) put('scope', positional.shift());
|
|
108
|
+
if (positional.length && ['install', 'update'].includes(positional[0])) put('operation', positional.shift());
|
|
109
|
+
if (positional.length) throw new Error(`Unexpected setup argument: ${positional.join(' ')}`);
|
|
110
|
+
return validateSetupOptions(expandPaths(options, home), { interactive: false });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function collectSetupOptions(effects) {
|
|
114
|
+
if (effects.interactive !== true) throw new Error('Interactive setup requires a TTY; provide the complete CLI options instead');
|
|
115
|
+
const options = { interactive: true, explicitPorts: [] };
|
|
116
|
+
options.scope = (await effects.askLine('Set up all, server, or client? ', 'all')).trim();
|
|
117
|
+
if (!scopes.includes(options.scope)) throw new Error('Scope must be all, server, or client');
|
|
118
|
+
let existing;
|
|
119
|
+
if (options.scope !== 'client') {
|
|
120
|
+
options.stateDir = await effects.askLine('Private installation root: ', join(effects.home, '.ours-install'));
|
|
121
|
+
options.stateDir = expandPaths({ stateDir: options.stateDir }, effects.home).stateDir;
|
|
122
|
+
existing = effects.readJson(join(options.stateDir, 'installation.json'));
|
|
123
|
+
const legacyRoot = join(effects.home, '.ours');
|
|
124
|
+
const legacy = !existing ? effects.readJson(join(legacyRoot, 'config.json')) : null;
|
|
125
|
+
if (legacy?.stateDir === legacyRoot) throw new Error(`An existing global daemon was found at ${legacyRoot}, without a managed installation record for the selected root. This form cannot migrate that installation yet. Keep its state; use explicit CLI presets with a different empty directory only if you intend a separate installation.`);
|
|
126
|
+
}
|
|
127
|
+
options.operation = (await effects.askLine('Install or update? ', existing ? 'update' : 'install')).trim();
|
|
128
|
+
if (options.scope !== 'client') {
|
|
129
|
+
const recommendation = recommendedMode({ ...effects.platform, arch: effects.platform?.arch ?? process.arch });
|
|
130
|
+
effects.out(`Detected ${effects.platform?.platform ?? 'unknown'} / ${effects.platform?.arch ?? process.arch}. ${recommendation.reason}`);
|
|
131
|
+
const defaultMode = existing?.mode ?? recommendation.mode;
|
|
132
|
+
options.mode = await effects.askLine('Runtime mode (native or docker): ', defaultMode === 'packages' ? 'native' : defaultMode);
|
|
133
|
+
options.identityName = await effects.askLine('What name should others see? ', existing?.messengerIdentity ?? effects.username?.() ?? 'me');
|
|
134
|
+
for (const [key, fallback] of Object.entries(defaults)) {
|
|
135
|
+
const label = { port: 'Daemon', coworkPort: 'Cowork', messengerPort: 'Messenger' }[key];
|
|
136
|
+
const value = await effects.askLine(`${label} port: `, String(existing?.[key] ?? fallback));
|
|
137
|
+
if (!/^[1-9]\d*$/.test(value)) throw new Error(`${label} port must be an integer`);
|
|
138
|
+
options[key] = Number(value); options.explicitPorts.push(key);
|
|
139
|
+
}
|
|
140
|
+
if (options.operation === 'update') options.compatible = await effects.ask('Confirm that the selected update is compatible with retained state?', false);
|
|
141
|
+
} else options.config = await effects.askLine('Prepared client profile path: ', join(effects.home, '.ours-client', 'profile.json'));
|
|
142
|
+
if (options.scope !== 'server') {
|
|
143
|
+
const detected = typeof effects.detectHarnesses === 'function' ? await effects.detectHarnesses() : [];
|
|
144
|
+
options.integrations = [];
|
|
145
|
+
for (const name of integrations) if (await effects.ask(`Install ${name}?`, name === 'fleet' || detected.some(item => item.name === name && item.status === 'ok'))) options.integrations.push(name);
|
|
146
|
+
if (options.integrations.includes('fleet')) {
|
|
147
|
+
const path = await effects.askLine('Fleet settings file (leave empty for the interactive Fleet wizard): ', '');
|
|
148
|
+
if (path.trim()) options.fleetSettingsPath = path;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const sources = await effects.askLine('Source policy override (leave empty for the packaged release): ', '');
|
|
152
|
+
if (sources.trim()) options.sources = sources;
|
|
153
|
+
const validated = validateSetupOptions(expandPaths(options, effects.home), { interactive: true });
|
|
154
|
+
effects.out(`Setup: ${validated.operation} ${validated.scope}${validated.mode ? ` using ${validated.mode} in ${validated.stateDir}` : ` from ${validated.config}`}; integrations: ${validated.integrations?.join(', ') || 'none'}.`);
|
|
155
|
+
if (!await effects.ask('Continue with this setup?', false)) throw new Error('Setup cancelled; nothing was changed');
|
|
156
|
+
return validated;
|
|
157
|
+
}
|
package/lib/setup.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { parseSetupArgs, collectSetupOptions, validateSetupOptions } from './setup-options.mjs';
|
|
3
|
+
import { parseNetworkArgs, validateHostProfile, InstallUsageError } from './target.mjs';
|
|
4
|
+
import { validateInstallation } from './plan.mjs';
|
|
5
|
+
import { runServerCommand, runClientCommand } from './orchestrate.mjs';
|
|
6
|
+
import { banner, heading, info, ok, warn, progress } from './ui.mjs';
|
|
7
|
+
import { isCancel } from './prompt.mjs';
|
|
8
|
+
import { USAGE } from './usage.mjs';
|
|
9
|
+
import { validateIdentityName } from './server-onboarding.mjs';
|
|
10
|
+
import { validateFleetSettings } from './fleet-settings.mjs';
|
|
11
|
+
|
|
12
|
+
const maintenance = new Set(['status', 'start', 'stop', 'restart', 'rebuild', 'access-issue', 'access-replace', 'backup', 'restore', 'reset']);
|
|
13
|
+
const clientPackages = integrations => [...new Set(['sdk', ...(integrations.includes('fleet') ? ['cli'] : []), ...integrations])];
|
|
14
|
+
|
|
15
|
+
export function completeReleasePolicy(retained, supplied) {
|
|
16
|
+
if (retained?.release) {
|
|
17
|
+
return { release: retained.release, packages: Object.fromEntries(Object.entries(retained.release.packages).map(([name, entry]) => [name, { type: 'npm', version: entry.version }])) };
|
|
18
|
+
}
|
|
19
|
+
if (supplied) return supplied;
|
|
20
|
+
throw new InstallUsageError('This development installation needs its full --sources policy to configure clients; server-only selections cannot supply client packages');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readObject(effects, path, label) {
|
|
24
|
+
const value = effects.readJson(path);
|
|
25
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new InstallUsageError(`${label} must be a readable JSON object: ${path}`);
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Read-only validation of the complete plan, before any locks, installs or service changes. */
|
|
30
|
+
export async function prepareSetupPlan(options, effects) {
|
|
31
|
+
options = validateSetupOptions(options, { interactive: options.interactive });
|
|
32
|
+
if (effects.platform?.platform === 'win32') throw new InstallUsageError('Run ours-install inside WSL with Docker Desktop integration on Windows. Direct Windows Node installations are not supported.');
|
|
33
|
+
const plan = { ...options };
|
|
34
|
+
if (options.scope !== 'server' && options.integrations.length) {
|
|
35
|
+
for (const name of ['OURS_API_TOKEN', 'OURS_PORT', 'OURS_STATE_DIR', 'OURS_DAEMON_ID']) {
|
|
36
|
+
if (effects.env?.[name]?.trim()) throw new InstallUsageError(`${name} conflicts with the selected client profile. Clear this override before full-stack/client setup; nothing was changed.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (options.scope !== 'client') validateIdentityName(options.identityName);
|
|
40
|
+
if (options.fleetSettingsPath) validateFleetSettings(readObject(effects, options.fleetSettingsPath, 'Fleet settings'));
|
|
41
|
+
const policy = options.sources ? readObject(effects, options.sources, 'Source policy') : effects.packagedSourcePolicy();
|
|
42
|
+
plan.sourcePolicy = policy;
|
|
43
|
+
if (options.scope !== 'client') {
|
|
44
|
+
const value = effects.readJson(join(options.stateDir, 'installation.json'));
|
|
45
|
+
if (value) {
|
|
46
|
+
plan.existing = validateInstallation(value, options.stateDir);
|
|
47
|
+
if (options.mode !== plan.existing.mode) throw new InstallUsageError('The selected mode conflicts with the retained installation; choose its existing mode or a separate empty directory');
|
|
48
|
+
for (const key of ['port', 'coworkPort', 'messengerPort']) {
|
|
49
|
+
if (options.explicitPorts?.includes(key) && options[key] !== plan.existing[key]) throw new InstallUsageError(`${key} conflicts with the retained installation`);
|
|
50
|
+
plan[key] = plan.existing[key];
|
|
51
|
+
}
|
|
52
|
+
if (options.operation === 'install') {
|
|
53
|
+
const retained = readObject(effects, plan.existing.sourcesPath, 'Retained source policy');
|
|
54
|
+
plan.sourcePolicy = options.scope === 'server' || !options.integrations?.length ? retained : completeReleasePolicy(retained, options.sources ? policy : null);
|
|
55
|
+
}
|
|
56
|
+
} else if (options.operation === 'update') {
|
|
57
|
+
throw new InstallUsageError('Update requires an existing installation.json; choose install for a new installation');
|
|
58
|
+
}
|
|
59
|
+
// Reject a local client already attached to another server before changing the server.
|
|
60
|
+
const saved = options.scope === 'all' && options.integrations.length ? effects.readManagedClientProfile() : null;
|
|
61
|
+
if (saved && (!plan.existing || saved.expectedInstanceId !== plan.existing.instanceId || saved.endpoint !== `http://127.0.0.1:${plan.port}`)) {
|
|
62
|
+
throw new InstallUsageError('This user already has clients attached to a different server; their saved connection was not changed');
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
const profile = validateHostProfile(readObject(effects, options.config, 'Client profile'));
|
|
66
|
+
if (!profile) throw new InstallUsageError('Client profile must contain endpoint, expectedInstanceId and credentialPath');
|
|
67
|
+
if (!effects.readText(profile.credentialPath)?.trim()) throw new InstallUsageError('Client credential file is missing or empty');
|
|
68
|
+
plan.profile = profile;
|
|
69
|
+
const saved = effects.readManagedClientProfile();
|
|
70
|
+
if (saved && (saved.endpoint !== profile.endpoint || saved.expectedInstanceId !== profile.expectedInstanceId)) throw new InstallUsageError('Managed clients already select another server; existing connection was not changed');
|
|
71
|
+
if (saved && options.operation === 'install' && !options.sources) plan.sourcePolicy = readObject(effects, saved.installer.sourcesPath, 'Retained client source policy');
|
|
72
|
+
}
|
|
73
|
+
// Dry-run never spawns a resolver or acquires an installation lock.
|
|
74
|
+
if (!options.dryRun) {
|
|
75
|
+
if (options.scope === 'all' && options.operation === 'update' && options.integrations.length) {
|
|
76
|
+
const running = await effects.serverLifecycle(plan.existing, 'status', ['daemon']);
|
|
77
|
+
if (!running.includes('daemon')) throw new InstallUsageError('Full-stack update requires the selected daemon to be running for client verification. Start it first, or use server update to preserve its stopped state.');
|
|
78
|
+
}
|
|
79
|
+
if (options.scope !== 'client') await effects.resolveSourcePolicy(plan.sourcePolicy, 'server');
|
|
80
|
+
if (options.scope !== 'server' && options.integrations.length) await effects.resolveSourcePolicy(plan.sourcePolicy, 'client', clientPackages(options.integrations));
|
|
81
|
+
}
|
|
82
|
+
return plan;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function executeSetupPlan(plan, effects, { server = runServerCommand, client = runClientCommand } = {}) {
|
|
86
|
+
if (!plan.interactive) effects.out(banner());
|
|
87
|
+
effects.out(heading(`${plan.operation === 'update' ? 'Update' : 'Install'} ours.network`));
|
|
88
|
+
effects.out(info(`Scope: ${plan.scope}; ${plan.scope === 'client' ? `profile: ${plan.config}` : `mode: ${plan.mode === 'packages' ? 'native' : 'docker'}; directory: ${plan.stateDir}`}`));
|
|
89
|
+
if (plan.scope !== 'server') effects.out(info(`Client integrations: ${plan.integrations.join(', ') || 'none'}`));
|
|
90
|
+
if (plan.dryRun) {
|
|
91
|
+
effects.out(info('Preview only. No packages, identities, credentials or services will be changed.'));
|
|
92
|
+
if (plan.scope !== 'client') effects.out(info('Server: prerequisites → runtime preparation/update → retained identity/state restoration → readiness.'));
|
|
93
|
+
if (plan.scope !== 'server' && plan.integrations.length) effects.out(info('Clients: private connection → exact packages → selected integrations → Fleet settings when selected.'));
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
let clientConfig = plan.config;
|
|
97
|
+
let clientPolicy = plan.sourcePolicy;
|
|
98
|
+
if (plan.scope !== 'client') {
|
|
99
|
+
effects.out(heading(plan.operation === 'update' ? 'Server update and identity restoration' : 'Server installation'));
|
|
100
|
+
const result = await server({ ...plan, role: 'server', operation: plan.operation, sourcePolicy: plan.sourcePolicy }, effects);
|
|
101
|
+
if (result !== 0) return result;
|
|
102
|
+
const record = validateInstallation(effects.readJson(join(plan.stateDir, 'installation.json')), plan.stateDir);
|
|
103
|
+
if (plan.operation === 'update') {
|
|
104
|
+
effects.out(progress(0, 1, 'Retained identities', 'Verify the Human identity after state restoration; existing names and keys are retained.'));
|
|
105
|
+
const running = await effects.serverLifecycle(record, 'status', ['daemon']);
|
|
106
|
+
if (running.includes('daemon')) {
|
|
107
|
+
await effects.serverEnsureIdentity(record, plan.identityName);
|
|
108
|
+
effects.out(ok('Retained identities verified.'));
|
|
109
|
+
} else effects.out(info('Daemon remains stopped; stored identities are retained and will restore on the next start.'));
|
|
110
|
+
}
|
|
111
|
+
if (plan.scope === 'all' && plan.integrations.length) {
|
|
112
|
+
clientPolicy = completeReleasePolicy(readObject(effects, record.sourcesPath, 'Server source policy'), plan.sourcePolicy);
|
|
113
|
+
effects.out(heading('Connect local clients'));
|
|
114
|
+
const handoff = await effects.prepareLocalClient(record, plan.integrations, plan.fleetSettingsPath);
|
|
115
|
+
clientConfig = handoff.configPath;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (plan.scope !== 'server' && plan.integrations.length) {
|
|
119
|
+
const result = await client({ role: 'client', operation: 'install', config: clientConfig,
|
|
120
|
+
integrations: plan.integrations, fleetSettingsPath: plan.fleetSettingsPath, sourcePolicy: clientPolicy,
|
|
121
|
+
preset: true, nonInteractive: !plan.interactive }, effects);
|
|
122
|
+
if (result !== 0) return result;
|
|
123
|
+
}
|
|
124
|
+
effects.out(ok(`Requested ${plan.operation} completed. Existing identities were retained.`));
|
|
125
|
+
if (plan.integrations?.includes('fleet')) effects.out(info('Fleet is configured but stopped. Review its settings, then run ours-fleet doctor, ours-fleet config and ours-fleet up.'));
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The sole executable entry: manual answers and CLI presets share one plan/executor. */
|
|
130
|
+
export async function runSetup(argv, effects) {
|
|
131
|
+
try {
|
|
132
|
+
if (argv.includes('--help') || argv.includes('-h')) { effects.out(USAGE); return 0; }
|
|
133
|
+
if (argv.length === 1 && ['--version', '-V'].includes(argv[0])) { effects.out(effects.version ?? 'unknown'); return 0; }
|
|
134
|
+
if (argv[0] === 'server' && maintenance.has(argv[1])) return await runServerCommand(parseNetworkArgs(argv), effects);
|
|
135
|
+
if (!argv.length && effects.env?.OURS_ASSUME_YES) throw new InstallUsageError('OURS_ASSUME_YES cannot fill an interactive setup plan. Supply complete CLI presets for unattended installation.');
|
|
136
|
+
if (!argv.length) { effects.out(banner()); effects.out(heading('Interactive setup')); }
|
|
137
|
+
const options = argv.length ? parseSetupArgs(argv, { home: effects.home }) : await collectSetupOptions(effects);
|
|
138
|
+
const plan = await prepareSetupPlan(options, effects);
|
|
139
|
+
return await executeSetupPlan(plan, effects);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (isCancel(error)) { effects.out(warn('Installation cancelled.')); return 130; }
|
|
142
|
+
effects.out(warn(`ours-install: ${error.message}`));
|
|
143
|
+
return 2;
|
|
144
|
+
}
|
|
145
|
+
}
|