@ours.network/install 1.2.1-nightly.2 → 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 +43 -4
- 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/lib/effects.mjs +18 -5
- 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 +5 -1
- package/lib/prompt.mjs +113 -119
- package/lib/server-onboarding.mjs +1 -0
- package/lib/setup-options.mjs +127 -31
- package/lib/setup.mjs +12 -2
- package/lib/usage.mjs +4 -2
- package/package.json +1 -1
package/lib/prompt.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Interactive prompts drawn on the controlling terminal (/dev/tty), so they work under
|
|
2
2
|
// `curl | bash` (where stdin/stdout are the pipe). Synchronous, dependency-free: line prompts via
|
|
3
|
-
// fs.readSync, and
|
|
3
|
+
// fs.readSync, and raw-mode single/multiple choices. When there is no tty or OURS_ASSUME_YES is
|
|
4
4
|
// set, every prompt returns its default without reading — the headless/CI path never blocks.
|
|
5
5
|
import { readSync } from 'node:fs';
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
@@ -30,77 +30,66 @@ function readByte(fd) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
export function askLine(write, fd, prompt, def = '') {
|
|
33
|
+
// Decode incrementally: a terminal read may split a UTF-8 code point across bytes.
|
|
34
|
+
// Fatal decoding rejects malformed input instead of storing a corrupted name or path.
|
|
35
|
+
function textEntry(write, fd, prompt, def, secret, {
|
|
36
|
+
read = readByte, enterRaw = enterSelectionMode,
|
|
37
|
+
} = {}) {
|
|
39
38
|
if (fd == null || ASSUME_YES()) return def;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
let
|
|
39
|
+
let restore;
|
|
40
|
+
try { restore = enterRaw(fd); } catch (error) {
|
|
41
|
+
// A secret must never fall back to an echoing cooked terminal.
|
|
42
|
+
if (secret && isCancel(error)) { write(`${prompt}\n`); return null; }
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
let decoder = new TextDecoder('utf-8', { fatal: true });
|
|
46
|
+
let pending = false;
|
|
47
|
+
const characters = [];
|
|
48
48
|
try {
|
|
49
|
+
write(prompt);
|
|
49
50
|
for (;;) {
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
const byte = read(fd);
|
|
52
|
+
if (byte == null || byte === 0x03 || byte === 0x04) throw new InstallCancelled();
|
|
53
|
+
if (byte === 0x7f || byte === 0x08) {
|
|
54
|
+
if (pending) {
|
|
55
|
+
decoder = new TextDecoder('utf-8', { fatal: true });
|
|
56
|
+
pending = false;
|
|
57
|
+
} else if (characters.length) {
|
|
58
|
+
characters.pop();
|
|
59
|
+
if (!secret) write('\b \b');
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (byte === 0x0a || byte === 0x0d) {
|
|
64
|
+
try { decoder.decode(); } catch { throw new InstallCancelled(); }
|
|
65
|
+
write('\n');
|
|
66
|
+
const answer = characters.join('').trim();
|
|
67
|
+
return answer === '' ? def : answer;
|
|
68
|
+
}
|
|
69
|
+
if (byte < 0x20) {
|
|
70
|
+
if (pending) throw new InstallCancelled();
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
let decoded;
|
|
74
|
+
try { decoded = decoder.decode(Uint8Array.of(byte), { stream: true }); }
|
|
75
|
+
catch { throw new InstallCancelled(); }
|
|
76
|
+
pending = decoded.length === 0;
|
|
77
|
+
for (const character of decoded) {
|
|
78
|
+
characters.push(character);
|
|
79
|
+
if (!secret) write(character);
|
|
80
|
+
}
|
|
58
81
|
}
|
|
59
82
|
} finally {
|
|
60
83
|
restore();
|
|
61
84
|
}
|
|
62
|
-
const ans = s.trim();
|
|
63
|
-
return ans === '' ? def : ans;
|
|
64
85
|
}
|
|
65
86
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
export function askSecret(write, fd, prompt, def = '') {
|
|
70
|
-
if (fd == null || ASSUME_YES()) return def;
|
|
71
|
-
|
|
72
|
-
const saved = spawnSync('stty', ['-g'], { stdio: [fd, 'pipe', 'ignore'], encoding: 'utf8' });
|
|
73
|
-
const rawOk = saved.status === 0
|
|
74
|
-
&& spawnSync('stty', ['-icanon', '-echo', '-isig', 'min', '1', 'time', '0'], { stdio: [fd, 'ignore', 'ignore'] }).status === 0;
|
|
75
|
-
const restore = () => {
|
|
76
|
-
if (rawOk) spawnSync('stty', (saved.stdout || '').trim() ? [(saved.stdout || '').trim()] : ['sane'], { stdio: [fd, 'ignore', 'ignore'] });
|
|
77
|
-
};
|
|
78
|
-
if (!rawOk) {
|
|
79
|
-
// Fail closed: a cooked fallback would echo the secret. Returning null lets the caller
|
|
80
|
-
// explain that secure input is unavailable without ever reading a credential.
|
|
81
|
-
write(`${prompt}\n`);
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
// Disable echo BEFORE displaying the prompt. Otherwise an automated or very fast typist can
|
|
85
|
-
// submit bytes in the small prompt→stty window and have the terminal driver echo the secret.
|
|
86
|
-
write(prompt);
|
|
87
|
+
export function askLine(write, fd, prompt, def = '', controls) {
|
|
88
|
+
return textEntry(write, fd, prompt, def, false, controls);
|
|
89
|
+
}
|
|
87
90
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
for (;;) {
|
|
91
|
-
const b = readByte(fd);
|
|
92
|
-
if (b === null || b === 0x04) break;
|
|
93
|
-
if (b === 0x03) { restore(); write('^C'); throw new InstallCancelled(); }
|
|
94
|
-
if (b === 0x0a || b === 0x0d) { write('\n'); break; }
|
|
95
|
-
if (b === 0x7f || b === 0x08) { if (s.length) s = s.slice(0, -1); continue; }
|
|
96
|
-
if (b < 0x20) continue;
|
|
97
|
-
s += String.fromCharCode(b);
|
|
98
|
-
}
|
|
99
|
-
} finally {
|
|
100
|
-
restore();
|
|
101
|
-
}
|
|
102
|
-
const ans = s.trim();
|
|
103
|
-
return ans === '' ? def : ans;
|
|
91
|
+
export function askSecret(write, fd, prompt, def = '', controls) {
|
|
92
|
+
return textEntry(write, fd, prompt, def, true, controls);
|
|
104
93
|
}
|
|
105
94
|
|
|
106
95
|
// askYesNo: y/n with a default shown in caps. Returns boolean.
|
|
@@ -111,69 +100,74 @@ export function askYesNo(write, fd, prompt, def = false) {
|
|
|
111
100
|
return /^y/i.test(ans);
|
|
112
101
|
}
|
|
113
102
|
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
// `specs` are { name, label } (optionally { checked }). No-tty → returns pre-checked defaults.
|
|
117
|
-
export function checkboxSelect(write, fd, specs, { title } = {}) {
|
|
118
|
-
const names = specs.map((s) => s.name);
|
|
119
|
-
const labels = specs.map((s) => s.label);
|
|
120
|
-
const sel = specs.map((s) => (s.checked ? 1 : 0));
|
|
121
|
-
const n = specs.length;
|
|
122
|
-
const chosen = () => names.filter((_, i) => sel[i] === 1);
|
|
123
|
-
|
|
124
|
-
if (fd == null) return chosen();
|
|
125
|
-
|
|
126
|
-
// Enter raw mode via `stty` on the tty fd (NOT tty.ReadStream.setRawMode, which would flip the
|
|
127
|
-
// fd to non-blocking and break our fs.readSync). Save the current settings and restore them
|
|
128
|
-
// after. If stty is unavailable / this isn't a real tty, fall back to the pre-checked defaults.
|
|
103
|
+
// Keep terminal controls injectable so key sequences and cleanup can be checked without a TTY.
|
|
104
|
+
function enterSelectionMode(fd) {
|
|
129
105
|
const saved = spawnSync('stty', ['-g'], { stdio: [fd, 'pipe', 'ignore'], encoding: 'utf8' });
|
|
130
|
-
if (saved.status !== 0)
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
let cur = 0;
|
|
137
|
-
let drawn = 0;
|
|
106
|
+
if (saved.status !== 0 || !saved.stdout?.trim()) throw new InstallCancelled();
|
|
107
|
+
const restore = () => spawnSync('stty', [saved.stdout.trim()], { stdio: [fd, 'ignore', 'ignore'] });
|
|
108
|
+
const raw = spawnSync('stty', ['-echo', '-icanon', '-isig', 'min', '1', 'time', '0'], { stdio: [fd, 'ignore', 'ignore'] });
|
|
109
|
+
if (raw.status !== 0) { restore(); throw new InstallCancelled(); }
|
|
110
|
+
return restore;
|
|
111
|
+
}
|
|
138
112
|
|
|
113
|
+
function choose(write, fd, question, choices, defaults, multiple, {
|
|
114
|
+
read = readByte, enterRaw = enterSelectionMode,
|
|
115
|
+
} = {}) {
|
|
116
|
+
if (!choices.length) throw new Error('A choice prompt requires at least one choice');
|
|
117
|
+
const selected = choices.map(choice => defaults.includes(choice.value));
|
|
118
|
+
let current = Math.max(0, selected.indexOf(true));
|
|
119
|
+
const result = () => multiple
|
|
120
|
+
? choices.filter((_, i) => selected[i]).map(choice => choice.value)
|
|
121
|
+
: choices[current].value;
|
|
122
|
+
if (fd == null || ASSUME_YES()) return result();
|
|
123
|
+
const restore = enterRaw(fd);
|
|
124
|
+
let drawn = false;
|
|
125
|
+
const next = () => {
|
|
126
|
+
const byte = read(fd);
|
|
127
|
+
if (byte == null || byte === 0x03 || byte === 0x04) throw new InstallCancelled();
|
|
128
|
+
return byte;
|
|
129
|
+
};
|
|
139
130
|
const redraw = () => {
|
|
140
|
-
if (drawn) write(`\x1b[${
|
|
141
|
-
drawn =
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
131
|
+
if (drawn) write(`\x1b[${choices.length}A`);
|
|
132
|
+
drawn = true;
|
|
133
|
+
choices.forEach((choice, i) => {
|
|
134
|
+
const marker = multiple ? (selected[i] ? c.green('[x]') : '[ ]') : (i === current ? c.green('(●)') : '( )');
|
|
135
|
+
write(`\r\x1b[K ${i === current ? c.cyan('> ') : ' '}${marker} ${choice.label}\n`);
|
|
136
|
+
});
|
|
147
137
|
};
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (b2 === 0x41) cur = (cur - 1 + n) % n; // up
|
|
161
|
-
else if (b2 === 0x42) cur = (cur + 1) % n; // down
|
|
138
|
+
try {
|
|
139
|
+
write(`\x1b[?25l ${question}\n ${c.bold('↑/↓')} move${multiple ? ', ' + c.bold('Space') + ' toggle' : ''}, ${c.bold('Enter')} confirm\n`);
|
|
140
|
+
redraw();
|
|
141
|
+
for (;;) {
|
|
142
|
+
const byte = next();
|
|
143
|
+
if (byte === 0x0d || byte === 0x0a) return result();
|
|
144
|
+
if (byte === 0x1b) {
|
|
145
|
+
const prefix = next();
|
|
146
|
+
if (prefix === 0x5b || prefix === 0x4f) {
|
|
147
|
+
const direction = next();
|
|
148
|
+
if (direction === 0x41) current = (current - 1 + choices.length) % choices.length;
|
|
149
|
+
if (direction === 0x42) current = (current + 1) % choices.length;
|
|
162
150
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
case 0x6b: case 0x4b: cur = (cur - 1 + n) % n; break; // k/K
|
|
166
|
-
case 0x6a: case 0x4a: cur = (cur + 1) % n; break; // j/J
|
|
167
|
-
case 0x20: sel[cur] = 1 - sel[cur]; break; // space
|
|
168
|
-
case 0x61: case 0x41: for (let i = 0; i < n; i++) sel[i] = 1; break; // a/A
|
|
169
|
-
case 0x6e: case 0x4e: for (let i = 0; i < n; i++) sel[i] = 0; break; // n/N
|
|
170
|
-
case 0x71: case 0x51: for (let i = 0; i < n; i++) sel[i] = 0; break loop; // q/Q cancel
|
|
171
|
-
case 0x0d: case 0x0a: break loop; // enter
|
|
172
|
-
default: break;
|
|
151
|
+
} else if (multiple && byte === 0x20) selected[current] = !selected[current];
|
|
152
|
+
redraw();
|
|
173
153
|
}
|
|
174
|
-
|
|
154
|
+
} finally {
|
|
155
|
+
try { restore(); } finally { write('\x1b[?25h'); }
|
|
175
156
|
}
|
|
157
|
+
}
|
|
176
158
|
|
|
177
|
-
|
|
178
|
-
return
|
|
159
|
+
export function select(write, fd, question, choices, defaultValue, controls) {
|
|
160
|
+
return choose(write, fd, question, choices, [defaultValue], false, controls);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function multiselect(write, fd, question, choices, defaults = [], controls) {
|
|
164
|
+
return choose(write, fd, question, choices, defaults, true, controls);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Preserve the existing component selection API using the same keyboard implementation.
|
|
168
|
+
export function checkboxSelect(write, fd, specs, { title } = {}) {
|
|
169
|
+
if (!specs.length) return [];
|
|
170
|
+
return multiselect(write, fd, title || 'Choose components',
|
|
171
|
+
specs.map(({ name, label }) => ({ value: name, label })),
|
|
172
|
+
specs.filter(spec => spec.checked).map(spec => spec.name));
|
|
179
173
|
}
|
|
@@ -50,6 +50,7 @@ export function createServerOnboarding(effects, { compose, localEnv, bin }) {
|
|
|
50
50
|
return { name: root.name, cid: root.cid, created: false };
|
|
51
51
|
}
|
|
52
52
|
return {
|
|
53
|
+
async serverListIdentities(record) { return (await identities(record)).rows; },
|
|
53
54
|
async serverEnsureIdentity(record, name) {
|
|
54
55
|
validateRecord(record);
|
|
55
56
|
validateIdentityName(name);
|
package/lib/setup-options.mjs
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { join, resolve } from 'node:path';
|
|
1
|
+
import { join, resolve, isAbsolute, dirname } from 'node:path';
|
|
2
2
|
|
|
3
3
|
const scopes = ['all', 'server', 'client'];
|
|
4
4
|
const integrations = ['codex', 'claude-code', 'fleet'];
|
|
5
5
|
const valueFlags = new Map(Object.entries({
|
|
6
6
|
'--scope': 'scope', '--action': 'operation', '--mode': 'mode', '--state-dir': 'stateDir',
|
|
7
7
|
'--identity-name': 'identityName', '--integrations': 'integrations', '--fleet-settings': 'fleetSettingsPath',
|
|
8
|
-
'--config': 'config', '--sources': 'sources', '--port': 'port', '--cowork-port': 'coworkPort', '--messenger-port': 'messengerPort',
|
|
8
|
+
'--config': 'config', '--sources': 'sources', '--migrate-from': 'migrateFrom', '--port': 'port', '--cowork-port': 'coworkPort', '--messenger-port': 'messengerPort',
|
|
9
9
|
}));
|
|
10
10
|
const boolFlags = new Map([['--compatible', 'compatible'], ['--dry-run', 'dryRun'], ['--migrate', 'migrate']]);
|
|
11
11
|
const allowed = new Set([...valueFlags.values(), ...boolFlags.values(), 'interactive', 'explicitPorts']);
|
|
12
12
|
const defaults = { port: 3050, coworkPort: 3052, messengerPort: 8420 };
|
|
13
|
-
const paths = ['stateDir', 'config', 'sources', 'fleetSettingsPath'];
|
|
13
|
+
const paths = ['stateDir', 'config', 'sources', 'fleetSettingsPath', 'migrateFrom'];
|
|
14
14
|
const nonempty = value => typeof value === 'string' && value.trim().length > 0 && !/[\x00-\x1f\x7f]/.test(value);
|
|
15
15
|
|
|
16
16
|
export function recommendedMode({ platform, arch, release = '' } = {}) {
|
|
@@ -29,11 +29,13 @@ export function validateSetupOptions(input, { interactive = input?.interactive =
|
|
|
29
29
|
if (!['install', 'update'].includes(options.operation)) throw new Error('Operation must be install or update');
|
|
30
30
|
const server = options.scope !== 'client';
|
|
31
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');
|
|
32
34
|
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);
|
|
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);
|
|
34
36
|
if (client && options.integrations === undefined) missing.push('--integrations (use none to skip)');
|
|
35
37
|
if (!server && !nonempty(options.config)) missing.push('--config');
|
|
36
|
-
if (server && options.operation === 'update' && options.compatible !== true) missing.push('--compatible');
|
|
38
|
+
if (server && (options.operation === 'update' || options.migrateFrom !== undefined) && options.compatible !== true) missing.push('--compatible');
|
|
37
39
|
if (Array.isArray(options.integrations) && options.integrations.includes('fleet') && !interactive && !nonempty(options.fleetSettingsPath)) missing.push('--fleet-settings');
|
|
38
40
|
if (missing.length) throw new Error(`Missing required setup options: ${missing.join(', ')}`);
|
|
39
41
|
if (options.mode === 'native') options.mode = 'packages';
|
|
@@ -51,7 +53,10 @@ export function validateSetupOptions(input, { interactive = input?.interactive =
|
|
|
51
53
|
for (const key of paths) if (options[key] !== undefined && !nonempty(options[key])) throw new Error(`Invalid path for ${key}`);
|
|
52
54
|
for (const key of ['compatible', 'migrate']) if (options[key] !== undefined && typeof options[key] !== 'boolean') throw new Error(`${key} must be a boolean`);
|
|
53
55
|
if (server) {
|
|
54
|
-
options.identityName
|
|
56
|
+
if (options.identityName !== undefined) {
|
|
57
|
+
if (!nonempty(options.identityName)) throw new Error('Invalid Human identity name');
|
|
58
|
+
options.identityName = options.identityName.trim();
|
|
59
|
+
}
|
|
55
60
|
for (const [key, fallback] of Object.entries(defaults)) {
|
|
56
61
|
options[key] ??= fallback;
|
|
57
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`);
|
|
@@ -71,7 +76,10 @@ function expandPaths(options, home) {
|
|
|
71
76
|
if (value === '~' || value.startsWith('~/')) {
|
|
72
77
|
if (!nonempty(home)) throw new Error('Home directory is required to expand ~/ paths');
|
|
73
78
|
output[key] = resolve(home, value === '~' ? '' : value.slice(2));
|
|
74
|
-
} else
|
|
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
|
+
}
|
|
75
83
|
}
|
|
76
84
|
return output;
|
|
77
85
|
}
|
|
@@ -113,45 +121,133 @@ export function parseSetupArgs(argv, { home } = {}) {
|
|
|
113
121
|
export async function collectSetupOptions(effects) {
|
|
114
122
|
if (effects.interactive !== true) throw new Error('Interactive setup requires a TTY; provide the complete CLI options instead');
|
|
115
123
|
const options = { interactive: true, explicitPorts: [] };
|
|
116
|
-
|
|
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');
|
|
117
131
|
if (!scopes.includes(options.scope)) throw new Error('Scope must be all, server, or client');
|
|
118
132
|
let existing;
|
|
133
|
+
let pendingMigration;
|
|
119
134
|
if (options.scope !== 'client') {
|
|
120
|
-
|
|
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);
|
|
121
142
|
options.stateDir = expandPaths({ stateDir: options.stateDir }, effects.home).stateDir;
|
|
122
143
|
existing = effects.readJson(join(options.stateDir, 'installation.json'));
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
}
|
|
126
190
|
}
|
|
127
|
-
options.operation = (await effects.askLine('Install or update? ', existing ? 'update' : 'install')).trim();
|
|
128
191
|
if (options.scope !== 'client') {
|
|
129
192
|
const recommendation = recommendedMode({ ...effects.platform, arch: effects.platform?.arch ?? process.arch });
|
|
130
|
-
effects.out(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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);
|
|
139
205
|
}
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
}
|
|
142
214
|
if (options.scope !== 'server') {
|
|
143
215
|
const detected = typeof effects.detectHarnesses === 'function' ? await effects.detectHarnesses() : [];
|
|
144
|
-
|
|
145
|
-
|
|
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);
|
|
146
221
|
if (options.integrations.includes('fleet')) {
|
|
147
|
-
|
|
148
|
-
|
|
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
|
+
}
|
|
149
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;
|
|
150
246
|
}
|
|
151
|
-
const sources = await effects.askLine('Source policy override (leave empty for the packaged release): ', '');
|
|
152
|
-
if (sources.trim()) options.sources = sources;
|
|
153
247
|
const validated = validateSetupOptions(expandPaths(options, effects.home), { interactive: true });
|
|
154
|
-
|
|
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.');
|
|
155
251
|
if (!await effects.ask('Continue with this setup?', false)) throw new Error('Setup cancelled; nothing was changed');
|
|
156
252
|
return validated;
|
|
157
253
|
}
|
package/lib/setup.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { inspectLegacyMigration } from './legacy-migration.mjs';
|
|
1
2
|
import { join } from 'node:path';
|
|
2
3
|
import { parseSetupArgs, collectSetupOptions, validateSetupOptions } from './setup-options.mjs';
|
|
3
4
|
import { parseNetworkArgs, validateHostProfile, InstallUsageError } from './target.mjs';
|
|
@@ -36,10 +37,19 @@ export async function prepareSetupPlan(options, effects) {
|
|
|
36
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.`);
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
|
-
if (options.scope !== 'client') validateIdentityName(options.identityName);
|
|
40
|
+
if (options.scope !== 'client' && !options.migrateFrom) validateIdentityName(options.identityName);
|
|
40
41
|
if (options.fleetSettingsPath) validateFleetSettings(readObject(effects, options.fleetSettingsPath, 'Fleet settings'));
|
|
41
42
|
const policy = options.sources ? readObject(effects, options.sources, 'Source policy') : effects.packagedSourcePolicy();
|
|
42
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
|
+
}
|
|
43
53
|
if (options.scope !== 'client') {
|
|
44
54
|
const value = effects.readJson(join(options.stateDir, 'installation.json'));
|
|
45
55
|
if (value) {
|
|
@@ -49,7 +59,7 @@ export async function prepareSetupPlan(options, effects) {
|
|
|
49
59
|
if (options.explicitPorts?.includes(key) && options[key] !== plan.existing[key]) throw new InstallUsageError(`${key} conflicts with the retained installation`);
|
|
50
60
|
plan[key] = plan.existing[key];
|
|
51
61
|
}
|
|
52
|
-
if (options.operation === 'install') {
|
|
62
|
+
if (options.operation === 'install' && !options.migrateFrom) {
|
|
53
63
|
const retained = readObject(effects, plan.existing.sourcesPath, 'Retained source policy');
|
|
54
64
|
plan.sourcePolicy = options.scope === 'server' || !options.integrations?.length ? retained : completeReleasePolicy(retained, options.sources ? policy : null);
|
|
55
65
|
}
|
package/lib/usage.mjs
CHANGED
|
@@ -31,8 +31,10 @@ export const USAGE = `ours-install — interactive setup or complete CLI presets
|
|
|
31
31
|
--port N daemon port (default 3050 on a fresh installation)
|
|
32
32
|
--cowork-port N cowork port (default 3052)
|
|
33
33
|
--messenger-port N messenger port (default 8420)
|
|
34
|
-
--compatible required
|
|
35
|
-
--migrate
|
|
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
|
|
36
38
|
--dry-run show the validated plan without changing anything
|
|
37
39
|
--help, -h show help
|
|
38
40
|
--version, -V print installer version
|
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",
|