@ours.network/install 1.2.1-nightly.4 → 1.2.1-nightly.40

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 (57) hide show
  1. package/GATEWAY_SETUP.md +178 -0
  2. package/README.md +453 -9
  3. package/ROOTLESS_PODMAN_VALIDATION.md +210 -0
  4. package/assets/Dockerfile +14 -12
  5. package/assets/Dockerfile.gateway +5 -0
  6. package/assets/docker-compose.yaml +8 -1
  7. package/assets/fleet-settings.example.json +48 -0
  8. package/assets/gateway-entrypoint.sh +37 -0
  9. package/assets/release-lock.json +1139 -4470
  10. package/assets/release.json +37 -19
  11. package/assets/scripts/build/build-common.mjs +2 -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 +12 -2
  18. package/assets/scripts/runtime/README.md +7 -0
  19. package/assets/scripts/runtime/client-setup.mjs +80 -6
  20. package/assets/scripts/runtime/dependency-ready.mjs +13 -0
  21. package/assets/scripts/runtime/diagnostics.mjs +72 -0
  22. package/assets/scripts/runtime/entrypoint.sh +5 -1
  23. package/assets/scripts/runtime/health-messenger.mjs +7 -6
  24. package/assets/scripts/runtime/notifications-gateway.mjs +90 -0
  25. package/assets/scripts/runtime/recover.mjs +165 -0
  26. package/assets/scripts/runtime/start-cowork.sh +5 -0
  27. package/assets/scripts/runtime/start-messenger.sh +29 -4
  28. package/assets/scripts/runtime/start-telegram.sh +5 -0
  29. package/assets/scripts/runtime/supervise.mjs +35 -0
  30. package/assets/sources.json +54 -28
  31. package/install.mjs +7 -2
  32. package/lib/boot-readiness.mjs +43 -0
  33. package/lib/build-transition.mjs +3 -0
  34. package/lib/client-cli.mjs +127 -0
  35. package/lib/container-engine.mjs +127 -0
  36. package/lib/diagnostics.mjs +1 -0
  37. package/lib/docker-conversion-runtime.mjs +3 -2
  38. package/lib/docker-layout-installation.mjs +2 -1
  39. package/lib/docker-recovery.mjs +55 -0
  40. package/lib/docker-runtime-repair.mjs +160 -0
  41. package/lib/effects.mjs +323 -132
  42. package/lib/gateway-transition.mjs +87 -0
  43. package/lib/gateway.mjs +158 -0
  44. package/lib/legacy-migration.mjs +13 -2
  45. package/lib/orchestrate-uninstall.mjs +18 -6
  46. package/lib/orchestrate.mjs +193 -43
  47. package/lib/plan.mjs +27 -11
  48. package/lib/podman-preflight.mjs +40 -0
  49. package/lib/server-onboarding.mjs +164 -16
  50. package/lib/server-ports.mjs +17 -0
  51. package/lib/setup-options.mjs +99 -28
  52. package/lib/setup.mjs +35 -17
  53. package/lib/target.mjs +24 -8
  54. package/lib/uninstall.mjs +2 -2
  55. package/lib/usage.mjs +21 -7
  56. package/lib/workspace-setup.mjs +83 -0
  57. package/package.json +4 -2
@@ -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',
7
- '--identity-name': 'identityName', '--integrations': 'integrations', '--fleet-settings': 'fleetSettingsPath',
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',
10
+ '--identity-name': 'identityName', '--integrations': 'integrations', '--fleet-settings': 'fleetSettingsPath', '--fleet-task-workflow': 'fleetTaskWorkflowAgents',
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,51 @@ 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');
57
+ if (options.fleetTaskWorkflowAgents !== undefined) {
58
+ // Fleet owns what the workflow is; the installer only carries which agents the operator named.
59
+ const agents = options.fleetTaskWorkflowAgents;
60
+ if (!Array.isArray(agents) || !agents.length || agents.some(name => typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name)) || new Set(agents).size !== agents.length)
61
+ throw new Error('--fleet-task-workflow requires unique Fleet agent names separated by commas');
62
+ if (!options.integrations?.includes?.('fleet')) throw new Error('--fleet-task-workflow requires the fleet integration');
63
+ if (options.disableFleetAgentsSetup) throw new Error('--fleet-task-workflow conflicts with --disable-fleet-agents-setup: no agents are configured by this run');
64
+ options.fleetTaskWorkflowAgents = [...agents];
65
+ }
34
66
  const missing = [];
35
67
  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
68
  if (client && options.integrations === undefined) missing.push('--integrations (use none to skip)');
37
69
  if (!server && !nonempty(options.config)) missing.push('--config');
38
70
  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');
71
+ if (Array.isArray(options.integrations) && options.integrations.includes('fleet') && !interactive && !options.disableFleetAgentsSetup && !nonempty(options.fleetSettingsPath)) missing.push('--fleet-settings');
40
72
  if (missing.length) throw new Error(`Missing required setup options: ${missing.join(', ')}`);
41
73
  if (options.mode === 'native') options.mode = 'packages';
