@ours.network/install 0.11.0
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/LICENSE +98 -0
- package/README.md +136 -0
- package/install.mjs +604 -0
- package/install.sh +92 -0
- package/lib/logic.mjs +192 -0
- package/lib/prompt.mjs +139 -0
- package/lib/ui.mjs +126 -0
- package/package.json +33 -0
- package/uninstall.mjs +198 -0
- package/uninstall.sh +71 -0
package/install.mjs
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ours.network — the unified `ours-install` experience (the real UX behind install.sh's thin
|
|
3
|
+
// bootstrap, and the `ours-install` command once the stack is on the machine).
|
|
4
|
+
//
|
|
5
|
+
// ONE installer for the WHOLE stack — ours core (the daemon) + the harness plugins (Claude Code /
|
|
6
|
+
// Codex) + ours-fleet + the Telegram connector — for someone who ALREADY has Claude and/or Codex.
|
|
7
|
+
// Its whole job: install the stack cleanly, then hand back ONE copy-paste prompt the user drops
|
|
8
|
+
// into their agent to finish all real configuration conversationally. No tokens, no port editing,
|
|
9
|
+
// no config files. See packages/installer/README.md and the UX spec for the full contract.
|
|
10
|
+
//
|
|
11
|
+
// Design pillars (from the spec): config FIRST then act once; consent-first (Enter = no change);
|
|
12
|
+
// slow, per-step "✓ … no problems" + Continue?; never silently broken; idempotent + safe re-run;
|
|
13
|
+
// alias-safety / never-hang; deep config deferred to the copy-paste hand-off.
|
|
14
|
+
//
|
|
15
|
+
// SAFETY: every side-effecting action goes through act(); with OURS_INSTALL_DRY_RUN=1 nothing is
|
|
16
|
+
// installed/started/restarted — it prints exactly what it WOULD do. That is the safe way to walk
|
|
17
|
+
// the whole flow on a machine you don't want to touch (and how the tests drive it).
|
|
18
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
19
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
20
|
+
import { homedir, userInfo, platform as osPlatform, release as osRelease } from 'node:os';
|
|
21
|
+
import { join, dirname } from 'node:path';
|
|
22
|
+
import { banner, heading, ok, info, warn, c, box, withSpinner, openTty, makeWriter, closeSync } from './lib/ui.mjs';
|
|
23
|
+
import { askLine, askYesNo, isCancel } from './lib/prompt.mjs';
|
|
24
|
+
import {
|
|
25
|
+
suggestPort, parsePort, validateBroker, mergeConfig, parseVersion, parseStatus,
|
|
26
|
+
detectPlatform, classifyHarnessProbe, buildHandoffPrompt,
|
|
27
|
+
DEFAULT_PORT,
|
|
28
|
+
} from './lib/logic.mjs';
|
|
29
|
+
|
|
30
|
+
const NPM = process.env.OURS_NPM || 'npm';
|
|
31
|
+
let DRY = !!process.env.OURS_INSTALL_DRY_RUN;
|
|
32
|
+
const SELFHOST_URL = 'ours.network';
|
|
33
|
+
const CLAUDE_MARKET = 'adapt-toolkit/ours-claude-marketplace';
|
|
34
|
+
const CODEX_MARKET = 'adapt-toolkit/ours-codex-marketplace';
|
|
35
|
+
|
|
36
|
+
const sink = (s) => process.stdout.write(s);
|
|
37
|
+
const line = (s = '') => sink(`${s}\n`);
|
|
38
|
+
const say = (s) => sink(`ours: ${s}\n`);
|
|
39
|
+
|
|
40
|
+
// --- external command helpers (never throw; the installer degrades, it doesn't crash) ----------
|
|
41
|
+
function run(bin, args, { capture = false, timeout } = {}) {
|
|
42
|
+
const r = spawnSync(bin, args, {
|
|
43
|
+
encoding: 'utf8',
|
|
44
|
+
timeout,
|
|
45
|
+
stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
46
|
+
});
|
|
47
|
+
const timedOut = !!(r.error && (r.error.code === 'ETIMEDOUT' || r.signal === 'SIGTERM'));
|
|
48
|
+
return {
|
|
49
|
+
code: r.status ?? (r.error ? -1 : 0),
|
|
50
|
+
out: r.stdout || '', err: r.stderr || '',
|
|
51
|
+
ok: !r.error && r.status === 0,
|
|
52
|
+
timedOut,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function runAsync(bin, args) {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
let child;
|
|
58
|
+
try { child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] }); }
|
|
59
|
+
catch { resolve({ code: -1, out: '', err: '', ok: false }); return; }
|
|
60
|
+
let out = '', errOut = '';
|
|
61
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
62
|
+
child.stderr.on('data', (d) => { errOut += d; });
|
|
63
|
+
child.on('error', () => resolve({ code: -1, out, err: errOut, ok: false }));
|
|
64
|
+
child.on('close', (code) => resolve({ code: code ?? -1, out, err: errOut, ok: code === 0 }));
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// act(): the single seam every side-effecting step passes through. In DRY-RUN it prints the exact
|
|
69
|
+
// command instead of running it and reports a synthetic success, so the whole UX can be walked on
|
|
70
|
+
// a machine we must not disturb. `fn` runs the real thing and returns {ok,...}.
|
|
71
|
+
async function act(desc, fn) {
|
|
72
|
+
if (DRY) { line(' ' + c.dim(`[dry-run] would: ${desc}`)); return { ok: true, dry: true }; }
|
|
73
|
+
return fn();
|
|
74
|
+
}
|
|
75
|
+
async function actSpin(label, desc, fn) {
|
|
76
|
+
if (DRY) { line(' ' + c.dim(`[dry-run] would: ${desc}`)); return { ok: true, dry: true }; }
|
|
77
|
+
return withSpinner(label, fn);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- daemon probes (always safe to run — read-only) --------------------------------------------
|
|
81
|
+
const daemonVersionLine = () => (run('ours-mcp', ['--version'], { capture: true }).out.split('\n')[0] || '').trim();
|
|
82
|
+
const daemonStatusText = () => run('ours-mcp', ['status'], { capture: true }).out;
|
|
83
|
+
const daemonRunning = () => run('ours-mcp', ['status'], { capture: true }).code === 0;
|
|
84
|
+
const globalVersion = (pkg) => {
|
|
85
|
+
const ls = run(NPM, ['ls', '-g', pkg], { capture: true }).out;
|
|
86
|
+
const m = ls.match(new RegExp(pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') + '@([0-9][0-9.]*)'));
|
|
87
|
+
return m ? m[1] : '';
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// --- config file (fixed home location; mirrors core/config.ts) ---------------------------------
|
|
91
|
+
function configPath() { return process.env.OURS_CONFIG || join(homedir(), '.ours', 'config.json'); }
|
|
92
|
+
function readConfigObject() { try { return JSON.parse(readFileSync(configPath(), 'utf8')); } catch { return {}; } }
|
|
93
|
+
function writeConfigPatch(patch) {
|
|
94
|
+
const p = configPath();
|
|
95
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
96
|
+
writeFileSync(p, mergeConfig(readConfigObject(), patch));
|
|
97
|
+
return p;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Block the thread for `ms` without a subprocess — used for the brief daemon-reachability wait
|
|
101
|
+
// before creating the human identity (a freshly-started daemon needs a moment to bind its port).
|
|
102
|
+
function sleepMs(ms) {
|
|
103
|
+
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* ignore */ }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Is `port` already bound? Probe in a throwaway child and read its exit code (EADDRINUSE ⇒ taken).
|
|
107
|
+
function portTakenSync(port) {
|
|
108
|
+
const scriptSrc = `const net=require('net');const s=net.createServer();s.once('error',e=>{process.exit(e.code==='EADDRINUSE'?3:0)});s.listen(${port},'127.0.0.1',()=>{s.close(()=>process.exit(0))});`;
|
|
109
|
+
const r = spawnSync(process.execPath, ['-e', scriptSrc], { timeout: 3000 });
|
|
110
|
+
return r.status === 3;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- harness detection with ALIAS-SAFETY (never call an unsafe command) -------------------------
|
|
114
|
+
// Gather three read-only observations, then let the pure classifier decide. We NEVER run the
|
|
115
|
+
// harness in a way that could hang: --version is spawned directly (no shell → real PATH binary)
|
|
116
|
+
// with a hard timeout; the shell `type` lookup is also timeout-guarded.
|
|
117
|
+
function detectHarness(name) {
|
|
118
|
+
const onPath = run('bash', ['-c', `command -v ${name}`], { capture: true }).ok;
|
|
119
|
+
const probe = run(name, ['--version'], { capture: true, timeout: 6000 });
|
|
120
|
+
const versionOk = probe.ok && /\d+\.\d+/.test(probe.out);
|
|
121
|
+
const shell = process.env.SHELL || '/bin/bash';
|
|
122
|
+
const typeProbe = run(shell, ['-ic', `type -t ${name} 2>/dev/null`], { capture: true, timeout: 4000 });
|
|
123
|
+
const shellType = (typeProbe.out || '').trim();
|
|
124
|
+
const verdict = classifyHarnessProbe({ onPath, versionOk, timedOut: probe.timedOut, shellType });
|
|
125
|
+
return { name, ...verdict };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Set by main() so the top-level catch can route a Ctrl+C (InstallCancelled) through the same
|
|
129
|
+
// clean-exit path as the SIGINT handler.
|
|
130
|
+
let cancelHandler = null;
|
|
131
|
+
|
|
132
|
+
// A tiny package version read (best-effort) for `--version`.
|
|
133
|
+
function pkgVersion() {
|
|
134
|
+
try { return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version || '?'; }
|
|
135
|
+
catch { return '?'; }
|
|
136
|
+
}
|
|
137
|
+
const USAGE = `ours-install — the unified ours.network stack installer.
|
|
138
|
+
|
|
139
|
+
Install: npm i -g @ours.network/install && ours-install (recommended)
|
|
140
|
+
npx @ours.network/install (one-off)
|
|
141
|
+
|
|
142
|
+
ours-install [--dry-run] [--help] [--version]
|
|
143
|
+
|
|
144
|
+
Guided ~3-minute setup for the whole stack: ours core (the daemon), the harness
|
|
145
|
+
plugins (Claude Code + Codex), ours-fleet, and the Telegram connector — then one
|
|
146
|
+
copy-paste hand-off prompt. You approve each step; re-run any time to add a piece
|
|
147
|
+
or update.
|
|
148
|
+
|
|
149
|
+
--dry-run walk the whole flow and print what it WOULD do — install/change nothing
|
|
150
|
+
--help show this help and exit
|
|
151
|
+
--version print the installer version and exit
|
|
152
|
+
|
|
153
|
+
Env: OURS_ASSUME_YES=1 (accept defaults, no prompts) · OURS_INSTALL_DRY_RUN=1 ·
|
|
154
|
+
OURS_NPM · OURS_CONFIG (default ~/.ours/config.json). Docs: https://ours.network`;
|
|
155
|
+
|
|
156
|
+
// ===============================================================================================
|
|
157
|
+
async function main() {
|
|
158
|
+
const argv = process.argv.slice(2);
|
|
159
|
+
if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(USAGE + '\n'); return; }
|
|
160
|
+
if (argv.includes('--version') || argv.includes('-V')) { process.stdout.write(`ours-install v${pkgVersion()}\n`); return; }
|
|
161
|
+
if (argv.includes('--dry-run')) DRY = true;
|
|
162
|
+
|
|
163
|
+
const ttyFd = openTty();
|
|
164
|
+
const interactive = ttyFd != null && !process.env.OURS_ASSUME_YES;
|
|
165
|
+
const write = makeWriter(ttyFd);
|
|
166
|
+
|
|
167
|
+
// Ctrl+C at ANY prompt aborts cleanly (never the old "^C^C^C and keeps going"): print one line
|
|
168
|
+
// and exit 130. The SIGINT handler covers a ^C while we're idle/spinning; the InstallCancelled
|
|
169
|
+
// thrown out of a blocked prompt read covers a ^C mid-prompt (both routed here, guarded once).
|
|
170
|
+
let cancelling = false;
|
|
171
|
+
const cancel = () => {
|
|
172
|
+
if (cancelling) return; cancelling = true;
|
|
173
|
+
try { process.stdout.write('\n' + warn('Installation cancelled — re-run any time.') + '\n'); } catch { /* ignore */ }
|
|
174
|
+
finish(ttyFd);
|
|
175
|
+
process.exit(130);
|
|
176
|
+
};
|
|
177
|
+
cancelHandler = cancel;
|
|
178
|
+
if (interactive) process.on('SIGINT', cancel);
|
|
179
|
+
const yes = (prompt, def) => (process.env.OURS_ASSUME_YES ? def : askYesNo(write, ttyFd, prompt, def));
|
|
180
|
+
const ask = (prompt, def) => (process.env.OURS_ASSUME_YES ? def : askLine(write, ttyFd, prompt, def));
|
|
181
|
+
// A Continue? beat: one clean acknowledgement AFTER a step that actually did something (delta
|
|
182
|
+
// #1860). A step the user SKIPPED (answered No) shows no "you skipped — press Enter" pause — we
|
|
183
|
+
// move straight on. No-op when we can't prompt (headless / assume-yes) so scripted runs stay linear.
|
|
184
|
+
const cont = (acted = true) => { if (interactive && acted) askLine(write, ttyFd, ' ' + c.gray('Continue? [Enter] '), ''); };
|
|
185
|
+
|
|
186
|
+
line(banner());
|
|
187
|
+
say('setting up the ours.network stack — ours core, your harness plugins, ours-fleet, Telegram.');
|
|
188
|
+
line(' ' + c.dim('~3 minutes. You approve each step; re-run any time to add a piece or update.'));
|
|
189
|
+
if (DRY) line(' ' + c.yellow('(dry-run: nothing will be installed or changed — this just walks the flow.)'));
|
|
190
|
+
|
|
191
|
+
// ============================================================================================
|
|
192
|
+
// PRE-FLIGHT — silent-ish checks, no changes. Detect the disasters up front. A checklist, not
|
|
193
|
+
// logs. (Native Windows → WSL pointer + exit; no harness at all → tell them + exit.)
|
|
194
|
+
// ============================================================================================
|
|
195
|
+
line(heading('Checking your machine'));
|
|
196
|
+
const plat = detectPlatform({ platform: osPlatform(), release: osRelease(), env: process.env });
|
|
197
|
+
if (!plat.supported) {
|
|
198
|
+
if (plat.os === 'windows') {
|
|
199
|
+
line(warn(`${plat.label} isn't supported directly yet.`));
|
|
200
|
+
line(info('Install this inside WSL (Windows Subsystem for Linux), then re-run there:'));
|
|
201
|
+
line(' ' + c.cyan('https://learn.microsoft.com/windows/wsl/install'));
|
|
202
|
+
} else {
|
|
203
|
+
line(warn(`Platform "${plat.label}" isn't supported. ours runs on Linux, macOS, or WSL.`));
|
|
204
|
+
}
|
|
205
|
+
finish(ttyFd); return;
|
|
206
|
+
}
|
|
207
|
+
line(ok(`Platform: ${plat.label} (supported)`));
|
|
208
|
+
|
|
209
|
+
const nodeMajor = Number.parseInt((process.versions.node || '0').split('.')[0], 10);
|
|
210
|
+
if (nodeMajor >= 20) line(ok(`Node.js ${process.versions.node}`));
|
|
211
|
+
else { line(warn(`Node.js ${process.versions.node} — ours needs v20 or newer. Update Node and re-run.`)); finish(ttyFd); return; }
|
|
212
|
+
|
|
213
|
+
// Harness detection with alias-safety. Report each, keep the drivable ones.
|
|
214
|
+
const harnessSpecs = [
|
|
215
|
+
{ name: 'claude', label: 'Claude Code' },
|
|
216
|
+
{ name: 'codex', label: 'Codex' },
|
|
217
|
+
];
|
|
218
|
+
const harnesses = harnessSpecs.map((h) => ({ ...h, ...detectHarness(h.name) }));
|
|
219
|
+
for (const h of harnesses) {
|
|
220
|
+
if (h.status === 'ok') line(ok(`'${h.name}' → real program (its plugin can be installed)`));
|
|
221
|
+
else if (h.status === 'alias') line(warn(`'${h.name}' → ${h.detail} (I won't call it — see the note below; you can still install it by hand)`));
|
|
222
|
+
else if (h.status === 'unsafe') line(warn(`'${h.name}' → on your PATH but didn't answer safely (manual install shown below if you want it)`));
|
|
223
|
+
else line(info(`'${h.name}' → not installed (skipped — install it first if you want it wired up)`));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const anyHarness = harnesses.some((h) => h.status !== 'absent');
|
|
227
|
+
if (!anyHarness) {
|
|
228
|
+
line('');
|
|
229
|
+
line(warn('No Claude Code or Codex found on this machine.'));
|
|
230
|
+
line(info('Install one of them first, then re-run ours-install to wire it up.'));
|
|
231
|
+
finish(ttyFd); return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Daemon state up front (decides first-install vs update, and whether Step 0 runs at all).
|
|
235
|
+
const versionBefore = daemonVersionLine();
|
|
236
|
+
const daemonInstalled = !!versionBefore;
|
|
237
|
+
line(daemonInstalled
|
|
238
|
+
? ok(`ours core already present (${versionBefore})`)
|
|
239
|
+
: ok('No ours daemon yet — will offer to install it'));
|
|
240
|
+
line('');
|
|
241
|
+
cont();
|
|
242
|
+
|
|
243
|
+
// ============================================================================================
|
|
244
|
+
// STEP 0 — the two config questions (asked ONCE, up front). SKIPPED entirely when a daemon is
|
|
245
|
+
// already configured (update path reuses its port/broker; delta #1859).
|
|
246
|
+
// ============================================================================================
|
|
247
|
+
const status0 = parseStatus(daemonStatusText());
|
|
248
|
+
let chosenBroker; // undefined = keep default / existing
|
|
249
|
+
let chosenPort = status0.port || DEFAULT_PORT;
|
|
250
|
+
const configFirst = !daemonInstalled;
|
|
251
|
+
|
|
252
|
+
if (configFirst) {
|
|
253
|
+
line(heading('A couple of quick settings'));
|
|
254
|
+
// 0a — broker (owner edit #1: SECURE wording; owner edit #2: self-host → website only).
|
|
255
|
+
line(info('Your agents connect through a "broker" — a shared meeting point that lets them find'));
|
|
256
|
+
line(info("each other. It's secure: your messages are end-to-end encrypted, so the broker never"));
|
|
257
|
+
line(info('sees what they say. Almost everyone uses the standard one — just press Enter.'));
|
|
258
|
+
const custom = yes(' Use a custom broker address?', false);
|
|
259
|
+
if (custom) {
|
|
260
|
+
line(info(`(Only needed if you run your own broker. More at ${SELFHOST_URL}.)`));
|
|
261
|
+
const entered = ask(' Enter the broker address: ', '');
|
|
262
|
+
const v = validateBroker(entered);
|
|
263
|
+
if (entered && v.ok && !v.empty) {
|
|
264
|
+
// Undo safety net: a mistaken custom entry is one keystroke back to the standard broker.
|
|
265
|
+
const keep = yes(` Use "${v.value}"? (No = go back to the standard broker)`, true);
|
|
266
|
+
if (keep) { chosenBroker = v.value; line(ok(`broker set to ${chosenBroker}.`)); }
|
|
267
|
+
else line(ok('using the standard broker.'));
|
|
268
|
+
} else {
|
|
269
|
+
if (entered) line(warn(`"${entered}" doesn't look like a ws:// address — using the standard broker.`));
|
|
270
|
+
else line(ok('using the standard broker.'));
|
|
271
|
+
}
|
|
272
|
+
} else {
|
|
273
|
+
line(ok('using the standard broker.'));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 0b — port: probe 3050; only ask if busy. Minimize the concept.
|
|
277
|
+
if (!portTakenSync(DEFAULT_PORT)) {
|
|
278
|
+
chosenPort = DEFAULT_PORT;
|
|
279
|
+
line(ok(`Using local port ${DEFAULT_PORT}.`));
|
|
280
|
+
} else {
|
|
281
|
+
line(info(`The standard local port (${DEFAULT_PORT}) is already in use on your machine.`));
|
|
282
|
+
let candidate = suggestPort(DEFAULT_PORT + 1, portTakenSync);
|
|
283
|
+
const raw = ask(` Pick another number for the ours daemon? ${c.gray(`[Enter for ${candidate}]`)}: `, String(candidate));
|
|
284
|
+
const parsed = parsePort(raw, candidate);
|
|
285
|
+
candidate = suggestPort(parsed.ok ? parsed.port : candidate, portTakenSync);
|
|
286
|
+
chosenPort = candidate;
|
|
287
|
+
line(ok(`Using local port ${chosenPort}.`));
|
|
288
|
+
}
|
|
289
|
+
line('');
|
|
290
|
+
line(ok(`Config ready — broker: ${chosenBroker ? 'custom' : 'standard'}, port: ${chosenPort}.`));
|
|
291
|
+
cont();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Track outcomes for the summary + hand-off.
|
|
295
|
+
const summary = [];
|
|
296
|
+
const record = (row) => summary.push(row);
|
|
297
|
+
|
|
298
|
+
// ============================================================================================
|
|
299
|
+
// STEP 1 / 4 — ours core (the daemon). Config-first: write config → install/start ONCE.
|
|
300
|
+
// ============================================================================================
|
|
301
|
+
line(heading('1/4 — ours core (the daemon)'));
|
|
302
|
+
line(info('This is the piece that lets your agents talk to each other securely. Everything else'));
|
|
303
|
+
line(info('needs it.'));
|
|
304
|
+
const before = parseVersion(versionBefore);
|
|
305
|
+
|
|
306
|
+
if (!daemonInstalled) {
|
|
307
|
+
const goCore = yes(' Install and start it?', true);
|
|
308
|
+
if (!goCore) {
|
|
309
|
+
line(info("skipped — nothing else can run without it. Re-run ours-install when you're ready."));
|
|
310
|
+
record({ key: 'core', label: 'ours core (daemon)', state: 'skipped', note: 'declined' });
|
|
311
|
+
// Without a daemon the rest is moot; go straight to the summary.
|
|
312
|
+
return endScreen({ ttyFd, summary, chosenPort, chosenBroker });
|
|
313
|
+
}
|
|
314
|
+
await actSpin('ensuring @ours.network/mcp@latest…', 'npm i -g @ours.network/mcp@latest', () => runAsync(NPM, ['i', '-g', '@ours.network/mcp@latest']));
|
|
315
|
+
const patch = { port: chosenPort };
|
|
316
|
+
if (chosenBroker) patch.brokerUrl = chosenBroker;
|
|
317
|
+
await act(`write config (${configPath()}) with port ${chosenPort}${chosenBroker ? ' + custom broker' : ''}`, async () => { writeConfigPatch(patch); return { ok: true }; });
|
|
318
|
+
const started = await act(`ours-mcp start (port ${chosenPort})`, async () => run('ours-mcp', ['start']));
|
|
319
|
+
const svc = await act('ours-mcp install-service (survives reboot)', async () => run('ours-mcp', ['install-service']));
|
|
320
|
+
if (started.ok) line(ok(`ours core ready — running on port ${chosenPort}. No problems.`));
|
|
321
|
+
else line(warn(`could not auto-start — run '${c.cyan('ours-mcp start')}' to bring it up.`));
|
|
322
|
+
if (!svc.ok && !svc.dry) line(warn(`boot-service not installed — retry '${c.cyan('ours-mcp install-service')}' later.`));
|
|
323
|
+
record({ key: 'core', label: 'ours core (daemon)', state: started.ok ? 'installed' : 'failed', version: parseVersion(daemonVersionLine()), note: 'starts on boot' });
|
|
324
|
+
} else {
|
|
325
|
+
// Installed: offer an update; never re-ask config; reuse the running port everywhere.
|
|
326
|
+
const running = daemonRunning();
|
|
327
|
+
const upd = yes(` ours core is installed (${before || '?'}) — check for an update now?`, false);
|
|
328
|
+
if (upd) {
|
|
329
|
+
await actSpin('updating @ours.network/mcp@latest…', 'npm i -g @ours.network/mcp@latest', () => runAsync(NPM, ['i', '-g', '@ours.network/mcp@latest']));
|
|
330
|
+
const after = parseVersion(daemonVersionLine());
|
|
331
|
+
if (before && after && before !== after) {
|
|
332
|
+
await act(`ours-mcp restart (now v${after})`, async () => { if (!run('ours-mcp', ['restart']).ok) run('ours-mcp', ['start']); return { ok: true }; });
|
|
333
|
+
line(ok(`ours core updated (v${before} → v${after}) and restarted. No problems.`));
|
|
334
|
+
} else {
|
|
335
|
+
line(ok(`ours core already current${after ? ` (v${after})` : ''} — nothing to change.`));
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
line(ok(`ours core ready — running on port ${chosenPort}${running ? '' : ' (start with ours-mcp start)'}. No problems.`));
|
|
339
|
+
}
|
|
340
|
+
record({ key: 'core', label: 'ours core (daemon)', state: 'current', version: (parseVersion(daemonVersionLine()) || before), note: `port ${chosenPort}` });
|
|
341
|
+
}
|
|
342
|
+
cont();
|
|
343
|
+
|
|
344
|
+
// ============================================================================================
|
|
345
|
+
// Human identity — created DURING install, right after the daemon is confirmed reachable (the
|
|
346
|
+
// owner change that supersedes "defer to the hand-off"). `ours-mcp create-root` is the internal
|
|
347
|
+
// seam; ALL user-facing copy says "human identity". Already-exists is a friendly keep, not an
|
|
348
|
+
// error; an unreachable daemon gives an exact retry command (never "ask your agent later").
|
|
349
|
+
// ============================================================================================
|
|
350
|
+
const coreReady = summary.some((r) => r.key === 'core' && (r.state === 'installed' || r.state === 'current'));
|
|
351
|
+
if (coreReady) {
|
|
352
|
+
line(heading('Your human identity'));
|
|
353
|
+
line(info('This is you — the human. Your agents act on your behalf, and it lets you message'));
|
|
354
|
+
line(info('people. (Internally this is your ours root; you just give it a name.)'));
|
|
355
|
+
let defName = 'me';
|
|
356
|
+
try { defName = userInfo().username || defName; } catch { /* keep fallback */ }
|
|
357
|
+
const name = (ask(` What name should others see? ${c.gray(`[${defName}]`)}: `, defName) || defName).trim() || defName;
|
|
358
|
+
let idActed = true;
|
|
359
|
+
if (DRY) {
|
|
360
|
+
line(' ' + c.dim(`[dry-run] would: ours-mcp create-root "${name}"`));
|
|
361
|
+
line(ok(`Your human identity "${name}" is created.`));
|
|
362
|
+
record({ key: 'identity', label: 'Human identity', state: 'installed', note: name });
|
|
363
|
+
} else {
|
|
364
|
+
// A freshly-started daemon may need a moment to bind its port before create-root can reach it.
|
|
365
|
+
let reachable = daemonRunning();
|
|
366
|
+
for (let i = 0; i < 6 && !reachable; i++) { sleepMs(400); reachable = daemonRunning(); }
|
|
367
|
+
const r = reachable ? run('ours-mcp', ['create-root', name], { capture: true }) : { ok: false, out: '', err: 'daemon not running' };
|
|
368
|
+
const outText = `${r.out} ${r.err}`;
|
|
369
|
+
const existing = outText.match(/already exists \("([^"]+)"\)/);
|
|
370
|
+
if (r.ok && existing) {
|
|
371
|
+
line(ok(`You already have a human identity ("${existing[1]}") — keeping it.`));
|
|
372
|
+
record({ key: 'identity', label: 'Human identity', state: 'current', note: existing[1] });
|
|
373
|
+
} else if (r.ok) {
|
|
374
|
+
line(ok(`Your human identity "${name}" is created.`));
|
|
375
|
+
record({ key: 'identity', label: 'Human identity', state: 'installed', note: name });
|
|
376
|
+
} else if (!reachable || /not running|not reachable/i.test(outText)) {
|
|
377
|
+
line(warn("The daemon isn't reachable yet — couldn't create your human identity."));
|
|
378
|
+
line(info(`Fix: run '${c.cyan('ours-mcp start')}', then '${c.cyan(`ours-mcp create-root "${name}"`)}'.`));
|
|
379
|
+
record({ key: 'identity', label: 'Human identity', state: 'failed', note: 'daemon not reachable' });
|
|
380
|
+
} else {
|
|
381
|
+
const msg = (r.err || r.out || '').trim().split('\n')[0] || 'unknown error';
|
|
382
|
+
line(warn(`Couldn't create your human identity: ${msg}`));
|
|
383
|
+
line(info(`Retry any time: '${c.cyan(`ours-mcp create-root "${name}"`)}'.`));
|
|
384
|
+
record({ key: 'identity', label: 'Human identity', state: 'failed', note: 'create-root failed' });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
cont(idActed);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Step 2 harness installers (closures — share the helpers + `record` above). Alias / failure →
|
|
391
|
+
// NEVER dead-end (owner edit #3): plain reason + manual path, always. Each returns whether it
|
|
392
|
+
// ACTED (so a plain user-No skip shows no Continue pause).
|
|
393
|
+
async function installClaude(h) {
|
|
394
|
+
if (h.status !== 'ok') { manualClaude(h); record({ key: 'claude', label: 'Claude Code plugin', state: 'skipped', note: h.status === 'alias' ? 'installed as an alias' : 'not drivable' }); return true; }
|
|
395
|
+
const go = yes(' Install the ours plugin into Claude Code?', true);
|
|
396
|
+
if (!go) { line(info('skipped — re-run ours-install to add it.')); record({ key: 'claude', label: 'Claude Code plugin', state: 'skipped' }); return false; }
|
|
397
|
+
const add = await act(`claude plugin marketplace add ${CLAUDE_MARKET}`, async () => run('claude', ['plugin', 'marketplace', 'add', CLAUDE_MARKET], { capture: true }));
|
|
398
|
+
const inst = add.ok ? await act('claude plugin install ours@ours.network', async () => run('claude', ['plugin', 'install', 'ours@ours.network'], { capture: true })) : add;
|
|
399
|
+
if (inst.ok) { line(ok(`Claude Code plugin installed — pointed at port ${chosenPort}. No problems.`)); line(info('(restart Claude Code to load it.)')); record({ key: 'claude', label: 'Claude Code plugin', state: 'installed', note: 'restart Claude Code' }); }
|
|
400
|
+
else { failClaude(); record({ key: 'claude', label: 'Claude Code plugin', state: 'failed', note: 'marketplace/install step failed' }); }
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
async function installCodex(h) {
|
|
404
|
+
if (h.status !== 'ok') { manualCodex(h); record({ key: 'codex', label: 'Codex plugin + ours-codex', state: 'skipped', note: h.status === 'alias' ? 'installed as an alias' : 'not drivable' }); return true; }
|
|
405
|
+
const go = yes(' Install the ours plugin into Codex?', true);
|
|
406
|
+
if (!go) { line(info('skipped — re-run ours-install to add it.')); record({ key: 'codex', label: 'Codex plugin + ours-codex', state: 'skipped' }); return false; }
|
|
407
|
+
const add = await act(`codex plugin marketplace add ${CODEX_MARKET}`, async () => run('codex', ['plugin', 'marketplace', 'add', CODEX_MARKET], { capture: true }));
|
|
408
|
+
const inst = add.ok ? await act('codex plugin add ours@ours-codex-marketplace', async () => run('codex', ['plugin', 'add', 'ours@ours-codex-marketplace'], { capture: true })) : add;
|
|
409
|
+
// Owner-mandated: choosing the Codex plugin ALSO installs the ours-codex live launcher, same step.
|
|
410
|
+
const wrap = inst.ok ? await actSpin('installing the ours-codex live launcher…', 'npm i -g @ours.network/codex@latest (provides ours-codex)', () => runAsync(NPM, ['i', '-g', '@ours.network/codex@latest'])) : inst;
|
|
411
|
+
if (inst.ok && wrap.ok) {
|
|
412
|
+
line(ok(`Codex plugin + ours-codex live launcher installed — pointed at port ${chosenPort}. No problems.`));
|
|
413
|
+
// Plain-language: what ours-codex is and why you'd use it (background wake vs blocking).
|
|
414
|
+
line(info('Two ways to run Codex now:'));
|
|
415
|
+
line(info(' • plain "codex" — waits for mail in the foreground: it EITHER watches for new'));
|
|
416
|
+
line(info(' messages OR takes your typing, one at a time — not both at once.'));
|
|
417
|
+
line(info(' • "ours-codex" — our wrapper that turns on AUTO wake-up: it uses Codex\'s built-in'));
|
|
418
|
+
line(info(' app server to watch for new mail in the BACKGROUND while you keep typing, so a'));
|
|
419
|
+
line(info(" reply wakes it without interrupting you. Use 'ours-codex' for hands-off replies."));
|
|
420
|
+
record({ key: 'codex', label: 'Codex plugin + ours-codex', state: 'installed', note: 'new Codex thread' });
|
|
421
|
+
} else { failCodex(); record({ key: 'codex', label: 'Codex plugin + ours-codex', state: 'failed', note: 'marketplace/install step failed' }); }
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ============================================================================================
|
|
426
|
+
// STEP 2 / 4 — harness plugins (Claude Code + Codex). The installer drives the plugin CLIs.
|
|
427
|
+
// ============================================================================================
|
|
428
|
+
line(heading('2/4 — harness plugins'));
|
|
429
|
+
line(info('These teach Claude Code and Codex the ours skills, so you can just talk to your agent'));
|
|
430
|
+
line(info("to message people and set things up. I'll install them for you — no commands to type."));
|
|
431
|
+
for (const h of harnesses) {
|
|
432
|
+
if (h.status === 'absent') continue; // nothing to offer; pre-flight already noted it
|
|
433
|
+
line('');
|
|
434
|
+
const acted = h.name === 'claude' ? await installClaude(h) : await installCodex(h);
|
|
435
|
+
cont(acted);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ============================================================================================
|
|
439
|
+
// STEP 3 / 4 — ours-fleet. Appealing wording (owner edit #4); default YES.
|
|
440
|
+
// ============================================================================================
|
|
441
|
+
line(heading('3/4 — ours-fleet (your always-online agent team)'));
|
|
442
|
+
line(info('This makes your harnesses PERSISTENT: Claude Code and Codex stop being just a terminal'));
|
|
443
|
+
line(info('session and become always-online daemons that survive a reboot. Stand up your own team'));
|
|
444
|
+
line(info('of always-online developers, combine harnesses, run several Claude Codes, and link them'));
|
|
445
|
+
line(info('over Telegram so they talk to each other — and it all configures maximally easily.'));
|
|
446
|
+
const goFleet = yes(' Install it?', true);
|
|
447
|
+
if (goFleet) {
|
|
448
|
+
await actSpin('installing @ours.network/fleet@latest…', 'npm i -g @ours.network/fleet@latest', () => runAsync(NPM, ['i', '-g', '@ours.network/fleet@latest']));
|
|
449
|
+
const init = await act('ours-fleet init (one-time host setup: units, dirs, linger)', async () => run('ours-fleet', ['init']));
|
|
450
|
+
if (init.ok) { line(ok('ours-fleet ready — Claude and Codex both know the fleet skill. No problems.')); record({ key: 'fleet', label: 'ours-fleet', state: 'installed', version: globalVersion('@ours.network/fleet') }); }
|
|
451
|
+
else { line(warn(`ours-fleet host setup didn't finish — retry '${c.cyan('ours-fleet init')}'.`)); record({ key: 'fleet', label: 'ours-fleet', state: 'failed', note: 'ours-fleet init failed' }); }
|
|
452
|
+
} else {
|
|
453
|
+
line(info('skipped cleanly — re-run ours-install any time to add it.'));
|
|
454
|
+
record({ key: 'fleet', label: 'ours-fleet', state: 'skipped' });
|
|
455
|
+
}
|
|
456
|
+
cont(goFleet);
|
|
457
|
+
|
|
458
|
+
// ============================================================================================
|
|
459
|
+
// STEP 4 / 4 — Telegram connector. Install-only (no bot tokens here). Then: run as a service?
|
|
460
|
+
// ============================================================================================
|
|
461
|
+
line(heading('4/4 — Telegram connector'));
|
|
462
|
+
line(info('This bridges a Telegram bot to your Ours node, so you can talk to your agent from'));
|
|
463
|
+
line(info("Telegram. (You'll set up the actual bot later, with your agent — not here.)"));
|
|
464
|
+
const goTg = yes(' Install it?', false);
|
|
465
|
+
if (goTg) {
|
|
466
|
+
await actSpin('installing @ours.network/tg-connector@latest…', 'npm i -g @ours.network/tg-connector@latest', () => runAsync(NPM, ['i', '-g', '@ours.network/tg-connector@latest']));
|
|
467
|
+
const asService = yes(' Keep it running in the background so it starts automatically on boot?', true);
|
|
468
|
+
if (asService) {
|
|
469
|
+
const svc = await act('ours-tg-connector install-service (starts on boot)', async () => run('ours-tg-connector', ['install-service']));
|
|
470
|
+
if (svc.ok) line(ok('Telegram connector installed and running as a service (starts on boot). No problems.'));
|
|
471
|
+
else line(warn(`connector installed, but the service didn't start — retry '${c.cyan('ours-tg-connector install-service')}'.`));
|
|
472
|
+
record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: 'service (boot)' });
|
|
473
|
+
} else {
|
|
474
|
+
line(ok(`Telegram connector installed. Start it any time with '${c.cyan('ours-tg-connector start')}'. No problems.`));
|
|
475
|
+
record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: 'start on demand' });
|
|
476
|
+
}
|
|
477
|
+
} else {
|
|
478
|
+
line(info('skipped cleanly.'));
|
|
479
|
+
record({ key: 'telegram', label: 'Telegram connector', state: 'skipped' });
|
|
480
|
+
}
|
|
481
|
+
cont(goTg);
|
|
482
|
+
|
|
483
|
+
return endScreen({ ttyFd, summary, chosenPort, chosenBroker });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// --- never-dead-end messaging (owner edit #3) --------------------------------------------------
|
|
487
|
+
function manualClaude(h) {
|
|
488
|
+
if (h.status === 'alias') {
|
|
489
|
+
line(warn('Heads-up: on your machine, "claude" is installed as an alias, not the real command,'));
|
|
490
|
+
line(info('so I can\'t drive it safely. To fix it: run ' + c.cyan('type claude') + ' , remove/rename that'));
|
|
491
|
+
line(info('alias in your shell config, open a new terminal, and re-run ours-install.'));
|
|
492
|
+
} else {
|
|
493
|
+
line(warn('I couldn\'t safely drive the "claude" command on this machine.'));
|
|
494
|
+
}
|
|
495
|
+
line(info('You can still install the plugin yourself — inside Claude Code, run these two:'));
|
|
496
|
+
line(' ' + c.cyan(`/plugin marketplace add ${CLAUDE_MARKET}`));
|
|
497
|
+
line(' ' + c.cyan('/plugin install ours'));
|
|
498
|
+
}
|
|
499
|
+
function failClaude() {
|
|
500
|
+
line(warn('Couldn\'t install the Claude Code plugin automatically (network or plugin cache).'));
|
|
501
|
+
line(info('Install it by hand — inside Claude Code, run these two, then re-run ours-install:'));
|
|
502
|
+
line(' ' + c.cyan(`/plugin marketplace add ${CLAUDE_MARKET}`));
|
|
503
|
+
line(' ' + c.cyan('/plugin install ours'));
|
|
504
|
+
line(info('Your daemon and other steps are intact. Continuing.'));
|
|
505
|
+
}
|
|
506
|
+
function manualCodex(h) {
|
|
507
|
+
if (h.status === 'alias') {
|
|
508
|
+
line(warn('Heads-up: on your machine, "codex" is installed as an alias, not the real command,'));
|
|
509
|
+
line(info('so I can\'t drive it safely. To fix it: run ' + c.cyan('type codex') + ' , remove/rename that'));
|
|
510
|
+
line(info('alias in your shell config, open a new terminal, and re-run ours-install.'));
|
|
511
|
+
} else {
|
|
512
|
+
line(warn('I couldn\'t safely drive the "codex" command on this machine.'));
|
|
513
|
+
}
|
|
514
|
+
line(info('You can still install it yourself — run these three in your terminal:'));
|
|
515
|
+
line(' ' + c.cyan(`codex plugin marketplace add ${CODEX_MARKET}`));
|
|
516
|
+
line(' ' + c.cyan('codex plugin add ours@ours-codex-marketplace'));
|
|
517
|
+
line(' ' + c.cyan('npm i -g @ours.network/codex') + c.gray(' (adds the ours-codex live launcher)'));
|
|
518
|
+
}
|
|
519
|
+
function failCodex() {
|
|
520
|
+
line(warn('Couldn\'t install the Codex plugin automatically (network or plugin cache).'));
|
|
521
|
+
line(info('Install it by hand — run these three, then re-run ours-install:'));
|
|
522
|
+
line(' ' + c.cyan(`codex plugin marketplace add ${CODEX_MARKET}`));
|
|
523
|
+
line(' ' + c.cyan('codex plugin add ours@ours-codex-marketplace'));
|
|
524
|
+
line(' ' + c.cyan('npm i -g @ours.network/codex'));
|
|
525
|
+
line(info('Your daemon and other steps are intact. Continuing.'));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// --- final summary + copy-paste hand-off -------------------------------------------------------
|
|
529
|
+
function endScreen({ ttyFd, summary, chosenPort, chosenBroker }) {
|
|
530
|
+
line('');
|
|
531
|
+
line(' ' + c.cyan('═'.repeat(64)));
|
|
532
|
+
line(' ' + c.bold('ours.network — install complete'));
|
|
533
|
+
line(' ' + c.gray(`Daemon port: ${chosenPort} • Broker: ${chosenBroker ? 'custom' : 'standard'}`));
|
|
534
|
+
line(' ' + c.cyan('═'.repeat(64)));
|
|
535
|
+
for (const row of summary) {
|
|
536
|
+
const m = row.state === 'failed' ? c.red('✗') : row.state === 'skipped' ? c.gray('·') : c.green('✓');
|
|
537
|
+
const label = row.label.padEnd(26);
|
|
538
|
+
const ver = (row.version ? `v${row.version}` : '').padEnd(9);
|
|
539
|
+
const state = (row.state === 'installed' || row.state === 'current') ? (row.note || 'ready')
|
|
540
|
+
: row.state === 'skipped' ? c.gray('skipped' + (row.note ? ` (${row.note})` : ''))
|
|
541
|
+
: c.red('needs attention' + (row.note ? ` — ${row.note}` : ''));
|
|
542
|
+
line(` ${m} ${label}${ver}${state}`);
|
|
543
|
+
}
|
|
544
|
+
const anyFail = summary.some((r) => r.state === 'failed');
|
|
545
|
+
line('');
|
|
546
|
+
line(anyFail
|
|
547
|
+
? ' ' + c.yellow('Some pieces need a hand — see the notes above; re-run ours-install after fixing.')
|
|
548
|
+
: ' ' + c.green('Everything installed cleanly. No problems.'));
|
|
549
|
+
|
|
550
|
+
// The literal copy-paste hand-off — skipped/failed components drop out. The human identity is
|
|
551
|
+
// normally created in-install, so its step appears ONLY as a fallback when that didn't succeed.
|
|
552
|
+
const has = (k) => summary.some((r) => r.key === k && (r.state === 'installed' || r.state === 'current'));
|
|
553
|
+
if (has('core')) {
|
|
554
|
+
const identityDone = has('identity');
|
|
555
|
+
const { text, empty } = buildHandoffPrompt({ identity: !identityDone, fleet: has('fleet'), telegram: has('telegram') });
|
|
556
|
+
if (empty) {
|
|
557
|
+
// Nothing left to finish (identity created in-install, no fleet/Telegram). Don't show an empty box.
|
|
558
|
+
line('');
|
|
559
|
+
line(' ' + c.green("You're all set — open Claude Code or Codex and just start talking to your agent."));
|
|
560
|
+
} else {
|
|
561
|
+
line('');
|
|
562
|
+
line(' ' + c.gray('─'.repeat(64)));
|
|
563
|
+
line(' ' + c.bold('ONE LAST STEP') + ' — copy the prompt below and paste it into Claude Code');
|
|
564
|
+
line(' (or Codex). Your agent walks you through the rest, conversationally.');
|
|
565
|
+
line(' ' + c.gray('─'.repeat(64)));
|
|
566
|
+
line('');
|
|
567
|
+
line(box(text.split('\n'), 'paste this into your agent'));
|
|
568
|
+
copyToClipboard(text);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
line('');
|
|
572
|
+
line(' ' + c.gray('Re-run ') + c.cyan('ours-install') + c.gray(' any time to add a skipped piece or update.'));
|
|
573
|
+
line(' ' + c.cyan('═'.repeat(64)));
|
|
574
|
+
finish(ttyFd);
|
|
575
|
+
// Exit CLEANLY right after the summary — never leave the user at a hung installer (the tty is
|
|
576
|
+
// closed above; a lingering clipboard child or signal listener must not keep us alive).
|
|
577
|
+
process.exit(0);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Best-effort clipboard copy (pbcopy/wl-copy/xclip/clip). Silent when unsupported. A hard timeout
|
|
581
|
+
// keeps a lingering/blocking clipboard helper (e.g. xclip holding the selection) from ever stalling
|
|
582
|
+
// the exit.
|
|
583
|
+
function copyToClipboard(text) {
|
|
584
|
+
if (DRY) return;
|
|
585
|
+
const tools = [['pbcopy', []], ['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['clip.exe', []]];
|
|
586
|
+
for (const [bin, args] of tools) {
|
|
587
|
+
try {
|
|
588
|
+
const r = spawnSync(bin, args, { input: text, timeout: 2000 });
|
|
589
|
+
if (!r.error && !r.timedOut && (r.status === 0 || r.status == null)) { line(' ' + c.gray('(copied to your clipboard.)')); return; }
|
|
590
|
+
} catch { /* try the next one */ }
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function finish(ttyFd) {
|
|
595
|
+
if (ttyFd != null) { try { closeSync(ttyFd); } catch { /* ignore */ } }
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
main().catch((e) => {
|
|
599
|
+
// A Ctrl+C thrown out of a blocked prompt read → the clean cancel path (exit 130).
|
|
600
|
+
if (isCancel(e)) { if (cancelHandler) return cancelHandler(); process.exit(130); }
|
|
601
|
+
// Otherwise: degrade, don't crash — one honest line, non-zero exit.
|
|
602
|
+
say(`unexpected error: ${String(e)}`);
|
|
603
|
+
process.exitCode = 1;
|
|
604
|
+
});
|