@ours.network/install 1.2.1-nightly.1 → 1.2.1-nightly.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
  }
@@ -0,0 +1,114 @@
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 serverListIdentities(record) { return (await identities(record)).rows; },
54
+ async serverEnsureIdentity(record, name) {
55
+ validateRecord(record);
56
+ validateIdentityName(name);
57
+ const prior = await identities(record);
58
+ if (prior.root) return retained(prior);
59
+ if (prior.rows.some(row => row.name === name)) throw new Error('Requested Human identity name already exists; no identity was changed');
60
+ effects.out?.(`Creating Human identity ${name}; retaining ${prior.rows.length} existing identities.`);
61
+ try {
62
+ await identityCommand(record, ['identity', 'create-root', '--name', name, '--skip-if-root-exists', 'true']);
63
+ } catch (error) {
64
+ // ROOT_EXISTS is currently a CLI error. A concurrent creator is safe only
65
+ // when the authoritative list now contains a root; other errors stay errors.
66
+ const after = await identities(record);
67
+ if (after.root) return retained(after);
68
+ throw error;
69
+ }
70
+ const after = await identities(record);
71
+ if (!after.root) throw new Error('Human identity creation completed without a visible root');
72
+ if (after.root.name !== name) return retained(after);
73
+ effects.out?.(`Human identity ${after.root.name} is ready.`);
74
+ return { name: after.root.name, cid: after.root.cid, created: true };
75
+ },
76
+ async prepareLocalClient(record, integrations, fleetSettingsPath) {
77
+ validateRecord(record);
78
+ if (!Array.isArray(integrations) || !integrations.length || new Set(integrations).size !== integrations.length
79
+ || integrations.some(name => !['codex', 'claude-code', 'fleet'].includes(name))) throw new Error('Client integrations must select codex, claude-code and/or fleet');
80
+ if (fleetSettingsPath !== undefined) {
81
+ if (typeof fleetSettingsPath !== 'string' || !isAbsolute(fleetSettingsPath) || resolve(fleetSettingsPath) !== fleetSettingsPath) throw new Error('Fleet settings path must be absolute');
82
+ const settings = JSON.parse(readFileSync(fleetSettingsPath, 'utf8'));
83
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) throw new Error('Fleet settings must be a JSON object');
84
+ }
85
+ const endpoint = `http://127.0.0.1:${record.port}`;
86
+ const current = effects.readManagedClientProfile();
87
+ if (current && (current.endpoint !== endpoint || current.expectedInstanceId !== record.instanceId)) throw new Error('Managed client already selects another server; no credential was issued');
88
+ privatePath(record.root, true);
89
+ const root = join(record.root, 'client');
90
+ if (!existsSync(root)) mkdirSync(root, { mode: 0o700 });
91
+ privatePath(root, true);
92
+ const stage = mkdtempSync(join(root, '.pending-'));
93
+ const published = join(root, 'issued-' + randomUUID());
94
+ const credential = join(stage, 'credential');
95
+ try {
96
+ effects.out?.('Issuing a separate local client credential with the retained server authority.');
97
+ try { await effects.serverAccess(record, 'access-issue', { output: credential }); }
98
+ catch { throw new Error('Client credential issuance failed; existing profiles were retained'); }
99
+ const stat = privatePath(credential);
100
+ if (stat.size > 4096 || !readFileSync(credential, 'utf8').trim()) throw new Error('Issued client credential is empty or invalid');
101
+ const profile = {
102
+ ...validateHostProfile({ endpoint, expectedInstanceId: record.instanceId, credentialPath: join(published, 'credential') }),
103
+ installer: { integrations: [...integrations], ...(fleetSettingsPath !== undefined ? { fleetSettingsPath } : {}) },
104
+ };
105
+ writeFileSync(join(stage, 'profile.json'), JSON.stringify(profile, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
106
+ // Publish the complete pair in one rename. Managed-default activation and
107
+ // package/source selection remain the normal client installer's job.
108
+ renameSync(stage, published);
109
+ effects.out?.('Local client profile is ready for authenticated client setup.');
110
+ return { configPath: join(published, 'profile.json'), profile };
111
+ } finally { rmSync(stage, { recursive: true, force: true }); }
112
+ },
113
+ };
114
+ }