@ours.network/install 1.2.0-nightly.2 → 1.2.1-nightly.2

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.
@@ -0,0 +1,113 @@
1
+ /** Human bootstrap and local client handoff through the existing owner interfaces. */
2
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join, resolve, isAbsolute } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { installationPaths } from './plan.mjs';
6
+ import { validateHostProfile } from './target.mjs';
7
+
8
+ export function validateIdentityName(name) {
9
+ if (typeof name !== 'string' || [...name].length < 1 || [...name].length > 64 || name !== name.normalize('NFC')
10
+ || /[\\/\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u.test(name)
11
+ || ['.', '..', 'contact-book', 'root.json', 'bindings.json'].includes(name)) {
12
+ throw new Error('Invalid Human identity name; use 1–64 NFC characters without reserved names or path/control characters');
13
+ }
14
+ }
15
+ function privatePath(path, directory = false) {
16
+ const stat = lstatSync(path);
17
+ if (!(directory ? stat.isDirectory() : stat.isFile()) || realpathSync(path) !== path
18
+ || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0 || (!directory && stat.nlink !== 1)) {
19
+ throw new Error('Onboarding requires an owned private ' + (directory ? 'directory' : 'regular credential file'));
20
+ }
21
+ return stat;
22
+ }
23
+ function validateRecord(record) {
24
+ if (!record || !['packages', 'docker'].includes(record.mode) || typeof record.root !== 'string'
25
+ || !isAbsolute(record.root) || resolve(record.root) !== record.root
26
+ || !Number.isInteger(record.port) || record.port < 1 || record.port > 65535) throw new Error('Invalid local server selection');
27
+ validateHostProfile({ endpoint: `http://127.0.0.1:${record.port}`, expectedInstanceId: record.instanceId, credentialPath: join(record.root, 'client', 'credential') });
28
+ }
29
+
30
+ export function createServerOnboarding(effects, { compose, localEnv, bin }) {
31
+ async function identityCommand(record, args) {
32
+ const result = record.mode === 'docker'
33
+ ? await compose(record, ['exec', '-T', 'daemon', 'node', '/opt/ours/node_modules/@ours.network/cli/dist/cli.js', ...args, '--config', '/var/lib/ours/config.json', '--state-dir', '/var/lib/ours', '--json'])
34
+ : await effects.run(bin(record, 'ours'), [...args, '--config', record.configPath, '--state-dir', installationPaths(record).daemon, '--json'], { env: localEnv(record) });
35
+ if (result.code !== undefined && result.code !== 0) throw new Error('Owner identity operation failed');
36
+ try { return JSON.parse(result.stdout); }
37
+ catch { throw new Error('Owner identity operation returned malformed JSON'); }
38
+ }
39
+ async function identities(record) {
40
+ const rows = await identityCommand(record, ['identity', 'list']);
41
+ if (!Array.isArray(rows) || rows.some(row => !row || typeof row.name !== 'string'
42
+ || (!['root', 'role'].includes(row.kind) && !['reconciling', 'awaiting-root', 'migration-failed', 'refresh-failed'].includes(row.status))
43
+ || (row.kind && (typeof row.cid !== 'string' || !row.cid)))) throw new Error('Owner identity list is malformed');
44
+ const roots = rows.filter(row => row.kind === 'root');
45
+ if (roots.length > 1) throw new Error('Owner identity list contains multiple Human roots');
46
+ return { rows, root: roots[0] };
47
+ }
48
+ function retained({ rows, root }) {
49
+ effects.out?.(`Retained ${rows.length} existing ${rows.length === 1 ? 'identity' : 'identities'}; Human identity: ${root.name}.`);
50
+ return { name: root.name, cid: root.cid, created: false };
51
+ }
52
+ return {
53
+ async serverEnsureIdentity(record, name) {
54
+ validateRecord(record);
55
+ validateIdentityName(name);
56
+ const prior = await identities(record);
57
+ if (prior.root) return retained(prior);
58
+ if (prior.rows.some(row => row.name === name)) throw new Error('Requested Human identity name already exists; no identity was changed');
59
+ effects.out?.(`Creating Human identity ${name}; retaining ${prior.rows.length} existing identities.`);
60
+ try {
61
+ await identityCommand(record, ['identity', 'create-root', '--name', name, '--skip-if-root-exists', 'true']);
62
+ } catch (error) {
63
+ // ROOT_EXISTS is currently a CLI error. A concurrent creator is safe only
64
+ // when the authoritative list now contains a root; other errors stay errors.
65
+ const after = await identities(record);
66
+ if (after.root) return retained(after);
67
+ throw error;
68
+ }
69
+ const after = await identities(record);
70
+ if (!after.root) throw new Error('Human identity creation completed without a visible root');
71
+ if (after.root.name !== name) return retained(after);
72
+ effects.out?.(`Human identity ${after.root.name} is ready.`);
73
+ return { name: after.root.name, cid: after.root.cid, created: true };
74
+ },
75
+ async prepareLocalClient(record, integrations, fleetSettingsPath) {
76
+ validateRecord(record);
77
+ if (!Array.isArray(integrations) || !integrations.length || new Set(integrations).size !== integrations.length
78
+ || integrations.some(name => !['codex', 'claude-code', 'fleet'].includes(name))) throw new Error('Client integrations must select codex, claude-code and/or fleet');
79
+ if (fleetSettingsPath !== undefined) {
80
+ if (typeof fleetSettingsPath !== 'string' || !isAbsolute(fleetSettingsPath) || resolve(fleetSettingsPath) !== fleetSettingsPath) throw new Error('Fleet settings path must be absolute');
81
+ const settings = JSON.parse(readFileSync(fleetSettingsPath, 'utf8'));
82
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) throw new Error('Fleet settings must be a JSON object');
83
+ }
84
+ const endpoint = `http://127.0.0.1:${record.port}`;
85
+ const current = effects.readManagedClientProfile();
86
+ if (current && (current.endpoint !== endpoint || current.expectedInstanceId !== record.instanceId)) throw new Error('Managed client already selects another server; no credential was issued');
87
+ privatePath(record.root, true);
88
+ const root = join(record.root, 'client');
89
+ if (!existsSync(root)) mkdirSync(root, { mode: 0o700 });
90
+ privatePath(root, true);
91
+ const stage = mkdtempSync(join(root, '.pending-'));
92
+ const published = join(root, 'issued-' + randomUUID());
93
+ const credential = join(stage, 'credential');
94
+ try {
95
+ effects.out?.('Issuing a separate local client credential with the retained server authority.');
96
+ try { await effects.serverAccess(record, 'access-issue', { output: credential }); }
97
+ catch { throw new Error('Client credential issuance failed; existing profiles were retained'); }
98
+ const stat = privatePath(credential);
99
+ if (stat.size > 4096 || !readFileSync(credential, 'utf8').trim()) throw new Error('Issued client credential is empty or invalid');
100
+ const profile = {
101
+ ...validateHostProfile({ endpoint, expectedInstanceId: record.instanceId, credentialPath: join(published, 'credential') }),
102
+ installer: { integrations: [...integrations], ...(fleetSettingsPath !== undefined ? { fleetSettingsPath } : {}) },
103
+ };
104
+ writeFileSync(join(stage, 'profile.json'), JSON.stringify(profile, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
105
+ // Publish the complete pair in one rename. Managed-default activation and
106
+ // package/source selection remain the normal client installer's job.
107
+ renameSync(stage, published);
108
+ effects.out?.('Local client profile is ready for authenticated client setup.');
109
+ return { configPath: join(published, 'profile.json'), profile };
110
+ } finally { rmSync(stage, { recursive: true, force: true }); }
111
+ },
112
+ };
113
+ }
@@ -0,0 +1,157 @@
1
+ import { join, resolve } 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', '--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'];
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
+ const missing = [];
33
+ if (server) for (const [key, flag] of [['mode', '--mode'], ['stateDir', '--state-dir'], ['identityName', '--identity-name']]) if (!nonempty(options[key])) missing.push(flag);
34
+ if (client && options.integrations === undefined) missing.push('--integrations (use none to skip)');
35
+ if (!server && !nonempty(options.config)) missing.push('--config');
36
+ if (server && options.operation === 'update' && options.compatible !== true) missing.push('--compatible');
37
+ if (Array.isArray(options.integrations) && options.integrations.includes('fleet') && !interactive && !nonempty(options.fleetSettingsPath)) missing.push('--fleet-settings');
38
+ if (missing.length) throw new Error(`Missing required setup options: ${missing.join(', ')}`);
39
+ if (options.mode === 'native') options.mode = 'packages';
40
+ if (server && !['packages', 'docker'].includes(options.mode)) throw new Error('Mode must be packages (or native) or docker');
41
+ if (server && options.config !== undefined) throw new Error('--config is only valid for client scope');
42
+ if (!server) for (const key of ['mode', 'stateDir', 'identityName', ...Object.keys(defaults), 'compatible', 'migrate']) {
43
+ if (options[key] !== undefined) throw new Error(`${key} is only valid for server or all scope`);
44
+ }
45
+ if (!client && (options.integrations !== undefined || options.fleetSettingsPath !== undefined)) throw new Error('--integrations and --fleet-settings require all or client scope');
46
+ if (options.integrations !== undefined) {
47
+ 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');
48
+ options.integrations = [...options.integrations];
49
+ }
50
+ if (options.fleetSettingsPath !== undefined && !options.integrations?.includes('fleet')) throw new Error('--fleet-settings requires the fleet integration');
51
+ for (const key of paths) if (options[key] !== undefined && !nonempty(options[key])) throw new Error(`Invalid path for ${key}`);
52
+ for (const key of ['compatible', 'migrate']) if (options[key] !== undefined && typeof options[key] !== 'boolean') throw new Error(`${key} must be a boolean`);
53
+ if (server) {
54
+ options.identityName = options.identityName.trim();
55
+ for (const [key, fallback] of Object.entries(defaults)) {
56
+ options[key] ??= fallback;
57
+ if (!Number.isInteger(options[key]) || options[key] < 1 || options[key] > 65535) throw new Error(`${key} must be an integer port between 1 and 65535`);
58
+ }
59
+ if (new Set(Object.keys(defaults).map(key => options[key])).size !== 3) throw new Error('Server ports must be distinct');
60
+ }
61
+ options.explicitPorts ??= [];
62
+ 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');
63
+ options.explicitPorts = [...options.explicitPorts];
64
+ return options;
65
+ }
66
+
67
+ function expandPaths(options, home) {
68
+ const output = { ...options };
69
+ for (const key of paths) if (nonempty(output[key])) {
70
+ const value = output[key];
71
+ if (value === '~' || value.startsWith('~/')) {
72
+ if (!nonempty(home)) throw new Error('Home directory is required to expand ~/ paths');
73
+ output[key] = resolve(home, value === '~' ? '' : value.slice(2));
74
+ } else output[key] = resolve(value);
75
+ }
76
+ return output;
77
+ }
78
+
79
+ export function parseSetupArgs(argv, { home } = {}) {
80
+ if (!Array.isArray(argv) || !argv.every(arg => typeof arg === 'string')) throw new Error('Arguments must be strings');
81
+ const options = { interactive: false, explicitPorts: [] };
82
+ const seen = new Set();
83
+ const positional = [];
84
+ const put = (key, value) => {
85
+ if (seen.has(key)) throw new Error(`Duplicate or conflicting setup option: ${key}`);
86
+ seen.add(key); options[key] = value;
87
+ };
88
+ for (let i = 0; i < argv.length; i++) {
89
+ const arg = argv[i];
90
+ if (!arg.startsWith('-')) { positional.push(arg); continue; }
91
+ const equal = arg.indexOf('=');
92
+ const flag = equal < 0 ? arg : arg.slice(0, equal);
93
+ if (boolFlags.has(flag)) {
94
+ if (equal >= 0) throw new Error(`${flag} takes no value`);
95
+ put(boolFlags.get(flag), true); continue;
96
+ }
97
+ if (!valueFlags.has(flag)) throw new Error(`Unknown setup flag: ${flag}`);
98
+ const value = equal < 0 ? argv[++i] : arg.slice(equal + 1);
99
+ if (!nonempty(value) || value.startsWith('--')) throw new Error(`${flag} requires a value`);
100
+ const key = valueFlags.get(flag);
101
+ if (key === 'integrations') put(key, value === 'none' ? [] : value.split(',').map(item => item.trim()));
102
+ else if (Object.hasOwn(defaults, key)) {
103
+ if (!/^[1-9]\d*$/.test(value)) throw new Error(`${flag} requires an integer port`);
104
+ put(key, Number(value)); options.explicitPorts.push(key);
105
+ } else put(key, value);
106
+ }
107
+ if (positional.length && scopes.includes(positional[0])) put('scope', positional.shift());
108
+ if (positional.length && ['install', 'update'].includes(positional[0])) put('operation', positional.shift());
109
+ if (positional.length) throw new Error(`Unexpected setup argument: ${positional.join(' ')}`);
110
+ return validateSetupOptions(expandPaths(options, home), { interactive: false });
111
+ }
112
+
113
+ export async function collectSetupOptions(effects) {
114
+ if (effects.interactive !== true) throw new Error('Interactive setup requires a TTY; provide the complete CLI options instead');
115
+ const options = { interactive: true, explicitPorts: [] };
116
+ options.scope = (await effects.askLine('Set up all, server, or client? ', 'all')).trim();
117
+ if (!scopes.includes(options.scope)) throw new Error('Scope must be all, server, or client');
118
+ let existing;
119
+ if (options.scope !== 'client') {
120
+ options.stateDir = await effects.askLine('Private installation root: ', join(effects.home, '.ours-install'));
121
+ options.stateDir = expandPaths({ stateDir: options.stateDir }, effects.home).stateDir;
122
+ existing = effects.readJson(join(options.stateDir, 'installation.json'));
123
+ const legacyRoot = join(effects.home, '.ours');
124
+ const legacy = !existing ? effects.readJson(join(legacyRoot, 'config.json')) : null;
125
+ if (legacy?.stateDir === legacyRoot) throw new Error(`An existing global daemon was found at ${legacyRoot}, without a managed installation record for the selected root. This form cannot migrate that installation yet. Keep its state; use explicit CLI presets with a different empty directory only if you intend a separate installation.`);
126
+ }
127
+ options.operation = (await effects.askLine('Install or update? ', existing ? 'update' : 'install')).trim();
128
+ if (options.scope !== 'client') {
129
+ const recommendation = recommendedMode({ ...effects.platform, arch: effects.platform?.arch ?? process.arch });
130
+ effects.out(`Detected ${effects.platform?.platform ?? 'unknown'} / ${effects.platform?.arch ?? process.arch}. ${recommendation.reason}`);
131
+ const defaultMode = existing?.mode ?? recommendation.mode;
132
+ options.mode = await effects.askLine('Runtime mode (native or docker): ', defaultMode === 'packages' ? 'native' : defaultMode);
133
+ options.identityName = await effects.askLine('What name should others see? ', existing?.messengerIdentity ?? effects.username?.() ?? 'me');
134
+ for (const [key, fallback] of Object.entries(defaults)) {
135
+ const label = { port: 'Daemon', coworkPort: 'Cowork', messengerPort: 'Messenger' }[key];
136
+ const value = await effects.askLine(`${label} port: `, String(existing?.[key] ?? fallback));
137
+ if (!/^[1-9]\d*$/.test(value)) throw new Error(`${label} port must be an integer`);
138
+ options[key] = Number(value); options.explicitPorts.push(key);
139
+ }
140
+ if (options.operation === 'update') options.compatible = await effects.ask('Confirm that the selected update is compatible with retained state?', false);
141
+ } else options.config = await effects.askLine('Prepared client profile path: ', join(effects.home, '.ours-client', 'profile.json'));
142
+ if (options.scope !== 'server') {
143
+ const detected = typeof effects.detectHarnesses === 'function' ? await effects.detectHarnesses() : [];
144
+ options.integrations = [];
145
+ for (const name of integrations) if (await effects.ask(`Install ${name}?`, name === 'fleet' || detected.some(item => item.name === name && item.status === 'ok'))) options.integrations.push(name);
146
+ if (options.integrations.includes('fleet')) {
147
+ const path = await effects.askLine('Fleet settings file (leave empty for the interactive Fleet wizard): ', '');
148
+ if (path.trim()) options.fleetSettingsPath = path;
149
+ }
150
+ }
151
+ const sources = await effects.askLine('Source policy override (leave empty for the packaged release): ', '');
152
+ if (sources.trim()) options.sources = sources;
153
+ const validated = validateSetupOptions(expandPaths(options, effects.home), { interactive: true });
154
+ effects.out(`Setup: ${validated.operation} ${validated.scope}${validated.mode ? ` using ${validated.mode} in ${validated.stateDir}` : ` from ${validated.config}`}; integrations: ${validated.integrations?.join(', ') || 'none'}.`);
155
+ if (!await effects.ask('Continue with this setup?', false)) throw new Error('Setup cancelled; nothing was changed');
156
+ return validated;
157
+ }
package/lib/setup.mjs ADDED
@@ -0,0 +1,145 @@
1
+ import { join } from 'node:path';
2
+ import { parseSetupArgs, collectSetupOptions, validateSetupOptions } from './setup-options.mjs';
3
+ import { parseNetworkArgs, validateHostProfile, InstallUsageError } from './target.mjs';
4
+ import { validateInstallation } from './plan.mjs';
5
+ import { runServerCommand, runClientCommand } from './orchestrate.mjs';
6
+ import { banner, heading, info, ok, warn, progress } from './ui.mjs';
7
+ import { isCancel } from './prompt.mjs';
8
+ import { USAGE } from './usage.mjs';
9
+ import { validateIdentityName } from './server-onboarding.mjs';
10
+ import { validateFleetSettings } from './fleet-settings.mjs';
11
+
12
+ const maintenance = new Set(['status', 'start', 'stop', 'restart', 'rebuild', 'access-issue', 'access-replace', 'backup', 'restore', 'reset']);
13
+ const clientPackages = integrations => [...new Set(['sdk', ...(integrations.includes('fleet') ? ['cli'] : []), ...integrations])];
14
+
15
+ export function completeReleasePolicy(retained, supplied) {
16
+ if (retained?.release) {
17
+ return { release: retained.release, packages: Object.fromEntries(Object.entries(retained.release.packages).map(([name, entry]) => [name, { type: 'npm', version: entry.version }])) };
18
+ }
19
+ if (supplied) return supplied;
20
+ throw new InstallUsageError('This development installation needs its full --sources policy to configure clients; server-only selections cannot supply client packages');
21
+ }
22
+
23
+ function readObject(effects, path, label) {
24
+ const value = effects.readJson(path);
25
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new InstallUsageError(`${label} must be a readable JSON object: ${path}`);
26
+ return value;
27
+ }
28
+
29
+ /** Read-only validation of the complete plan, before any locks, installs or service changes. */
30
+ export async function prepareSetupPlan(options, effects) {
31
+ options = validateSetupOptions(options, { interactive: options.interactive });
32
+ 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.');
33
+ const plan = { ...options };
34
+ if (options.scope !== 'server' && options.integrations.length) {
35
+ for (const name of ['OURS_API_TOKEN', 'OURS_PORT', 'OURS_STATE_DIR', 'OURS_DAEMON_ID']) {
36
+ 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.`);
37
+ }
38
+ }
39
+ if (options.scope !== 'client') validateIdentityName(options.identityName);
40
+ if (options.fleetSettingsPath) validateFleetSettings(readObject(effects, options.fleetSettingsPath, 'Fleet settings'));
41
+ const policy = options.sources ? readObject(effects, options.sources, 'Source policy') : effects.packagedSourcePolicy();
42
+ plan.sourcePolicy = policy;
43
+ if (options.scope !== 'client') {
44
+ const value = effects.readJson(join(options.stateDir, 'installation.json'));
45
+ if (value) {
46
+ plan.existing = validateInstallation(value, options.stateDir);
47
+ 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');
48
+ for (const key of ['port', 'coworkPort', 'messengerPort']) {
49
+ if (options.explicitPorts?.includes(key) && options[key] !== plan.existing[key]) throw new InstallUsageError(`${key} conflicts with the retained installation`);
50
+ plan[key] = plan.existing[key];
51
+ }
52
+ if (options.operation === 'install') {
53
+ const retained = readObject(effects, plan.existing.sourcesPath, 'Retained source policy');
54
+ plan.sourcePolicy = options.scope === 'server' || !options.integrations?.length ? retained : completeReleasePolicy(retained, options.sources ? policy : null);
55
+ }
56
+ } else if (options.operation === 'update') {
57
+ throw new InstallUsageError('Update requires an existing installation.json; choose install for a new installation');
58
+ }
59
+ // Reject a local client already attached to another server before changing the server.
60
+ const saved = options.scope === 'all' && options.integrations.length ? effects.readManagedClientProfile() : null;
61
+ if (saved && (!plan.existing || saved.expectedInstanceId !== plan.existing.instanceId || saved.endpoint !== `http://127.0.0.1:${plan.port}`)) {
62
+ throw new InstallUsageError('This user already has clients attached to a different server; their saved connection was not changed');
63
+ }
64
+ } else {
65
+ const profile = validateHostProfile(readObject(effects, options.config, 'Client profile'));
66
+ if (!profile) throw new InstallUsageError('Client profile must contain endpoint, expectedInstanceId and credentialPath');
67
+ if (!effects.readText(profile.credentialPath)?.trim()) throw new InstallUsageError('Client credential file is missing or empty');
68
+ plan.profile = profile;
69
+ const saved = effects.readManagedClientProfile();
70
+ if (saved && (saved.endpoint !== profile.endpoint || saved.expectedInstanceId !== profile.expectedInstanceId)) throw new InstallUsageError('Managed clients already select another server; existing connection was not changed');
71
+ if (saved && options.operation === 'install' && !options.sources) plan.sourcePolicy = readObject(effects, saved.installer.sourcesPath, 'Retained client source policy');
72
+ }
73
+ // Dry-run never spawns a resolver or acquires an installation lock.
74
+ if (!options.dryRun) {
75
+ if (options.scope === 'all' && options.operation === 'update' && options.integrations.length) {
76
+ const running = await effects.serverLifecycle(plan.existing, 'status', ['daemon']);
77
+ 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.');
78
+ }
79
+ if (options.scope !== 'client') await effects.resolveSourcePolicy(plan.sourcePolicy, 'server');
80
+ if (options.scope !== 'server' && options.integrations.length) await effects.resolveSourcePolicy(plan.sourcePolicy, 'client', clientPackages(options.integrations));
81
+ }
82
+ return plan;
83
+ }
84
+
85
+ export async function executeSetupPlan(plan, effects, { server = runServerCommand, client = runClientCommand } = {}) {
86
+ if (!plan.interactive) effects.out(banner());
87
+ effects.out(heading(`${plan.operation === 'update' ? 'Update' : 'Install'} ours.network`));
88
+ effects.out(info(`Scope: ${plan.scope}; ${plan.scope === 'client' ? `profile: ${plan.config}` : `mode: ${plan.mode === 'packages' ? 'native' : 'docker'}; directory: ${plan.stateDir}`}`));
89
+ if (plan.scope !== 'server') effects.out(info(`Client integrations: ${plan.integrations.join(', ') || 'none'}`));
90
+ if (plan.dryRun) {
91
+ effects.out(info('Preview only. No packages, identities, credentials or services will be changed.'));
92
+ if (plan.scope !== 'client') effects.out(info('Server: prerequisites → runtime preparation/update → retained identity/state restoration → readiness.'));
93
+ if (plan.scope !== 'server' && plan.integrations.length) effects.out(info('Clients: private connection → exact packages → selected integrations → Fleet settings when selected.'));
94
+ return 0;
95
+ }
96
+ let clientConfig = plan.config;
97
+ let clientPolicy = plan.sourcePolicy;
98
+ if (plan.scope !== 'client') {
99
+ effects.out(heading(plan.operation === 'update' ? 'Server update and identity restoration' : 'Server installation'));
100
+ const result = await server({ ...plan, role: 'server', operation: plan.operation, sourcePolicy: plan.sourcePolicy }, effects);
101
+ if (result !== 0) return result;
102
+ const record = validateInstallation(effects.readJson(join(plan.stateDir, 'installation.json')), plan.stateDir);
103
+ if (plan.operation === 'update') {
104
+ effects.out(progress(0, 1, 'Retained identities', 'Verify the Human identity after state restoration; existing names and keys are retained.'));
105
+ const running = await effects.serverLifecycle(record, 'status', ['daemon']);
106
+ if (running.includes('daemon')) {
107
+ await effects.serverEnsureIdentity(record, plan.identityName);
108
+ effects.out(ok('Retained identities verified.'));
109
+ } else effects.out(info('Daemon remains stopped; stored identities are retained and will restore on the next start.'));
110
+ }
111
+ if (plan.scope === 'all' && plan.integrations.length) {
112
+ clientPolicy = completeReleasePolicy(readObject(effects, record.sourcesPath, 'Server source policy'), plan.sourcePolicy);
113
+ effects.out(heading('Connect local clients'));
114
+ const handoff = await effects.prepareLocalClient(record, plan.integrations, plan.fleetSettingsPath);
115
+ clientConfig = handoff.configPath;
116
+ }
117
+ }
118
+ if (plan.scope !== 'server' && plan.integrations.length) {
119
+ const result = await client({ role: 'client', operation: 'install', config: clientConfig,
120
+ integrations: plan.integrations, fleetSettingsPath: plan.fleetSettingsPath, sourcePolicy: clientPolicy,
121
+ preset: true, nonInteractive: !plan.interactive }, effects);
122
+ if (result !== 0) return result;
123
+ }
124
+ effects.out(ok(`Requested ${plan.operation} completed. Existing identities were retained.`));
125
+ 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.'));
126
+ return 0;
127
+ }
128
+
129
+ /** The sole executable entry: manual answers and CLI presets share one plan/executor. */
130
+ export async function runSetup(argv, effects) {
131
+ try {
132
+ if (argv.includes('--help') || argv.includes('-h')) { effects.out(USAGE); return 0; }
133
+ if (argv.length === 1 && ['--version', '-V'].includes(argv[0])) { effects.out(effects.version ?? 'unknown'); return 0; }
134
+ if (argv[0] === 'server' && maintenance.has(argv[1])) return await runServerCommand(parseNetworkArgs(argv), effects);
135
+ 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.');
136
+ if (!argv.length) { effects.out(banner()); effects.out(heading('Interactive setup')); }
137
+ const options = argv.length ? parseSetupArgs(argv, { home: effects.home }) : await collectSetupOptions(effects);
138
+ const plan = await prepareSetupPlan(options, effects);
139
+ return await executeSetupPlan(plan, effects);
140
+ } catch (error) {
141
+ if (isCancel(error)) { effects.out(warn('Installation cancelled.')); return 130; }
142
+ effects.out(warn(`ours-install: ${error.message}`));
143
+ return 2;
144
+ }
145
+ }
package/lib/usage.mjs CHANGED
@@ -1,55 +1,58 @@
1
- // ours-install v3 the help text.
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
- export const USAGE = `ours-install — the unified ours.network stack installer.
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
- Selected network installation (uses the packaged compatible source policy):
10
- ours-install server install --mode packages|docker [--sources PATH] --state-dir PATH
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
- Install: npm i -g @ours.network/install && ours-install (recommended)
29
- npx @ours.network/install (one-off)
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
- ours-install [--state-dir PATH] [--port N] [--dry-run] [--help] [--version]
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
- Progress-driven setup for the whole stack: one shared daemon, MCP, Telegram,
34
- cowork, detected harness plugins (Claude Code / Codex / Hermes), a Human
35
- identity, and ours-fleet. The daemon, Telegram connector, and cowork shim start
36
- as durable services; only Fleet is staged but stopped. The installer asks only
37
- for information it cannot infer and
38
- ends with exact next commands plus a copy-paste agent hand-off prompt.
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 attestation for server updates of retained state
35
+ --migrate explicit legacy access migration during install
36
+ --dry-run show the validated plan without changing anything
37
+ --help, -h show help
38
+ --version, -V print installer version
39
39
 
40
- --state-dir the daemon's STATE DIRECTORY, which is what identifies a daemon
41
- (default ~/.ours). A second state directory is a second daemon.
42
- --port used only when CREATING a daemon. For a daemon that already owns
43
- the state directory the port comes from its own record, and a
44
- --port that disagrees with it is refused rather than corrected.
45
- --dry-run walk the whole flow and print what it WOULD do — change nothing
46
- --help show this help and exit
47
- --version print the installer version and exit
40
+ CLI presets must be complete and never open the interactive form or a Fleet wizard.
41
+ Missing answers are reported before installation. Both input modes run the same
42
+ installer with preparation, identity restoration, update and readiness progress.
43
+ Fleet is configured but left stopped for operator review.
44
+
45
+ Scoped maintenance:
46
+ ours-install server status|start|stop|restart|rebuild --state-dir PATH
47
+ ours-install server access-issue --state-dir PATH --output PATH
48
+ ours-install server access-replace --state-dir PATH --confirm
49
+ ours-install server backup|restore server|daemon|telegram|cowork|messenger LABEL --state-dir PATH
50
+ ours-install server reset daemon|telegram|cowork|messenger --state-dir PATH --confirm
48
51
 
49
- Env: OURS_ASSUME_YES=1 (accept defaults, no prompts) · OURS_INSTALL_DRY_RUN=1 ·
50
- OURS_CONFIG=/private/host-profile.json · OURS_CHANNEL=nightly ·
51
- OURS_BROKER_URL · OURS_NPM. A complete host profile selects client-only
52
- setup for an existing Compose-owned daemon. Docs: https://ours.network`;
52
+ Install the selected channel with npm install -g @ours.network/install@nightly
53
+ (or @latest for a qualified stable release). Component versions come from the
54
+ installer's embedded release manifest. Node.js 22+ is required.
55
+ `;
53
56
 
54
57
  export const UNINSTALL_USAGE = `ours-uninstall — remove one ours daemon and what attaches to it.
55
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "1.2.0-nightly.2",
3
+ "version": "1.2.1-nightly.2",
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",
@@ -19,17 +19,18 @@
19
19
  ],
20
20
  "license": "FSL-1.1-Apache-2.0",
21
21
  "author": "Adapt Toolkit",
22
- "homepage": "https://github.com/adapt-toolkit/ours-mcp/tree/main/packages/installer#readme",
22
+ "homepage": "https://github.com/adapt-toolkit/ours-network/tree/main/packages/installer#readme",
23
23
  "repository": {
24
24
  "type": "git",
25
- "url": "git+https://github.com/adapt-toolkit/ours-mcp.git",
25
+ "url": "git+https://github.com/adapt-toolkit/ours-network.git",
26
26
  "directory": "packages/installer"
27
27
  },
28
28
  "engines": {
29
29
  "node": ">=22"
30
30
  },
31
31
  "scripts": {
32
- "test": "node --test"
32
+ "test": "node --test",
33
+ "prepack": "node ../../scripts/prepare-installer.mjs"
33
34
  },
34
35
  "dependencies": {
35
36
  "koffi": "3.2.1",