@ours.network/install 1.2.1-nightly.1 → 1.2.1-nightly.3
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/README.md +94 -126
- package/assets/Dockerfile +1 -0
- package/assets/docker-compose.yaml +20 -0
- package/assets/release-lock.json +6 -6
- package/assets/release.json +1 -1
- package/assets/scripts/runtime/legacy-import.mjs +103 -0
- package/assets/sources.json +1 -1
- package/install.mjs +2 -2
- package/lib/build-transition.mjs +13 -7
- package/lib/effects.mjs +99 -30
- package/lib/fleet-settings.mjs +43 -0
- package/lib/legacy-migration.mjs +202 -0
- package/lib/legacy-state.mjs +205 -0
- package/lib/managed-cli.mjs +164 -0
- package/lib/orchestrate.mjs +69 -24
- package/lib/prompt.mjs +113 -119
- package/lib/server-onboarding.mjs +114 -0
- package/lib/setup-options.mjs +253 -0
- package/lib/setup.mjs +155 -0
- package/lib/usage.mjs +49 -44
- package/package.json +1 -1
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { join, resolve, isAbsolute, dirname } from 'node:path';
|
|
2
|
+
|
|
3
|
+
const scopes = ['all', 'server', 'client'];
|
|
4
|
+
const integrations = ['codex', 'claude-code', 'fleet'];
|
|
5
|
+
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
|
+
'--config': 'config', '--sources': 'sources', '--migrate-from': 'migrateFrom', '--port': 'port', '--cowork-port': 'coworkPort', '--messenger-port': 'messengerPort',
|
|
9
|
+
}));
|
|
10
|
+
const boolFlags = new Map([['--compatible', 'compatible'], ['--dry-run', 'dryRun'], ['--migrate', 'migrate']]);
|
|
11
|
+
const allowed = new Set([...valueFlags.values(), ...boolFlags.values(), 'interactive', 'explicitPorts']);
|
|
12
|
+
const defaults = { port: 3050, coworkPort: 3052, messengerPort: 8420 };
|
|
13
|
+
const paths = ['stateDir', 'config', 'sources', 'fleetSettingsPath', 'migrateFrom'];
|
|
14
|
+
const nonempty = value => typeof value === 'string' && value.trim().length > 0 && !/[\x00-\x1f\x7f]/.test(value);
|
|
15
|
+
|
|
16
|
+
export function recommendedMode({ platform, arch, release = '' } = {}) {
|
|
17
|
+
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.' };
|
|
19
|
+
if (platform === 'darwin') return { mode: 'docker', reason: 'Docker is recommended on macOS.' };
|
|
20
|
+
if (platform === 'win32') return { mode: 'docker', reason: 'Docker is recommended on Windows; run the installer inside WSL with Docker Desktop.' };
|
|
21
|
+
return { mode: 'docker', reason: 'Docker is recommended for this platform.' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function validateSetupOptions(input, { interactive = input?.interactive === true } = {}) {
|
|
25
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Setup options must be an object');
|
|
26
|
+
for (const key of Object.keys(input)) if (!allowed.has(key)) throw new Error(`Unknown setup option: ${key}`);
|
|
27
|
+
const options = { ...input, scope: input.scope ?? 'all', operation: input.operation ?? 'install', interactive };
|
|
28
|
+
if (!scopes.includes(options.scope)) throw new Error('Scope must be all, server, or client');
|
|
29
|
+
if (!['install', 'update'].includes(options.operation)) throw new Error('Operation must be install or update');
|
|
30
|
+
const server = options.scope !== 'client';
|
|
31
|
+
const client = options.scope !== 'server';
|
|
32
|
+
if (options.migrateFrom !== undefined && (!server || options.operation !== 'install')) throw new Error('--migrate-from requires server or all scope and operation install');
|
|
33
|
+
if (options.migrateFrom !== undefined && (!nonempty(options.migrateFrom) || !isAbsolute(options.migrateFrom))) throw new Error('--migrate-from requires an absolute daemon config path');
|
|
34
|
+
const missing = [];
|
|
35
|
+
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
|
+
if (client && options.integrations === undefined) missing.push('--integrations (use none to skip)');
|
|
37
|
+
if (!server && !nonempty(options.config)) missing.push('--config');
|
|
38
|
+
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');
|
|
40
|
+
if (missing.length) throw new Error(`Missing required setup options: ${missing.join(', ')}`);
|
|
41
|
+
if (options.mode === 'native') options.mode = 'packages';
|
|
42
|
+
if (server && !['packages', 'docker'].includes(options.mode)) throw new Error('Mode must be packages (or native) or docker');
|
|
43
|
+
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']) {
|
|
45
|
+
if (options[key] !== undefined) throw new Error(`${key} is only valid for server or all scope`);
|
|
46
|
+
}
|
|
47
|
+
if (!client && (options.integrations !== undefined || options.fleetSettingsPath !== undefined)) throw new Error('--integrations and --fleet-settings require all or client scope');
|
|
48
|
+
if (options.integrations !== undefined) {
|
|
49
|
+
if (!Array.isArray(options.integrations) || options.integrations.some(name => !integrations.includes(name)) || new Set(options.integrations).size !== options.integrations.length) throw new Error('Integrations must be unique codex, claude-code, fleet, or none');
|
|
50
|
+
options.integrations = [...options.integrations];
|
|
51
|
+
}
|
|
52
|
+
if (options.fleetSettingsPath !== undefined && !options.integrations?.includes('fleet')) throw new Error('--fleet-settings requires the fleet integration');
|
|
53
|
+
for (const key of paths) if (options[key] !== undefined && !nonempty(options[key])) throw new Error(`Invalid path for ${key}`);
|
|
54
|
+
for (const key of ['compatible', 'migrate']) if (options[key] !== undefined && typeof options[key] !== 'boolean') throw new Error(`${key} must be a boolean`);
|
|
55
|
+
if (server) {
|
|
56
|
+
if (options.identityName !== undefined) {
|
|
57
|
+
if (!nonempty(options.identityName)) throw new Error('Invalid Human identity name');
|
|
58
|
+
options.identityName = options.identityName.trim();
|
|
59
|
+
}
|
|
60
|
+
for (const [key, fallback] of Object.entries(defaults)) {
|
|
61
|
+
options[key] ??= fallback;
|
|
62
|
+
if (!Number.isInteger(options[key]) || options[key] < 1 || options[key] > 65535) throw new Error(`${key} must be an integer port between 1 and 65535`);
|
|
63
|
+
}
|
|
64
|
+
if (new Set(Object.keys(defaults).map(key => options[key])).size !== 3) throw new Error('Server ports must be distinct');
|
|
65
|
+
}
|
|
66
|
+
options.explicitPorts ??= [];
|
|
67
|
+
if (!Array.isArray(options.explicitPorts) || options.explicitPorts.some(key => !Object.hasOwn(defaults, key)) || new Set(options.explicitPorts).size !== options.explicitPorts.length || (!server && options.explicitPorts.length)) throw new Error('Invalid explicitPorts selection');
|
|
68
|
+
options.explicitPorts = [...options.explicitPorts];
|
|
69
|
+
return options;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function expandPaths(options, home) {
|
|
73
|
+
const output = { ...options };
|
|
74
|
+
for (const key of paths) if (nonempty(output[key])) {
|
|
75
|
+
const value = output[key];
|
|
76
|
+
if (value === '~' || value.startsWith('~/')) {
|
|
77
|
+
if (!nonempty(home)) throw new Error('Home directory is required to expand ~/ paths');
|
|
78
|
+
output[key] = resolve(home, value === '~' ? '' : value.slice(2));
|
|
79
|
+
} else {
|
|
80
|
+
if (key === 'migrateFrom' && !isAbsolute(value)) throw new Error('--migrate-from requires an absolute daemon config path');
|
|
81
|
+
output[key] = resolve(value);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return output;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function parseSetupArgs(argv, { home } = {}) {
|
|
88
|
+
if (!Array.isArray(argv) || !argv.every(arg => typeof arg === 'string')) throw new Error('Arguments must be strings');
|
|
89
|
+
const options = { interactive: false, explicitPorts: [] };
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
const positional = [];
|
|
92
|
+
const put = (key, value) => {
|
|
93
|
+
if (seen.has(key)) throw new Error(`Duplicate or conflicting setup option: ${key}`);
|
|
94
|
+
seen.add(key); options[key] = value;
|
|
95
|
+
};
|
|
96
|
+
for (let i = 0; i < argv.length; i++) {
|
|
97
|
+
const arg = argv[i];
|
|
98
|
+
if (!arg.startsWith('-')) { positional.push(arg); continue; }
|
|
99
|
+
const equal = arg.indexOf('=');
|
|
100
|
+
const flag = equal < 0 ? arg : arg.slice(0, equal);
|
|
101
|
+
if (boolFlags.has(flag)) {
|
|
102
|
+
if (equal >= 0) throw new Error(`${flag} takes no value`);
|
|
103
|
+
put(boolFlags.get(flag), true); continue;
|
|
104
|
+
}
|
|
105
|
+
if (!valueFlags.has(flag)) throw new Error(`Unknown setup flag: ${flag}`);
|
|
106
|
+
const value = equal < 0 ? argv[++i] : arg.slice(equal + 1);
|
|
107
|
+
if (!nonempty(value) || value.startsWith('--')) throw new Error(`${flag} requires a value`);
|
|
108
|
+
const key = valueFlags.get(flag);
|
|
109
|
+
if (key === 'integrations') put(key, value === 'none' ? [] : value.split(',').map(item => item.trim()));
|
|
110
|
+
else if (Object.hasOwn(defaults, key)) {
|
|
111
|
+
if (!/^[1-9]\d*$/.test(value)) throw new Error(`${flag} requires an integer port`);
|
|
112
|
+
put(key, Number(value)); options.explicitPorts.push(key);
|
|
113
|
+
} else put(key, value);
|
|
114
|
+
}
|
|
115
|
+
if (positional.length && scopes.includes(positional[0])) put('scope', positional.shift());
|
|
116
|
+
if (positional.length && ['install', 'update'].includes(positional[0])) put('operation', positional.shift());
|
|
117
|
+
if (positional.length) throw new Error(`Unexpected setup argument: ${positional.join(' ')}`);
|
|
118
|
+
return validateSetupOptions(expandPaths(options, home), { interactive: false });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function collectSetupOptions(effects) {
|
|
122
|
+
if (effects.interactive !== true) throw new Error('Interactive setup requires a TTY; provide the complete CLI options instead');
|
|
123
|
+
const options = { interactive: true, explicitPorts: [] };
|
|
124
|
+
const scopeChoices = [
|
|
125
|
+
{ value: 'all', label: 'Everything on this computer' },
|
|
126
|
+
{ value: 'server', label: 'Server only' },
|
|
127
|
+
{ value: 'client', label: 'Connect this computer to an existing server' },
|
|
128
|
+
];
|
|
129
|
+
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');
|
|
131
|
+
if (!scopes.includes(options.scope)) throw new Error('Scope must be all, server, or client');
|
|
132
|
+
let existing;
|
|
133
|
+
let pendingMigration;
|
|
134
|
+
if (options.scope !== 'client') {
|
|
135
|
+
const recommendedRoot = join(effects.home, '.ours-install');
|
|
136
|
+
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?', [
|
|
138
|
+
{ value: 'recommended', label: `Use the recommended folder (${recommendedRoot})` },
|
|
139
|
+
{ value: 'custom', label: 'Choose another folder' },
|
|
140
|
+
], 'recommended');
|
|
141
|
+
options.stateDir = location === 'recommended' ? recommendedRoot : await effects.askLine('Folder for server programs and data: ', recommendedRoot);
|
|
142
|
+
options.stateDir = expandPaths({ stateDir: options.stateDir }, effects.home).stateDir;
|
|
143
|
+
existing = effects.readJson(join(options.stateDir, 'installation.json'));
|
|
144
|
+
const migrationJournal = existing?.legacyMigrationSource ? effects.readJson(join(options.stateDir, 'legacy-migration.json')) : null;
|
|
145
|
+
pendingMigration = existing?.legacyMigrationSource && migrationJournal?.phase !== 'complete' ? existing.legacyMigrationSource : null;
|
|
146
|
+
}
|
|
147
|
+
effects.out(pendingMigration
|
|
148
|
+
? 'An earlier migration is unfinished. Resume it in this same folder to retain the copied data and repair the installation.'
|
|
149
|
+
: existing
|
|
150
|
+
? 'This folder already contains an installation. Update selects this installer’s release while retaining state; repair reinstalls the retained package selection.'
|
|
151
|
+
: options.scope === 'client' ? 'Install connects your selected apps. Update refreshes their packages while keeping the server connection.'
|
|
152
|
+
: '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?', [
|
|
154
|
+
{ value: 'install', label: pendingMigration ? 'Resume the unfinished migration' : existing ? 'Repair this installation' : 'Install and configure' },
|
|
155
|
+
{ value: 'update', label: 'Update an existing installation' },
|
|
156
|
+
], existing && !pendingMigration ? 'update' : 'install');
|
|
157
|
+
if (options.scope !== 'client' && options.operation === 'install') {
|
|
158
|
+
const defaultConfig = join(effects.home, '.ours', 'config.json');
|
|
159
|
+
const legacy = !existing ? effects.readJson(defaultConfig) : null;
|
|
160
|
+
const legacyState = nonempty(legacy?.stateDir) ? legacy.stateDir
|
|
161
|
+
: legacy && legacy.stateDir === undefined && nonempty(effects.readJson(join(effects.home, '.ours', 'root.json'))?.name)
|
|
162
|
+
? join(effects.home, '.ours') : null;
|
|
163
|
+
if (pendingMigration) options.migrateFrom = pendingMigration;
|
|
164
|
+
else if (legacyState) {
|
|
165
|
+
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
|
+
if (await effects.ask('Upgrade this existing ours installation and keep its data?', true)) options.migrateFrom = defaultConfig;
|
|
167
|
+
else {
|
|
168
|
+
effects.out('A separate installation creates a different server. It does not move or share the identities and messages in the existing installation.');
|
|
169
|
+
if (!await effects.ask('Create a separate fresh installation and keep the existing daemon unchanged?', false)) throw new Error('Setup cancelled; existing daemon was not changed');
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
effects.out('Start fresh if you have no existing ours data to bring over. If your old installation is stored elsewhere, select its configuration file to retain its data.');
|
|
173
|
+
const migration = await effects.select('Should existing ours data be brought over?', [
|
|
174
|
+
{ value: 'fresh', label: 'Start a fresh installation' },
|
|
175
|
+
{ value: 'migrate', label: 'Bring data from another existing installation' },
|
|
176
|
+
], 'fresh');
|
|
177
|
+
if (migration === 'migrate') options.migrateFrom = expandPaths({ migrateFrom: await effects.askLine('Existing daemon configuration file: ', '') }, effects.home).migrateFrom;
|
|
178
|
+
}
|
|
179
|
+
if (options.migrateFrom) {
|
|
180
|
+
if (existing && pendingMigration !== options.migrateFrom) throw new Error('--migrate-from requires a new managed installation root; the selected root already has installation.json');
|
|
181
|
+
const sourceConfig = effects.readJson(options.migrateFrom);
|
|
182
|
+
const sourceState = sourceConfig?.stateDir ?? dirname(options.migrateFrom);
|
|
183
|
+
const root = effects.readJson(join(sourceState, 'root.json'));
|
|
184
|
+
if (!nonempty(root?.name)) throw new Error('Cannot read the existing Human identity name; choose the configuration file for a complete existing installation');
|
|
185
|
+
options.identityName = root.name;
|
|
186
|
+
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);
|
|
188
|
+
if (!options.compatible) throw new Error('Migration requires explicit compatibility confirmation (--compatible)');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (options.scope !== 'client') {
|
|
192
|
+
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);
|
|
197
|
+
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');
|
|
200
|
+
}
|
|
201
|
+
for (const [key, fallback] of Object.entries(defaults)) options[key] = existing?.[key] ?? fallback;
|
|
202
|
+
if (options.operation === 'update') {
|
|
203
|
+
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);
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
const savedProfile = join(effects.home, '.ours-client', 'profile.json');
|
|
208
|
+
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?', [
|
|
210
|
+
{ value: 'saved', label: 'Use the saved server connection' }, { value: 'file', label: 'Choose a connection profile file' },
|
|
211
|
+
], effects.readJson(savedProfile) ? 'saved' : 'file');
|
|
212
|
+
options.config = connection === 'saved' ? savedProfile : await effects.askLine('Connection profile file: ', savedProfile);
|
|
213
|
+
}
|
|
214
|
+
if (options.scope !== 'server') {
|
|
215
|
+
const detected = typeof effects.detectHarnesses === 'function' ? await effects.detectHarnesses() : [];
|
|
216
|
+
const selected = integrations.filter(name => name === 'fleet' || detected.some(item => item.name === name && item.status === 'ok'));
|
|
217
|
+
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?', [
|
|
219
|
+
{ value: 'codex', label: 'Codex' }, { value: 'claude-code', label: 'Claude Code' }, { value: 'fleet', label: 'Fleet — persistent agents' },
|
|
220
|
+
], selected);
|
|
221
|
+
if (options.integrations.includes('fleet')) {
|
|
222
|
+
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
|
+
const fleetMode = await effects.select('How should Fleet be configured?', [
|
|
224
|
+
{ value: 'wizard', label: 'Configure interactively with Fleet' }, { value: 'file', label: 'Use a prepared settings file' },
|
|
225
|
+
], 'wizard');
|
|
226
|
+
if (fleetMode === 'file') options.fleetSettingsPath = await effects.askLine('Fleet settings file: ', '');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
effects.out('Recommended settings use the standard service ports and this installer’s packaged release. Customize only if you need different ports or a development package selection.');
|
|
230
|
+
const advanced = await effects.select('Installation settings', [
|
|
231
|
+
{ value: 'recommended', label: 'Use recommended settings' }, { value: 'custom', label: 'Customize' },
|
|
232
|
+
], 'recommended');
|
|
233
|
+
if (advanced === 'custom') {
|
|
234
|
+
if (options.scope !== 'client') {
|
|
235
|
+
effects.out('Ports determine where local apps reach each service. Use three different available ports; an existing installation keeps its recorded ports.');
|
|
236
|
+
for (const key of Object.keys(defaults)) {
|
|
237
|
+
const label = { port: 'Daemon', coworkPort: 'Cowork', messengerPort: 'Messenger' }[key];
|
|
238
|
+
const value = await effects.askLine(`${label} port: `, String(options[key]));
|
|
239
|
+
if (!/^[1-9]\d*$/.test(value)) throw new Error(`${label} port must be an integer`);
|
|
240
|
+
options[key] = Number(value); options.explicitPorts.push(key);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
effects.out('A source override replaces the packaged release selection. Leave it empty for the supported packaged release; use a file only for a deliberate development override.');
|
|
244
|
+
const sources = await effects.askLine('Source policy override file (optional): ', '');
|
|
245
|
+
if (sources.trim()) options.sources = sources;
|
|
246
|
+
}
|
|
247
|
+
const validated = validateSetupOptions(expandPaths(options, effects.home), { interactive: true });
|
|
248
|
+
const scopeLabel = scopeChoices.find(choice => choice.value === validated.scope).label;
|
|
249
|
+
effects.out(`Setup: ${scopeLabel}; ${validated.operation}${validated.mode ? ` using ${validated.mode === 'packages' ? 'native packages' : 'Docker'} in ${validated.stateDir}` : ` from ${validated.config}`}; integrations: ${validated.integrations?.join(', ') || 'none'}${validated.migrateFrom ? `; migrate from: ${validated.migrateFrom}` : ''}.`);
|
|
250
|
+
effects.out('Continue to apply these choices. Cancelling now leaves programs, services and data unchanged.');
|
|
251
|
+
if (!await effects.ask('Continue with this setup?', false)) throw new Error('Setup cancelled; nothing was changed');
|
|
252
|
+
return validated;
|
|
253
|
+
}
|
package/lib/setup.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { inspectLegacyMigration } from './legacy-migration.mjs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseSetupArgs, collectSetupOptions, validateSetupOptions } from './setup-options.mjs';
|
|
4
|
+
import { parseNetworkArgs, validateHostProfile, InstallUsageError } from './target.mjs';
|
|
5
|
+
import { validateInstallation } from './plan.mjs';
|
|
6
|
+
import { runServerCommand, runClientCommand } from './orchestrate.mjs';
|
|
7
|
+
import { banner, heading, info, ok, warn, progress } from './ui.mjs';
|
|
8
|
+
import { isCancel } from './prompt.mjs';
|
|
9
|
+
import { USAGE } from './usage.mjs';
|
|
10
|
+
import { validateIdentityName } from './server-onboarding.mjs';
|
|
11
|
+
import { validateFleetSettings } from './fleet-settings.mjs';
|
|
12
|
+
|
|
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])];
|
|
15
|
+
|
|
16
|
+
export function completeReleasePolicy(retained, supplied) {
|
|
17
|
+
if (retained?.release) {
|
|
18
|
+
return { release: retained.release, packages: Object.fromEntries(Object.entries(retained.release.packages).map(([name, entry]) => [name, { type: 'npm', version: entry.version }])) };
|
|
19
|
+
}
|
|
20
|
+
if (supplied) return supplied;
|
|
21
|
+
throw new InstallUsageError('This development installation needs its full --sources policy to configure clients; server-only selections cannot supply client packages');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readObject(effects, path, label) {
|
|
25
|
+
const value = effects.readJson(path);
|
|
26
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new InstallUsageError(`${label} must be a readable JSON object: ${path}`);
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Read-only validation of the complete plan, before any locks, installs or service changes. */
|
|
31
|
+
export async function prepareSetupPlan(options, effects) {
|
|
32
|
+
options = validateSetupOptions(options, { interactive: options.interactive });
|
|
33
|
+
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
|
+
const plan = { ...options };
|
|
35
|
+
if (options.scope !== 'server' && options.integrations.length) {
|
|
36
|
+
for (const name of ['OURS_API_TOKEN', 'OURS_PORT', 'OURS_STATE_DIR', 'OURS_DAEMON_ID']) {
|
|
37
|
+
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
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (options.scope !== 'client' && !options.migrateFrom) validateIdentityName(options.identityName);
|
|
41
|
+
if (options.fleetSettingsPath) validateFleetSettings(readObject(effects, options.fleetSettingsPath, 'Fleet settings'));
|
|
42
|
+
const policy = options.sources ? readObject(effects, options.sources, 'Source policy') : effects.packagedSourcePolicy();
|
|
43
|
+
plan.sourcePolicy = policy;
|
|
44
|
+
if (options.migrateFrom) {
|
|
45
|
+
for (const name of ['OURS_API_TOKEN', 'OURS_API_VISIBILITY', 'OURS_STATE_DIR', 'OURS_PORT', 'OURS_DAEMON_ID', 'OURS_DATABASE_PROVIDER', 'OURS_DATABASE_URL']) {
|
|
46
|
+
if (effects.env?.[name]?.trim()) throw new InstallUsageError(`${name} conflicts with legacy migration. Clear it before setup; nothing was changed.`);
|
|
47
|
+
}
|
|
48
|
+
plan.legacyPlan = await (effects.inspectLegacyMigration ?? inspectLegacyMigration)(options, effects);
|
|
49
|
+
plan.sourcePolicy = plan.legacyPlan.journal.sourcePolicy ?? policy;
|
|
50
|
+
plan.identityName = plan.legacyPlan.source?.rootName ?? plan.legacyPlan.journal.identities.find(row => row.kind === 'root')?.name;
|
|
51
|
+
validateIdentityName(plan.identityName);
|
|
52
|
+
}
|
|
53
|
+
if (options.scope !== 'client') {
|
|
54
|
+
const value = effects.readJson(join(options.stateDir, 'installation.json'));
|
|
55
|
+
if (value) {
|
|
56
|
+
plan.existing = validateInstallation(value, options.stateDir);
|
|
57
|
+
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
|
+
for (const key of ['port', 'coworkPort', 'messengerPort']) {
|
|
59
|
+
if (options.explicitPorts?.includes(key) && options[key] !== plan.existing[key]) throw new InstallUsageError(`${key} conflicts with the retained installation`);
|
|
60
|
+
plan[key] = plan.existing[key];
|
|
61
|
+
}
|
|
62
|
+
if (options.operation === 'install' && !options.migrateFrom) {
|
|
63
|
+
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);
|
|
65
|
+
}
|
|
66
|
+
} else if (options.operation === 'update') {
|
|
67
|
+
throw new InstallUsageError('Update requires an existing installation.json; choose install for a new installation');
|
|
68
|
+
}
|
|
69
|
+
// 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}`)) {
|
|
72
|
+
throw new InstallUsageError('This user already has clients attached to a different server; their saved connection was not changed');
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
const profile = validateHostProfile(readObject(effects, options.config, 'Client profile'));
|
|
76
|
+
if (!profile) throw new InstallUsageError('Client profile must contain endpoint, expectedInstanceId and credentialPath');
|
|
77
|
+
if (!effects.readText(profile.credentialPath)?.trim()) throw new InstallUsageError('Client credential file is missing or empty');
|
|
78
|
+
plan.profile = profile;
|
|
79
|
+
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');
|
|
81
|
+
if (saved && options.operation === 'install' && !options.sources) plan.sourcePolicy = readObject(effects, saved.installer.sourcesPath, 'Retained client source policy');
|
|
82
|
+
}
|
|
83
|
+
// Dry-run never spawns a resolver or acquires an installation lock.
|
|
84
|
+
if (!options.dryRun) {
|
|
85
|
+
if (options.scope === 'all' && options.operation === 'update' && options.integrations.length) {
|
|
86
|
+
const running = await effects.serverLifecycle(plan.existing, 'status', ['daemon']);
|
|
87
|
+
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
|
+
}
|
|
89
|
+
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));
|
|
91
|
+
}
|
|
92
|
+
return plan;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function executeSetupPlan(plan, effects, { server = runServerCommand, client = runClientCommand } = {}) {
|
|
96
|
+
if (!plan.interactive) effects.out(banner());
|
|
97
|
+
effects.out(heading(`${plan.operation === 'update' ? 'Update' : 'Install'} ours.network`));
|
|
98
|
+
effects.out(info(`Scope: ${plan.scope}; ${plan.scope === 'client' ? `profile: ${plan.config}` : `mode: ${plan.mode === 'packages' ? 'native' : 'docker'}; directory: ${plan.stateDir}`}`));
|
|
99
|
+
if (plan.scope !== 'server') effects.out(info(`Client integrations: ${plan.integrations.join(', ') || 'none'}`));
|
|
100
|
+
if (plan.dryRun) {
|
|
101
|
+
effects.out(info('Preview only. No packages, identities, credentials or services will be changed.'));
|
|
102
|
+
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.'));
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
let clientConfig = plan.config;
|
|
107
|
+
let clientPolicy = plan.sourcePolicy;
|
|
108
|
+
if (plan.scope !== 'client') {
|
|
109
|
+
effects.out(heading(plan.operation === 'update' ? 'Server update and identity restoration' : 'Server installation'));
|
|
110
|
+
const result = await server({ ...plan, role: 'server', operation: plan.operation, sourcePolicy: plan.sourcePolicy }, effects);
|
|
111
|
+
if (result !== 0) return result;
|
|
112
|
+
const record = validateInstallation(effects.readJson(join(plan.stateDir, 'installation.json')), plan.stateDir);
|
|
113
|
+
if (plan.operation === 'update') {
|
|
114
|
+
effects.out(progress(0, 1, 'Retained identities', 'Verify the Human identity after state restoration; existing names and keys are retained.'));
|
|
115
|
+
const running = await effects.serverLifecycle(record, 'status', ['daemon']);
|
|
116
|
+
if (running.includes('daemon')) {
|
|
117
|
+
await effects.serverEnsureIdentity(record, plan.identityName);
|
|
118
|
+
effects.out(ok('Retained identities verified.'));
|
|
119
|
+
} else effects.out(info('Daemon remains stopped; stored identities are retained and will restore on the next start.'));
|
|
120
|
+
}
|
|
121
|
+
if (plan.scope === 'all' && plan.integrations.length) {
|
|
122
|
+
clientPolicy = completeReleasePolicy(readObject(effects, record.sourcesPath, 'Server source policy'), plan.sourcePolicy);
|
|
123
|
+
effects.out(heading('Connect local clients'));
|
|
124
|
+
const handoff = await effects.prepareLocalClient(record, plan.integrations, plan.fleetSettingsPath);
|
|
125
|
+
clientConfig = handoff.configPath;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (plan.scope !== 'server' && plan.integrations.length) {
|
|
129
|
+
const result = await client({ role: 'client', operation: 'install', config: clientConfig,
|
|
130
|
+
integrations: plan.integrations, fleetSettingsPath: plan.fleetSettingsPath, sourcePolicy: clientPolicy,
|
|
131
|
+
preset: true, nonInteractive: !plan.interactive }, effects);
|
|
132
|
+
if (result !== 0) return result;
|
|
133
|
+
}
|
|
134
|
+
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.'));
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The sole executable entry: manual answers and CLI presets share one plan/executor. */
|
|
140
|
+
export async function runSetup(argv, effects) {
|
|
141
|
+
try {
|
|
142
|
+
if (argv.includes('--help') || argv.includes('-h')) { effects.out(USAGE); return 0; }
|
|
143
|
+
if (argv.length === 1 && ['--version', '-V'].includes(argv[0])) { effects.out(effects.version ?? 'unknown'); return 0; }
|
|
144
|
+
if (argv[0] === 'server' && maintenance.has(argv[1])) return await runServerCommand(parseNetworkArgs(argv), effects);
|
|
145
|
+
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
|
+
if (!argv.length) { effects.out(banner()); effects.out(heading('Interactive setup')); }
|
|
147
|
+
const options = argv.length ? parseSetupArgs(argv, { home: effects.home }) : await collectSetupOptions(effects);
|
|
148
|
+
const plan = await prepareSetupPlan(options, effects);
|
|
149
|
+
return await executeSetupPlan(plan, effects);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (isCancel(error)) { effects.out(warn('Installation cancelled.')); return 130; }
|
|
152
|
+
effects.out(warn(`ours-install: ${error.message}`));
|
|
153
|
+
return 2;
|
|
154
|
+
}
|
|
155
|
+
}
|
package/lib/usage.mjs
CHANGED
|
@@ -1,55 +1,60 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
// It lives here rather than in the bin because `--help` is a behaviour with a
|
|
4
|
-
// contract (the flags it names must be the flags target.mjs accepts), and a bin
|
|
5
|
-
// that is three lines long cannot be the place a contract is asserted.
|
|
1
|
+
// Public setup and maintenance commands.
|
|
2
|
+
export const USAGE = `ours-install — interactive setup or complete CLI presets.
|
|
6
3
|
|
|
7
|
-
|
|
4
|
+
ours-install
|
|
5
|
+
Opens the console form. Choose all (server + clients), server, or client;
|
|
6
|
+
runtime, installation directory, identity, integrations and Fleet settings.
|
|
7
|
+
Linux x64 recommends native; macOS/Windows recommend Docker. Windows uses WSL.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
ours-install
|
|
11
|
-
ours-install server status|start|stop|restart --state-dir PATH
|
|
12
|
-
ours-install server rebuild --state-dir PATH
|
|
13
|
-
ours-install server update --state-dir PATH [--sources PATH] --compatible
|
|
14
|
-
ours-install server access-issue --state-dir PATH --output PATH
|
|
15
|
-
ours-install server access-replace --state-dir PATH --confirm
|
|
16
|
-
ours-install server backup|restore server LABEL --state-dir PATH
|
|
17
|
-
ours-install server backup|restore daemon|telegram|cowork|messenger LABEL --state-dir PATH
|
|
18
|
-
ours-install server reset daemon|telegram|cowork|messenger --confirm --state-dir PATH
|
|
19
|
-
ours-install client install --config PATH
|
|
20
|
-
Client profile settings select installer.integrations and may override installer.sourcesPath,
|
|
21
|
-
and optional installer.fleetSettingsPath (relative paths use the profile directory).
|
|
22
|
-
Repeat selected server install repairs setup without replacing existing authority.
|
|
23
|
-
Daemon maintenance includes MCP state. Full-server reset is not supported.
|
|
24
|
-
Update requires reviewed storage compatibility; --compatible records that attestation.
|
|
25
|
-
Existing managed layouts are converted before service startup.
|
|
9
|
+
Full stack, with every required answer preset (no prompts):
|
|
10
|
+
ours-install --mode docker --state-dir /private/ours --identity-name "Your Name" --integrations codex,fleet --fleet-settings /private/fleet.json
|
|
26
11
|
|
|
12
|
+
Server preset (native is an alias for packages):
|
|
13
|
+
ours-install server --mode native --state-dir /private/ours --identity-name "Your Name"
|
|
14
|
+
ours-install server install --mode docker --state-dir /private/ours --identity-name "Your Name"
|
|
27
15
|
|
|
28
|
-
|
|
29
|
-
|
|
16
|
+
Client preset for an existing server:
|
|
17
|
+
ours-install client --config /private/profile.json --integrations codex,fleet --fleet-settings /private/fleet.json
|
|
30
18
|
|
|
31
|
-
|
|
19
|
+
Update a retained installation, preserving its identities:
|
|
20
|
+
ours-install all update --mode docker --state-dir /private/ours --identity-name "Your Name" --integrations codex,fleet --fleet-settings /private/fleet.json --compatible
|
|
32
21
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
for
|
|
38
|
-
|
|
22
|
+
--scope all|server|client preset which parts to configure (default all)
|
|
23
|
+
--action install|update equivalent to the positional operation
|
|
24
|
+
--mode docker|native packages is also accepted for native mode
|
|
25
|
+
--state-dir PATH installation root; required for all/server
|
|
26
|
+
--identity-name NAME desired Human name for a fresh server; existing root is retained
|
|
27
|
+
--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
|
|
29
|
+
--config PATH complete connection profile for client-only setup
|
|
30
|
+
--sources PATH explicit full development source policy override
|
|
31
|
+
--port N daemon port (default 3050 on a fresh installation)
|
|
32
|
+
--cowork-port N cowork port (default 3052)
|
|
33
|
+
--messenger-port N messenger port (default 8420)
|
|
34
|
+
--compatible required for server updates and legacy state migration
|
|
35
|
+
--migrate-from CONFIG migrate an existing daemon into a new managed root; install only
|
|
36
|
+
requires an absolute config path and --compatible
|
|
37
|
+
--migrate explicit legacy credential migration; separate from --migrate-from
|
|
38
|
+
--dry-run show the validated plan without changing anything
|
|
39
|
+
--help, -h show help
|
|
40
|
+
--version, -V print installer version
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
44
|
+
installer with preparation, identity restoration, update and readiness progress.
|
|
45
|
+
Fleet is configured but left stopped for operator review.
|
|
46
|
+
|
|
47
|
+
Scoped maintenance:
|
|
48
|
+
ours-install server status|start|stop|restart|rebuild --state-dir PATH
|
|
49
|
+
ours-install server access-issue --state-dir PATH --output PATH
|
|
50
|
+
ours-install server access-replace --state-dir PATH --confirm
|
|
51
|
+
ours-install server backup|restore server|daemon|telegram|cowork|messenger LABEL --state-dir PATH
|
|
52
|
+
ours-install server reset daemon|telegram|cowork|messenger --state-dir PATH --confirm
|
|
48
53
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
54
|
+
Install the selected channel with npm install -g @ours.network/install@nightly
|
|
55
|
+
(or @latest for a qualified stable release). Component versions come from the
|
|
56
|
+
installer's embedded release manifest. Node.js 22+ is required.
|
|
57
|
+
`;
|
|
53
58
|
|
|
54
59
|
export const UNINSTALL_USAGE = `ours-uninstall — remove one ours daemon and what attaches to it.
|
|
55
60
|
|
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.3",
|
|
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",
|