@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.
- package/GATEWAY_SETUP.md +178 -0
- package/README.md +353 -9
- package/ROOTLESS_PODMAN_VALIDATION.md +210 -0
- package/assets/Dockerfile +17 -13
- package/assets/Dockerfile.gateway +5 -0
- package/assets/docker-compose.yaml +4 -1
- package/assets/fleet-settings.example.json +48 -0
- package/assets/gateway-entrypoint.sh +37 -0
- package/assets/release-lock.json +1167 -4499
- package/assets/release.json +37 -19
- package/assets/scripts/build/build-common.mjs +2 -1
- package/assets/scripts/build/build-sdk.mjs +6 -1
- package/assets/scripts/maintenance/build-context.mjs +1 -1
- package/assets/scripts/maintenance/daemon-owner.mjs +15 -0
- package/assets/scripts/maintenance/docker-layout-conversion.mjs +7 -3
- package/assets/scripts/maintenance/release-graph.mjs +18 -1
- package/assets/scripts/maintenance/state-operation.mjs +4 -2
- package/assets/scripts/runtime/client-setup.mjs +80 -6
- package/assets/scripts/runtime/diagnostics.mjs +72 -0
- package/assets/scripts/runtime/entrypoint.sh +1 -1
- package/assets/scripts/runtime/health-messenger.mjs +7 -6
- package/assets/scripts/runtime/notifications-gateway.mjs +82 -0
- package/assets/scripts/runtime/start-messenger.sh +25 -4
- package/assets/scripts/runtime/supervise.mjs +35 -0
- package/assets/sources.json +54 -28
- package/install.mjs +7 -2
- package/lib/build-transition.mjs +3 -0
- package/lib/client-cli.mjs +127 -0
- package/lib/container-engine.mjs +127 -0
- package/lib/diagnostics.mjs +1 -0
- package/lib/docker-conversion-runtime.mjs +3 -2
- package/lib/docker-layout-installation.mjs +2 -1
- package/lib/docker-runtime-repair.mjs +160 -0
- package/lib/effects.mjs +298 -132
- package/lib/gateway-transition.mjs +87 -0
- package/lib/gateway.mjs +158 -0
- package/lib/legacy-migration.mjs +13 -2
- package/lib/orchestrate.mjs +135 -43
- package/lib/plan.mjs +27 -11
- package/lib/podman-preflight.mjs +40 -0
- package/lib/server-onboarding.mjs +164 -16
- package/lib/server-ports.mjs +17 -0
- package/lib/setup-options.mjs +88 -27
- package/lib/setup.mjs +35 -18
- package/lib/target.mjs +24 -8
- package/lib/uninstall.mjs +2 -2
- package/lib/usage.mjs +19 -6
- package/lib/workspace-setup.mjs +83 -0
- package/package.json +4 -2
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
|
|
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'
|
|
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'
|
|
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 =
|
|
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 &&
|
|
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'
|
|
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'
|
|
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'
|
|
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'
|
|
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'
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
95
|
-
if (url.protocol !== 'http:'
|
|
96
|
-
throw profileError('endpoint must be an HTTP
|
|
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
|
-
|
|
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 === '
|
|
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
|
|
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
|
|
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
|
|
2
|
+
export const USAGE = `ours-install — interactive setup with optional CLI presets.
|
|
3
3
|
|
|
4
4
|
ours-install
|
|
5
5
|
Opens the console form. Choose all (server + clients), server, or client;
|
|
6
6
|
runtime, installation directory, identity, integrations and Fleet settings.
|
|
7
|
-
|
|
7
|
+
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;
|
|
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
|
|
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
|
|
43
|
-
|
|
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
|
+
"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
|
-
"
|
|
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",
|