@ours.network/install 1.2.1-nightly.23 → 1.2.1-nightly.24
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 +14 -0
- package/assets/release.json +1 -1
- package/assets/sources.json +1 -1
- package/lib/effects.mjs +2 -2
- package/lib/orchestrate.mjs +11 -4
- package/lib/server-onboarding.mjs +30 -0
- package/lib/setup-options.mjs +65 -22
- package/lib/setup.mjs +15 -3
- package/lib/usage.mjs +13 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## Install first, connect the App afterwards
|
|
2
|
+
|
|
3
|
+
```sh
|
|
4
|
+
ours-install --username alex --name Alex --surname Taylor --disable-fleet-agents-setup
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
This permanent command contains no tunnel credentials or enrollment expiry. On a terminal it asks for the remaining installation choices, including Docker, Podman or native packages. The username is local and has no global uniqueness check. A fresh root is named `<username>@<hostname>`; the optional `--hostname` defaults to the machine name. Messenger stores Name and Surname through its supported profile API. Retried setup retains root identities and existing Messenger profiles.
|
|
8
|
+
|
|
9
|
+
`--disable-fleet-agents-setup` installs the Fleet CLI and saves the choice without opening its agent/model wizard or rewriting existing Fleet configuration. Configure agents in the App after device linking. Complete presets still work unattended; partial presets require a terminal and cannot use `OURS_ASSUME_YES`.
|
|
10
|
+
|
|
11
|
+
The separate `ours-fleet setup-tunnel --file /private/setup-payload` step requires the matching Fleet follow-up release. Its private App payload remains scoped, signed-root-bound and expiring. Then run `ours-fleet link-device` and paste its private single-use code into the App. Do not put the expiring tunnel payload in the permanent installer command.
|
|
12
|
+
|
|
13
|
+
Native packages currently provide server-only installation; full Fleet/App onboarding requires the supported gateway (Docker or Podman). The installer refuses unsupported native full-stack setup before changing anything. This PR does not add a native gateway or release unpublished Fleet commands.
|
|
14
|
+
|
|
1
15
|
See [one-connection setup, localhost:4050 and systemd migration](GATEWAY_SETUP.md) for the gateway-only client workflow.
|
|
2
16
|
|
|
3
17
|
# @ours.network/install — one installer for the whole stack
|
package/assets/release.json
CHANGED
package/assets/sources.json
CHANGED
package/lib/effects.mjs
CHANGED
|
@@ -1288,7 +1288,7 @@ export function networkEffects(effects) {
|
|
|
1288
1288
|
if (discovery.ok) return validateGatewayDiscovery(base, await discovery.json(), resolve(credentialPath));
|
|
1289
1289
|
throw new Error(`Gateway discovery answered HTTP ${discovery.status}; enable the gateway. Direct daemon fallback is not supported.`);
|
|
1290
1290
|
},
|
|
1291
|
-
importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh = false }) {
|
|
1291
|
+
importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, disableFleetAgentsSetup = false, refresh = false }) {
|
|
1292
1292
|
profile = validateGatewayClientProfile(profile);
|
|
1293
1293
|
const root = join(home, '.ours-client');
|
|
1294
1294
|
const configPath = join(root, 'profile.json');
|
|
@@ -1314,7 +1314,7 @@ export function networkEffects(effects) {
|
|
|
1314
1314
|
atomicWriteConfig(configPath, JSON.stringify(saved, null, 2) + '\n');
|
|
1315
1315
|
return { configPath, profile: validateGatewayClientProfile(saved), settings: current.installer };
|
|
1316
1316
|
}
|
|
1317
|
-
const settings = { sourcesPath: join(root, 'sources.json'), integrations };
|
|
1317
|
+
const settings = { sourcesPath: join(root, 'sources.json'), integrations, ...(disableFleetAgentsSetup ? { disableFleetAgentsSetup: true } : {}) };
|
|
1318
1318
|
if (fleetSettings) settings.fleetSettingsPath = join(root, 'fleet-settings.json');
|
|
1319
1319
|
// Publish the profile last: clients cannot select incomplete imported inputs.
|
|
1320
1320
|
atomicWriteConfig(settings.sourcesPath, sources);
|
package/lib/orchestrate.mjs
CHANGED
|
@@ -99,11 +99,12 @@ function clientDiagnostic(error) {
|
|
|
99
99
|
|
|
100
100
|
function clientRetry(effects, imported, integrations) {
|
|
101
101
|
effects.out(info(`Saved server connection: ${imported.configPath}.`));
|
|
102
|
-
if (integrations.includes('fleet') && !imported.settings.fleetSettingsPath) {
|
|
102
|
+
if (integrations.includes('fleet') && !imported.settings.disableFleetAgentsSetup && !imported.settings.fleetSettingsPath) {
|
|
103
103
|
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.'));
|
|
104
104
|
return;
|
|
105
105
|
}
|
|
106
106
|
const args = ['client', 'install', '--config', imported.configPath, '--integrations', integrations.join(',') || 'none', '--sources', imported.settings.sourcesPath];
|
|
107
|
+
if (imported.settings.disableFleetAgentsSetup) args.push('--disable-fleet-agents-setup');
|
|
107
108
|
if (imported.settings.fleetSettingsPath) args.push('--fleet-settings', imported.settings.fleetSettingsPath);
|
|
108
109
|
const command = `ours-install client install ${args.slice(2).map((value, i) => i % 2 === 0 ? value : shellQuote(value)).join(' ')}`;
|
|
109
110
|
effects.out(info(`Saved profile and settings retained; re-run ${command}`));
|
|
@@ -1011,6 +1012,11 @@ export async function runFleetPhase(args, effects, { target, isDefaultStateDir }
|
|
|
1011
1012
|
return { key: 'fleet', label: plan.label, state: 'skipped' };
|
|
1012
1013
|
}
|
|
1013
1014
|
const install = args.acquiredFleet ? { ok: true } : await attempt(effects, args.dryRun, plan.install.join(' '), () => effects.run(plan.install[0], plan.install.slice(1)));
|
|
1015
|
+
if (args.disableFleetAgentsSetup) {
|
|
1016
|
+
if (!install.ok) return { key: 'fleet', label: plan.label, state: 'failed', note: 'Fleet installation failed' };
|
|
1017
|
+
effects.out(ok('Fleet CLI installed; agent and model setup deferred to the App. Existing Fleet configuration was retained.'));
|
|
1018
|
+
return { key: 'fleet', label: plan.label, state: 'installed', note: 'agents setup deferred' };
|
|
1019
|
+
}
|
|
1014
1020
|
// Fleet owns its v2 manifest, roles, models and permissions. With no prepared
|
|
1015
1021
|
// settings, its wizard keeps the user's terminal while this invocation carries
|
|
1016
1022
|
// the selected daemon; --settings makes the same call strictly noninteractive.
|
|
@@ -1474,7 +1480,8 @@ export async function runClientCommand(command, effects) {
|
|
|
1474
1480
|
}
|
|
1475
1481
|
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');
|
|
1476
1482
|
let fleetSettingsPath = command.preset ? command.fleetSettingsPath : settings?.fleetSettingsPath;
|
|
1477
|
-
|
|
1483
|
+
const disableFleetAgentsSetup = command.disableFleetAgentsSetup ?? settings?.disableFleetAgentsSetup ?? false;
|
|
1484
|
+
if (command.nonInteractive && !disableFleetAgentsSetup && integrations.includes('fleet') && !fleetSettingsPath) throw new InstallUsageError('Fleet in CLI mode requires --fleet-settings; no interactive wizard will be opened');
|
|
1478
1485
|
if (fleetSettingsPath !== undefined && (typeof fleetSettingsPath !== 'string' || !fleetSettingsPath))
|
|
1479
1486
|
throw new InstallUsageError('installer.fleetSettingsPath must be a non-empty path when supplied');
|
|
1480
1487
|
if (fleetSettingsPath) fleetSettingsPath = resolve(settingsBase, fleetSettingsPath);
|
|
@@ -1492,7 +1499,7 @@ export async function runClientCommand(command, effects) {
|
|
|
1492
1499
|
}
|
|
1493
1500
|
effects.out(progress(0, 4, 'Client configuration', 'Prepare the selected integrations and private connection profile.'));
|
|
1494
1501
|
if (profile.serverUrl && integrations.includes('fleet')) await effects.qualifyGatewayClient({ profile, sourcesPath, sources: resolvedSources, integrations, refresh: !!command.preset });
|
|
1495
|
-
const imported = effects.importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh: !!command.preset });
|
|
1502
|
+
const imported = effects.importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, disableFleetAgentsSetup, refresh: !!command.preset });
|
|
1496
1503
|
let phase = 'Validate saved server connection';
|
|
1497
1504
|
try {
|
|
1498
1505
|
await effects.verifyHostProfile(imported.configPath);
|
|
@@ -1500,7 +1507,7 @@ export async function runClientCommand(command, effects) {
|
|
|
1500
1507
|
phase = 'Acquire client packages';
|
|
1501
1508
|
const exactSuite = await effects.acquireClientPackages(imported.configPath, imported.settings.sourcesPath, integrations, { refresh: !!command.preset });
|
|
1502
1509
|
const args = { assumeYes: true, dryRun: false, channel: 'latest', clientIntegrations: integrations,
|
|
1503
|
-
acquiredFleet: exactSuite.fleetBin, fleetSettingsPath: imported.settings.fleetSettingsPath };
|
|
1510
|
+
acquiredFleet: exactSuite.fleetBin, fleetSettingsPath: imported.settings.fleetSettingsPath, disableFleetAgentsSetup: imported.settings.disableFleetAgentsSetup };
|
|
1504
1511
|
const target = { mode: 'host-profile', managed: true, configPath: imported.configPath, profile: imported.profile, endpoint: imported.profile.endpoint };
|
|
1505
1512
|
effects.out(progress(2, 4, 'Client integrations', 'Register the selected agent integrations.'));
|
|
1506
1513
|
phase = 'Register client integrations';
|
|
@@ -115,6 +115,36 @@ try {
|
|
|
115
115
|
effects.out?.(`Human identity ${after.root.name} is ready.`);
|
|
116
116
|
return { name: after.root.name, cid: after.root.cid, created: true };
|
|
117
117
|
},
|
|
118
|
+
async serverEnsureHumanProfile(record, human) {
|
|
119
|
+
validateRecord(record);
|
|
120
|
+
for (const key of ['name', 'surname']) if (typeof human[key] !== 'string' || !human[key].trim() || human[key].length > 100 || /[\x00-\x1f\x7f]/.test(human[key])) throw new Error('Invalid Messenger human profile');
|
|
121
|
+
// Messenger owns the profile lifecycle. Never rename the daemon root or
|
|
122
|
+
// overwrite a profile already present on a retained installation.
|
|
123
|
+
const script = `
|
|
124
|
+
import { readFileSync } from 'node:fs';
|
|
125
|
+
const selection = JSON.parse(process.argv[1]);
|
|
126
|
+
const token = readFileSync(selection.credentialPath, 'utf8').trim();
|
|
127
|
+
const base = selection.base;
|
|
128
|
+
async function request(path, value) {
|
|
129
|
+
const response = await fetch(base + '/api/' + path, { method: value === undefined ? 'GET' : 'POST', redirect: 'error', signal: AbortSignal.timeout(15000), headers: { 'X-Ours-Api-Token': token, ...(value === undefined ? {} : { 'Content-Type': 'application/json', Origin: selection.origin, 'X-Ours-Messenger-CSRF': '1' }) }, ...(value === undefined ? {} : { body: JSON.stringify(value) }) });
|
|
130
|
+
if (!response.ok) { await response.body?.cancel(); throw new Error('Messenger human profile operation failed (HTTP ' + response.status + ')'); }
|
|
131
|
+
return response.json();
|
|
132
|
+
}
|
|
133
|
+
const identity = await request('identity');
|
|
134
|
+
if (identity.humanProfile) process.stdout.write('Retained Messenger Name and Surname.');
|
|
135
|
+
else { await request('identity/profile', selection.human); process.stdout.write('Messenger Name and Surname initialized.'); }
|
|
136
|
+
`;
|
|
137
|
+
const docker = record.mode === 'docker';
|
|
138
|
+
const selection = { base: `http://127.0.0.1:${docker ? 8420 : record.messengerPort}`,
|
|
139
|
+
origin: `http://127.0.0.1:${record.messengerPort}`,
|
|
140
|
+
credentialPath: docker ? '/credentials/messenger/daemon-token' : installationPaths(record).credentials.messenger,
|
|
141
|
+
human: { name: human.name, surname: human.surname } };
|
|
142
|
+
const args = ['--input-type=module', '-e', script, JSON.stringify(selection)];
|
|
143
|
+
const result = docker ? await compose(record, ['exec', '-T', 'messenger', 'node', ...args])
|
|
144
|
+
: await effects.run(process.execPath, args, { env: localEnv(record), sensitive: true });
|
|
145
|
+
if (result.code !== undefined && result.code !== 0) throw new Error('Messenger human profile initialization failed');
|
|
146
|
+
effects.out?.(result.stdout);
|
|
147
|
+
},
|
|
118
148
|
async prepareLocalClient(record, integrations, fleetSettingsPath) {
|
|
119
149
|
validateRecord(record);
|
|
120
150
|
if (!Array.isArray(integrations) || new Set(integrations).size !== integrations.length
|
package/lib/setup-options.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hostname } from 'node:os';
|
|
1
2
|
import { serverBase } from './gateway.mjs';
|
|
2
3
|
import { join, resolve, isAbsolute, dirname } from 'node:path';
|
|
3
4
|
|
|
@@ -5,10 +6,11 @@ const scopes = ['all', 'server', 'client'];
|
|
|
5
6
|
const integrations = ['codex', 'claude-code', 'fleet'];
|
|
6
7
|
const valueFlags = new Map(Object.entries({
|
|
7
8
|
'--container-engine': 'containerEngine', '--server-url': 'serverUrl', '--scope': 'scope', '--action': 'operation', '--mode': 'mode', '--state-dir': 'stateDir',
|
|
9
|
+
'--username': 'localUsername', '--name': 'name', '--surname': 'surname', '--hostname': 'hostname',
|
|
8
10
|
'--identity-name': 'identityName', '--integrations': 'integrations', '--fleet-settings': 'fleetSettingsPath',
|
|
9
11
|
'--config': 'config', '--sources': 'sources', '--migrate-from': 'migrateFrom', '--port': 'port', '--cowork-port': 'coworkPort', '--messenger-port': 'messengerPort',
|
|
10
12
|
}));
|
|
11
|
-
const boolFlags = new Map([['--compatible', 'compatible'], ['--dry-run', 'dryRun'], ['--migrate', 'migrate']]);
|
|
13
|
+
const boolFlags = new Map([['--compatible', 'compatible'], ['--dry-run', 'dryRun'], ['--migrate', 'migrate'], ['--disable-fleet-agents-setup', 'disableFleetAgentsSetup']]);
|
|
12
14
|
const allowed = new Set([...valueFlags.values(), ...boolFlags.values(), 'interactive', 'explicitPorts']);
|
|
13
15
|
const defaults = { port: 3050, coworkPort: 3052, messengerPort: 8420 };
|
|
14
16
|
const paths = ['stateDir', 'config', 'sources', 'fleetSettingsPath', 'migrateFrom'];
|
|
@@ -32,12 +34,28 @@ export function validateSetupOptions(input, { interactive = input?.interactive =
|
|
|
32
34
|
const client = options.scope !== 'server';
|
|
33
35
|
if (options.migrateFrom !== undefined && (!server || options.operation !== 'install')) throw new Error('--migrate-from requires server or all scope and operation install');
|
|
34
36
|
if (options.migrateFrom !== undefined && (!nonempty(options.migrateFrom) || !isAbsolute(options.migrateFrom))) throw new Error('--migrate-from requires an absolute daemon config path');
|
|
37
|
+
const suppliedHuman = ['localUsername', 'name', 'surname'].some(key => options[key] !== undefined);
|
|
38
|
+
if (suppliedHuman) {
|
|
39
|
+
for (const key of ['localUsername', 'name', 'surname']) if (!nonempty(options[key])) throw new Error(`Missing human profile option: ${key}`);
|
|
40
|
+
if (!/^[a-z0-9][a-z0-9-]{0,29}$/.test(options.localUsername)) throw new Error('Local username must contain 1–30 lowercase letters, digits or hyphens');
|
|
41
|
+
for (const key of ['name', 'surname']) {
|
|
42
|
+
if (options[key].trim().length > 100) throw new Error(`${key} must contain at most 100 characters`);
|
|
43
|
+
options[key] = options[key].trim();
|
|
44
|
+
}
|
|
45
|
+
options.hostname = options.hostname?.trim() || defaultHostname();
|
|
46
|
+
if (!/^[a-z0-9][a-z0-9-]{0,29}$/.test(options.hostname)) throw new Error('Hostname must contain 1–30 lowercase letters, digits or hyphens');
|
|
47
|
+
const identity = `${options.localUsername}@${options.hostname}`;
|
|
48
|
+
if (options.migrateFrom === undefined && options.identityName !== undefined && options.identityName !== identity) throw new Error('--identity-name conflicts with username and hostname');
|
|
49
|
+
if (options.migrateFrom === undefined) options.identityName = identity;
|
|
50
|
+
} else if (options.hostname !== undefined) throw new Error('--hostname requires username, name and surname');
|
|
51
|
+
if (options.disableFleetAgentsSetup !== undefined && typeof options.disableFleetAgentsSetup !== 'boolean') throw new Error('disableFleetAgentsSetup must be a boolean');
|
|
52
|
+
if (options.disableFleetAgentsSetup && options.fleetSettingsPath) throw new Error('--disable-fleet-agents-setup conflicts with --fleet-settings');
|
|
35
53
|
const missing = [];
|
|
36
54
|
if (server) for (const [key, flag] of [['mode', '--mode'], ['stateDir', '--state-dir'], ...(options.migrateFrom === undefined ? [['identityName', '--identity-name']] : [])]) if (!nonempty(options[key])) missing.push(flag);
|
|
37
55
|
if (client && options.integrations === undefined) missing.push('--integrations (use none to skip)');
|
|
38
56
|
if (!server && !nonempty(options.config)) missing.push('--config');
|
|
39
57
|
if (server && (options.operation === 'update' || options.migrateFrom !== undefined) && options.compatible !== true) missing.push('--compatible');
|
|
40
|
-
if (Array.isArray(options.integrations) && options.integrations.includes('fleet') && !interactive && !nonempty(options.fleetSettingsPath)) missing.push('--fleet-settings');
|
|
58
|
+
if (Array.isArray(options.integrations) && options.integrations.includes('fleet') && !interactive && !options.disableFleetAgentsSetup && !nonempty(options.fleetSettingsPath)) missing.push('--fleet-settings');
|
|
41
59
|
if (missing.length) throw new Error(`Missing required setup options: ${missing.join(', ')}`);
|
|
42
60
|
if (options.mode === 'native') options.mode = 'packages';
|
|
43
61
|
if (server && !['packages', 'docker'].includes(options.mode)) throw new Error('Mode must be packages (or native) or docker');
|
|
@@ -47,7 +65,7 @@ export function validateSetupOptions(input, { interactive = input?.interactive =
|
|
|
47
65
|
options.serverUrl = serverBase(options.serverUrl);
|
|
48
66
|
}
|
|
49
67
|
if (server && options.config !== undefined) throw new Error('--config is only valid for client scope');
|
|
50
|
-
if (!server) for (const key of ['mode', 'stateDir', 'identityName', ...Object.keys(defaults), 'compatible', 'migrate']) {
|
|
68
|
+
if (!server) for (const key of ['mode', 'stateDir', 'identityName', 'localUsername', 'name', 'surname', 'hostname', ...Object.keys(defaults), 'compatible', 'migrate']) {
|
|
51
69
|
if (options[key] !== undefined) throw new Error(`${key} is only valid for server or all scope`);
|
|
52
70
|
}
|
|
53
71
|
if (!client && (options.integrations !== undefined || options.fleetSettingsPath !== undefined)) throw new Error('--integrations and --fleet-settings require all or client scope');
|
|
@@ -124,27 +142,31 @@ export function parseSetupArgs(argv, { home, validate = true } = {}) {
|
|
|
124
142
|
return validate ? validateSetupOptions(expandPaths(options, home), { interactive: false }) : expandPaths(options, home);
|
|
125
143
|
}
|
|
126
144
|
|
|
127
|
-
export
|
|
145
|
+
export function defaultHostname() {
|
|
146
|
+
return hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^[^a-z0-9]+/, '').slice(0, 30) || 'workspace';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function collectSetupOptions(effects, presets = {}) {
|
|
128
150
|
if (effects.interactive !== true) throw new Error('Interactive setup requires a TTY; provide the complete CLI options instead');
|
|
129
|
-
const options = { interactive: true, explicitPorts: [] };
|
|
151
|
+
const options = { ...presets, interactive: true, explicitPorts: presets.explicitPorts ?? [] };
|
|
130
152
|
const scopeChoices = [
|
|
131
153
|
{ value: 'all', label: 'Everything on this computer' },
|
|
132
154
|
{ value: 'server', label: 'Server only' },
|
|
133
155
|
{ value: 'client', label: 'Connect this computer to an existing server' },
|
|
134
156
|
];
|
|
135
157
|
effects.out('Choose what this computer should do. Everything runs the server here and connects your selected apps; client-only uses a server you already have.');
|
|
136
|
-
options.scope
|
|
158
|
+
options.scope ??= await effects.select('What would you like to set up?', scopeChoices, 'all');
|
|
137
159
|
if (!scopes.includes(options.scope)) throw new Error('Scope must be all, server, or client');
|
|
138
160
|
let existing;
|
|
139
161
|
let pendingMigration;
|
|
140
162
|
if (options.scope !== 'client') {
|
|
141
163
|
const recommendedRoot = join(effects.home, '.ours-install');
|
|
142
164
|
effects.out(`Stores the server programs and data, including identities and messages. The recommended folder is ${recommendedRoot}; choose another only if you want to manage its location yourself.`);
|
|
143
|
-
const location = await effects.select('Where should server programs and data be stored?', [
|
|
165
|
+
const location = options.stateDir ? 'preset' : await effects.select('Where should server programs and data be stored?', [
|
|
144
166
|
{ value: 'recommended', label: `Use the recommended folder (${recommendedRoot})` },
|
|
145
167
|
{ value: 'custom', label: 'Choose another folder' },
|
|
146
168
|
], 'recommended');
|
|
147
|
-
options.stateDir
|
|
169
|
+
options.stateDir ??= location === 'recommended' ? recommendedRoot : await effects.askLine('Folder for server programs and data: ', recommendedRoot);
|
|
148
170
|
options.stateDir = expandPaths({ stateDir: options.stateDir }, effects.home).stateDir;
|
|
149
171
|
existing = effects.readJson(join(options.stateDir, 'installation.json'));
|
|
150
172
|
const migrationJournal = existing?.legacyMigrationSource ? effects.readJson(join(options.stateDir, 'legacy-migration.json')) : null;
|
|
@@ -156,7 +178,7 @@ export async function collectSetupOptions(effects) {
|
|
|
156
178
|
? 'This folder already contains an installation. Update selects this installer’s release while retaining state; repair reinstalls the retained package selection.'
|
|
157
179
|
: options.scope === 'client' ? 'Install connects your selected apps. Update refreshes their packages while keeping the server connection.'
|
|
158
180
|
: 'Install creates a managed installation in the selected folder. Update requires an installation already recorded there.');
|
|
159
|
-
options.operation
|
|
181
|
+
options.operation ??= !existing && options.scope !== 'client' ? 'install' : await effects.select('What should happen?', [
|
|
160
182
|
{ value: 'install', label: pendingMigration ? 'Resume the unfinished migration' : existing ? 'Repair this installation' : 'Install and configure' },
|
|
161
183
|
{ value: 'update', label: 'Update an existing installation' },
|
|
162
184
|
], existing && !pendingMigration ? 'update' : 'install');
|
|
@@ -166,7 +188,8 @@ export async function collectSetupOptions(effects) {
|
|
|
166
188
|
const legacyState = nonempty(legacy?.stateDir) ? legacy.stateDir
|
|
167
189
|
: legacy && legacy.stateDir === undefined && nonempty(effects.readJson(join(effects.home, '.ours', 'root.json'))?.name)
|
|
168
190
|
? join(effects.home, '.ours') : null;
|
|
169
|
-
if (
|
|
191
|
+
if (options.migrateFrom) { /* Explicit selection is validated below. */ }
|
|
192
|
+
else if (pendingMigration) options.migrateFrom = pendingMigration;
|
|
170
193
|
else if (legacyState) {
|
|
171
194
|
effects.out(`Found an existing ours installation at ${legacyState}. Upgrade it to keep its identities, messages and settings. The old server will stop; its original state is kept as a recovery copy.`);
|
|
172
195
|
if (await effects.ask('Upgrade this existing ours installation and keep its data?', true)) options.migrateFrom = defaultConfig;
|
|
@@ -190,41 +213,61 @@ export async function collectSetupOptions(effects) {
|
|
|
190
213
|
if (!nonempty(root?.name)) throw new Error('Cannot read the existing Human identity name; choose the configuration file for a complete existing installation');
|
|
191
214
|
options.identityName = root.name;
|
|
192
215
|
effects.out(`Your existing Human identity, ${root.name}, will be retained; no replacement identity will be created. Migration keeps the original state, but compatibility with an older release cannot be guaranteed automatically.`);
|
|
193
|
-
options.compatible
|
|
216
|
+
options.compatible ??= await effects.ask('Proceed with this release and keep the original state as a recovery copy?', false);
|
|
194
217
|
if (!options.compatible) throw new Error('Migration requires explicit compatibility confirmation (--compatible)');
|
|
195
218
|
}
|
|
196
219
|
}
|
|
197
220
|
if (options.scope !== 'client') {
|
|
198
221
|
const recommendation = recommendedMode({ ...effects.platform, arch: effects.platform?.arch ?? process.arch });
|
|
199
222
|
effects.out(`${recommendation.reason} Native runs directly on this computer; Docker runs in containers and needs Docker installed and running. An existing installation must keep its current mode.`);
|
|
200
|
-
options.mode
|
|
201
|
-
{ value: 'packages', label: 'Native packages' }, { value: 'docker', label: 'Docker' },
|
|
202
|
-
], existing?.mode ?? recommendation.mode);
|
|
223
|
+
const runtime = options.mode ?? await effects.select('How should the server run?', [
|
|
224
|
+
{ value: 'packages', label: 'Native packages' }, { value: 'docker', label: 'Docker' }, { value: 'podman', label: 'Podman' },
|
|
225
|
+
], existing?.mode === 'docker' && existing?.containerEngine === 'podman' ? 'podman' : existing?.mode ?? recommendation.mode);
|
|
226
|
+
options.mode = runtime === 'podman' ? 'docker' : runtime;
|
|
227
|
+
if (runtime === 'podman') options.containerEngine = 'podman';
|
|
203
228
|
if (!options.migrateFrom) {
|
|
204
|
-
|
|
205
|
-
|
|
229
|
+
if (existing?.messengerIdentity) {
|
|
230
|
+
effects.out(`Existing Human identity ${existing.messengerIdentity} and Messenger profile will be retained.`);
|
|
231
|
+
// Supplied profile inputs never rename a retained root.
|
|
232
|
+
if (!['localUsername', 'name', 'surname'].some(key => options[key] !== undefined)) options.identityName ??= existing.messengerIdentity;
|
|
233
|
+
} else if (options.identityName === undefined || options.localUsername !== undefined) {
|
|
234
|
+
effects.out('Choose a local username (no global uniqueness check). Name and Surname appear in Messenger; the optional host name identifies this workspace.');
|
|
235
|
+
options.localUsername ??= await effects.askLine('Local username: ', (effects.username?.() ?? 'me').toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 30));
|
|
236
|
+
options.name ??= await effects.askLine('Name: ', '');
|
|
237
|
+
options.surname ??= await effects.askLine('Surname: ', '');
|
|
238
|
+
options.hostname ??= await effects.askLine('Host / machine name (optional): ', '');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (['localUsername', 'name', 'surname'].some(key => options[key] !== undefined)) {
|
|
242
|
+
// Partial profile presets still collect missing fields on retained and
|
|
243
|
+
// migration paths; these inputs never replace their authoritative root.
|
|
244
|
+
effects.out('Name and Surname initialize a missing Messenger profile; an existing profile is retained.');
|
|
245
|
+
options.localUsername ??= await effects.askLine('Local username: ', (effects.username?.() ?? 'me').toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 30));
|
|
246
|
+
options.name ??= await effects.askLine('Name: ', '');
|
|
247
|
+
options.surname ??= await effects.askLine('Surname: ', '');
|
|
248
|
+
options.hostname ??= await effects.askLine('Host / machine name (optional): ', '');
|
|
206
249
|
}
|
|
207
|
-
for (const [key, fallback] of Object.entries(defaults)) options[key]
|
|
250
|
+
for (const [key, fallback] of Object.entries(defaults)) options[key] ??= existing?.[key] ?? fallback;
|
|
208
251
|
if (options.operation === 'update') {
|
|
209
252
|
effects.out('The update retains identities and messages and creates a recovery backup before changing stored state. Proceed only if you accept this release for your existing data; compatibility is not automatically guaranteed.');
|
|
210
|
-
options.compatible
|
|
253
|
+
options.compatible ??= await effects.ask('Proceed with this update and keep a recovery backup?', false);
|
|
211
254
|
}
|
|
212
255
|
} else {
|
|
213
256
|
const savedProfile = join(effects.home, '.ours-client', 'profile.json');
|
|
214
257
|
effects.out('A connection profile identifies the server and its private access credential. Reuse your saved connection or select a profile supplied by the server owner.');
|
|
215
|
-
const connection = await effects.select('Which server connection should this computer use?', [
|
|
258
|
+
const connection = options.config ? 'preset' : await effects.select('Which server connection should this computer use?', [
|
|
216
259
|
{ value: 'saved', label: 'Use the saved server connection' }, { value: 'file', label: 'Choose a connection profile file' },
|
|
217
260
|
], effects.readJson(savedProfile) ? 'saved' : 'file');
|
|
218
|
-
options.config
|
|
261
|
+
options.config ??= connection === 'saved' ? savedProfile : await effects.askLine('Connection profile file: ', savedProfile);
|
|
219
262
|
}
|
|
220
263
|
if (options.scope !== 'server') {
|
|
221
264
|
const detected = typeof effects.detectHarnesses === 'function' ? await effects.detectHarnesses() : [];
|
|
222
265
|
const selected = integrations.filter(name => name === 'fleet' || detected.some(item => item.name === name && item.status === 'ok'));
|
|
223
266
|
effects.out('Choose the apps to connect. Detected agent apps are selected by default; Fleet configures persistent agents but leaves them stopped. Use Space to toggle choices, then Enter to continue. You can select none.');
|
|
224
|
-
options.integrations
|
|
267
|
+
options.integrations ??= await effects.multiselect('Which integrations should be configured?', [
|
|
225
268
|
{ value: 'codex', label: 'Codex' }, { value: 'claude-code', label: 'Claude Code' }, { value: 'fleet', label: 'Fleet — persistent agents' },
|
|
226
269
|
], selected);
|
|
227
|
-
if (options.integrations.includes('fleet')) {
|
|
270
|
+
if (options.integrations.includes('fleet') && !options.disableFleetAgentsSetup && !options.fleetSettingsPath) {
|
|
228
271
|
effects.out('Fleet needs model and agent settings. Its guided setup asks for these later; a prepared settings file applies your existing choices. Fleet roles will not start automatically.');
|
|
229
272
|
const fleetMode = await effects.select('How should Fleet be configured?', [
|
|
230
273
|
{ value: 'wizard', label: 'Configure interactively with Fleet' }, { value: 'file', label: 'Use a prepared settings file' },
|
package/lib/setup.mjs
CHANGED
|
@@ -114,6 +114,9 @@ export async function executeSetupPlan(plan, effects, { server = runServerComman
|
|
|
114
114
|
const result = await server({ ...plan, role: 'server', operation: plan.operation, sourcePolicy: plan.sourcePolicy }, effects);
|
|
115
115
|
if (result !== 0) return result;
|
|
116
116
|
const record = validateInstallation(effects.readJson(join(plan.stateDir, 'installation.json')), plan.stateDir);
|
|
117
|
+
if (plan.name && plan.surname && plan.operation === 'install') {
|
|
118
|
+
await effects.serverEnsureHumanProfile(record, { name: plan.name, surname: plan.surname });
|
|
119
|
+
}
|
|
117
120
|
if (plan.operation === 'update') {
|
|
118
121
|
effects.out(progress(0, 1, 'Retained identities', 'Verify the Human identity after state restoration; existing names and keys are retained.'));
|
|
119
122
|
const running = await effects.serverLifecycle(record, 'status', ['daemon']);
|
|
@@ -132,11 +135,12 @@ export async function executeSetupPlan(plan, effects, { server = runServerComman
|
|
|
132
135
|
if (plan.scope !== 'server') {
|
|
133
136
|
const result = await client({ role: 'client', operation: 'install', config: clientConfig,
|
|
134
137
|
integrations: plan.integrations, fleetSettingsPath: plan.fleetSettingsPath, sourcePolicy: clientPolicy,
|
|
135
|
-
preset: true, nonInteractive: !plan.interactive }, effects);
|
|
138
|
+
disableFleetAgentsSetup: plan.disableFleetAgentsSetup, preset: true, nonInteractive: !plan.interactive }, effects);
|
|
136
139
|
if (result !== 0) return result;
|
|
137
140
|
}
|
|
141
|
+
if (plan.disableFleetAgentsSetup && plan.integrations?.includes('fleet')) effects.out(info('Fleet is installed. Run ours-fleet setup-tunnel with the private expiring App setup file, then ours-fleet link-device. Configure agents and models in the App after linking.'));
|
|
138
142
|
effects.out(ok(`Requested ${plan.operation} completed. Existing identities were retained.`));
|
|
139
|
-
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.'));
|
|
143
|
+
if (plan.integrations?.includes('fleet') && !plan.disableFleetAgentsSetup) effects.out(info('Fleet is configured but stopped. Review its settings, then run ours-fleet doctor, ours-fleet config and ours-fleet up.'));
|
|
140
144
|
return 0;
|
|
141
145
|
}
|
|
142
146
|
|
|
@@ -148,7 +152,15 @@ export async function runSetup(argv, effects) {
|
|
|
148
152
|
if (argv[0] === 'server' && maintenance.has(argv[1])) return await runServerCommand(parseNetworkArgs(argv), effects);
|
|
149
153
|
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.');
|
|
150
154
|
if (!argv.length) { effects.out(banner()); effects.out(heading('Interactive setup')); }
|
|
151
|
-
|
|
155
|
+
let options;
|
|
156
|
+
if (argv.length) {
|
|
157
|
+
const presets = parseSetupArgs(argv, { home: effects.home, validate: false });
|
|
158
|
+
try { options = validateSetupOptions(presets, { interactive: false }); }
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (!effects.interactive || effects.env?.OURS_ASSUME_YES || !/^Missing (required setup options|human profile option):/.test(error.message)) throw error;
|
|
161
|
+
options = await collectSetupOptions(effects, presets);
|
|
162
|
+
}
|
|
163
|
+
} else options = await collectSetupOptions(effects);
|
|
152
164
|
const plan = await prepareSetupPlan(options, effects);
|
|
153
165
|
return await executeSetupPlan(plan, effects);
|
|
154
166
|
} catch (error) {
|
package/lib/usage.mjs
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
// Public setup and maintenance commands.
|
|
2
|
-
export const USAGE = `ours-install — interactive setup
|
|
2
|
+
export const USAGE = `ours-install — interactive setup with optional CLI presets.
|
|
3
3
|
|
|
4
4
|
ours-install
|
|
5
5
|
Opens the console form. Choose all (server + clients), server, or client;
|
|
6
6
|
runtime, installation directory, identity, integrations and Fleet settings.
|
|
7
7
|
Docker provides the full-stack gateway; native mode is server-only. Windows uses WSL.
|
|
8
8
|
|
|
9
|
+
Install now, configure Fleet agents in the App after linking:
|
|
10
|
+
ours-install --username alex --name Alex --surname Taylor --disable-fleet-agents-setup
|
|
11
|
+
Remaining installation choices are prompted on a terminal. No enrollment credentials or expiry.
|
|
12
|
+
|
|
9
13
|
Full stack, with every required answer preset (no prompts):
|
|
10
14
|
ours-install --mode docker --state-dir /private/ours --identity-name "Your Name" --integrations codex,fleet --fleet-settings /private/fleet.json
|
|
11
15
|
|
|
@@ -24,9 +28,14 @@ export const USAGE = `ours-install — interactive setup or complete CLI presets
|
|
|
24
28
|
--mode docker|native packages is also accepted for native mode
|
|
25
29
|
--container-engine docker|podman server container backend (default Docker); retained for maintenance
|
|
26
30
|
--state-dir PATH installation root; required for all/server
|
|
31
|
+
--username USERNAME local username; no global uniqueness check
|
|
32
|
+
--name NAME Messenger given name
|
|
33
|
+
--surname SURNAME Messenger family name
|
|
34
|
+
--hostname NAME optional host/workspace name; defaults to this machine
|
|
35
|
+
--disable-fleet-agents-setup install Fleet CLI without its roles/model wizard
|
|
27
36
|
--identity-name NAME desired Human name for a fresh server; existing root is retained
|
|
28
37
|
--integrations LIST codex,claude-code,fleet; use none to skip clients explicitly
|
|
29
|
-
--fleet-settings PATH JSON settings for Fleet;
|
|
38
|
+
--fleet-settings PATH JSON settings for Fleet; required unless Fleet agents setup is disabled
|
|
30
39
|
--config PATH complete connection profile for client-only setup
|
|
31
40
|
--sources PATH explicit full development source policy override
|
|
32
41
|
--port N public gateway port for Docker (default 3050; e.g. 4050)
|
|
@@ -41,8 +50,8 @@ export const USAGE = `ours-install — interactive setup or complete CLI presets
|
|
|
41
50
|
--help, -h show help
|
|
42
51
|
--version, -V print installer version
|
|
43
52
|
|
|
44
|
-
CLI presets
|
|
45
|
-
|
|
53
|
+
Partial CLI presets on a terminal ask for the remaining choices. Without a terminal,
|
|
54
|
+
or with OURS_ASSUME_YES, supply complete CLI presets; missing answers fail before installation. Both input modes run the same
|
|
46
55
|
installer with preparation, identity restoration, update and readiness progress.
|
|
47
56
|
Fleet is configured but left stopped for operator review.
|
|
48
57
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/install",
|
|
3
|
-
"version": "1.2.1-nightly.
|
|
3
|
+
"version": "1.2.1-nightly.24",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The all-in-one ours.network installer: one shared daemon, MCP, cowork, Telegram, Fleet initialization, harness plugins, Human identity, progress UI, and guided next steps.",
|
|
6
6
|
"type": "module",
|