@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/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 a raw-mode checkbox multi-select. When there is no tty or OURS_ASSUME_YES is
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
- // askLine: print `prompt` and read a line. We read in RAW mode with signal generation OFF (stty
34
- // -icanon -echo -isig) so Ctrl+C arrives as the byte 0x03 that we turn into a clean InstallCancelled
35
- // rather than a SIGINT that a blocked synchronous readSync would defer forever (the old
36
- // "^C^C^C… and keeps going" bug). We echo/backspace ourselves since -echo is off. If stty isn't
37
- // available (not a real tty), fall back to a plain cooked read. Throws InstallCancelled on Ctrl+C.
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
- write(prompt);
41
-
42
- const saved = spawnSync('stty', ['-g'], { stdio: [fd, 'pipe', 'ignore'], encoding: 'utf8' });
43
- const rawOk = saved.status === 0
44
- && spawnSync('stty', ['-icanon', '-echo', '-isig', 'min', '1', 'time', '0'], { stdio: [fd, 'ignore', 'ignore'] }).status === 0;
45
- const restore = () => { if (rawOk) spawnSync('stty', (saved.stdout || '').trim() ? [(saved.stdout || '').trim()] : ['sane'], { stdio: [fd, 'ignore', 'ignore'] }); };
46
-
47
- let s = '';
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 b = readByte(fd);
51
- if (b === null || b === 0x04) break; // EOF / Ctrl+D take the default
52
- if (b === 0x03) { restore(); write('^C'); throw new InstallCancelled(); } // Ctrl+C
53
- if (b === 0x0a || b === 0x0d) { if (rawOk) write('\n'); break; } // Enter
54
- if (b === 0x7f || b === 0x08) { if (s.length) { s = s.slice(0, -1); if (rawOk) write('\b \b'); } continue; } // backspace
55
- if (b < 0x20) continue; // ignore other control bytes
56
- s += String.fromCharCode(b);
57
- if (rawOk) write(String.fromCharCode(b)); // manual echo
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
- // Secret variant of askLine: identical cancellation/backspace/default semantics, but typed
67
- // characters are never echoed (not even as placeholder stars). This keeps provider keys out of
68
- // terminal scrollback, curl|bash output, and captured CI logs.
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
- let s = '';
89
- try {
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
- // checkboxSelect: raw-mode multi-select drawn on the tty. ↑/↓ (or k/j) move, Space toggles, Enter
115
- // confirms, a/n = all/none, q cancels (selects nothing). Returns the selected names in order.
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) return chosen();
131
- const savedMode = (saved.stdout || '').trim();
132
- const setRaw = spawnSync('stty', ['-echo', '-icanon', 'min', '1', 'time', '0'], { stdio: [fd, 'ignore', 'ignore'] });
133
- if (setRaw.status !== 0) return chosen();
134
- const restore = () => spawnSync('stty', savedMode ? [savedMode] : ['sane'], { stdio: [fd, 'ignore', 'ignore'] });
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[${n}A`); // rewind over the previous frame
141
- drawn = 1;
142
- for (let i = 0; i < n; i++) {
143
- const box = sel[i] ? c.green('[x]') : '[ ]';
144
- const point = i === cur ? c.cyan('> ') : ' ';
145
- write(`\r\x1b[K ${point}${box} ${labels[i]}\n`);
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
- write(` ${title || 'Choose — ' + c.bold('↑/↓') + ' move, ' + c.bold('Space') + ' toggle, ' + c.bold('Enter') + ' confirm (a=all, n=none)'}:\n`);
150
- redraw();
151
-
152
- loop: for (;;) {
153
- const b = readByte(fd);
154
- if (b === null) break;
155
- switch (b) {
156
- case 0x1b: { // ESC arrow key sequence \x1b [ A/B
157
- const b1 = readByte(fd);
158
- if (b1 === 0x5b || b1 === 0x4f) { // '[' or 'O'
159
- const b2 = readByte(fd);
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
- break;
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
- redraw();
154
+ } finally {
155
+ try { restore(); } finally { write('\x1b[?25h'); }
175
156
  }
157
+ }
176
158
 
177
- restore();
178
- return chosen();
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);
@@ -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 = options.identityName.trim();
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 output[key] = resolve(value);
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
- options.scope = (await effects.askLine('Set up all, server, or client? ', 'all')).trim();
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
- options.stateDir = await effects.askLine('Private installation root: ', join(effects.home, '.ours-install'));
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 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.`);
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(`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);
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
- 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'));
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
- 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);
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
- const path = await effects.askLine('Fleet settings file (leave empty for the interactive Fleet wizard): ', '');
148
- if (path.trim()) options.fleetSettingsPath = path;
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
- effects.out(`Setup: ${validated.operation} ${validated.scope}${validated.mode ? ` using ${validated.mode} in ${validated.stateDir}` : ` from ${validated.config}`}; integrations: ${validated.integrations?.join(', ') || 'none'}.`);
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 attestation for server updates of retained state
35
- --migrate explicit legacy access migration during install
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.2",
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",