@ours.network/install 1.2.1-nightly.3 → 1.2.1-nightly.31

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 (49) hide show
  1. package/GATEWAY_SETUP.md +178 -0
  2. package/README.md +353 -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 +4 -1
  7. package/assets/fleet-settings.example.json +48 -0
  8. package/assets/gateway-entrypoint.sh +37 -0
  9. package/assets/release-lock.json +1167 -4499
  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 +4 -2
  18. package/assets/scripts/runtime/client-setup.mjs +80 -6
  19. package/assets/scripts/runtime/diagnostics.mjs +72 -0
  20. package/assets/scripts/runtime/entrypoint.sh +1 -1
  21. package/assets/scripts/runtime/health-messenger.mjs +7 -6
  22. package/assets/scripts/runtime/notifications-gateway.mjs +82 -0
  23. package/assets/scripts/runtime/start-messenger.sh +25 -4
  24. package/assets/scripts/runtime/supervise.mjs +35 -0
  25. package/assets/sources.json +54 -28
  26. package/install.mjs +7 -2
  27. package/lib/build-transition.mjs +3 -0
  28. package/lib/client-cli.mjs +127 -0
  29. package/lib/container-engine.mjs +127 -0
  30. package/lib/diagnostics.mjs +1 -0
  31. package/lib/docker-conversion-runtime.mjs +3 -2
  32. package/lib/docker-layout-installation.mjs +2 -1
  33. package/lib/docker-runtime-repair.mjs +160 -0
  34. package/lib/effects.mjs +298 -132
  35. package/lib/gateway-transition.mjs +87 -0
  36. package/lib/gateway.mjs +158 -0
  37. package/lib/legacy-migration.mjs +13 -2
  38. package/lib/orchestrate.mjs +135 -43
  39. package/lib/plan.mjs +27 -11
  40. package/lib/podman-preflight.mjs +40 -0
  41. package/lib/server-onboarding.mjs +164 -16
  42. package/lib/server-ports.mjs +17 -0
  43. package/lib/setup-options.mjs +88 -27
  44. package/lib/setup.mjs +35 -18
  45. package/lib/target.mjs +24 -8
  46. package/lib/uninstall.mjs +2 -2
  47. package/lib/usage.mjs +19 -6
  48. package/lib/workspace-setup.mjs +83 -0
  49. package/package.json +4 -2
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', '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,22 @@ 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,
131
- preset: true, nonInteractive: !plan.interactive }, effects);
139
+ disableFleetAgentsSetup: plan.disableFleetAgentsSetup, preset: true, nonInteractive: !plan.interactive }, effects);
132
140
  if (result !== 0) return result;
133
141
  }
142
+ if (plan.disableFleetAgentsSetup && plan.integrations?.includes('fleet')) effects.out(info('Fleet is installed. Run ours-fleet setup-tunnel with the private expiring App setup file, then ours-fleet link-device. Configure agents and models in the App after linking.'));
134
143
  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.'));
144
+ 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
145
  return 0;
137
146
  }
138
147
 
@@ -144,12 +153,20 @@ export async function runSetup(argv, effects) {
144
153
  if (argv[0] === 'server' && maintenance.has(argv[1])) return await runServerCommand(parseNetworkArgs(argv), effects);
145
154
  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
155
  if (!argv.length) { effects.out(banner()); effects.out(heading('Interactive setup')); }
147
- const options = argv.length ? parseSetupArgs(argv, { home: effects.home }) : await collectSetupOptions(effects);
156
+ let options;
157
+ if (argv.length) {
158
+ const presets = parseSetupArgs(argv, { home: effects.home, validate: false });
159
+ try { options = validateSetupOptions(presets, { interactive: false }); }
160
+ catch (error) {
161
+ if (!effects.interactive || effects.env?.OURS_ASSUME_YES || !/^Missing (required setup options|human profile option):/.test(error.message)) throw error;
162
+ options = await collectSetupOptions(effects, presets);
163
+ }
164
+ } else options = await collectSetupOptions(effects);
148
165
  const plan = await prepareSetupPlan(options, effects);
149
166
  return await executeSetupPlan(plan, effects);
150
167
  } catch (error) {
151
168
  if (isCancel(error)) { effects.out(warn('Installation cancelled.')); return 130; }
152
- effects.out(warn(`ours-install: ${error.message}`));
169
+ effects.out(warn(`ours-install: ${installerFailure(error, effects.env)}`));
153
170
  return 2;
154
171
  }
