@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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gatewayAddress } from './gateway.mjs';
|
|
1
2
|
/** Human bootstrap and local client handoff through the existing owner interfaces. */
|
|
2
3
|
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
4
|
import { join, resolve, isAbsolute } from 'node:path';
|
|
@@ -28,16 +29,67 @@ function validateRecord(record) {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export function createServerOnboarding(effects, { compose, localEnv, bin }) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
// CLI client commands only accept gateway profiles in current releases. Use
|
|
33
|
+
// the installed SDK against the selected local daemon and its private file.
|
|
34
|
+
const identityScript = `
|
|
35
|
+
import { createRequire } from 'node:module';
|
|
36
|
+
import { pathToFileURL } from 'node:url';
|
|
37
|
+
import { randomUUID } from 'node:crypto';
|
|
38
|
+
const selection = JSON.parse(process.argv[1]);
|
|
39
|
+
const require = createRequire(selection.anchor);
|
|
40
|
+
const { attachOursClient } = await import(pathToFileURL(require.resolve('@ours.network/sdk/client')).href);
|
|
41
|
+
const client = await attachOursClient({ endpoint: selection.endpoint,
|
|
42
|
+
expectedInstanceId: selection.instanceId, credentialPath: selection.credentialPath,
|
|
43
|
+
sessionMode: 'external', leaseToken: randomUUID(), env: {} });
|
|
44
|
+
// The lease this short-lived client takes is released exactly once. An operation is not reported as done
|
|
45
|
+
// while that release is incomplete, and what failed first is what is reported.
|
|
46
|
+
let output, released = false;
|
|
47
|
+
try {
|
|
48
|
+
const result = selection.operation === 'list' ? await client.listIdentities()
|
|
49
|
+
: selection.operation === 'create-role' ? { created: await client.createIdentity({ name: selection.name, bio: '',
|
|
50
|
+
exposeLocal: true, localAutoAccept: true }), current: await client.currentIdentity() }
|
|
51
|
+
: selection.operation === 'describe-role' ? (await client.chooseIdentity({ name: selection.name, force: false }),
|
|
52
|
+
{ current: await client.currentIdentity() })
|
|
53
|
+
: await client.createRootIdentity({ name: selection.name, bio: '',
|
|
54
|
+
exposeLocal: true, localAutoAccept: true, skipIfRootExists: true });
|
|
55
|
+
released = true;
|
|
56
|
+
const release = await client.releaseLease();
|
|
57
|
+
output = release?.failed > 0 ? { onboardingError: 'LEASE_RELEASE_INCOMPLETE' } : result;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
output = { onboardingError: error.code || 'OWNER_OPERATION_FAILED' };
|
|
60
|
+
if (!released) try { await client.releaseLease(); } catch {}
|
|
61
|
+
} finally {
|
|
62
|
+
try { await client.close(); } catch {}
|
|
63
|
+
}
|
|
64
|
+
process.stdout.write(JSON.stringify(output));
|
|
65
|
+
`;
|
|
66
|
+
async function identityCommand(record, operation, name) {
|
|
67
|
+
validateRecord(record);
|
|
68
|
+
const docker = record.mode === 'docker';
|
|
69
|
+
const selection = {
|
|
70
|
+
operation, ...(name === undefined ? {} : { name }),
|
|
71
|
+
endpoint: `http://127.0.0.1:${docker ? 3050 : record.port}`,
|
|
72
|
+
instanceId: record.instanceId,
|
|
73
|
+
credentialPath: docker ? '/var/lib/ours/daemon-token' : join(installationPaths(record).daemon, 'daemon-token'),
|
|
74
|
+
anchor: docker ? '/opt/ours/package.json' : join(record.workDir, 'package.json'),
|
|
75
|
+
};
|
|
76
|
+
const args = ['--input-type=module', '-e', identityScript, JSON.stringify(selection)];
|
|
77
|
+
const result = docker
|
|
78
|
+
? await compose(record, ['exec', '-T', 'daemon', 'node', ...args])
|
|
79
|
+
: await effects.run(process.execPath, args, { env: localEnv(record), sensitive: true });
|
|
35
80
|
if (result.code !== undefined && result.code !== 0) throw new Error('Owner identity operation failed');
|
|
36
|
-
|
|
81
|
+
let value;
|
|
82
|
+
try { value = JSON.parse(result.stdout); }
|
|
37
83
|
catch { throw new Error('Owner identity operation returned malformed JSON'); }
|
|
84
|
+
if (value?.onboardingError) {
|
|
85
|
+
const error = new Error('Owner identity operation failed');
|
|
86
|
+
error.code = value.onboardingError;
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
38
90
|
}
|
|
39
91
|
async function identities(record) {
|
|
40
|
-
const rows = await identityCommand(record,
|
|
92
|
+
const rows = await identityCommand(record, 'list');
|
|
41
93
|
if (!Array.isArray(rows) || rows.some(row => !row || typeof row.name !== 'string'
|
|
42
94
|
|| (!['root', 'role'].includes(row.kind) && !['reconciling', 'awaiting-root', 'migration-failed', 'refresh-failed'].includes(row.status))
|
|
43
95
|
|| (row.kind && (typeof row.cid !== 'string' || !row.cid)))) throw new Error('Owner identity list is malformed');
|
|
@@ -59,10 +111,11 @@ export function createServerOnboarding(effects, { compose, localEnv, bin }) {
|
|
|
59
111
|
if (prior.rows.some(row => row.name === name)) throw new Error('Requested Human identity name already exists; no identity was changed');
|
|
60
112
|
effects.out?.(`Creating Human identity ${name}; retaining ${prior.rows.length} existing identities.`);
|
|
61
113
|
try {
|
|
62
|
-
await identityCommand(record,
|
|
114
|
+
await identityCommand(record, 'create-root', name);
|
|
63
115
|
} catch (error) {
|
|
64
|
-
//
|
|
116
|
+
// A concurrent creator is safe only
|
|
65
117
|
// when the authoritative list now contains a root; other errors stay errors.
|
|
118
|
+
if (error.code !== 'ROOT_EXISTS') throw error;
|
|
66
119
|
const after = await identities(record);
|
|
67
120
|
if (after.root) return retained(after);
|
|
68
121
|
throw error;
|
|
@@ -73,16 +126,98 @@ export function createServerOnboarding(effects, { compose, localEnv, bin }) {
|
|
|
73
126
|
effects.out?.(`Human identity ${after.root.name} is ready.`);
|
|
74
127
|
return { name: after.root.name, cid: after.root.cid, created: true };
|
|
75
128
|
},
|
|
129
|
+
/**
|
|
130
|
+
* The person's own identity under the Human root, which Messenger runs as. An identity of that name is
|
|
131
|
+
* used only when the daemon itself describes it as a permanent role of exactly the retained root;
|
|
132
|
+
* nothing else is renamed or adopted.
|
|
133
|
+
*/
|
|
134
|
+
async serverEnsureMessengerIdentity(record, name) {
|
|
135
|
+
validateRecord(record);
|
|
136
|
+
validateIdentityName(name);
|
|
137
|
+
const listed = row => row?.kind === 'role' && row.temp === null && typeof row.cid === 'string';
|
|
138
|
+
const taken = () => new Error('Requested Messenger identity name is already used by another identity; no identity was changed');
|
|
139
|
+
const prior = await identities(record);
|
|
140
|
+
if (!prior.root) throw new Error('Messenger identity requires the Human identity; no identity was changed');
|
|
141
|
+
// The list has no parent column. What the daemon describes for the identity, bound for a moment, names its root.
|
|
142
|
+
const under = (current, row) => current?.name === name && current.temporary === false && current.isRoot === false
|
|
143
|
+
&& current.described === true && typeof current.roleId === 'string' && current.roleId !== ''
|
|
144
|
+
&& typeof current.cid === 'string' && current.cid === row.cid
|
|
145
|
+
&& typeof current.rootCid === 'string' && current.rootCid.toLowerCase() === String(prior.root.cid).toLowerCase();
|
|
146
|
+
const adopt = async rows => {
|
|
147
|
+
const row = rows.rows.find(candidate => candidate.name === name);
|
|
148
|
+
if (rows.root?.cid !== prior.root.cid || !listed(row)) throw taken();
|
|
149
|
+
const { current } = await identityCommand(record, 'describe-role', name);
|
|
150
|
+
if (!under(current, row)) throw taken();
|
|
151
|
+
return row;
|
|
152
|
+
};
|
|
153
|
+
if (prior.rows.some(row => row.name === name)) {
|
|
154
|
+
const row = await adopt(prior);
|
|
155
|
+
effects.out?.(`Retained Messenger identity ${name}.`);
|
|
156
|
+
return { name, cid: row.cid, created: false };
|
|
157
|
+
}
|
|
158
|
+
effects.out?.(`Creating Messenger identity ${name} under ${prior.root.name}; retaining ${prior.rows.length} existing identities.`);
|
|
159
|
+
let result;
|
|
160
|
+
try {
|
|
161
|
+
result = await identityCommand(record, 'create-role', name);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
// Only a name collision can mean a concurrent creator; any other failure is reported as it is.
|
|
164
|
+
if (error.code !== 'NAME_TAKEN') throw error;
|
|
165
|
+
// That creator is accepted only when the authoritative list now shows the name under the same root.
|
|
166
|
+
const after = await identities(record);
|
|
167
|
+
if (!after.rows.some(row => row.name === name)) throw error;
|
|
168
|
+
const row = await adopt(after);
|
|
169
|
+
return { name, cid: row.cid, created: false };
|
|
170
|
+
}
|
|
171
|
+
const after = await identities(record);
|
|
172
|
+
const row = after.rows.find(candidate => candidate.name === name);
|
|
173
|
+
if (result?.created?.hierarchy !== 'role' || result.created.underRoot !== prior.root.name || after.root?.cid !== prior.root.cid
|
|
174
|
+
|| !listed(row) || result.created.info?.cid !== row.cid || !under(result.current, row))
|
|
175
|
+
throw new Error('Messenger identity creation completed without a usable identity under the Human identity');
|
|
176
|
+
effects.out?.(`Messenger identity ${name} is ready.`);
|
|
177
|
+
return { name, cid: row.cid, created: true };
|
|
178
|
+
},
|
|
179
|
+
async serverEnsureHumanProfile(record, human) {
|
|
180
|
+
validateRecord(record);
|
|
181
|
+
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');
|
|
182
|
+
// Messenger owns the profile lifecycle. Never rename the daemon root or
|
|
183
|
+
// overwrite a profile already present on a retained installation.
|
|
184
|
+
const script = `
|
|
185
|
+
import { readFileSync } from 'node:fs';
|
|
186
|
+
const selection = JSON.parse(process.argv[1]);
|
|
187
|
+
const token = readFileSync(selection.credentialPath, 'utf8').trim();
|
|
188
|
+
const base = selection.base;
|
|
189
|
+
async function request(path, value) {
|
|
190
|
+
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) }) });
|
|
191
|
+
if (!response.ok) { await response.body?.cancel(); throw new Error('Messenger human profile operation failed (HTTP ' + response.status + ')'); }
|
|
192
|
+
return response.json();
|
|
193
|
+
}
|
|
194
|
+
const identity = await request('identity');
|
|
195
|
+
if (identity.humanProfile) process.stdout.write('Retained Messenger Name and Surname.');
|
|
196
|
+
else { await request('identity/profile', selection.human); process.stdout.write('Messenger Name and Surname initialized.'); }
|
|
197
|
+
`;
|
|
198
|
+
const docker = record.mode === 'docker';
|
|
199
|
+
const selection = { base: `http://127.0.0.1:${docker ? 8420 : record.messengerPort}`,
|
|
200
|
+
// Behind the gateway Messenger accepts mutations only from the gateway origin.
|
|
201
|
+
origin: record.gateway ? gatewayAddress(record).origin : `http://127.0.0.1:${record.messengerPort}`,
|
|
202
|
+
credentialPath: docker ? '/credentials/messenger/daemon-token' : installationPaths(record).credentials.messenger,
|
|
203
|
+
human: { name: human.name, surname: human.surname } };
|
|
204
|
+
const args = ['--input-type=module', '-e', script, JSON.stringify(selection)];
|
|
205
|
+
const result = docker ? await compose(record, ['exec', '-T', 'messenger', 'node', ...args])
|
|
206
|
+
: await effects.run(process.execPath, args, { env: localEnv(record), sensitive: true });
|
|
207
|
+
if (result.code !== undefined && result.code !== 0) throw new Error('Messenger human profile initialization failed');
|
|
208
|
+
effects.out?.(result.stdout);
|
|
209
|
+
},
|
|
76
210
|
async prepareLocalClient(record, integrations, fleetSettingsPath) {
|
|
77
211
|
validateRecord(record);
|
|
78
|
-
if (!Array.isArray(integrations) ||
|
|
212
|
+
if (!Array.isArray(integrations) || new Set(integrations).size !== integrations.length
|
|
79
213
|
|| integrations.some(name => !['codex', 'claude-code', 'fleet'].includes(name))) throw new Error('Client integrations must select codex, claude-code and/or fleet');
|
|
80
214
|
if (fleetSettingsPath !== undefined) {
|
|
81
215
|
if (typeof fleetSettingsPath !== 'string' || !isAbsolute(fleetSettingsPath) || resolve(fleetSettingsPath) !== fleetSettingsPath) throw new Error('Fleet settings path must be absolute');
|
|
82
216
|
const settings = JSON.parse(readFileSync(fleetSettingsPath, 'utf8'));
|
|
83
217
|
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) throw new Error('Fleet settings must be a JSON object');
|
|
84
218
|
}
|
|
85
|
-
const
|
|
219
|
+
const serverUrl = record.gateway ? gatewayAddress(record).base : undefined;
|
|
220
|
+
const endpoint = serverUrl ? `${serverUrl}/daemon` : `http://127.0.0.1:${record.port}`;
|
|
86
221
|
const current = effects.readManagedClientProfile();
|
|
87
222
|
if (current && (current.endpoint !== endpoint || current.expectedInstanceId !== record.instanceId)) throw new Error('Managed client already selects another server; no credential was issued');
|
|
88
223
|
privatePath(record.root, true);
|
|
@@ -93,13 +228,16 @@ export function createServerOnboarding(effects, { compose, localEnv, bin }) {
|
|
|
93
228
|
const published = join(root, 'issued-' + randomUUID());
|
|
94
229
|
const credential = join(stage, 'credential');
|
|
95
230
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
231
|
+
if (!current) {
|
|
232
|
+
effects.out?.('Issuing a separate local client credential with the retained server authority.');
|
|
233
|
+
try { await effects.serverAccess(record, 'access-issue', { output: credential }); }
|
|
234
|
+
catch (cause) { throw new Error('Client credential issuance failed; existing profiles were retained', { cause }); }
|
|
235
|
+
}
|
|
236
|
+
const retainedCredential = current?.credentialPath ?? credential;
|
|
237
|
+
const stat = privatePath(retainedCredential);
|
|
238
|
+
if (stat.size > 4096 || !readFileSync(retainedCredential, 'utf8').trim()) throw new Error('Issued client credential is empty or invalid');
|
|
101
239
|
const profile = {
|
|
102
|
-
...validateHostProfile({ endpoint, expectedInstanceId: record.instanceId, credentialPath: join(published, 'credential') }),
|
|
240
|
+
...validateHostProfile({ ...(serverUrl ? { serverUrl } : {}), endpoint, expectedInstanceId: record.instanceId, credentialPath: current?.credentialPath ?? join(published, 'credential') }),
|
|
103
241
|
installer: { integrations: [...integrations], ...(fleetSettingsPath !== undefined ? { fleetSettingsPath } : {}) },
|
|
104
242
|
};
|
|
105
243
|
writeFileSync(join(stage, 'profile.json'), JSON.stringify(profile, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Fresh selections only; retained installations keep their recorded addresses. */
|
|
2
|
+
export function selectServerPorts(record, isTaken, explicitPorts = [], out = () => {}) {
|
|
3
|
+
const reserved = new Set([3051]);
|
|
4
|
+
for (const key of ['port', 'coworkPort', 'messengerPort']) {
|
|
5
|
+
const preferred = record[key];
|
|
6
|
+
let selected = preferred;
|
|
7
|
+
while (reserved.has(selected) || isTaken(selected)) {
|
|
8
|
+
if (explicitPorts.includes(key)) throw Error(`Requested ${key} ${preferred} is unavailable; select another explicit port`);
|
|
9
|
+
selected++;
|
|
10
|
+
if (selected > 65535) throw Error(`No free loopback port available for ${key}`);
|
|
11
|
+
}
|
|
12
|
+
reserved.add(selected);
|
|
13
|
+
record[key] = selected;
|
|
14
|
+
if (selected !== preferred) out(`${key}: ${preferred} is unavailable; selected loopback port ${selected}`);
|
|
15
|
+
}
|
|
16
|
+
return record;
|
|
17
|
+
}
|
package/lib/setup-options.mjs
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
import { hostname } from 'node:os';
|
|
2
|
+
import { serverBase } from './gateway.mjs';
|
|
1
3
|
import { join, resolve, isAbsolute, dirname } from 'node:path';
|
|
2
4
|
|
|
3
5
|
const scopes = ['all', 'server', 'client'];
|
|
4
6
|
const integrations = ['codex', 'claude-code', 'fleet'];
|
|
5
7
|
const valueFlags = new Map(Object.entries({
|
|
6
|
-
'--scope': 'scope', '--action': 'operation', '--mode': 'mode', '--state-dir': 'stateDir',
|
|
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',
|
|
7
10
|
'--identity-name': 'identityName', '--integrations': 'integrations', '--fleet-settings': 'fleetSettingsPath',
|
|
8
11
|
'--config': 'config', '--sources': 'sources', '--migrate-from': 'migrateFrom', '--port': 'port', '--cowork-port': 'coworkPort', '--messenger-port': 'messengerPort',
|
|
9
12
|
}));
|
|
10
|
-
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']]);
|
|
11
14
|
const allowed = new Set([...valueFlags.values(), ...boolFlags.values(), 'interactive', 'explicitPorts']);
|
|
12
15
|
const defaults = { port: 3050, coworkPort: 3052, messengerPort: 8420 };
|
|
13
16
|
const paths = ['stateDir', 'config', 'sources', 'fleetSettingsPath', 'migrateFrom'];
|
|
@@ -15,7 +18,7 @@ const nonempty = value => typeof value === 'string' && value.trim().length > 0 &
|
|
|
15
18
|
|
|
16
19
|
export function recommendedMode({ platform, arch, release = '' } = {}) {
|
|
17
20
|
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: '
|
|
21
|
+
if (platform === 'linux' && arch === 'x64') return { mode: 'docker', reason: 'Docker provides the integrated gateway for full-stack setup; native mode is server-only.' };
|
|
19
22
|
if (platform === 'darwin') return { mode: 'docker', reason: 'Docker is recommended on macOS.' };
|
|
20
23
|
if (platform === 'win32') return { mode: 'docker', reason: 'Docker is recommended on Windows; run the installer inside WSL with Docker Desktop.' };
|
|
21
24
|
return { mode: 'docker', reason: 'Docker is recommended for this platform.' };
|
|
@@ -31,17 +34,42 @@ export function validateSetupOptions(input, { interactive = input?.interactive =
|
|
|
31
34
|
const client = options.scope !== 'server';
|
|
32
35
|
if (options.migrateFrom !== undefined && (!server || options.operation !== 'install')) throw new Error('--migrate-from requires server or all scope and operation install');
|
|
33
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
|
+
// Name and Surname together name the person's own identity, which Messenger runs as.
|
|
46
|
+
const person = `${options.name} ${options.surname}`;
|
|
47
|
+
if ([...person].length > 64 || person !== person.normalize('NFC') || /[\\/\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u.test(person))
|
|
48
|
+
throw new Error('Name and Surname together must contain at most 64 NFC characters, including the space between them, without slashes or control characters');
|
|
49
|
+
options.hostname = options.hostname?.trim() || defaultHostname();
|
|
50
|
+
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');
|
|
51
|
+
const identity = `${options.localUsername}@${options.hostname}`;
|
|
52
|
+
if (options.migrateFrom === undefined && options.identityName !== undefined && options.identityName !== identity) throw new Error('--identity-name conflicts with username and hostname');
|
|
53
|
+
if (options.migrateFrom === undefined) options.identityName = identity;
|
|
54
|
+
} else if (options.hostname !== undefined) throw new Error('--hostname requires username, name and surname');
|
|
55
|
+
if (options.disableFleetAgentsSetup !== undefined && typeof options.disableFleetAgentsSetup !== 'boolean') throw new Error('disableFleetAgentsSetup must be a boolean');
|
|
56
|
+
if (options.disableFleetAgentsSetup && options.fleetSettingsPath) throw new Error('--disable-fleet-agents-setup conflicts with --fleet-settings');
|
|
34
57
|
const missing = [];
|
|
35
58
|
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);
|
|
36
59
|
if (client && options.integrations === undefined) missing.push('--integrations (use none to skip)');
|
|
37
60
|
if (!server && !nonempty(options.config)) missing.push('--config');
|
|
38
61
|
if (server && (options.operation === 'update' || options.migrateFrom !== undefined) && options.compatible !== true) missing.push('--compatible');
|
|
39
|
-
if (Array.isArray(options.integrations) && options.integrations.includes('fleet') && !interactive && !nonempty(options.fleetSettingsPath)) missing.push('--fleet-settings');
|
|
62
|
+
if (Array.isArray(options.integrations) && options.integrations.includes('fleet') && !interactive && !options.disableFleetAgentsSetup && !nonempty(options.fleetSettingsPath)) missing.push('--fleet-settings');
|
|
40
63
|
if (missing.length) throw new Error(`Missing required setup options: ${missing.join(', ')}`);
|
|
41
64
|
if (options.mode === 'native') options.mode = 'packages';
|
|
42
65
|
if (server && !['packages', 'docker'].includes(options.mode)) throw new Error('Mode must be packages (or native) or docker');
|
|
66
|
+
if (options.containerEngine !== undefined && (!server || options.mode !== 'docker' || !['docker', 'podman'].includes(options.containerEngine))) throw new Error('--container-engine docker|podman requires server container mode');
|
|
67
|
+
if (options.serverUrl !== undefined) {
|
|
68
|
+
if (!server || options.mode !== 'docker' || options.operation !== 'install') throw new Error('--server-url requires Docker server installation');
|
|
69
|
+
options.serverUrl = serverBase(options.serverUrl);
|
|
70
|
+
}
|
|
43
71
|
if (server && options.config !== undefined) throw new Error('--config is only valid for client scope');
|
|
44
|
-
if (!server) for (const key of ['mode', 'stateDir', 'identityName', ...Object.keys(defaults), 'compatible', 'migrate']) {
|
|
72
|
+
if (!server) for (const key of ['mode', 'stateDir', 'identityName', 'localUsername', 'name', 'surname', 'hostname', ...Object.keys(defaults), 'compatible', 'migrate']) {
|
|
45
73
|
if (options[key] !== undefined) throw new Error(`${key} is only valid for server or all scope`);
|
|
46
74
|
}
|
|
47
75
|
if (!client && (options.integrations !== undefined || options.fleetSettingsPath !== undefined)) throw new Error('--integrations and --fleet-settings require all or client scope');
|
|
@@ -84,7 +112,7 @@ function expandPaths(options, home) {
|
|
|
84
112
|
return output;
|
|
85
113
|
}
|
|
86
114
|
|
|
87
|
-
export function parseSetupArgs(argv, { home } = {}) {
|
|
115
|
+
export function parseSetupArgs(argv, { home, validate = true } = {}) {
|
|
88
116
|
if (!Array.isArray(argv) || !argv.every(arg => typeof arg === 'string')) throw new Error('Arguments must be strings');
|
|
89
117
|
const options = { interactive: false, explicitPorts: [] };
|
|
90
118
|
const seen = new Set();
|
|
@@ -115,30 +143,34 @@ export function parseSetupArgs(argv, { home } = {}) {
|
|
|
115
143
|
if (positional.length && scopes.includes(positional[0])) put('scope', positional.shift());
|
|
116
144
|
if (positional.length && ['install', 'update'].includes(positional[0])) put('operation', positional.shift());
|
|
117
145
|
if (positional.length) throw new Error(`Unexpected setup argument: ${positional.join(' ')}`);
|
|
118
|
-
return validateSetupOptions(expandPaths(options, home), { interactive: false });
|
|
146
|
+
return validate ? validateSetupOptions(expandPaths(options, home), { interactive: false }) : expandPaths(options, home);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function defaultHostname() {
|
|
150
|
+
return hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^[^a-z0-9]+/, '').slice(0, 30) || 'workspace';
|
|
119
151
|
}
|
|
120
152
|
|
|
121
|
-
export async function collectSetupOptions(effects) {
|
|
153
|
+
export async function collectSetupOptions(effects, presets = {}) {
|
|
122
154
|
if (effects.interactive !== true) throw new Error('Interactive setup requires a TTY; provide the complete CLI options instead');
|
|
123
|
-
const options = { interactive: true, explicitPorts: [] };
|
|
155
|
+
const options = { ...presets, interactive: true, explicitPorts: presets.explicitPorts ?? [] };
|
|
124
156
|
const scopeChoices = [
|
|
125
157
|
{ value: 'all', label: 'Everything on this computer' },
|
|
126
158
|
{ value: 'server', label: 'Server only' },
|
|
127
159
|
{ value: 'client', label: 'Connect this computer to an existing server' },
|
|
128
160
|
];
|
|
129
161
|
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.');
|
|
130
|
-
options.scope
|
|
162
|
+
options.scope ??= await effects.select('What would you like to set up?', scopeChoices, 'all');
|
|
131
163
|
if (!scopes.includes(options.scope)) throw new Error('Scope must be all, server, or client');
|
|
132
164
|
let existing;
|
|
133
165
|
let pendingMigration;
|
|
134
166
|
if (options.scope !== 'client') {
|
|
135
167
|
const recommendedRoot = join(effects.home, '.ours-install');
|
|
136
168
|
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.`);
|
|
137
|
-
const location = await effects.select('Where should server programs and data be stored?', [
|
|
169
|
+
const location = options.stateDir ? 'preset' : await effects.select('Where should server programs and data be stored?', [
|
|
138
170
|
{ value: 'recommended', label: `Use the recommended folder (${recommendedRoot})` },
|
|
139
171
|
{ value: 'custom', label: 'Choose another folder' },
|
|
140
172
|
], 'recommended');
|
|
141
|
-
options.stateDir
|
|
173
|
+
options.stateDir ??= location === 'recommended' ? recommendedRoot : await effects.askLine('Folder for server programs and data: ', recommendedRoot);
|
|
142
174
|
options.stateDir = expandPaths({ stateDir: options.stateDir }, effects.home).stateDir;
|
|
143
175
|
existing = effects.readJson(join(options.stateDir, 'installation.json'));
|
|
144
176
|
const migrationJournal = existing?.legacyMigrationSource ? effects.readJson(join(options.stateDir, 'legacy-migration.json')) : null;
|
|
@@ -150,7 +182,7 @@ export async function collectSetupOptions(effects) {
|
|
|
150
182
|
? 'This folder already contains an installation. Update selects this installer’s release while retaining state; repair reinstalls the retained package selection.'
|
|
151
183
|
: options.scope === 'client' ? 'Install connects your selected apps. Update refreshes their packages while keeping the server connection.'
|
|
152
184
|
: 'Install creates a managed installation in the selected folder. Update requires an installation already recorded there.');
|
|
153
|
-
options.operation
|
|
185
|
+
options.operation ??= !existing && options.scope !== 'client' ? 'install' : await effects.select('What should happen?', [
|
|
154
186
|
{ value: 'install', label: pendingMigration ? 'Resume the unfinished migration' : existing ? 'Repair this installation' : 'Install and configure' },
|
|
155
187
|
{ value: 'update', label: 'Update an existing installation' },
|
|
156
188
|
], existing && !pendingMigration ? 'update' : 'install');
|
|
@@ -160,7 +192,8 @@ export async function collectSetupOptions(effects) {
|
|
|
160
192
|
const legacyState = nonempty(legacy?.stateDir) ? legacy.stateDir
|
|
161
193
|
: legacy && legacy.stateDir === undefined && nonempty(effects.readJson(join(effects.home, '.ours', 'root.json'))?.name)
|
|
162
194
|
? join(effects.home, '.ours') : null;
|
|
163
|
-
if (
|
|
195
|
+
if (options.migrateFrom) { /* Explicit selection is validated below. */ }
|
|
196
|
+
else if (pendingMigration) options.migrateFrom = pendingMigration;
|
|
164
197
|
else if (legacyState) {
|
|
165
198
|
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.`);
|
|
166
199
|
if (await effects.ask('Upgrade this existing ours installation and keep its data?', true)) options.migrateFrom = defaultConfig;
|
|
@@ -184,41 +217,69 @@ export async function collectSetupOptions(effects) {
|
|
|
184
217
|
if (!nonempty(root?.name)) throw new Error('Cannot read the existing Human identity name; choose the configuration file for a complete existing installation');
|
|
185
218
|
options.identityName = root.name;
|
|
186
219
|
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.`);
|
|
187
|
-
options.compatible
|
|
220
|
+
options.compatible ??= await effects.ask('Proceed with this release and keep the original state as a recovery copy?', false);
|
|
188
221
|
if (!options.compatible) throw new Error('Migration requires explicit compatibility confirmation (--compatible)');
|
|
189
222
|
}
|
|
190
223
|
}
|
|
191
224
|
if (options.scope !== 'client') {
|
|
192
225
|
const recommendation = recommendedMode({ ...effects.platform, arch: effects.platform?.arch ?? process.arch });
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
226
|
+
// Native runs the server only: local clients need the container gateway. A retained
|
|
227
|
+
// native installation keeps its mode, so a full setup of it cannot finish.
|
|
228
|
+
// (An unfinished migration is resumed in its recorded mode and is validated with the plan.)
|
|
229
|
+
if (options.scope === 'all' && existing?.mode === 'packages' && !options.migrateFrom)
|
|
230
|
+
throw new Error('This installation runs natively, which supports the server only. Choose the server-only setup to maintain it (ours-install server); local clients need an installation that runs in Docker or Podman. Nothing was changed.');
|
|
231
|
+
const nativeOffered = options.scope === 'server' || existing?.mode === 'packages';
|
|
232
|
+
effects.out(nativeOffered
|
|
233
|
+
? `${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.`
|
|
234
|
+
: 'The server runs in containers. Docker and Podman both work; the one you choose must be installed and running. An existing installation must keep its current mode.');
|
|
235
|
+
const runtime = options.mode ?? await effects.select('How should the server run?', [
|
|
236
|
+
...(nativeOffered ? [{ value: 'packages', label: 'Native packages' }] : []), { value: 'docker', label: 'Docker' }, { value: 'podman', label: 'Podman' },
|
|
237
|
+
], existing?.mode === 'docker' && existing?.containerEngine === 'podman' ? 'podman' : existing?.mode ?? recommendation.mode);
|
|
238
|
+
options.mode = runtime === 'podman' ? 'docker' : runtime;
|
|
239
|
+
if (runtime === 'podman') options.containerEngine = 'podman';
|
|
197
240
|
if (!options.migrateFrom) {
|
|
198
|
-
|
|
199
|
-
|
|
241
|
+
if (existing?.messengerIdentity) {
|
|
242
|
+
effects.out(`Existing Human identity ${existing.messengerIdentity} and Messenger profile will be retained.`);
|
|
243
|
+
// Supplied profile inputs never rename a retained root.
|
|
244
|
+
if (!['localUsername', 'name', 'surname'].some(key => options[key] !== undefined)) options.identityName ??= existing.messengerIdentity;
|
|
245
|
+
} else if (options.identityName === undefined || options.localUsername !== undefined) {
|
|
246
|
+
effects.out('Choose a local username (no global uniqueness check). Name and Surname appear in Messenger; the optional host name identifies this workspace.');
|
|
247
|
+
options.localUsername ??= await effects.askLine('Local username: ', (effects.username?.() ?? 'me').toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 30));
|
|
248
|
+
options.name ??= await effects.askLine('Name: ', '');
|
|
249
|
+
options.surname ??= await effects.askLine('Surname: ', '');
|
|
250
|
+
options.hostname ??= await effects.askLine('Host / machine name (optional): ', '');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (['localUsername', 'name', 'surname'].some(key => options[key] !== undefined)) {
|
|
254
|
+
// Partial profile presets still collect missing fields on retained and
|
|
255
|
+
// migration paths; these inputs never replace their authoritative root.
|
|
256
|
+
effects.out('Name and Surname initialize a missing Messenger profile; an existing profile is retained.');
|
|
257
|
+
options.localUsername ??= await effects.askLine('Local username: ', (effects.username?.() ?? 'me').toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 30));
|
|
258
|
+
options.name ??= await effects.askLine('Name: ', '');
|
|
259
|
+
options.surname ??= await effects.askLine('Surname: ', '');
|
|
260
|
+
options.hostname ??= await effects.askLine('Host / machine name (optional): ', '');
|
|
200
261
|
}
|
|
201
|
-
for (const [key, fallback] of Object.entries(defaults)) options[key]
|
|
262
|
+
for (const [key, fallback] of Object.entries(defaults)) options[key] ??= existing?.[key] ?? fallback;
|
|
202
263
|
if (options.operation === 'update') {
|
|
203
264
|
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.');
|
|
204
|
-
options.compatible
|
|
265
|
+
options.compatible ??= await effects.ask('Proceed with this update and keep a recovery backup?', false);
|
|
205
266
|
}
|
|
206
267
|
} else {
|
|
207
268
|
const savedProfile = join(effects.home, '.ours-client', 'profile.json');
|
|
208
269
|
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.');
|
|
209
|
-
const connection = await effects.select('Which server connection should this computer use?', [
|
|
270
|
+
const connection = options.config ? 'preset' : await effects.select('Which server connection should this computer use?', [
|
|
210
271
|
{ value: 'saved', label: 'Use the saved server connection' }, { value: 'file', label: 'Choose a connection profile file' },
|
|
211
272
|
], effects.readJson(savedProfile) ? 'saved' : 'file');
|
|
212
|
-
options.config
|
|
273
|
+
options.config ??= connection === 'saved' ? savedProfile : await effects.askLine('Connection profile file: ', savedProfile);
|
|
213
274
|
}
|
|
214
275
|
if (options.scope !== 'server') {
|
|
215
276
|
const detected = typeof effects.detectHarnesses === 'function' ? await effects.detectHarnesses() : [];
|
|
216
277
|
const selected = integrations.filter(name => name === 'fleet' || detected.some(item => item.name === name && item.status === 'ok'));
|
|
217
278
|
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.');
|
|
218
|
-
options.integrations
|
|
279
|
+
options.integrations ??= await effects.multiselect('Which integrations should be configured?', [
|
|
219
280
|
{ value: 'codex', label: 'Codex' }, { value: 'claude-code', label: 'Claude Code' }, { value: 'fleet', label: 'Fleet — persistent agents' },
|
|
220
281
|
], selected);
|
|
221
|
-
if (options.integrations.includes('fleet')) {
|
|
282
|
+
if (options.integrations.includes('fleet') && !options.disableFleetAgentsSetup && !options.fleetSettingsPath) {
|
|
222
283
|
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.');
|
|
223
284
|
const fleetMode = await effects.select('How should Fleet be configured?', [
|
|
224
285
|
{ value: 'wizard', label: 'Configure interactively with Fleet' }, { value: 'file', label: 'Use a prepared settings file' },
|