42
74
  if (server && !['packages', 'docker'].includes(options.mode)) throw new Error('Mode must be packages (or native) or docker');
75
+ if (options.containerEngine !== undefined && (!server || options.mode !== 'docker' || !['docker', 'podman'].includes(options.containerEngine))) throw new Error('--container-engine docker|podman requires server container mode');
76
+ if (options.serverUrl !== undefined) {
77
+ if (!server || options.mode !== 'docker' || options.operation !== 'install') throw new Error('--server-url requires Docker server installation');
78
+ options.serverUrl = serverBase(options.serverUrl);
79
+ }
43
80
  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']) {
81
+ if (!server) for (const key of ['mode', 'stateDir', 'identityName', 'localUsername', 'name', 'surname', 'hostname', ...Object.keys(defaults), 'compatible', 'migrate']) {
45
82
  if (options[key] !== undefined) throw new Error(`${key} is only valid for server or all scope`);
46
83
  }
47
84
  if (!client && (options.integrations !== undefined || options.fleetSettingsPath !== undefined)) throw new Error('--integrations and --fleet-settings require all or client scope');
@@ -84,7 +121,7 @@ function expandPaths(options, home) {
84
121
  return output;
85
122
  }
86
123
 
87
- export function parseSetupArgs(argv, { home } = {}) {
124
+ export function parseSetupArgs(argv, { home, validate = true } = {}) {
88
125
  if (!Array.isArray(argv) || !argv.every(arg => typeof arg === 'string')) throw new Error('Arguments must be strings');
89
126
  const options = { interactive: false, explicitPorts: [] };
90
127
  const seen = new Set();
@@ -107,6 +144,7 @@ export function parseSetupArgs(argv, { home } = {}) {
107
144
  if (!nonempty(value) || value.startsWith('--')) throw new Error(`${flag} requires a value`);
108
145
  const key = valueFlags.get(flag);
109
146
  if (key === 'integrations') put(key, value === 'none' ? [] : value.split(',').map(item => item.trim()));
147
+ else if (key === 'fleetTaskWorkflowAgents') put(key, value.split(',').map(item => item.trim()));
110
148
  else if (Object.hasOwn(defaults, key)) {
111
149
  if (!/^[1-9]\d*$/.test(value)) throw new Error(`${flag} requires an integer port`);
112
150
  put(key, Number(value)); options.explicitPorts.push(key);
@@ -115,30 +153,34 @@ export function parseSetupArgs(argv, { home } = {}) {
115
153
  if (positional.length && scopes.includes(positional[0])) put('scope', positional.shift());
116
154
  if (positional.length && ['install', 'update'].includes(positional[0])) put('operation', positional.shift());
117
155
  if (positional.length) throw new Error(`Unexpected setup argument: ${positional.join(' ')}`);
118
- return validateSetupOptions(expandPaths(options, home), { interactive: false });
156
+ return validate ? validateSetupOptions(expandPaths(options, home), { interactive: false }) : expandPaths(options, home);
119
157
  }
120
158
 
121
- export async function collectSetupOptions(effects) {
159
+ export function defaultHostname() {
160
+ return hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^[^a-z0-9]+/, '').slice(0, 30) || 'workspace';
161
+ }
162
+
163
+ export async function collectSetupOptions(effects, presets = {}) {
122
164
  if (effects.interactive !== true) throw new Error('Interactive setup requires a TTY; provide the complete CLI options instead');
123
- const options = { interactive: true, explicitPorts: [] };
165
+ const options = { ...presets, interactive: true, explicitPorts: presets.explicitPorts ?? [] };
124
166
  const scopeChoices = [
125
167
  { value: 'all', label: 'Everything on this computer' },
126
168
  { value: 'server', label: 'Server only' },
127
169
  { value: 'client', label: 'Connect this computer to an existing server' },
128
170
  ];
129
171
  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');
172
+ options.scope ??= await effects.select('What would you like to set up?', scopeChoices, 'all');
131
173
  if (!scopes.includes(options.scope)) throw new Error('Scope must be all, server, or client');
132
174
  let existing;
133
175
  let pendingMigration;
134
176
  if (options.scope !== 'client') {
135
177
  const recommendedRoot = join(effects.home, '.ours-install');
136
178
  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?', [
179
+ const location = options.stateDir ? 'preset' : await effects.select('Where should server programs and data be stored?', [
138
180
  { value: 'recommended', label: `Use the recommended folder (${recommendedRoot})` },
139
181
  { value: 'custom', label: 'Choose another folder' },
140
182
  ], 'recommended');
141
- options.stateDir = location === 'recommended' ? recommendedRoot : await effects.askLine('Folder for server programs and data: ', recommendedRoot);
183
+ options.stateDir ??= location === 'recommended' ? recommendedRoot : await effects.askLine('Folder for server programs and data: ', recommendedRoot);
142
184
  options.stateDir = expandPaths({ stateDir: options.stateDir }, effects.home).stateDir;
143
185
  existing = effects.readJson(join(options.stateDir, 'installation.json'));
144
186
  const migrationJournal = existing?.legacyMigrationSource ? effects.readJson(join(options.stateDir, 'legacy-migration.json')) : null;
@@ -150,7 +192,7 @@ export async function collectSetupOptions(effects) {
150
192
  ? 'This folder already contains an installation. Update selects this installer’s release while retaining state; repair reinstalls the retained package selection.'
151
193
  : options.scope === 'client' ? 'Install connects your selected apps. Update refreshes their packages while keeping the server connection.'
152
194
  : '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?', [
195
+ options.operation ??= !existing && options.scope !== 'client' ? 'install' : await effects.select('What should happen?', [
154
196
  { value: 'install', label: pendingMigration ? 'Resume the unfinished migration' : existing ? 'Repair this installation' : 'Install and configure' },
155
197
  { value: 'update', label: 'Update an existing installation' },
156
198
  ], existing && !pendingMigration ? 'update' : 'install');
@@ -160,7 +202,8 @@ export async function collectSetupOptions(effects) {
160
202
  const legacyState = nonempty(legacy?.stateDir) ? legacy.stateDir
161
203
  : legacy && legacy.stateDir === undefined && nonempty(effects.readJson(join(effects.home, '.ours', 'root.json'))?.name)
162
204
  ? join(effects.home, '.ours') : null;
163
- if (pendingMigration) options.migrateFrom = pendingMigration;
205
+ if (options.migrateFrom) { /* Explicit selection is validated below. */ }
206
+ else if (pendingMigration) options.migrateFrom = pendingMigration;
164
207
  else if (legacyState) {
165
208
  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
209
  if (await effects.ask('Upgrade this existing ours installation and keep its data?', true)) options.migrateFrom = defaultConfig;
@@ -184,41 +227,69 @@ export async function collectSetupOptions(effects) {
184
227
  if (!nonempty(root?.name)) throw new Error('Cannot read the existing Human identity name; choose the configuration file for a complete existing installation');
185
228
  options.identityName = root.name;
186
229
  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);
230
+ options.compatible ??= await effects.ask('Proceed with this release and keep the original state as a recovery copy?', false);
188
231
  if (!options.compatible) throw new Error('Migration requires explicit compatibility confirmation (--compatible)');
189
232
  }
190
233
  }
191
234
  if (options.scope !== 'client') {
192
235
  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);
236
+ // Native runs the server only: local clients need the container gateway. A retained
237
+ // native installation keeps its mode, so a full setup of it cannot finish.
238
+ // (An unfinished migration is resumed in its recorded mode and is validated with the plan.)
239
+ if (options.scope === 'all' && existing?.mode === 'packages' && !options.migrateFrom)
240
+ 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.');
241
+ const nativeOffered = options.scope === 'server' || existing?.mode === 'packages';
242
+ effects.out(nativeOffered
243
+ ? `${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.`
244
+ : '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.');
245
+ const runtime = options.mode ?? await effects.select('How should the server run?', [
246
+ ...(nativeOffered ? [{ value: 'packages', label: 'Native packages' }] : []), { value: 'docker', label: 'Docker' }, { value: 'podman', label: 'Podman' },
247
+ ], existing?.mode === 'docker' && existing?.containerEngine === 'podman' ? 'podman' : existing?.mode ?? recommendation.mode);
248
+ options.mode = runtime === 'podman' ? 'docker' : runtime;
249
+ if (runtime === 'podman') options.containerEngine = 'podman';
197
250
  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');
251
+ if (existing?.messengerIdentity) {
252
+ effects.out(`Existing Human identity ${existing.messengerIdentity} and Messenger profile will be retained.`);
253
+ // Supplied profile inputs never rename a retained root.
254
+ if (!['localUsername', 'name', 'surname'].some(key => options[key] !== undefined)) options.identityName ??= existing.messengerIdentity;
255
+ } else if (options.identityName === undefined || options.localUsername !== undefined) {
256
+ effects.out('Choose a local username (no global uniqueness check). Name and Surname appear in Messenger; the optional host name identifies this workspace.');
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): ', '');
261
+ }
262
+ }
263
+ if (['localUsername', 'name', 'surname'].some(key => options[key] !== undefined)) {
264
+ // Partial profile presets still collect missing fields on retained and
265
+ // migration paths; these inputs never replace their authoritative root.
266
+ effects.out('Name and Surname initialize a missing Messenger profile; an existing profile is retained.');
267
+ options.localUsername ??= await effects.askLine('Local username: ', (effects.username?.() ?? 'me').toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 30));
268
+ options.name ??= await effects.askLine('Name: ', '');
269
+ options.surname ??= await effects.askLine('Surname: ', '');
270
+ options.hostname ??= await effects.askLine('Host / machine name (optional): ', '');
200
271
  }
201
- for (const [key, fallback] of Object.entries(defaults)) options[key] = existing?.[key] ?? fallback;
272
+ for (const [key, fallback] of Object.entries(defaults)) options[key] ??= existing?.[key] ?? fallback;
202
273
  if (options.operation === 'update') {
203
274
  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);
275
+ options.compatible ??= await effects.ask('Proceed with this update and keep a recovery backup?', false);
205
276
  }
206
277
  } else {
207
278
  const savedProfile = join(effects.home, '.ours-client', 'profile.json');
208
279
  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?', [
280
+ const connection = options.config ? 'preset' : await effects.select('Which server connection should this computer use?', [
210
281
  { value: 'saved', label: 'Use the saved server connection' }, { value: 'file', label: 'Choose a connection profile file' },
211
282
  ], effects.readJson(savedProfile) ? 'saved' : 'file');
212
- options.config = connection === 'saved' ? savedProfile : await effects.askLine('Connection profile file: ', savedProfile);
283
+ options.config ??= connection === 'saved' ? savedProfile : await effects.askLine('Connection profile file: ', savedProfile);
213
284
  }
214
285
  if (options.scope !== 'server') {
215
286
  const detected = typeof effects.detectHarnesses === 'function' ? await effects.detectHarnesses() : [];
216
287
  const selected = integrations.filter(name => name === 'fleet' || detected.some(item => item.name === name && item.status === 'ok'));
217
288
  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?', [
289
+ options.integrations ??= await effects.multiselect('Which integrations should be configured?', [
219
290
  { value: 'codex', label: 'Codex' }, { value: 'claude-code', label: 'Claude Code' }, { value: 'fleet', label: 'Fleet — persistent agents' },
220
291
  ], selected);
221
- if (options.integrations.includes('fleet')) {
292
+ if (options.integrations.includes('fleet') && !options.disableFleetAgentsSetup && !options.fleetSettingsPath) {
222
293
  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
294
  const fleetMode = await effects.select('How should Fleet be configured?', [
224
295
  { value: 'wizard', label: 'Configure interactively with Fleet' }, { value: 'file', label: 'Use a prepared settings file' },
package/lib/setup.mjs CHANGED
@@ -1,17 +1,19 @@
1
+ import { validateGatewayClientProfile } from './target.mjs';
2
+ import { gatewayAddress } from './gateway.mjs';
1
3
  import { inspectLegacyMigration } from './legacy-migration.mjs';
2
4
  import { join } from 'node:path';
3
5
  import { parseSetupArgs, collectSetupOptions, validateSetupOptions } from './setup-options.mjs';
4
6
  import { parseNetworkArgs, validateHostProfile, InstallUsageError } from './target.mjs';
5
- import { validateInstallation } from './plan.mjs';
7
+ import { clientPackageNames, validateInstallation } from './plan.mjs';
6
8
  import { runServerCommand, runClientCommand } from './orchestrate.mjs';
7
9
  import { banner, heading, info, ok, warn, progress } from './ui.mjs';
8
10
  import { isCancel } from './prompt.mjs';
9
11
  import { USAGE } from './usage.mjs';
10
12
  import { validateIdentityName } from './server-onboarding.mjs';
11
13
  import { validateFleetSettings } from './fleet-settings.mjs';
14
+ import { installerFailure } from './diagnostics.mjs';
12
15
 
13
- const maintenance = new Set(['status', 'start', 'stop', 'restart', 'rebuild', 'access-issue', 'access-replace', 'backup', 'restore', 'reset']);
14
- const clientPackages = integrations => [...new Set(['sdk', ...(integrations.includes('fleet') ? ['cli'] : []), ...integrations])];
16
+ const maintenance = new Set(['status', 'start', 'stop', 'restart', 'uninstall', 'rebuild', 'gateway-enable', 'access-issue', 'access-replace', 'backup', 'restore', 'reset']);
15
17
 
16
18
  export function completeReleasePolicy(retained, supplied) {
17
19
  if (retained?.release) {
@@ -32,7 +34,9 @@ export async function prepareSetupPlan(options, effects) {
32
34
  options = validateSetupOptions(options, { interactive: options.interactive });
33
35
  if (effects.platform?.platform === 'win32') throw new InstallUsageError('Run ours-install inside WSL with Docker Desktop integration on Windows. Direct Windows Node installations are not supported.');
34
36
  const plan = { ...options };
35
- if (options.scope !== 'server' && options.integrations.length) {
37
+ if (options.scope === 'all' && options.mode === 'packages' && options.integrations?.length)
38
+ throw new InstallUsageError('Gateway-only clients require a gateway. Use Docker full-stack setup, or install the native server separately and provision an HTTP gateway before client setup; nothing was changed.');
39
+ if (options.scope !== 'server') {
36
40
  for (const name of ['OURS_API_TOKEN', 'OURS_PORT', 'OURS_STATE_DIR', 'OURS_DAEMON_ID']) {
37
41
  if (effects.env?.[name]?.trim()) throw new InstallUsageError(`${name} conflicts with the selected client profile. Clear this override before full-stack/client setup; nothing was changed.`);
38
42
  }
@@ -54,6 +58,7 @@ export async function prepareSetupPlan(options, effects) {
54
58
  const value = effects.readJson(join(options.stateDir, 'installation.json'));
55
59
  if (value) {
56
60
  plan.existing = validateInstallation(value, options.stateDir);
61
+ if (options.containerEngine && options.containerEngine !== (plan.existing.containerEngine ?? 'docker')) throw new InstallUsageError('Container engine conflicts with retained installation');
57
62
  if (options.mode !== plan.existing.mode) throw new InstallUsageError('The selected mode conflicts with the retained installation; choose its existing mode or a separate empty directory');
58
63
  for (const key of ['port', 'coworkPort', 'messengerPort']) {
59
64
  if (options.explicitPorts?.includes(key) && options[key] !== plan.existing[key]) throw new InstallUsageError(`${key} conflicts with the retained installation`);
@@ -61,33 +66,33 @@ export async function prepareSetupPlan(options, effects) {
61
66
  }
62
67
  if (options.operation === 'install' && !options.migrateFrom) {
63
68
  const retained = readObject(effects, plan.existing.sourcesPath, 'Retained source policy');
64
- plan.sourcePolicy = options.scope === 'server' || !options.integrations?.length ? retained : completeReleasePolicy(retained, options.sources ? policy : null);
69
+ plan.sourcePolicy = options.scope === 'server' ? retained : completeReleasePolicy(retained, options.sources ? policy : null);
65
70
  }
66
71
  } else if (options.operation === 'update') {
67
72
  throw new InstallUsageError('Update requires an existing installation.json; choose install for a new installation');
68
73
  }
69
74
  // Reject a local client already attached to another server before changing the server.
70
- const saved = options.scope === 'all' && options.integrations.length ? effects.readManagedClientProfile() : null;
71
- if (saved && (!plan.existing || saved.expectedInstanceId !== plan.existing.instanceId || saved.endpoint !== `http://127.0.0.1:${plan.port}`)) {
75
+ const saved = options.scope === 'all' ? effects.readManagedClientProfile() : null;
76
+ if (saved && (!plan.existing || saved.expectedInstanceId !== plan.existing.instanceId || saved.endpoint !== (plan.existing?.gateway ? gatewayAddress(plan.existing).base + '/daemon' : `http://127.0.0.1:${plan.port}`))) {
72
77
  throw new InstallUsageError('This user already has clients attached to a different server; their saved connection was not changed');
73
78
  }
74
79
  } else {
75
- const profile = validateHostProfile(readObject(effects, options.config, 'Client profile'));
80
+ const profile = validateGatewayClientProfile(readObject(effects, options.config, 'Client profile'));
76
81
  if (!profile) throw new InstallUsageError('Client profile must contain endpoint, expectedInstanceId and credentialPath');
77
82
  if (!effects.readText(profile.credentialPath)?.trim()) throw new InstallUsageError('Client credential file is missing or empty');
78
83
  plan.profile = profile;
79
84
  const saved = effects.readManagedClientProfile();
80
- if (saved && (saved.endpoint !== profile.endpoint || saved.expectedInstanceId !== profile.expectedInstanceId)) throw new InstallUsageError('Managed clients already select another server; existing connection was not changed');
85
+ if (saved && saved.expectedInstanceId !== profile.expectedInstanceId) throw new InstallUsageError('Managed clients already select another server; existing connection was not changed');
81
86
  if (saved && options.operation === 'install' && !options.sources) plan.sourcePolicy = readObject(effects, saved.installer.sourcesPath, 'Retained client source policy');
82
87
  }
83
88
  // Dry-run never spawns a resolver or acquires an installation lock.
84
89
  if (!options.dryRun) {
85
- if (options.scope === 'all' && options.operation === 'update' && options.integrations.length) {
90
+ if (options.scope === 'all' && options.operation === 'update') {
86
91
  const running = await effects.serverLifecycle(plan.existing, 'status', ['daemon']);
87
92
  if (!running.includes('daemon')) throw new InstallUsageError('Full-stack update requires the selected daemon to be running for client verification. Start it first, or use server update to preserve its stopped state.');
88
93
  }
89
94
  if (options.scope !== 'client') await effects.resolveSourcePolicy(plan.sourcePolicy, 'server');
90
- if (options.scope !== 'server' && options.integrations.length) await effects.resolveSourcePolicy(plan.sourcePolicy, 'client', clientPackages(options.integrations));
95
+ if (options.scope !== 'server') await effects.resolveSourcePolicy(plan.sourcePolicy, 'client', clientPackageNames(options.integrations));
91
96
  }
92
97
  return plan;
93
98
  }
@@ -100,7 +105,7 @@ export async function executeSetupPlan(plan, effects, { server = runServerComman
100
105
  if (plan.dryRun) {
101
106
  effects.out(info('Preview only. No packages, identities, credentials or services will be changed.'));
102
107
  if (plan.scope !== 'client') effects.out(info('Server: prerequisites → runtime preparation/update → retained identity/state restoration → readiness.'));
103
- if (plan.scope !== 'server' && plan.integrations.length) effects.out(info('Clients: private connection → exact packages → selected integrations → Fleet settings when selected.'));
108
+ if (plan.scope !== 'server') effects.out(info('Clients: private connection → exact packages → selected integrations → Fleet settings when selected.'));
104
109
  return 0;
105
110
  }
106
111
  let clientConfig = plan.config;
@@ -110,6 +115,9 @@ export async function executeSetupPlan(plan, effects, { server = runServerComman
110
115
  const result = await server({ ...plan, role: 'server', operation: plan.operation, sourcePolicy: plan.sourcePolicy }, effects);
111
116
  if (result !== 0) return result;
112
117
  const record = validateInstallation(effects.readJson(join(plan.stateDir, 'installation.json')), plan.stateDir);
118
+ if (plan.name && plan.surname && plan.operation === 'install') {
119
+ await effects.serverEnsureHumanProfile(record, { name: plan.name, surname: plan.surname });
120
+ }
113
121
  if (plan.operation === 'update') {
114
122
  effects.out(progress(0, 1, 'Retained identities', 'Verify the Human identity after state restoration; existing names and keys are retained.'));
115
123
  const running = await effects.serverLifecycle(record, 'status', ['daemon']);
@@ -118,21 +126,23 @@ export async function executeSetupPlan(plan, effects, { server = runServerComman
118
126
  effects.out(ok('Retained identities verified.'));
119
127
  } else effects.out(info('Daemon remains stopped; stored identities are retained and will restore on the next start.'));
120
128
  }
121
- if (plan.scope === 'all' && plan.integrations.length) {
129
+ if (plan.scope === 'all') {
122
130
  clientPolicy = completeReleasePolicy(readObject(effects, record.sourcesPath, 'Server source policy'), plan.sourcePolicy);
123
131
  effects.out(heading('Connect local clients'));
124
132
  const handoff = await effects.prepareLocalClient(record, plan.integrations, plan.fleetSettingsPath);
125
133
  clientConfig = handoff.configPath;
126
134
  }
127
135
  }
128
- if (plan.scope !== 'server' && plan.integrations.length) {
136
+ if (plan.scope !== 'server') {
129
137
  const result = await client({ role: 'client', operation: 'install', config: clientConfig,
130
138
  integrations: plan.integrations, fleetSettingsPath: plan.fleetSettingsPath, sourcePolicy: clientPolicy,
139
+ disableFleetAgentsSetup: plan.disableFleetAgentsSetup, fleetTaskWorkflowAgents: plan.fleetTaskWorkflowAgents,
131
140
  preset: true, nonInteractive: !plan.interactive }, effects);
132
141
  if (result !== 0) return result;
133
142
  }
143
+ if (plan.disableFleetAgentsSetup && plan.integrations?.includes('fleet')) effects.out(info('Fleet is installed. Run ours-fleet setup-tunnel with the expiring one-use payload from the App command; a compatible Fleet prints the QR and connection code automatically. Configure agents and models in the App after linking.'));
134
144
  effects.out(ok(`Requested ${plan.operation} completed. Existing identities were retained.`));
135
- 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.'));
145
+ 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.'));
136
146
  return 0;
137
147
  }
138
148
 
@@ -144,12 +154,20 @@ export async function runSetup(argv, effects) {
144
154
  if (argv[0] === 'server' && maintenance.has(argv[1])) return await runServerCommand(parseNetworkArgs(argv), effects);
145
155
  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.');
146
156
  if (!argv.length) { effects.out(banner()); effects.out(heading('Interactive setup')); }
147
- const options = argv.length ? parseSetupArgs(argv, { home: effects.home }) : await collectSetupOptions(effects);
157
+ let options;
158
+ if (argv.length) {
159
+ const presets = parseSetupArgs(argv, { home: effects.home, validate: false });
160
+ try { options = validateSetupOptions(presets, { interactive: false }); }
161
+ catch (error) {
162
+ if (!effects.interactive || effects.env?.OURS_ASSUME_YES || !/^Missing (required setup options|human profile option):/.test(error.message)) throw error;
163
+ options = await collectSetupOptions(effects, presets);
164
+ }
165
+ } else options = await collectSetupOptions(effects);
148
166
  const plan = await prepareSetupPlan(options, effects);
149
167
  return await executeSetupPlan(plan, effects);
150
168
  } catch (error) {
151
169
  if (isCancel(error)) { effects.out(warn('Installation cancelled.')); return 130; }
152
- effects.out(warn(`ours-install: ${error.message}`));
170
+ effects.out(warn(`ours-install: ${installerFailure(error, effects.env)}`));
153
171
  return 2;
154
172
  }
155
173
  }
package/lib/target.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { serverBase } from './gateway.mjs';
1
2
  // ours-install v3 — argument handling and daemon detection.
2
3
  //
3
4
  // Pure argument handling and daemon-target selection. The orchestrator
@@ -85,17 +86,30 @@ export function validateHostProfile(value) {
85
86
  const mixed = LEGACY_PROFILE_KEYS.filter((key) => Object.hasOwn(value, key));
86
87
  if (mixed.length) throw profileError(`legacy selection keys cannot be mixed with a host profile (${mixed.join(', ')}).`);
87
88
  const { endpoint, expectedInstanceId, credentialPath } = value;
88
- if (typeof endpoint !== 'string' || endpoint.trim() !== endpoint || endpoint === '') throw profileError('endpoint must be a non-empty HTTP origin.');
89
+ if (typeof endpoint !== 'string' || endpoint.trim() !== endpoint || endpoint === '') throw profileError('endpoint must be a non-empty HTTP or HTTPS base URL.');
89
90
  if (typeof expectedInstanceId !== 'string' || !PROFILE_UUID.test(expectedInstanceId)) throw profileError('expectedInstanceId must be a lowercase UUID.');
90
91
  if (typeof credentialPath !== 'string' || credentialPath === '' || !credentialPath.startsWith('/') || resolve(credentialPath) !== credentialPath) {
91
92
  throw profileError('credentialPath must be a normalized absolute path.');
92
93
  }
93
94
  let url;
94
- try { url = new URL(endpoint); } catch { throw profileError('endpoint must be an HTTP origin.'); }
95
- if (url.protocol !== 'http:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
96
- throw profileError('endpoint must be an HTTP origin without credentials, path, query, or fragment.');
95
+ try { url = new URL(endpoint); } catch { throw profileError('endpoint must be an HTTP or HTTPS base URL.'); }
96
+ if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password || url.search || url.hash) {
97
+ throw profileError('endpoint must be an HTTP or HTTPS base URL without credentials, query, or fragment.');
97
98
  }
98
- return { endpoint: url.origin, expectedInstanceId, credentialPath };
99
+ const endpointBase = serverBase(endpoint);
100
+ if (value.serverUrl !== undefined) {
101
+ const serverUrl = serverBase(value.serverUrl);
102
+ if (endpointBase !== serverUrl + '/daemon') throw profileError('endpoint must select serverUrl/daemon.');
103
+ return { serverUrl, endpoint: endpointBase, expectedInstanceId, credentialPath };
104
+ }
105
+ return { endpoint: endpointBase, expectedInstanceId, credentialPath };
106
+ }
107
+
108
+ /** Client installs require the single gateway even when a legacy server profile exists. */
109
+ export function validateGatewayClientProfile(value) {
110
+ if (!value || typeof value.serverUrl !== 'string') throw profileError('serverUrl is required; enable the server gateway and import its client profile. Direct daemon endpoints are not client routes.');
111
+ const serverUrl = serverBase(value.serverUrl);
112
+ return validateHostProfile({ ...value, endpoint: value.endpoint ?? serverUrl + '/daemon' });
99
113
  }
100
114
 
101
115
  export function resolveProfileSelection({ args, env = {}, home = homedir(), exists, readProfile }) {
@@ -454,10 +468,11 @@ export function searchFreePort(isTaken, { floor = FREE_PORT_FLOOR, reserved = IN
454
468
  export function parseNetworkArgs(argv) {
455
469
  if (!['server', 'client'].includes(argv[0])) return null;
456
470
  const [role, operation] = argv;
457
- const operations = role === 'client' ? ['install'] : ['install', 'status', 'start', 'stop', 'restart', 'access-issue', 'access-replace', 'backup', 'restore', 'reset', 'update', 'rebuild'];
471
+ const operations = role === 'client' ? ['install'] : ['install', 'status', 'start', 'stop', 'restart', 'uninstall', 'access-issue', 'access-replace', 'backup', 'restore', 'reset', 'update', 'rebuild', 'gateway-enable'];
458
472
  if (!operations.includes(operation)) throw new InstallUsageError(`Unsupported ${role} operation: ${operation ?? '(missing)'}`);
459
473
  const allowed = role === 'client' ? ['config'] : ['state-dir'];
460
- if (role === 'server' && operation === 'install') allowed.push('mode', 'sources', 'migrate');
474
+ if (role === 'server' && operation === 'install') allowed.push('mode', 'sources', 'migrate', 'container-engine');
475
+ if (role === 'server' && ['install', 'gateway-enable'].includes(operation)) allowed.push('server-url');
461
476
  if (operation === 'access-issue') allowed.push('output');
462
477
  if (['access-replace', 'reset'].includes(operation)) allowed.push('confirm');
463
478
  if (['restore', 'update'].includes(operation)) allowed.push('compatible');
@@ -486,13 +501,14 @@ export function parseNetworkArgs(argv) {
486
501
  } else {
487
502
  const value = inline ?? argv[++i];
488
503
  if (!value || value.startsWith('--')) throw new InstallUsageError(`--${name} requires a value`);
489
- result[key] = name === 'mode' ? value : resolve(value);
504
+ result[key] = ['mode', 'container-engine'].includes(name) ? value : name === 'server-url' ? serverBase(value) : resolve(value);
490
505
  }
491
506
  }
492
507
  const required = role === 'client' ? [] : ['stateDir'];
493
508
  if (operation === 'access-issue') required.push('output');
494
509
  if (['access-replace', 'reset'].includes(operation)) required.push('confirm');
495
510
  for (const key of required) if (!result[key]) throw new InstallUsageError(`${operation} requires --${key.replace(/[A-Z]/g, c => '-' + c.toLowerCase())}`);
511
+ if (result.containerEngine !== undefined && (result.mode !== 'docker' || !['docker', 'podman'].includes(result.containerEngine))) throw new InstallUsageError('--container-engine docker|podman requires --mode docker');
496
512
  if (result.mode && !['packages', 'docker'].includes(result.mode)) throw new InstallUsageError('--mode must be packages or docker');
497
513
  return result;
498
514
  }
package/lib/uninstall.mjs CHANGED
@@ -174,13 +174,13 @@ export function planDaemonRemoval({ stateDir, cliStartedIt }) {
174
174
  const service = {
175
175
  id: 'service',
176
176
  unit: unit.ok ? unit.unit : null,
177
- command: ['ours', 'daemon', 'uninstall-service', '--yes', '--state-dir', dir, '--config', join(dir, 'config.json')],
177
+ command: ['ours-daemon', 'uninstall-service', '--yes', '--state-dir', dir, '--config', join(dir, 'config.json')],
178
178
  note: 'removes the unit managed by the ours CLI',
179
179
  };
180
180
  return [
181
181
  service,
182
182
  cliStartedIt
183
- ? { id: 'stop', command: ['ours', 'daemon', 'stop', '--state-dir', dir, '--config', join(dir, 'config.json')] }
183
+ ? { id: 'stop', command: ['ours-daemon', 'stop', '--state-dir', dir, '--config', join(dir, 'config.json')] }
184
184
  : { id: 'stop-external', command: null, continues: true, note: 'this daemon was not started by the CLI; naming its launcher and continuing' },
185
185
  ];
186
186
  }
package/lib/usage.mjs CHANGED
@@ -1,10 +1,15 @@
1
1
  // Public setup and maintenance commands.
2
- export const USAGE = `ours-install — interactive setup or complete CLI presets.
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
- Linux x64 recommends native; macOS/Windows recommend Docker. Windows uses WSL.
7
+ Full setup runs the server in Docker or Podman, which provide the gateway local clients need.
8
+ Native mode is offered for server-only setup. Windows uses WSL.
9
+
10
+ Install now, configure Fleet agents in the App after linking:
11
+ ours-install --username alex --name Alex --surname Taylor --disable-fleet-agents-setup
12
+ Remaining installation choices are prompted on a terminal. No enrollment credentials or expiry.
8
13
 
9
14
  Full stack, with every required answer preset (no prompts):
10
15
  ours-install --mode docker --state-dir /private/ours --identity-name "Your Name" --integrations codex,fleet --fleet-settings /private/fleet.json
@@ -22,13 +27,21 @@ export const USAGE = `ours-install — interactive setup or complete CLI presets
22
27
  --scope all|server|client preset which parts to configure (default all)
23
28
  --action install|update equivalent to the positional operation
24
29
  --mode docker|native packages is also accepted for native mode
30
+ --container-engine docker|podman server container backend (default Docker); retained for maintenance
25
31
  --state-dir PATH installation root; required for all/server
32
+ --username USERNAME local username; no global uniqueness check
33
+ --name NAME Messenger given name
34
+ --surname SURNAME Messenger family name
35
+ --hostname NAME optional host/workspace name; defaults to this machine
36
+ --disable-fleet-agents-setup install Fleet CLI without its roles/model wizard
26
37
  --identity-name NAME desired Human name for a fresh server; existing root is retained
27
38
  --integrations LIST codex,claude-code,fleet; use none to skip clients explicitly
28
- --fleet-settings PATH JSON settings for Fleet; mandatory for CLI presets selecting Fleet
39
+ --fleet-settings PATH JSON settings for Fleet; required unless Fleet agents setup is disabled
40
+ --fleet-task-workflow A[,B] let these Fleet agents run Fleet task commands outside their command sandbox
29
41
  --config PATH complete connection profile for client-only setup
30
42
  --sources PATH explicit full development source policy override
31
- --port N daemon port (default 3050 on a fresh installation)
43
+ --port N public gateway port for Docker (default 3050; e.g. 4050)
44
+ --server-url URL external gateway URL (Docker install; configure authenticated entry separately)
32
45
  --cowork-port N cowork port (default 3052)
33
46
  --messenger-port N messenger port (default 8420)
34
47
  --compatible required for server updates and legacy state migration
@@ -39,13 +52,14 @@ export const USAGE = `ours-install — interactive setup or complete CLI presets
39
52
  --help, -h show help
40
53
  --version, -V print installer version
41
54
 
42
- CLI presets must be complete and never open the interactive form or a Fleet wizard.
43
- Missing answers are reported before installation. Both input modes run the same
55
+ Partial CLI presets on a terminal ask for the remaining choices. Without a terminal,
56
+ or with OURS_ASSUME_YES, supply complete CLI presets; missing answers fail before installation. Both input modes run the same
44
57
  installer with preparation, identity restoration, update and readiness progress.
45
58
  Fleet is configured but left stopped for operator review.
46
59
 
47
60
  Scoped maintenance:
48
- ours-install server status|start|stop|restart|rebuild --state-dir PATH
61
+ ours-install server status|start|stop|restart|rebuild|uninstall --state-dir PATH
62
+ ours-install server gateway-enable --state-dir PATH [--server-url URL]
49
63
  ours-install server access-issue --state-dir PATH --output PATH
50
64
  ours-install server access-replace --state-dir PATH --confirm
51
65
  ours-install server backup|restore server|daemon|telegram|cowork|messenger LABEL --state-dir PATH