155
172
  }
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', '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,20 @@ 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
29
40
  --config PATH complete connection profile for client-only setup
30
41
  --sources PATH explicit full development source policy override
31
- --port N daemon port (default 3050 on a fresh installation)
42
+ --port N public gateway port for Docker (default 3050; e.g. 4050)
43
+ --server-url URL external gateway URL (Docker install; configure authenticated entry separately)
32
44
  --cowork-port N cowork port (default 3052)
33
45
  --messenger-port N messenger port (default 8420)
34
46
  --compatible required for server updates and legacy state migration
@@ -39,13 +51,14 @@ export const USAGE = `ours-install — interactive setup or complete CLI presets
39
51
  --help, -h show help
40
52
  --version, -V print installer version
41
53
 
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
54
+ Partial CLI presets on a terminal ask for the remaining choices. Without a terminal,
55
+ or with OURS_ASSUME_YES, supply complete CLI presets; missing answers fail before installation. Both input modes run the same
44
56
  installer with preparation, identity restoration, update and readiness progress.
45
57
  Fleet is configured but left stopped for operator review.
46
58
 
47
59
  Scoped maintenance:
48
60
  ours-install server status|start|stop|restart|rebuild --state-dir PATH
61
+ ours-install server gateway-enable --state-dir PATH [--server-url URL]
49
62
  ours-install server access-issue --state-dir PATH --output PATH
50
63
  ours-install server access-replace --state-dir PATH --confirm
51
64
  ours-install server backup|restore server|daemon|telegram|cowork|messenger LABEL --state-dir PATH
@@ -0,0 +1,83 @@
1
+ import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join,resolve } from 'node:path';
3
+ import {parseSetupArgs} from './setup-options.mjs';
4
+ /** Base64 is transport, not encryption. Broad provider credentials are never accepted. */
5
+ export function decodeWorkspacePayload(encoded,now=Date.now()) {
6
+ if(typeof encoded!=='string' || encoded.length>32768 || !/^[A-Za-z0-9_-]+$/.test(encoded.trim()))throw Error('Invalid private workspace payload');
7
+ let p;try{p=JSON.parse(Buffer.from(encoded.trim(),'base64url'));}catch{throw Error('Invalid private workspace payload');}
8
+ const allowed=['version','appOrigin','hostname','rootName','name','surname','connectorToken','invitation','serverCid','challenge'];
9
+ if(!p || Object.keys(p).some(k=>!allowed.includes(k)) || p.version!==1 || !['https://app.ours.network','https://app.ours-tunnel.com'].includes(p.appOrigin) || !/^[a-z0-9][a-z0-9-]{2,60}\.ours-tunnel\.com$/.test(p.hostname) || !/^[a-z0-9-]{2,30}@[a-z0-9-]{2,30}$/.test(p.rootName) || !/^[a-f0-9]{64}$/i.test(p.serverCid))throw Error('Invalid workspace payload boundary');
10
+ for(const key of ['name','surname','connectorToken','invitation'])if(typeof p[key]!=='string' || !p[key].trim() || p[key].length>8192 || /[\x00-\x1f\x7f]/.test(p[key]))throw Error('Invalid private workspace payload');
11
+ if(!p.challenge || !['nonce','accountId','workspaceId'].every(k=>/^[\w-]{43}$/.test(p.challenge[k])) || !Number.isFinite(p.challenge.expiresAt) || p.challenge.expiresAt<=now || p.challenge.expiresAt>now+16*60000)throw Error('Workspace challenge expired or invalid');
12
+ return p;
13
+ }
14
+ export function extractWorkspaceArgs(args){
15
+ const flags=['--setup-workspace','--setup-workspace-file','--workspace-fleet-bin','--fleet-settings'];const values={},rest=[];
16
+ for(let i=0;i<args.length;i++){const at=args[i].indexOf('='),flag=at<0?args[i]:args[i].slice(0,at);if(!flags.includes(flag)){rest.push(args[i]);continue;}if(values[flag]!==undefined)throw Error('Duplicate workspace flag');const value=at<0?args[++i]:args[i].slice(at+1);if(!value || value.startsWith('--'))throw Error('Workspace flag requires a value');values[flag]=value;}
17
+ if(values['--setup-workspace'] && values['--setup-workspace-file'])throw Error('Choose one private payload transport');
18
+ return {values,rest};
19
+ }
20
+ export async function runWorkspaceSetup(args,effects,runSetup){
21
+ const migrateAppOrigin=args.includes('--workspace-migrate-app-origin');
22
+ if(args.filter(arg=>arg==='--workspace-migrate-app-origin').length>1)throw Error('Duplicate workspace migration flag');
23
+ const {values,rest}=extractWorkspaceArgs(args.filter(arg=>arg!=='--workspace-migrate-app-origin'));let encoded;
24
+ if(values['--setup-workspace-file']){const path=resolve(values['--setup-workspace-file']),stat=lstatSync(path);if(!stat.isFile() || stat.isSymbolicLink() || stat.nlink!==1 || stat.uid!==process.getuid?.() || stat.size>32768 || (stat.mode&0o077)!==0)throw Error('Setup file must be owned and private (chmod 600)');encoded=readFileSync(path,'utf8').trim();}
25
+ else if(values['--setup-workspace']==='-'){const chunks=[];let bytes=0;for await(const chunk of process.stdin){bytes+=chunk.length;if(bytes>32768)throw Error('Private payload too large');chunks.push(chunk);}encoded=Buffer.concat(chunks).toString().trim();}
26
+ else encoded=values['--setup-workspace'];
27
+ const p=decodeWorkspacePayload(encoded),fleetBin=values['--workspace-fleet-bin'] || 'ours-fleet';
28
+ const parsed=parseSetupArgs(rest,{home:effects.home,validate:false});
29
+ if(parsed.scope==='server')throw Error('Workspace setup requires all or client scope');
30
+ if(parsed.identityName!==undefined || parsed.integrations!==undefined)throw Error('Workspace setup supplies identity and client integration selection');
31
+ // Check feature availability before changing an installation. Task acceptance uses
32
+ // an exact task-built binary; @latest cannot contain an unmerged feature.
33
+ const root=parsed.stateDir || join(effects.home,'.ours-network','workspace'),fleetSettings=values['--fleet-settings'];
34
+ // The saved gateway profile selects the actual host, including non-default server state.
35
+ // Reuse it directly: server/client reinstall would also rewrite or downgrade retained setup.
36
+ const managed=effects.readManagedClientProfile?.();
37
+ const managedPath=join(effects.home,'.ours-client','profile.json');
38
+ const existingProfile=parsed.config || effects.env?.OURS_CONFIG || (managed?managedPath:undefined);
39
+ const existing=Boolean(existingProfile);
40
+ const marker=join(effects.home,'.ours-client','workspace-setup-pending.json');
41
+ let pending;
42
+ if(existsSync(marker)){
43
+ const stat=lstatSync(marker);
44
+ if(!stat.isFile() || stat.isSymbolicLink() || stat.nlink!==1 || stat.uid!==process.getuid?.() || (stat.mode&0o077)!==0 || stat.size>4096)throw Error('Workspace retry marker must be owned and private');
45
+ pending=JSON.parse(readFileSync(marker,'utf8'));
46
+ if(pending.version!==1 || pending.accountId!==p.challenge.accountId || pending.workspaceId!==p.challenge.workspaceId || pending.appOrigin!==p.appOrigin)throw Error('Pending fresh installation belongs to another workspace; identity was not changed. Resume the original workspace, or back up the host and remove only ~/.ours-client/workspace-setup-pending.json to treat it as existing (preserving its profile)');
47
+ }
48
+ const preserveProfile=existing && !pending;
49
+ if(existing && parsed.scope && parsed.scope!=='client')throw Error('Existing workspace setup requires client scope; root identity was not changed');
50
+ if(!existing && !pending && (existsSync(join(root,'installation.json')) || existsSync(join(effects.home,'fleet.yaml'))))throw Error('Existing host requires its gateway client profile; no new root was created');
51
+ const options=[...rest];
52
+ if(!parsed.scope)options.push('--scope',existing?'client':'all');
53
+ if(existing && !parsed.config)options.push('--config',existingProfile);
54
+ if(!existing){if(!parsed.mode)options.push('--mode','docker');if(!parsed.stateDir)options.push('--state-dir',root);options.push('--identity-name',p.rootName);}
55
+ options.push('--integrations','none');
56
+ parseSetupArgs(options,{home:effects.home});
57
+ if(preserveProfile){
58
+ const version=await effects.run(fleetBin,['version','--json'],{sensitive:true});
59
+ let info;try{info=JSON.parse(version.stdout);}catch{}
60
+ if(!version.ok || !info?.capabilities?.includes('workspace.enroll.preserve-profile-v1'))throw Error('Existing-host setup requires a profile-preserving Fleet release; nothing was changed');
61
+ }
62
+ const help=await effects.run(fleetBin,['workspace-enroll','--help'],{sensitive:true});if(!help.ok)throw Error('Fleet workspace enrollment is unavailable');
63
+ const tunnel=await effects.run('cloudflared',['--version'],{sensitive:true});if(!tunnel.ok)throw Error('Tunnel connector is unavailable');
64
+ if(options.includes('--dry-run')){if(!existing)return runSetup(options,effects);return 0;}
65
+ if(!existing){
66
+ if(!pending){mkdirSync(join(effects.home,'.ours-client'),{recursive:true,mode:0o700});writeFileSync(marker,JSON.stringify({version:1,accountId:p.challenge.accountId,workspaceId:p.challenge.workspaceId,appOrigin:p.appOrigin})+'\n',{mode:0o600,flag:'wx'});}
67
+ const result=await runSetup(options,effects);if(result!==0)return result;
68
+ }
69
+ const selected=existingProfile || effects.readManagedClientProfile();if(!selected)throw Error('Workspace client profile was not installed');
70
+ const env={OURS_CONFIG:existingProfile || managedPath};
71
+ for(const key of ['OURS_PORT','OURS_STATE_DIR','OURS_API_TOKEN','OURS_DAEMON_ID','OURS_DAEMON_URL','OURS_DAEMON_CREDENTIAL_PATH'])env[key]=undefined;
72
+ const fleetHome=effects.env?.OURS_FLEET_HOME || effects.home,fleetConfig=join(fleetHome,'fleet.yaml');
73
+ if(!existsSync(fleetConfig)){
74
+ const initArgs=['init',...(fleetSettings?['--settings',fleetSettings]:[])];
75
+ const initialized=fleetSettings?await effects.run(fleetBin,initArgs,{env}):await effects.runInteractive(fleetBin,initArgs,{env});if(!initialized.ok)throw Error('Fleet configuration incomplete');
76
+ }
77
+ mkdirSync(root,{recursive:true,mode:0o700});const temporary=mkdtempSync(join(root,'.workspace-payload-'));chmodSync(temporary,0o700);
78
+ const payloadFile=join(temporary,'payload');writeFileSync(payloadFile,encoded,{mode:0o600,flag:'wx'});
79
+ try{const enrolled=await effects.run(fleetBin,['workspace-enroll','--file',payloadFile,...(preserveProfile?['--preserve-profile']:[]),...(migrateAppOrigin?['--migrate-app-origin']:[])],{env,stream:true,sensitive:true});if(!enrolled.ok)throw Error('Workspace enrollment incomplete; existing identity and profile were retained');}
80
+ finally{rmSync(temporary,{recursive:true,force:true});}
81
+ if(pending || !existing)rmSync(marker);
82
+ effects.out('Workspace proof submitted. Check verified binding/tunnel status in your account, then scan or paste the private device code.');return 0;
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "1.2.1-nightly.3",
3
+ "version": "1.2.1-nightly.31",
4
4
  "private": false,
5
5
  "description": "The all-in-one ours.network installer: one shared daemon, MCP, cowork, Telegram, Fleet initialization, harness plugins, Human identity, progress UI, and guided next steps.",
6
6
  "type": "module",
@@ -15,7 +15,9 @@
15
15
  "uninstall.mjs",
16
16
  "uninstall.sh",
17
17
  "README.md",
18
- "LICENSE"
18
+ "ROOTLESS_PODMAN_VALIDATION.md",
19
+ "LICENSE",
20
+ "GATEWAY_SETUP.md"
19
21
  ],
20
22
  "license": "FSL-1.1-Apache-2.0",
21
23
  "author": "Adapt Toolkit",