@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.
Files changed (45) hide show
  1. package/GATEWAY_SETUP.md +178 -0
  2. package/README.md +320 -9
  3. package/ROOTLESS_PODMAN_VALIDATION.md +210 -0
  4. package/assets/Dockerfile +17 -13
  5. package/assets/Dockerfile.gateway +5 -0
  6. package/assets/docker-compose.yaml +1 -1
  7. package/assets/fleet-settings.example.json +48 -0
  8. package/assets/gateway-entrypoint.sh +37 -0
  9. package/assets/release-lock.json +1122 -4471
  10. package/assets/release.json +33 -19
  11. package/assets/scripts/build/build-common.mjs +1 -1
  12. package/assets/scripts/build/build-sdk.mjs +6 -1
  13. package/assets/scripts/maintenance/build-context.mjs +1 -1
  14. package/assets/scripts/maintenance/daemon-owner.mjs +15 -0
  15. package/assets/scripts/maintenance/docker-layout-conversion.mjs +7 -3
  16. package/assets/scripts/maintenance/release-graph.mjs +18 -1
  17. package/assets/scripts/maintenance/state-operation.mjs +4 -2
  18. package/assets/scripts/runtime/client-setup.mjs +23 -5
  19. package/assets/scripts/runtime/diagnostics.mjs +72 -0
  20. package/assets/scripts/runtime/entrypoint.sh +1 -1
  21. package/assets/sources.json +46 -28
  22. package/install.mjs +7 -2
  23. package/lib/build-transition.mjs +1 -0
  24. package/lib/client-cli.mjs +127 -0
  25. package/lib/container-engine.mjs +127 -0
  26. package/lib/diagnostics.mjs +1 -0
  27. package/lib/docker-conversion-runtime.mjs +3 -2
  28. package/lib/docker-layout-installation.mjs +2 -1
  29. package/lib/docker-runtime-repair.mjs +160 -0
  30. package/lib/effects.mjs +278 -130
  31. package/lib/gateway-transition.mjs +87 -0
  32. package/lib/gateway.mjs +142 -0
  33. package/lib/legacy-migration.mjs +13 -2
  34. package/lib/orchestrate.mjs +129 -43
  35. package/lib/plan.mjs +22 -9
  36. package/lib/podman-preflight.mjs +40 -0
  37. package/lib/server-onboarding.mjs +154 -16
  38. package/lib/server-ports.mjs +17 -0
  39. package/lib/setup-options.mjs +88 -27
  40. package/lib/setup.mjs +35 -18
  41. package/lib/target.mjs +24 -8
  42. package/lib/uninstall.mjs +2 -2
  43. package/lib/usage.mjs +19 -6
  44. package/lib/workspace-setup.mjs +83 -0
  45. 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
- 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) });
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
- try { return JSON.parse(result.stdout); }
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, ['identity', 'list']);
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, ['identity', 'create-root', '--name', name, '--skip-if-root-exists', 'true']);
114
+ await identityCommand(record, 'create-root', name);
63
115
  } catch (error) {
64
- // ROOT_EXISTS is currently a CLI error. A concurrent creator is safe only
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) || !integrations.length || new Set(integrations).size !== integrations.length
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 endpoint = `http://127.0.0.1:${record.port}`;
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
- effects.out?.('Issuing a separate local client credential with the retained server authority.');
97
- try { await effects.serverAccess(record, 'access-issue', { output: credential }); }
98
- catch { throw new Error('Client credential issuance failed; existing profiles were retained'); }
99
- const stat = privatePath(credential);
100
- if (stat.size > 4096 || !readFileSync(credential, 'utf8').trim()) throw new Error('Issued client credential is empty or invalid');
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
+ }
@@ -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: 'packages', reason: 'Native packages are recommended on Linux x64 with a systemd user manager.' };
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 = await effects.select('What would you like to set up?', scopeChoices, 'all');
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 = location === 'recommended' ? recommendedRoot : await effects.askLine('Folder for server programs and data: ', recommendedRoot);
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 = !existing && options.scope !== 'client' ? 'install' : await effects.select('What should happen?', [
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 (pendingMigration) options.migrateFrom = pendingMigration;
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 = await effects.ask('Proceed with this release and keep the original state as a recovery copy?', false);
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
- 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.`);
194
- options.mode = await effects.select('How should the server run?', [
195
- { value: 'packages', label: 'Native packages' }, { value: 'docker', label: 'Docker' },
196
- ], existing?.mode ?? recommendation.mode);
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
- effects.out(existing ? 'Existing identities and names are retained. This name is used only if a Human identity needs to be created.' : 'Choose the name other people and agents should see for your Human identity.');
199
- options.identityName = await effects.askLine('What name should others see? ', existing?.messengerIdentity ?? effects.username?.() ?? 'me');
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] = existing?.[key] ?? fallback;
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 = await effects.ask('Proceed with this update and keep a recovery backup?', false);
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 = connection === 'saved' ? savedProfile : await effects.askLine('Connection profile file: ', savedProfile);
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 = await effects.multiselect('Which integrations should be configured?', [
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' },