@ours.network/install 0.17.0-nightly.9 → 0.18.0-nightly.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -315
- package/install.mjs +22 -1181
- package/lib/components.mjs +18 -9
- package/lib/detect.mjs +8 -21
- package/lib/effects.mjs +32 -14
- package/lib/extras.mjs +5 -85
- package/lib/journal.mjs +46 -1
- package/lib/logic.mjs +13 -85
- package/lib/orchestrate-uninstall.mjs +105 -19
- package/lib/orchestrate.mjs +134 -118
- package/lib/plan.mjs +40 -8
- package/lib/target.mjs +38 -5
- package/lib/uninstall.mjs +285 -16
- package/package.json +1 -1
- package/uninstall.mjs +22 -225
- package/uninstall.sh +13 -4
- package/lib/nightly-install.mjs +0 -739
- package/lib/nightly-uninstall.mjs +0 -396
- package/lib/profiles.mjs +0 -524
package/install.mjs
CHANGED
|
@@ -1,1186 +1,27 @@
|
|
|
1
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 — the shared ours daemon + the harness plugins (Claude Code /
|
|
6
|
-
// Codex / Hermes) + ours-fleet + the Telegram connector + Rooms (ours-cowork) — for someone who
|
|
7
|
-
// ALREADY has Claude, Codex, and/or Hermes.
|
|
8
|
-
//
|
|
9
|
-
// TOPOLOGY. Step 1 installs and configures ONE shared daemon and the user picks its listen port
|
|
10
|
-
// there; every consumer below is wired to that endpoint. The Telegram connector may instead be
|
|
11
|
-
// given its OWN daemon — its own port, state directory and boot unit — chosen independently, and
|
|
12
|
-
// defaulting to the shared one so Enter and non-interactive runs keep the historical topology.
|
|
13
|
-
// Rooms answers the same question, plus a third answer the connector has no use for: keeping
|
|
14
|
-
// cowork's own EMBEDDED daemon, which is what every pre-PR#9 cowork install runs (see step 5).
|
|
15
|
-
// Its whole job: install the stack cleanly, then hand back ONE copy-paste prompt the user drops
|
|
16
|
-
// into their agent to finish remaining configuration conversationally. Voice API credentials are
|
|
17
|
-
// the one guided secret flow: interactive, masked, optional, and written atomically with mode 0600.
|
|
18
|
-
// See packages/installer/README.md and the UX spec for the full contract.
|
|
19
|
-
//
|
|
20
|
-
// Design pillars (from the spec): config FIRST then act once; consent-first (Enter = no change);
|
|
21
|
-
// slow, per-step "✓ … no problems" + Continue?; never silently broken; idempotent + safe re-run;
|
|
22
|
-
// alias-safety / never-hang; most deep config deferred to the copy-paste hand-off.
|
|
23
|
-
//
|
|
24
|
-
// SAFETY: every side-effecting action goes through act(); with OURS_INSTALL_DRY_RUN=1 nothing is
|
|
25
|
-
// installed/started/restarted — it prints exactly what it WOULD do. That is the safe way to walk
|
|
26
|
-
// the whole flow on a machine you don't want to touch (and how the tests drive it).
|
|
27
|
-
import { spawn, spawnSync } from 'node:child_process';
|
|
28
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
29
|
-
import { homedir, userInfo, platform as osPlatform, release as osRelease } from 'node:os';
|
|
30
|
-
import { join, resolve } from 'node:path';
|
|
31
|
-
import { banner, heading, ok, info, warn, c, box, withSpinner, openTty, makeWriter, closeSync } from './lib/ui.mjs';
|
|
32
|
-
import { askLine, askYesNo, isCancel } from './lib/prompt.mjs';
|
|
33
|
-
import {
|
|
34
|
-
suggestPort, parsePort, validateBroker, mergeConfig, parseVersion, parseStatus,
|
|
35
|
-
detectPlatform, classifyHarnessProbe, buildHandoffPrompt,
|
|
36
|
-
voiceSetupStatus, resolveSharedBroker, tgConfigPath, planTgDaemonConfig, daemonEndpoint,
|
|
37
|
-
DEFAULT_PORT, resolveChannel, pkgSpec,
|
|
38
|
-
validateDaemonPort, planPorts, dedicatedDaemonPaths, DEDICATED_INSTANCES,
|
|
39
|
-
coworkConfigPath, planCoworkConfig, COWORK_DEFAULT_PORT, coworkDaemonMode,
|
|
40
|
-
coworkSupportsExternalDaemon, COWORK_EXTERNAL_MIN_VERSION,
|
|
41
|
-
} from './lib/logic.mjs';
|
|
42
|
-
import { atomicWriteConfig } from './lib/config.mjs';
|
|
43
|
-
import { realEffects } from './lib/effects.mjs';
|
|
44
|
-
import { runInstall as runInstallV3 } from './lib/orchestrate.mjs';
|
|
45
|
-
|
|
46
|
-
const NPM = process.env.OURS_NPM || 'npm';
|
|
47
|
-
// Release channel: OURS_CHANNEL=nightly installs each package's PRERELEASE dist-tag —
|
|
48
|
-
// @nightly for mcp/tg-connector/fleet/the plugin launchers, and @latest for cowork,
|
|
49
|
-
// and @next for cowork, whose repo has always called its prerelease line `next`
|
|
50
|
-
// (see PKG_CHANNEL_TAGS in lib/logic.mjs). With no explicit
|
|
51
|
-
// selection the installer follows its OWN channel, so a nightly installer builds a
|
|
52
|
-
// nightly stack instead of silently mixing tags across an architecture boundary.
|
|
53
|
-
const CHANNEL = resolveChannel(process.env.OURS_CHANNEL || process.env.OURS_INSTALL_CHANNEL, pkgVersion());
|
|
54
|
-
const spec = (pkgKey) => pkgSpec(pkgKey, CHANNEL); // → "@ours.network/<key>@<tag>"
|
|
55
|
-
let DRY = !!process.env.OURS_INSTALL_DRY_RUN;
|
|
56
|
-
const SELFHOST_URL = 'ours.network';
|
|
57
|
-
const CLAUDE_MARKET = 'adapt-toolkit/ours-claude-marketplace';
|
|
58
|
-
const CODEX_MARKET = 'adapt-toolkit/ours-codex-marketplace';
|
|
59
|
-
|
|
60
|
-
const sink = (s) => process.stdout.write(s);
|
|
61
|
-
const line = (s = '') => sink(`${s}\n`);
|
|
62
|
-
const say = (s) => sink(`ours: ${s}\n`);
|
|
63
|
-
|
|
64
|
-
// --- external command helpers (never throw; the installer degrades, it doesn't crash) ----------
|
|
65
|
-
function run(bin, args, { capture = false, timeout, env } = {}) {
|
|
66
|
-
const r = spawnSync(bin, args, {
|
|
67
|
-
encoding: 'utf8',
|
|
68
|
-
timeout,
|
|
69
|
-
env: env ? { ...process.env, ...env } : process.env,
|
|
70
|
-
stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
71
|
-
});
|
|
72
|
-
const timedOut = !!(r.error && (r.error.code === 'ETIMEDOUT' || r.signal === 'SIGTERM'));
|
|
73
|
-
return {
|
|
74
|
-
code: r.status ?? (r.error ? -1 : 0),
|
|
75
|
-
out: r.stdout || '', err: r.stderr || '',
|
|
76
|
-
ok: !r.error && r.status === 0,
|
|
77
|
-
timedOut,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
function runAsync(bin, args) {
|
|
81
|
-
return new Promise((resolve) => {
|
|
82
|
-
let child;
|
|
83
|
-
try { child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] }); }
|
|
84
|
-
catch { resolve({ code: -1, out: '', err: '', ok: false }); return; }
|
|
85
|
-
let out = '', errOut = '';
|
|
86
|
-
child.stdout.on('data', (d) => { out += d; });
|
|
87
|
-
child.stderr.on('data', (d) => { errOut += d; });
|
|
88
|
-
child.on('error', () => resolve({ code: -1, out, err: errOut, ok: false }));
|
|
89
|
-
child.on('close', (code) => resolve({ code: code ?? -1, out, err: errOut, ok: code === 0 }));
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// act(): the single seam every side-effecting step passes through. In DRY-RUN it prints the exact
|
|
94
|
-
// command instead of running it and reports a synthetic success, so the whole UX can be walked on
|
|
95
|
-
// a machine we must not disturb. `fn` runs the real thing and returns {ok,...}.
|
|
96
|
-
async function act(desc, fn) {
|
|
97
|
-
if (DRY) { line(' ' + c.dim(`[dry-run] would: ${desc}`)); return { ok: true, dry: true }; }
|
|
98
|
-
return fn();
|
|
99
|
-
}
|
|
100
|
-
async function actSpin(label, desc, fn) {
|
|
101
|
-
if (DRY) { line(' ' + c.dim(`[dry-run] would: ${desc}`)); return { ok: true, dry: true }; }
|
|
102
|
-
return withSpinner(label, fn);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// --- daemon probes (always safe to run — read-only) --------------------------------------------
|
|
106
|
-
const daemonVersionLine = () => (run('ours-mcp', ['--version'], { capture: true }).out.split('\n')[0] || '').trim();
|
|
107
|
-
const daemonStatusText = (env) => run('ours-mcp', ['status'], { capture: true, ...(env ? { env } : {}) }).out;
|
|
108
|
-
function daemonLifecycleState() {
|
|
109
|
-
const status = run('ours-mcp', ['status'], { capture: true });
|
|
110
|
-
if (!status.ok) return 'stopped';
|
|
111
|
-
return /^\s*pid:\s*\d+/m.test(status.out) ? 'managed' : 'external';
|
|
112
|
-
}
|
|
113
|
-
const daemonRunning = () => daemonLifecycleState() !== 'stopped';
|
|
114
|
-
// LISTENING, not merely alive — and the difference is not academic. `ours-mcp start`
|
|
115
|
-
// exits 0 when it only FINDS a live pid in the state directory (core cmdStart: it
|
|
116
|
-
// checks `isAlive(pid)` and nothing else, so a pid file left by a previous boot whose
|
|
117
|
-
// number now belongs to any other process reads as "already running"). `ours-mcp
|
|
118
|
-
// status` then exits 0 for that same pid while printing "(port not answering!)" —
|
|
119
|
-
// core cmdStatus only sets a non-zero exit for the no-pid case. So neither exit code
|
|
120
|
-
// proves a daemon is there, and a readiness line built from one can tell the user
|
|
121
|
-
// "ready — no problems" about a port nothing is bound to. The status line the daemon
|
|
122
|
-
// prints only when the port actually answered is the honest signal, so use it.
|
|
123
|
-
const daemonReachable = (env) => /\(reachable\)/.test(daemonStatusText(env));
|
|
124
|
-
// The installed version of a global package, INCLUDING any prerelease suffix. The
|
|
125
|
-
// suffix is not cosmetic here: the Rooms daemon guard compares against an exact
|
|
126
|
-
// `0.4.1-nightly.<date>.<sha>` floor, and truncating at the dash would make every
|
|
127
|
-
// 0.4.1 nightly look alike — including ones published before the mode existed.
|
|
128
|
-
const globalVersion = (pkg) => {
|
|
129
|
-
const ls = run(NPM, ['ls', '-g', pkg], { capture: true }).out;
|
|
130
|
-
const m = ls.match(new RegExp(pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') + '@(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?)'));
|
|
131
|
-
return m ? m[1] : '';
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
// --- config file (fixed home location; mirrors core/config.ts) ---------------------------------
|
|
135
|
-
function configPath() { return process.env.OURS_CONFIG || join(homedir(), '.ours', 'config.json'); }
|
|
136
|
-
function readConfigObject() { try { return JSON.parse(readFileSync(configPath(), 'utf8')); } catch { return {}; } }
|
|
137
|
-
function writeConfigPatch(patch) {
|
|
138
|
-
const p = configPath();
|
|
139
|
-
atomicWriteConfig(p, mergeConfig(readConfigObject(), patch));
|
|
140
|
-
return p;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// The daemon's state directory, resolved exactly the way packages/core/src/config.ts
|
|
144
|
-
// resolves it (env > config file > ~/.ours). The Telegram connector needs the ABSOLUTE
|
|
145
|
-
// path: the SDK reads the daemon's API token from it and refuses to send that token to
|
|
146
|
-
// a separately-chosen endpoint unless the state dir was chosen just as deliberately.
|
|
147
|
-
function daemonStateDir() {
|
|
148
|
-
const fromEnv = process.env.OURS_STATE_DIR?.trim();
|
|
149
|
-
if (fromEnv) return resolve(fromEnv);
|
|
150
|
-
const fromFile = readConfigObject().stateDir;
|
|
151
|
-
if (typeof fromFile === 'string' && fromFile.trim()) return resolve(fromFile.trim());
|
|
152
|
-
return join(homedir(), '.ours');
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function daemonVoiceCapability() {
|
|
156
|
-
const r = run('ours-mcp', ['voice-status', '--json'], { capture: true, timeout: 6000 });
|
|
157
|
-
if (!r.ok) return null;
|
|
158
|
-
try {
|
|
159
|
-
const parsed = JSON.parse(r.out.trim());
|
|
160
|
-
return typeof parsed?.ready === 'boolean' ? parsed : null;
|
|
161
|
-
} catch {
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Block the thread for `ms` without a subprocess — used for the brief daemon-reachability wait
|
|
167
|
-
// before creating the human identity (a freshly-started daemon needs a moment to bind its port).
|
|
168
|
-
function sleepMs(ms) {
|
|
169
|
-
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* ignore */ }
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Is `port` already bound? Probe in a throwaway child and read its exit code (EADDRINUSE ⇒ taken).
|
|
173
|
-
function portTakenSync(port) {
|
|
174
|
-
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))});`;
|
|
175
|
-
const r = spawnSync(process.execPath, ['-e', scriptSrc], { timeout: 3000 });
|
|
176
|
-
return r.status === 3;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// --- harness detection with ALIAS-SAFETY (never call an unsafe command) -------------------------
|
|
180
|
-
// Gather three read-only observations, then let the pure classifier decide. We NEVER run the
|
|
181
|
-
// harness in a way that could hang: --version is spawned directly (no shell → real PATH binary)
|
|
182
|
-
// with a hard timeout; the shell `type` lookup is also timeout-guarded.
|
|
183
|
-
function detectHarness(name) {
|
|
184
|
-
const onPath = run('bash', ['-c', `command -v ${name}`], { capture: true }).ok;
|
|
185
|
-
const probe = run(name, ['--version'], { capture: true, timeout: 6000 });
|
|
186
|
-
const versionOk = probe.ok && /\d+\.\d+/.test(probe.out);
|
|
187
|
-
const shell = process.env.SHELL || '/bin/bash';
|
|
188
|
-
const typeProbe = run(shell, ['-ic', `type -t ${name} 2>/dev/null`], { capture: true, timeout: 4000 });
|
|
189
|
-
const shellType = (typeProbe.out || '').trim();
|
|
190
|
-
const verdict = classifyHarnessProbe({ onPath, versionOk, timedOut: probe.timedOut, shellType });
|
|
191
|
-
return { name, ...verdict };
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Hermes detection is DIFFERENT from Claude Code / Codex: Hermes has no driven CLI. Its ours plugin
|
|
195
|
-
// (`ours-hermes-install`) never calls a `hermes` binary — it just writes ~/.hermes/config.yaml + the
|
|
196
|
-
// skills — so "is it drivable?" is the wrong question. Per the Hermes plugin's own prerequisites,
|
|
197
|
-
// presence == the config dir (~/.hermes, override with HERMES_DIR) exists. We still run the alias-safe
|
|
198
|
-
// CLI probe in case a real `hermes` command is on PATH, purely to enrich detection; either signal
|
|
199
|
-
// makes it installable. No config dir and no CLI → absent (skipped, like an uninstalled harness).
|
|
200
|
-
function detectHermes() {
|
|
201
|
-
const dir = process.env.HERMES_DIR || join(homedir(), '.hermes');
|
|
202
|
-
const dirPresent = existsSync(dir);
|
|
203
|
-
const cli = detectHarness('hermes'); // best-effort — Hermes usually has no `--version` CLI
|
|
204
|
-
const status = dirPresent || cli.status === 'ok' ? 'ok' : 'absent';
|
|
205
|
-
const detail = dirPresent ? `config dir ${dir} present` : cli.detail;
|
|
206
|
-
return { name: 'hermes', label: 'Hermes', status, detail };
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Set by main() so the top-level catch can route a Ctrl+C (InstallCancelled) through the same
|
|
210
|
-
// clean-exit path as the SIGINT handler.
|
|
211
|
-
let cancelHandler = null;
|
|
212
|
-
|
|
213
|
-
// A tiny package version read (best-effort) for `--version`.
|
|
214
|
-
function pkgVersion() {
|
|
215
|
-
try { return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version || '?'; }
|
|
216
|
-
catch { return '?'; }
|
|
217
|
-
}
|
|
218
|
-
const USAGE = `ours-install — the unified ours.network stack installer.
|
|
219
|
-
|
|
220
|
-
Install: npm i -g @ours.network/install && ours-install (recommended)
|
|
221
|
-
npx @ours.network/install (one-off)
|
|
222
|
-
|
|
223
|
-
ours-install [--dry-run] [--help] [--version]
|
|
224
|
-
|
|
225
|
-
Guided ~3-minute setup for the whole stack: the shared ours daemon (you pick its
|
|
226
|
-
port), the harness plugins (Claude Code + Codex + Hermes), ours-fleet, the Telegram
|
|
227
|
-
connector, and Rooms (ours-cowork) — then one copy-paste hand-off prompt. You approve
|
|
228
|
-
each step; re-run any time to add a piece or update.
|
|
229
|
-
|
|
230
|
-
Telegram can share the daemon from step 1 or be given its own (its own port, state
|
|
231
|
-
directory and boot service); Enter keeps the shared one.
|
|
232
|
-
|
|
233
|
-
--dry-run walk the whole flow and print what it WOULD do — install/change nothing
|
|
234
|
-
--help show this help and exit
|
|
235
|
-
--version print the installer version and exit
|
|
236
|
-
|
|
237
|
-
Env: OURS_ASSUME_YES=1 (accept defaults, no prompts) · OURS_INSTALL_DRY_RUN=1 ·
|
|
238
|
-
OURS_NPM · OURS_CONFIG (default ~/.ours/config.json). Docs: https://ours.network`;
|
|
239
|
-
|
|
240
|
-
// ===============================================================================================
|
|
241
|
-
async function main() {
|
|
242
|
-
const argv = process.argv.slice(2);
|
|
243
|
-
|
|
244
|
-
// ─── THE CHANNEL FORK ───────────────────────────────────────────────────────
|
|
245
|
-
// Nightly is the v3 installer, end to end. Owner ruling 2026-08-17: v3 SUBSUMES
|
|
246
|
-
// the nightly flow rather than being hosted by it, so this hands the whole run
|
|
247
|
-
// over — arguments, screens, refusals and exit code — and never returns.
|
|
248
|
-
//
|
|
249
|
-
// FIRST IN main(), BEFORE --help/--version, ON PURPOSE. Those used to be
|
|
250
|
-
// answered by the v2 body above, which would have printed v2's usage describing
|
|
251
|
-
// v2's flags for a run that is about to be v3's. Whatever prints the help must
|
|
252
|
-
// be whatever runs.
|
|
253
|
-
//
|
|
254
|
-
// The latest/stable body below is untouched and still serves that channel byte
|
|
255
|
-
// for byte; nothing a stable user does changes.
|
|
256
|
-
if (CHANNEL === 'nightly') {
|
|
257
|
-
const ttyFd = openTty();
|
|
258
|
-
const code = await runInstallV3(argv, realEffects({
|
|
259
|
-
write: makeWriter(ttyFd), ttyFd, env: process.env, version: pkgVersion(),
|
|
260
|
-
}));
|
|
261
|
-
finish(ttyFd);
|
|
262
|
-
process.exit(code);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(USAGE + '\n'); return; }
|
|
266
|
-
if (argv.includes('--version') || argv.includes('-V')) { process.stdout.write(`ours-install v${pkgVersion()}\n`); return; }
|
|
267
|
-
if (argv.includes('--dry-run')) DRY = true;
|
|
268
|
-
|
|
269
|
-
const ttyFd = openTty();
|
|
270
|
-
const interactive = ttyFd != null && !process.env.OURS_ASSUME_YES;
|
|
271
|
-
const write = makeWriter(ttyFd);
|
|
272
|
-
|
|
273
|
-
// Ctrl+C at ANY prompt aborts cleanly (never the old "^C^C^C and keeps going"): print one line
|
|
274
|
-
// and exit 130. The SIGINT handler covers a ^C while we're idle/spinning; the InstallCancelled
|
|
275
|
-
// thrown out of a blocked prompt read covers a ^C mid-prompt (both routed here, guarded once).
|
|
276
|
-
let cancelling = false;
|
|
277
|
-
const cancel = () => {
|
|
278
|
-
if (cancelling) return; cancelling = true;
|
|
279
|
-
try { process.stdout.write('\n' + warn('Installation cancelled — re-run any time.') + '\n'); } catch { /* ignore */ }
|
|
280
|
-
finish(ttyFd);
|
|
281
|
-
process.exit(130);
|
|
282
|
-
};
|
|
283
|
-
cancelHandler = cancel;
|
|
284
|
-
if (interactive) process.on('SIGINT', cancel);
|
|
285
|
-
const yes = (prompt, def) => (process.env.OURS_ASSUME_YES ? def : askYesNo(write, ttyFd, prompt, def));
|
|
286
|
-
const ask = (prompt, def) => (process.env.OURS_ASSUME_YES ? def : askLine(write, ttyFd, prompt, def));
|
|
287
|
-
// A Continue? beat: one clean acknowledgement AFTER a step that actually did something (delta
|
|
288
|
-
// #1860). A step the user SKIPPED (answered No) shows no "you skipped — press Enter" pause — we
|
|
289
|
-
// move straight on. No-op when we can't prompt (headless / assume-yes) so scripted runs stay linear.
|
|
290
|
-
const cont = (acted = true) => { if (interactive && acted) askLine(write, ttyFd, ' ' + c.gray('Continue? [Enter] '), ''); };
|
|
291
|
-
|
|
292
|
-
line(banner());
|
|
293
|
-
say('setting up the ours.network stack — ours core, your harness plugins, ours-fleet, Telegram.');
|
|
294
|
-
line(' ' + c.dim('~3 minutes. You approve each step; re-run any time to add a piece or update.'));
|
|
295
|
-
if (DRY) line(' ' + c.yellow('(dry-run: nothing will be installed or changed — this just walks the flow.)'));
|
|
296
|
-
|
|
297
|
-
// ============================================================================================
|
|
298
|
-
// PRE-FLIGHT — silent-ish checks, no changes. Detect the disasters up front. A checklist, not
|
|
299
|
-
// logs. (Native Windows → WSL pointer + exit; no harness at all → tell them + exit.)
|
|
300
|
-
// ============================================================================================
|
|
301
|
-
line(heading('Checking your machine'));
|
|
302
|
-
const plat = detectPlatform({ platform: osPlatform(), release: osRelease(), env: process.env });
|
|
303
|
-
if (!plat.supported) {
|
|
304
|
-
if (plat.os === 'windows') {
|
|
305
|
-
line(warn(`${plat.label} isn't supported directly yet.`));
|
|
306
|
-
line(info('Install this inside WSL (Windows Subsystem for Linux), then re-run there:'));
|
|
307
|
-
line(' ' + c.cyan('https://learn.microsoft.com/windows/wsl/install'));
|
|
308
|
-
} else {
|
|
309
|
-
line(warn(`Platform "${plat.label}" isn't supported. ours runs on Linux, macOS, or WSL.`));
|
|
310
|
-
}
|
|
311
|
-
finish(ttyFd); return;
|
|
312
|
-
}
|
|
313
|
-
line(ok(`Platform: ${plat.label} (supported)`));
|
|
314
|
-
|
|
315
|
-
const nodeMajor = Number.parseInt((process.versions.node || '0').split('.')[0], 10);
|
|
316
|
-
if (nodeMajor >= 20) line(ok(`Node.js ${process.versions.node}`));
|
|
317
|
-
else { line(warn(`Node.js ${process.versions.node} — ours needs v20 or newer. Update Node and re-run.`)); finish(ttyFd); return; }
|
|
318
|
-
|
|
319
|
-
// Harness detection with alias-safety. Report each, keep the drivable ones.
|
|
320
|
-
const harnessSpecs = [
|
|
321
|
-
{ name: 'claude', label: 'Claude Code' },
|
|
322
|
-
{ name: 'codex', label: 'Codex' },
|
|
323
|
-
];
|
|
324
|
-
// Claude Code + Codex are driven CLIs (alias-safe --version probe); Hermes is config-dir based
|
|
325
|
-
// (see detectHermes) so it gets its own detector, appended after them.
|
|
326
|
-
const harnesses = harnessSpecs.map((h) => ({ ...h, ...detectHarness(h.name) }));
|
|
327
|
-
harnesses.push(detectHermes());
|
|
328
|
-
for (const h of harnesses) {
|
|
329
|
-
if (h.status === 'ok') line(ok(`'${h.name}' → real program (its plugin can be installed)`));
|
|
330
|
-
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)`));
|
|
331
|
-
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)`));
|
|
332
|
-
else line(info(`'${h.name}' → not installed (skipped — install it first if you want it wired up)`));
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
const anyHarness = harnesses.some((h) => h.status !== 'absent');
|
|
336
|
-
if (!anyHarness) {
|
|
337
|
-
line('');
|
|
338
|
-
line(warn('No Claude Code, Codex, or Hermes found on this machine.'));
|
|
339
|
-
line(info('Install one of them first, then re-run ours-install to wire it up.'));
|
|
340
|
-
finish(ttyFd); return;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// Daemon state up front (decides first-install vs update, and whether Step 0 runs at all).
|
|
344
|
-
const versionBefore = daemonVersionLine();
|
|
345
|
-
const daemonInstalled = !!versionBefore;
|
|
346
|
-
line(daemonInstalled
|
|
347
|
-
? ok(`ours core already present (${versionBefore})`)
|
|
348
|
-
: ok('No ours daemon yet — will offer to install it'));
|
|
349
|
-
line('');
|
|
350
|
-
cont();
|
|
351
|
-
|
|
352
|
-
const status0 = parseStatus(daemonStatusText());
|
|
353
|
-
let chosenBroker; // undefined = keep default / existing
|
|
354
|
-
let chosenPort = status0.port || DEFAULT_PORT;
|
|
355
|
-
const configFirst = !daemonInstalled;
|
|
356
|
-
|
|
357
|
-
// Every port this run has committed to, so a later daemon can't be handed one an
|
|
358
|
-
// earlier daemon claimed. A live bind probe cannot see these — nothing is
|
|
359
|
-
// listening on them yet — which is exactly why they're tracked by hand.
|
|
360
|
-
const claimedPorts = [];
|
|
361
|
-
// The finished topology, for the end-of-run cross-check. Each entry is one thing that
|
|
362
|
-
// will try to BIND a port, named so a collision can be reported in the user's terms.
|
|
363
|
-
const topology = [];
|
|
364
|
-
const claimPort = (port, label) => {
|
|
365
|
-
if (!Number.isInteger(port)) return;
|
|
366
|
-
if (!claimedPorts.includes(port)) claimedPorts.push(port);
|
|
367
|
-
if (label) topology.push({ label, port });
|
|
368
|
-
};
|
|
369
|
-
|
|
370
|
-
// Ask for ONE daemon's port, validate it, and keep asking until the answer is
|
|
371
|
-
// usable. Enter (and every non-interactive run) takes `def` unchanged — that is
|
|
372
|
-
// what keeps the historical behaviour and scripted installs identical.
|
|
373
|
-
const askDaemonPort = (prompt, def) => {
|
|
374
|
-
let candidate = def;
|
|
375
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
376
|
-
const raw = ask(` ${prompt} ${c.gray(`[Enter for ${candidate}]`)}: `, String(candidate));
|
|
377
|
-
const v = validateDaemonPort(raw, { fallback: candidate, isTaken: portTakenSync, taken: claimedPorts });
|
|
378
|
-
if (v.ok) return v.port;
|
|
379
|
-
line(warn(`${v.reason}.`));
|
|
380
|
-
if (!interactive) break; // no one to re-ask; fall through to a suggestion
|
|
381
|
-
candidate = suggestPort(v.port + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
|
|
382
|
-
line(info(`Suggesting ${candidate} instead.`));
|
|
383
|
-
}
|
|
384
|
-
// Out of attempts (or headless): take the first genuinely free port rather
|
|
385
|
-
// than persisting one we know is unusable.
|
|
386
|
-
const fallback = suggestPort(candidate, (p) => claimedPorts.includes(p) || portTakenSync(p));
|
|
387
|
-
line(ok(`Using port ${fallback}.`));
|
|
388
|
-
return fallback;
|
|
389
|
-
};
|
|
390
|
-
|
|
391
|
-
// Track outcomes for the summary + hand-off.
|
|
392
|
-
const summary = [];
|
|
393
|
-
const record = (row) => summary.push(row);
|
|
394
|
-
|
|
395
|
-
// Voice setup is part of the core lifecycle, not a follow-up after it. On a
|
|
396
|
-
// fresh install this runs after the CLI is installed/configured but before
|
|
397
|
-
// the first daemon start. On an update it runs before any pending restart;
|
|
398
|
-
// a successful canonical voice-setup owns that one restart/readiness check.
|
|
399
|
-
// Declining, headless skipping, or an already-complete setup leaves the
|
|
400
|
-
// caller's normal start/restart lifecycle untouched.
|
|
401
|
-
const offerVoiceSetup = ({ readinessAfterStart = false, daemonState = 'stopped' } = {}) => {
|
|
402
|
-
line(heading('Voice messages'));
|
|
403
|
-
const cfgBefore = readConfigObject();
|
|
404
|
-
const probed = daemonVoiceCapability();
|
|
405
|
-
const localStatus = voiceSetupStatus(cfgBefore, process.env);
|
|
406
|
-
const voiceStatus = probed ?? localStatus;
|
|
407
|
-
if (voiceStatus.ready) {
|
|
408
|
-
line(ok(`Voice transcription is configured (${voiceStatus.provider}). API key: configured, never displayed.`));
|
|
409
|
-
record({ key: 'voice', label: 'Voice transcription', state: 'current', note: voiceStatus.provider });
|
|
410
|
-
cont(false);
|
|
411
|
-
return { setupRan: false, restartHandled: false };
|
|
412
|
-
}
|
|
413
|
-
if (!interactive) {
|
|
414
|
-
line(info('Voice transcription is not configured. Non-interactive mode leaves it unchanged.'));
|
|
415
|
-
line(info('Run `ours-mcp voice-setup` in a terminal, or supply OURS_STT_* environment values.'));
|
|
416
|
-
record({ key: 'voice', label: 'Voice transcription', state: 'skipped', note: 'interactive setup available on re-run' });
|
|
417
|
-
cont(false);
|
|
418
|
-
return { setupRan: false, restartHandled: false };
|
|
419
|
-
}
|
|
420
2
|
|
|
421
|
-
|
|
422
|
-
line(info('provider; use a self-hosted endpoint if it must stay local. The API key is hidden.'));
|
|
423
|
-
if (!yes(' Set up voice transcription now?', true)) {
|
|
424
|
-
line(info('skipped — this will be offered again on the next installer run.'));
|
|
425
|
-
record({ key: 'voice', label: 'Voice transcription', state: 'skipped', note: 'declined; offered again on re-run' });
|
|
426
|
-
cont(false);
|
|
427
|
-
return { setupRan: false, restartHandled: false };
|
|
428
|
-
}
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
429
4
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
record({ key: 'voice', label: 'Voice transcription', state: 'installed', note: 'configured before first start' });
|
|
452
|
-
cont();
|
|
453
|
-
return { setupRan: true, restartHandled: false };
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
const verified = daemonVoiceCapability();
|
|
457
|
-
if (verified?.ready) {
|
|
458
|
-
line(ok(`Voice transcription is ready (${verified.provider}); API key remains hidden.`));
|
|
459
|
-
record({ key: 'voice', label: 'Voice transcription', state: 'installed', note: verified.provider });
|
|
460
|
-
} else {
|
|
461
|
-
line(warn('Voice setup returned without a ready capability; inspect `ours-mcp voice-status`.'));
|
|
462
|
-
record({ key: 'voice', label: 'Voice transcription', state: 'failed', note: 'readiness check failed' });
|
|
463
|
-
}
|
|
464
|
-
cont();
|
|
465
|
-
// Exit 0 also covers config-only success for a stopped daemon or an
|
|
466
|
-
// externally launched daemon. Only a still-managed daemon proves that
|
|
467
|
-
// canonical setup owned apply + readiness and may suppress an update
|
|
468
|
-
// restart.
|
|
469
|
-
return {
|
|
470
|
-
setupRan: true,
|
|
471
|
-
restartHandled: daemonState === 'managed' && daemonLifecycleState() === 'managed',
|
|
472
|
-
};
|
|
473
|
-
};
|
|
474
|
-
|
|
475
|
-
// ============================================================================================
|
|
476
|
-
// STEP 1 / 5 — the SHARED ours daemon. Its own visible step, and it owns its own configuration
|
|
477
|
-
// (broker + listen port) rather than a nameless "quick settings" preamble: every consumer below
|
|
478
|
-
// is wired to the endpoint chosen HERE, so the choice belongs to the step that makes it.
|
|
479
|
-
// Config-first within the step: choose → write config → optional voice → start ONCE.
|
|
480
|
-
// ============================================================================================
|
|
481
|
-
line(heading('1/5 — the shared ours daemon'));
|
|
482
|
-
line(info('This is the piece that lets your agents talk to each other securely. The harness'));
|
|
483
|
-
line(info('plugins, ours-fleet and the Telegram connector all connect to it — Telegram can be'));
|
|
484
|
-
line(info('given its own instead, later — and so can Rooms.'));
|
|
485
|
-
const before = parseVersion(versionBefore);
|
|
486
|
-
|
|
487
|
-
if (configFirst) {
|
|
488
|
-
// Broker (owner edit #1: SECURE wording; owner edit #2: self-host → website only).
|
|
489
|
-
line('');
|
|
490
|
-
line(info('Your agents connect through a "broker" — a shared meeting point that lets them find'));
|
|
491
|
-
line(info("each other. It's secure: your messages are end-to-end encrypted, so the broker never"));
|
|
492
|
-
line(info('sees what they say. Almost everyone uses the standard one — just press Enter.'));
|
|
493
|
-
const custom = yes(' Use a custom broker address?', false);
|
|
494
|
-
if (custom) {
|
|
495
|
-
line(info(`(Only needed if you run your own broker. More at ${SELFHOST_URL}.)`));
|
|
496
|
-
const entered = ask(' Enter the broker address: ', '');
|
|
497
|
-
const v = validateBroker(entered);
|
|
498
|
-
if (entered && v.ok && !v.empty) {
|
|
499
|
-
// Undo safety net: a mistaken custom entry is one keystroke back to the standard broker.
|
|
500
|
-
const keep = yes(` Use "${v.value}"? (No = go back to the standard broker)`, true);
|
|
501
|
-
if (keep) { chosenBroker = v.value; line(ok(`broker set to ${chosenBroker}.`)); }
|
|
502
|
-
else line(ok('using the standard broker.'));
|
|
503
|
-
} else {
|
|
504
|
-
if (entered) line(warn(`"${entered}" doesn't look like a ws:// address — using the standard broker.`));
|
|
505
|
-
else line(ok('using the standard broker.'));
|
|
506
|
-
}
|
|
507
|
-
} else {
|
|
508
|
-
line(ok('using the standard broker.'));
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// Listen port. ALWAYS asked now, so the shared daemon's endpoint is a decision the
|
|
512
|
-
// user makes rather than one they only hear about when 3050 happens to be busy.
|
|
513
|
-
// The default is still 3050 (the next free port when it is taken), so Enter and
|
|
514
|
-
// every non-interactive run land exactly where they always did.
|
|
515
|
-
line('');
|
|
516
|
-
line(info('The daemon listens on a local port. Everything else in this install is pointed at it.'));
|
|
517
|
-
const portDefault = portTakenSync(DEFAULT_PORT) ? suggestPort(DEFAULT_PORT + 1, portTakenSync) : DEFAULT_PORT;
|
|
518
|
-
if (portDefault !== DEFAULT_PORT) line(info(`The standard port (${DEFAULT_PORT}) is already in use on your machine.`));
|
|
519
|
-
chosenPort = askDaemonPort('Which local port should the shared daemon use?', portDefault);
|
|
520
|
-
line(ok(`Shared daemon: port ${chosenPort}, broker ${chosenBroker ? 'custom' : 'standard'}.`));
|
|
521
|
-
line('');
|
|
522
|
-
} else {
|
|
523
|
-
line(ok(`Shared daemon already configured — port ${chosenPort}. Keeping it.`));
|
|
524
|
-
}
|
|
525
|
-
claimPort(chosenPort, 'the shared ours daemon');
|
|
526
|
-
|
|
527
|
-
if (!daemonInstalled) {
|
|
528
|
-
const goCore = yes(' Install and start it?', true);
|
|
529
|
-
if (!goCore) {
|
|
530
|
-
line(info("skipped — nothing else can run without it. Re-run ours-install when you're ready."));
|
|
531
|
-
record({ key: 'core', label: 'ours core (daemon)', state: 'skipped', note: 'declined' });
|
|
532
|
-
// Without a daemon the rest is moot; go straight to the summary.
|
|
533
|
-
return endScreen({ ttyFd, summary, chosenPort, chosenBroker });
|
|
534
|
-
}
|
|
535
|
-
await actSpin(`ensuring ${spec('mcp')}…`, `npm i -g ${spec('mcp')}`, () => runAsync(NPM, ['i', '-g', spec('mcp')]));
|
|
536
|
-
const patch = { port: chosenPort };
|
|
537
|
-
if (chosenBroker) patch.brokerUrl = chosenBroker;
|
|
538
|
-
await act(`write config (${configPath()}) with port ${chosenPort}${chosenBroker ? ' + custom broker' : ''}`, async () => { writeConfigPatch(patch); return { ok: true }; });
|
|
539
|
-
const voice = offerVoiceSetup({ readinessAfterStart: true });
|
|
540
|
-
const started = await act(`ours-mcp start (port ${chosenPort})`, async () => run('ours-mcp', ['start']));
|
|
541
|
-
const svc = await act('ours-mcp install-service (survives reboot)', async () => run('ours-mcp', ['install-service']));
|
|
542
|
-
// `ours-mcp install-service` STOPS the daemon before it writes the unit (core's
|
|
543
|
-
// cmdInstallService), then exits non-zero if `systemctl --user enable --now` fails —
|
|
544
|
-
// no linger, no user bus, a container, WSL without systemd. Left alone that turns a
|
|
545
|
-
// WORKING daemon into no daemon at all, while the line below still said "ready": the
|
|
546
|
-
// human identity and every MCP client then fail against a port nothing is listening
|
|
547
|
-
// on. Put the one shared daemon back up and report what is actually true.
|
|
548
|
-
// `started.ok` is the exit code of `ours-mcp start`, which is not evidence that
|
|
549
|
-
// anything is listening (see daemonReachable). Ask the daemon instead.
|
|
550
|
-
let running = DRY ? started.ok : daemonReachable();
|
|
551
|
-
if (!DRY && !svc.ok && !running) {
|
|
552
|
-
line(info('the boot-service step stopped the daemon before it failed — restarting it.'));
|
|
553
|
-
run('ours-mcp', ['start']);
|
|
554
|
-
running = daemonReachable();
|
|
555
|
-
}
|
|
556
|
-
if (running) line(ok(`ours core ready — running on port ${chosenPort}. No problems.`));
|
|
557
|
-
else line(warn(`could not auto-start — run '${c.cyan('ours-mcp start')}' to bring it up.`));
|
|
558
|
-
if (!svc.ok && !svc.dry) line(warn(`boot-service not installed — retry '${c.cyan('ours-mcp install-service')}' later.`));
|
|
559
|
-
if (voice.setupRan && !DRY && running) {
|
|
560
|
-
const verified = daemonVoiceCapability();
|
|
561
|
-
if (verified?.ready) {
|
|
562
|
-
line(ok(`Voice transcription readiness confirmed (${verified.provider}) after the first start.`));
|
|
563
|
-
} else {
|
|
564
|
-
line(warn('Voice configuration was saved, but readiness was not confirmed after start; run `ours-mcp voice-status`.'));
|
|
565
|
-
const row = summary.find((entry) => entry.key === 'voice');
|
|
566
|
-
if (row) {
|
|
567
|
-
row.state = 'failed';
|
|
568
|
-
row.note = 'readiness check failed after first start';
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
record({
|
|
573
|
-
key: 'core',
|
|
574
|
-
label: 'ours core (daemon)',
|
|
575
|
-
state: running ? 'installed' : 'failed',
|
|
576
|
-
version: parseVersion(daemonVersionLine()),
|
|
577
|
-
note: svc.ok ? 'starts on boot' : 'running; no boot service',
|
|
578
|
-
});
|
|
579
|
-
} else {
|
|
580
|
-
// Installed: offer an update; never re-ask config; reuse the running port everywhere.
|
|
581
|
-
const daemonState = daemonLifecycleState();
|
|
582
|
-
const running = daemonState !== 'stopped';
|
|
583
|
-
const upd = yes(` ours core is installed (${before || '?'}) — check for an update now?`, false);
|
|
584
|
-
let pendingUpdateRestart = false;
|
|
585
|
-
let after = before;
|
|
586
|
-
if (upd) {
|
|
587
|
-
await actSpin(`updating ${spec('mcp')}…`, `npm i -g ${spec('mcp')}`, () => runAsync(NPM, ['i', '-g', spec('mcp')]));
|
|
588
|
-
after = parseVersion(daemonVersionLine());
|
|
589
|
-
pendingUpdateRestart = !!(before && after && before !== after);
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
const voice = offerVoiceSetup({ daemonState });
|
|
593
|
-
if (pendingUpdateRestart && !voice.restartHandled) {
|
|
594
|
-
const restartState = daemonLifecycleState();
|
|
595
|
-
if (restartState === 'external') {
|
|
596
|
-
line(warn(`ours core updated (v${before} → v${after}); restart its external launcher to load the update.`));
|
|
597
|
-
} else {
|
|
598
|
-
await act(`ours-mcp restart (now v${after})`, async () => { if (!run('ours-mcp', ['restart']).ok) run('ours-mcp', ['start']); return { ok: true }; });
|
|
599
|
-
line(ok(`ours core updated (v${before} → v${after}) and restarted. No problems.`));
|
|
600
|
-
}
|
|
601
|
-
} else if (pendingUpdateRestart) {
|
|
602
|
-
const voiceFailed = summary.find((entry) => entry.key === 'voice')?.state === 'failed';
|
|
603
|
-
line(voiceFailed
|
|
604
|
-
? warn(`ours core updated (v${before} → v${after}); voice setup handled restart/rollback but did not change voice settings.`)
|
|
605
|
-
: ok(`ours core updated (v${before} → v${after}); voice setup performed the required restart and readiness check.`));
|
|
606
|
-
} else if (upd) {
|
|
607
|
-
line(ok(`ours core already current${after ? ` (v${after})` : ''} — nothing to change.`));
|
|
608
|
-
} else {
|
|
609
|
-
line(ok(`ours core ready — running on port ${chosenPort}${running ? '' : ' (start with ours-mcp start)'}. No problems.`));
|
|
610
|
-
}
|
|
611
|
-
record({ key: 'core', label: 'ours core (daemon)', state: 'current', version: (parseVersion(daemonVersionLine()) || before), note: `port ${chosenPort}` });
|
|
612
|
-
}
|
|
613
|
-
cont();
|
|
614
|
-
|
|
615
|
-
// ============================================================================================
|
|
616
|
-
// Human identity — created DURING install, right after the daemon is confirmed reachable (the
|
|
617
|
-
// owner change that supersedes "defer to the hand-off"). `ours-mcp create-root` is the internal
|
|
618
|
-
// seam; ALL user-facing copy says "human identity". Already-exists is a friendly keep, not an
|
|
619
|
-
// error; an unreachable daemon gives an exact retry command (never "ask your agent later").
|
|
620
|
-
// ============================================================================================
|
|
621
|
-
const coreReady = summary.some((r) => r.key === 'core' && (r.state === 'installed' || r.state === 'current'));
|
|
622
|
-
if (coreReady) {
|
|
623
|
-
line(heading('Your human identity'));
|
|
624
|
-
line(info('This is you — the human. Your agents act on your behalf, and it lets you message'));
|
|
625
|
-
line(info('people. (Internally this is your ours root; you just give it a name.)'));
|
|
626
|
-
let defName = 'me';
|
|
627
|
-
try { defName = userInfo().username || defName; } catch { /* keep fallback */ }
|
|
628
|
-
const name = (ask(` What name should others see? ${c.gray(`[${defName}]`)}: `, defName) || defName).trim() || defName;
|
|
629
|
-
let idActed = true;
|
|
630
|
-
if (DRY) {
|
|
631
|
-
line(' ' + c.dim(`[dry-run] would: ours-mcp create-root "${name}"`));
|
|
632
|
-
line(ok(`Your human identity "${name}" is created.`));
|
|
633
|
-
record({ key: 'identity', label: 'Human identity', state: 'installed', note: name });
|
|
634
|
-
} else {
|
|
635
|
-
// A freshly-started daemon may need a moment to BIND ITS PORT before create-root
|
|
636
|
-
// can reach it — which is the one thing a liveness check cannot see, so this
|
|
637
|
-
// waits on the port answering rather than on a process existing.
|
|
638
|
-
let reachable = daemonReachable();
|
|
639
|
-
for (let i = 0; i < 6 && !reachable; i++) { sleepMs(400); reachable = daemonReachable(); }
|
|
640
|
-
const r = reachable ? run('ours-mcp', ['create-root', name], { capture: true }) : { ok: false, out: '', err: 'daemon not running' };
|
|
641
|
-
const outText = `${r.out} ${r.err}`;
|
|
642
|
-
const existing = outText.match(/already exists \("([^"]+)"\)/);
|
|
643
|
-
if (r.ok && existing) {
|
|
644
|
-
line(ok(`You already have a human identity ("${existing[1]}") — keeping it.`));
|
|
645
|
-
record({ key: 'identity', label: 'Human identity', state: 'current', note: existing[1] });
|
|
646
|
-
} else if (r.ok) {
|
|
647
|
-
line(ok(`Your human identity "${name}" is created.`));
|
|
648
|
-
record({ key: 'identity', label: 'Human identity', state: 'installed', note: name });
|
|
649
|
-
} else if (!reachable || /not running|not reachable/i.test(outText)) {
|
|
650
|
-
line(warn("The daemon isn't reachable yet — couldn't create your human identity."));
|
|
651
|
-
line(info(`Fix: run '${c.cyan('ours-mcp start')}', then '${c.cyan(`ours-mcp create-root "${name}"`)}'.`));
|
|
652
|
-
record({ key: 'identity', label: 'Human identity', state: 'failed', note: 'daemon not reachable' });
|
|
653
|
-
} else {
|
|
654
|
-
const msg = (r.err || r.out || '').trim().split('\n')[0] || 'unknown error';
|
|
655
|
-
line(warn(`Couldn't create your human identity: ${msg}`));
|
|
656
|
-
line(info(`Retry any time: '${c.cyan(`ours-mcp create-root "${name}"`)}'.`));
|
|
657
|
-
record({ key: 'identity', label: 'Human identity', state: 'failed', note: 'create-root failed' });
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
cont(idActed);
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
// Step 2 harness installers (closures — share the helpers + `record` above). Alias / failure →
|
|
664
|
-
// NEVER dead-end (owner edit #3): plain reason + manual path, always. Each returns whether it
|
|
665
|
-
// ACTED (so a plain user-No skip shows no Continue pause).
|
|
666
|
-
async function installClaude(h) {
|
|
667
|
-
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; }
|
|
668
|
-
const go = yes(' Install the ours plugin into Claude Code?', true);
|
|
669
|
-
if (!go) { line(info('skipped — re-run ours-install to add it.')); record({ key: 'claude', label: 'Claude Code plugin', state: 'skipped' }); return false; }
|
|
670
|
-
const add = await act(`claude plugin marketplace add ${CLAUDE_MARKET}`, async () => run('claude', ['plugin', 'marketplace', 'add', CLAUDE_MARKET], { capture: true }));
|
|
671
|
-
const inst = add.ok ? await act('claude plugin install ours@ours.network', async () => run('claude', ['plugin', 'install', 'ours@ours.network'], { capture: true })) : add;
|
|
672
|
-
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' }); }
|
|
673
|
-
else { failClaude(); record({ key: 'claude', label: 'Claude Code plugin', state: 'failed', note: 'marketplace/install step failed' }); }
|
|
674
|
-
return true;
|
|
675
|
-
}
|
|
676
|
-
async function installCodex(h) {
|
|
677
|
-
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; }
|
|
678
|
-
const go = yes(' Install the ours plugin into Codex?', true);
|
|
679
|
-
if (!go) { line(info('skipped — re-run ours-install to add it.')); record({ key: 'codex', label: 'Codex plugin + ours-codex', state: 'skipped' }); return false; }
|
|
680
|
-
const add = await act(`codex plugin marketplace add ${CODEX_MARKET}`, async () => run('codex', ['plugin', 'marketplace', 'add', CODEX_MARKET], { capture: true }));
|
|
681
|
-
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;
|
|
682
|
-
// Owner-mandated: choosing the Codex plugin ALSO installs the ours-codex live launcher, same step.
|
|
683
|
-
const wrap = inst.ok ? await actSpin('installing the ours-codex live launcher…', `npm i -g ${spec('codex')} (provides ours-codex)`, () => runAsync(NPM, ['i', '-g', spec('codex')])) : inst;
|
|
684
|
-
if (inst.ok && wrap.ok) {
|
|
685
|
-
line(ok(`Codex plugin + ours-codex live launcher installed — pointed at port ${chosenPort}. No problems.`));
|
|
686
|
-
// Plain-language: what ours-codex is and why you'd use it (background wake vs blocking).
|
|
687
|
-
line(info('Two ways to run Codex now:'));
|
|
688
|
-
line(info(' • plain "codex" — waits for mail in the foreground: it EITHER watches for new'));
|
|
689
|
-
line(info(' messages OR takes your typing, one at a time — not both at once.'));
|
|
690
|
-
line(info(' • "ours-codex" — our wrapper that turns on AUTO wake-up: it uses Codex\'s built-in'));
|
|
691
|
-
line(info(' app server to watch for new mail in the BACKGROUND while you keep typing, so a'));
|
|
692
|
-
line(info(" reply wakes it without interrupting you. Use 'ours-codex' for hands-off replies."));
|
|
693
|
-
record({ key: 'codex', label: 'Codex plugin + ours-codex', state: 'installed', note: 'new Codex thread' });
|
|
694
|
-
} else { failCodex(); record({ key: 'codex', label: 'Codex plugin + ours-codex', state: 'failed', note: 'marketplace/install step failed' }); }
|
|
695
|
-
return true;
|
|
696
|
-
}
|
|
697
|
-
// Hermes: NOT a driven CLI. Its plugin install is `npm i -g @ours.network/hermes@<channel>` then
|
|
698
|
-
// `ours-hermes-install`, which writes ~/.hermes/config.yaml (the ours MCP server) + the skills. No
|
|
699
|
-
// marketplace/plugin-add, no alias-safety gate — nothing here calls the `hermes` binary. We pass
|
|
700
|
-
// --skip-daemon because the unified installer already owns the daemon (Step 1, chosen port);
|
|
701
|
-
// ours-hermes-install would otherwise re-ensure/restart it. Same never-dead-end contract as above.
|
|
702
|
-
async function installHermes() {
|
|
703
|
-
const go = yes(' Install the ours plugin into Hermes?', true);
|
|
704
|
-
if (!go) { line(info('skipped — re-run ours-install to add it.')); record({ key: 'hermes', label: 'Hermes plugin', state: 'skipped' }); return false; }
|
|
705
|
-
const npmOk = await actSpin(`installing ${spec('hermes')}…`, `npm i -g ${spec('hermes')} (provides ours-hermes-install)`, () => runAsync(NPM, ['i', '-g', spec('hermes')]));
|
|
706
|
-
const inst = npmOk.ok
|
|
707
|
-
? await act('ours-hermes-install --skip-daemon (writes ~/.hermes: ours MCP server + skills)', async () => run('ours-hermes-install', ['--skip-daemon'], { capture: true }))
|
|
708
|
-
: npmOk;
|
|
709
|
-
if (inst.ok) {
|
|
710
|
-
line(ok('Hermes plugin installed — the ours MCP server + skills are registered in ~/.hermes. No problems.'));
|
|
711
|
-
line(info("(run '/reload-mcp' in Hermes to load the ours tools.)"));
|
|
712
|
-
record({ key: 'hermes', label: 'Hermes plugin', state: 'installed', note: 'run /reload-mcp' });
|
|
713
|
-
} else { failHermes(); record({ key: 'hermes', label: 'Hermes plugin', state: 'failed', note: 'npm/ours-hermes-install step failed' }); }
|
|
714
|
-
return true;
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
// ============================================================================================
|
|
718
|
-
// STEP 2 / 5 — harness plugins (Claude Code + Codex + Hermes). The installer drives the plugin
|
|
719
|
-
// CLIs for Claude/Codex; Hermes installs via npm + ours-hermes-install (no CLI driving).
|
|
720
|
-
// ============================================================================================
|
|
721
|
-
line(heading('2/5 — harness plugins'));
|
|
722
|
-
line(info('These teach Claude Code, Codex, and Hermes the ours skills, so you can just talk to your'));
|
|
723
|
-
line(info("agent to message people and set things up. I'll install them for you — no commands to type."));
|
|
724
|
-
for (const h of harnesses) {
|
|
725
|
-
if (h.status === 'absent') continue; // nothing to offer; pre-flight already noted it
|
|
726
|
-
line('');
|
|
727
|
-
const acted = h.name === 'claude' ? await installClaude(h)
|
|
728
|
-
: h.name === 'codex' ? await installCodex(h)
|
|
729
|
-
: await installHermes(h);
|
|
730
|
-
cont(acted);
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
// ============================================================================================
|
|
734
|
-
// STEP 3 / 5 — ours-fleet. Appealing wording (owner edit #4); default YES.
|
|
735
|
-
// ============================================================================================
|
|
736
|
-
line(heading('3/5 — ours-fleet (your always-online agent team)'));
|
|
737
|
-
line(info('This makes your harnesses PERSISTENT: Claude Code and Codex stop being just a terminal'));
|
|
738
|
-
line(info('session and become always-online daemons that survive a reboot. Stand up your own team'));
|
|
739
|
-
line(info('of always-online developers, combine harnesses, run several Claude Codes, and link them'));
|
|
740
|
-
line(info('over Telegram so they talk to each other — and it all configures maximally easily.'));
|
|
741
|
-
const goFleet = yes(' Install it?', true);
|
|
742
|
-
if (goFleet) {
|
|
743
|
-
// ours-fleet FOLLOWS the channel, like everything else here: it publishes its own
|
|
744
|
-
// nightly dist-tag from adapt-toolkit/ours-fleet, and lib/logic.mjs maps it
|
|
745
|
-
// accordingly. (This comment used to claim the opposite — that fleet had no nightly
|
|
746
|
-
// tag and was pinned to @latest even under OURS_CHANNEL=nightly — which stopped
|
|
747
|
-
// being true when the map gained its `fleet` entry. The code always followed the
|
|
748
|
-
// map; only the comment was stale, which is the more dangerous of the two.)
|
|
749
|
-
await actSpin(`installing ${spec('fleet')}…`, `npm i -g ${spec('fleet')}`, () => runAsync(NPM, ['i', '-g', spec('fleet')]));
|
|
750
|
-
const init = await act('ours-fleet init (one-time host setup: units, dirs, linger)', async () => run('ours-fleet', ['init']));
|
|
751
|
-
if (!init.ok) line(warn(`ours-fleet host setup didn't finish — retry '${c.cyan('ours-fleet init')}'.`));
|
|
752
|
-
|
|
753
|
-
if (init.ok) {
|
|
754
|
-
line(ok('ours-fleet ready — the core ours plugin discovers every option through `ours-fleet docs`. No problems.'));
|
|
755
|
-
}
|
|
756
|
-
record({
|
|
757
|
-
key: 'fleet',
|
|
758
|
-
label: 'ours-fleet',
|
|
759
|
-
state: init.ok ? 'installed' : 'failed',
|
|
760
|
-
version: globalVersion('@ours.network/fleet'),
|
|
761
|
-
note: init.ok ? 'CLI + core-plugin discovery' : 'ours-fleet init failed',
|
|
762
|
-
});
|
|
763
|
-
} else {
|
|
764
|
-
line(info('skipped cleanly — re-run ours-install any time to add it.'));
|
|
765
|
-
record({ key: 'fleet', label: 'ours-fleet', state: 'skipped' });
|
|
766
|
-
}
|
|
767
|
-
cont(goFleet);
|
|
768
|
-
|
|
769
|
-
// The broker the WHOLE deployment shares, whichever daemon a consumer talks to.
|
|
770
|
-
const sharedBroker = () => resolveSharedBroker({
|
|
771
|
-
chosenBroker,
|
|
772
|
-
statusBroker: status0.broker,
|
|
773
|
-
configBroker: readConfigObject().brokerUrl,
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
// Provision a daemon that belongs to ONE consumer: its own config file, its own
|
|
777
|
-
// state directory, its own port, and — via core's OURS_SERVICE_NAME — its own boot
|
|
778
|
-
// unit, so `install-service` cannot overwrite the shared daemon's. Returns the
|
|
779
|
-
// endpoint + state dir to wire that consumer to, and whether it came up.
|
|
780
|
-
async function provisionDedicatedDaemon({ instance, port, label }) {
|
|
781
|
-
const { stateDir, configPath: cfgPath, serviceName } = dedicatedDaemonPaths(homedir(), instance);
|
|
782
|
-
const env = { OURS_CONFIG: cfgPath, OURS_STATE_DIR: stateDir, OURS_SERVICE_NAME: serviceName };
|
|
783
|
-
// The dedicated daemon has to exist as a package before it can be started; on a
|
|
784
|
-
// fresh machine step 1 already installed it, but a re-run that skipped core has not.
|
|
785
|
-
await actSpin(`ensuring ${spec('mcp')}…`, `npm i -g ${spec('mcp')}`, () => runAsync(NPM, ['i', '-g', spec('mcp')]));
|
|
786
|
-
const desired = { port, stateDir, serviceName };
|
|
787
|
-
const broker = sharedBroker();
|
|
788
|
-
if (broker) desired.brokerUrl = broker;
|
|
789
|
-
let existing = {};
|
|
790
|
-
try { existing = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch { /* absent or unreadable */ }
|
|
791
|
-
const sameAlready = existing.port === port && existing.stateDir === stateDir && existing.serviceName === serviceName;
|
|
792
|
-
if (sameAlready) {
|
|
793
|
-
line(ok(`The ${label} daemon is already configured on port ${port} — no change.`));
|
|
794
|
-
// Nothing to change AND it is already up: do not touch it. `install-service` STOPS
|
|
795
|
-
// the daemon before rewriting the unit, so re-running it here would bounce a healthy
|
|
796
|
-
// daemon for no reason.
|
|
797
|
-
if (!DRY && daemonReachable(env)) {
|
|
798
|
-
line(ok(`Dedicated ${label} daemon already running on port ${port} — left alone.`));
|
|
799
|
-
return { endpoint: daemonEndpoint(port), stateDir, serviceName, configPath: cfgPath, running: true, port };
|
|
800
|
-
}
|
|
801
|
-
} else {
|
|
802
|
-
await act(`write ${cfgPath} (dedicated ${label} daemon, port ${port}, state ${stateDir})`, async () => {
|
|
803
|
-
atomicWriteConfig(cfgPath, mergeConfig(existing, desired));
|
|
804
|
-
return { ok: true };
|
|
805
|
-
});
|
|
806
|
-
}
|
|
807
|
-
const started = await act(`ours-mcp start (dedicated ${label} daemon, port ${port})`, async () => run('ours-mcp', ['start'], { env }));
|
|
808
|
-
const svc = await act(`ours-mcp install-service (dedicated ${label} daemon, unit ours-${serviceName})`, async () => run('ours-mcp', ['install-service'], { env }));
|
|
809
|
-
// Same recovery as the shared daemon: install-service STOPS the daemon before it
|
|
810
|
-
// writes the unit, so a failure there leaves nothing listening on this port.
|
|
811
|
-
// Same rule as the shared daemon: an exit code is not evidence of a bound port.
|
|
812
|
-
let running = DRY ? started.ok : daemonReachable(env);
|
|
813
|
-
if (!DRY && !svc.ok) {
|
|
814
|
-
if (!running) {
|
|
815
|
-
line(info(`the boot-service step stopped the ${label} daemon before it failed — restarting it.`));
|
|
816
|
-
run('ours-mcp', ['start'], { env });
|
|
817
|
-
running = daemonReachable(env);
|
|
818
|
-
}
|
|
819
|
-
line(warn(`the ${label} daemon has no boot service — retry '${c.cyan(`OURS_CONFIG=${cfgPath} OURS_SERVICE_NAME=${serviceName} ours-mcp install-service`)}'.`));
|
|
820
|
-
}
|
|
821
|
-
if (running || DRY) line(ok(`Dedicated ${label} daemon ready on port ${port} (state ${stateDir}, unit ours-${serviceName}).`));
|
|
822
|
-
else line(warn(`could not start the dedicated ${label} daemon — run '${c.cyan(`OURS_CONFIG=${cfgPath} ours-mcp start`)}'.`));
|
|
823
|
-
return { endpoint: daemonEndpoint(port), stateDir, serviceName, configPath: cfgPath, running: running || DRY, port };
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
// Ask one consumer whether it uses the COMMON daemon or gets its own. Enter and
|
|
827
|
-
// every non-interactive run answer "common" — the historical topology.
|
|
828
|
-
const askDaemonMode = (what) => {
|
|
829
|
-
line(info(`${what} can share the daemon from step 1, or run against its own isolated one.`));
|
|
830
|
-
line(info('Sharing is right for almost everyone — press Enter. A dedicated daemon gets its own'));
|
|
831
|
-
line(info('port, state directory and boot service, and does not see the shared daemon\'s identities.'));
|
|
832
|
-
return yes(` Give ${what} its OWN dedicated daemon?`, false) ? 'dedicated' : 'common';
|
|
833
|
-
};
|
|
834
|
-
|
|
835
|
-
// Give the Telegram connector the daemon it was assigned: that daemon's loopback
|
|
836
|
-
// endpoint, the state directory that endpoint's API token belongs to, and — for a
|
|
837
|
-
// pre-0.3.3 connector that still meets the daemon at a broker instead — that broker.
|
|
838
|
-
// Idempotent: an unchanged selection writes nothing. Returns { changed, hadPrevious }
|
|
839
|
-
// so the caller can warn about a service unit that froze an older selection.
|
|
840
|
-
async function writeTgDaemonConfig({ endpoint, stateDir }) {
|
|
841
|
-
const path = tgConfigPath(process.env, homedir());
|
|
842
|
-
const desired = {
|
|
843
|
-
daemonUrl: endpoint,
|
|
844
|
-
daemonStateDir: stateDir,
|
|
845
|
-
brokerUrl: sharedBroker(),
|
|
846
|
-
};
|
|
847
|
-
let existing = {};
|
|
848
|
-
try { existing = JSON.parse(readFileSync(path, 'utf8')); } catch { /* absent or unreadable */ }
|
|
849
|
-
const plan = planTgDaemonConfig(existing, desired);
|
|
850
|
-
const hadPrevious = !!(plan.previous.daemonUrl || plan.previous.brokerUrl);
|
|
851
|
-
if (!plan.changed) {
|
|
852
|
-
line(ok(`Telegram connector already points at this daemon (${desired.daemonUrl}) — no change.`));
|
|
853
|
-
return { changed: false, hadPrevious };
|
|
854
|
-
}
|
|
855
|
-
await act(`write ${path} (daemon ${desired.daemonUrl}, state ${stateDir})`, async () => {
|
|
856
|
-
atomicWriteConfig(path, plan.text);
|
|
857
|
-
return { ok: true };
|
|
858
|
-
});
|
|
859
|
-
line(ok(`Telegram connector configured to use this daemon (${desired.daemonUrl}).`));
|
|
860
|
-
return { changed: true, hadPrevious };
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
// ============================================================================================
|
|
864
|
-
// STEP 4 / 5 — Telegram connector. Install-only (no bot tokens here). Then: run as a service?
|
|
865
|
-
// ============================================================================================
|
|
866
|
-
line(heading('4/5 — Telegram connector'));
|
|
867
|
-
line(info('This bridges a Telegram bot to your Ours node, so you can talk to your agent from'));
|
|
868
|
-
line(info("Telegram. (You'll set up the actual bot later, with your agent — not here.)"));
|
|
869
|
-
const goTg = yes(' Install it?', false);
|
|
870
|
-
if (goTg) {
|
|
871
|
-
await actSpin(`installing ${spec('tg-connector')}…`, `npm i -g ${spec('tg-connector')}`, () => runAsync(NPM, ['i', '-g', spec('tg-connector')]));
|
|
872
|
-
// WHICH daemon — asked independently of every other consumer, and answered
|
|
873
|
-
// "common" by Enter / non-interactive so the historical topology is the default.
|
|
874
|
-
line('');
|
|
875
|
-
const tgMode = askDaemonMode('the Telegram connector');
|
|
876
|
-
let tgDaemon = { endpoint: daemonEndpoint(chosenPort), stateDir: daemonStateDir(), port: chosenPort, mode: 'common' };
|
|
877
|
-
if (tgMode === 'dedicated') {
|
|
878
|
-
const instance = DEDICATED_INSTANCES.telegram;
|
|
879
|
-
const suggested = suggestPort(chosenPort + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
|
|
880
|
-
const port = askDaemonPort('Which local port should the Telegram daemon use?', suggested);
|
|
881
|
-
claimPort(port, 'the dedicated Telegram daemon');
|
|
882
|
-
const provisioned = await provisionDedicatedDaemon({ instance, port, label: 'Telegram' });
|
|
883
|
-
tgDaemon = { ...provisioned, mode: 'dedicated' };
|
|
884
|
-
} else {
|
|
885
|
-
line(ok(`Telegram will use the shared daemon on port ${chosenPort}.`));
|
|
886
|
-
}
|
|
887
|
-
// POINT IT AT THAT DAEMON — BEFORE it is started or installed as a service.
|
|
888
|
-
// The connector never inherits ~/.ours/config.json (its SDK reports configPath:
|
|
889
|
-
// null unless told otherwise), and `install-service` bakes whatever it resolves
|
|
890
|
-
// into the unit as environment variables that outrank the file from then on. So
|
|
891
|
-
// the daemon's identity has to be in its config BEFORE either happens. See
|
|
892
|
-
// planTgDaemonConfig for why all three keys are written.
|
|
893
|
-
const tgConfigured = await writeTgDaemonConfig(tgDaemon);
|
|
894
|
-
const where = tgDaemon.mode === 'dedicated' ? `its own daemon on port ${tgDaemon.port}` : `the shared daemon on port ${tgDaemon.port}`;
|
|
895
|
-
const asService = yes(' Keep it running in the background so it starts automatically on boot?', true);
|
|
896
|
-
if (asService) {
|
|
897
|
-
const svc = await act('ours-tg-connector install-service (starts on boot)', async () => run('ours-tg-connector', ['install-service']));
|
|
898
|
-
if (svc.ok) line(ok(`Telegram connector installed and running as a service (starts on boot), pointed at ${where}. No problems.`));
|
|
899
|
-
else line(warn(`connector installed, but the service didn't start — retry '${c.cyan('ours-tg-connector install-service')}'.`));
|
|
900
|
-
record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: `service (boot) · ${tgDaemon.mode} daemon ${tgDaemon.port}` });
|
|
901
|
-
} else {
|
|
902
|
-
line(ok(`Telegram connector installed, pointed at ${where}. Start it any time with '${c.cyan('ours-tg-connector start')}'. No problems.`));
|
|
903
|
-
// A connector already installed as a service froze its OLD daemon selection into
|
|
904
|
-
// the unit's environment, which outranks the file we just wrote. Config alone
|
|
905
|
-
// cannot repair that — say so plainly rather than let it look fixed.
|
|
906
|
-
if (tgConfigured.changed && tgConfigured.hadPrevious) {
|
|
907
|
-
line(warn(`if you previously ran '${c.cyan('ours-tg-connector install-service')}', re-run it — the old service froze the previous daemon selection in its unit.`));
|
|
908
|
-
}
|
|
909
|
-
record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: `start on demand · ${tgDaemon.mode} daemon ${tgDaemon.port}` });
|
|
910
|
-
}
|
|
911
|
-
} else {
|
|
912
|
-
line(info('skipped cleanly.'));
|
|
913
|
-
record({ key: 'telegram', label: 'Telegram connector', state: 'skipped' });
|
|
914
|
-
}
|
|
915
|
-
cont(goTg);
|
|
916
|
-
|
|
917
|
-
// ============================================================================================
|
|
918
|
-
// STEP 5 / 5 — Rooms (ours-cowork). Two independent things get configured here.
|
|
919
|
-
//
|
|
920
|
-
// ITS OWN SURFACE — the deployment broker, its private state directory, and its loopback
|
|
921
|
-
// console/REST port. Those it has always had.
|
|
922
|
-
//
|
|
923
|
-
// WHICH DAEMON — ours-cowork used to host its own, always. It now supports an EXTERNAL ours
|
|
924
|
-
// daemon (cowork PR #9), so Rooms answers the same common-vs-dedicated question the Telegram
|
|
925
|
-
// connector does, plus a third state the connector does not have: EMBEDDED, cowork's own.
|
|
926
|
-
// Contract (see logic.mjs): the `daemon` block is optional; absent means embedded; external is
|
|
927
|
-
// { mode:'external', endpoint, stateDir } and REQUIRES both halves, because cowork holds no
|
|
928
|
-
// token and its SDK reads <stateDir>/daemon-token.
|
|
929
|
-
//
|
|
930
|
-
// Boot is FAIL-CLOSED on an unreachable endpoint, a non-ours daemon, or a mismatched state
|
|
931
|
-
// directory — there is no embedded fallback. So an install that is ALREADY running embedded is
|
|
932
|
-
// never migrated behind the user's back: non-interactively it is left exactly as it is, and
|
|
933
|
-
// interactively the question is asked plainly before anything is written.
|
|
934
|
-
// ============================================================================================
|
|
935
|
-
line(heading('5/5 — Rooms (ours-cowork)'));
|
|
936
|
-
line(info('Durable mission rooms: a room keeps its own ordered history, and people and agents'));
|
|
937
|
-
line(info('join it as seats. It serves a local web console, and reaches everyone through the'));
|
|
938
|
-
line(info('same broker as the rest of your install.'));
|
|
939
|
-
const goRooms = yes(' Install it?', false);
|
|
940
|
-
if (goRooms) {
|
|
941
|
-
await actSpin(`installing ${spec('cowork')}…`, `npm i -g ${spec('cowork')}`, () => runAsync(NPM, ['i', '-g', spec('cowork')]));
|
|
942
|
-
const roomsStateDir = join(homedir(), '.ours-cowork');
|
|
943
|
-
const cfgPath = coworkConfigPath(process.env, homedir());
|
|
944
|
-
let existingRooms = {};
|
|
945
|
-
try { existingRooms = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch { /* absent or unreadable */ }
|
|
946
|
-
const restDefault = Number.isInteger(existingRooms.rest?.port) ? existingRooms.rest.port : COWORK_DEFAULT_PORT;
|
|
947
|
-
line('');
|
|
948
|
-
line(info('Rooms serves a console on a loopback port — 127.0.0.1 only, never exposed.'));
|
|
949
|
-
// COWORK_DEFAULT_PORT is in RESERVED_PORTS (so no ours daemon can be handed it),
|
|
950
|
-
// so validate this one against the daemon ports only.
|
|
951
|
-
const roomsPort = (() => {
|
|
952
|
-
let candidate = restDefault;
|
|
953
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
954
|
-
const raw = ask(` Which local port should the Rooms console use? ${c.gray(`[Enter for ${candidate}]`)}: `, String(candidate));
|
|
955
|
-
const v = validateDaemonPort(raw, {
|
|
956
|
-
fallback: candidate, isTaken: (p) => (p === restDefault ? false : portTakenSync(p)),
|
|
957
|
-
taken: claimedPorts, reserved: [],
|
|
958
|
-
});
|
|
959
|
-
if (v.ok) return v.port;
|
|
960
|
-
line(warn(`${v.reason}.`));
|
|
961
|
-
if (!interactive) break;
|
|
962
|
-
candidate = suggestPort(v.port + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
|
|
963
|
-
line(info(`Suggesting ${candidate} instead.`));
|
|
964
|
-
}
|
|
965
|
-
return candidate;
|
|
966
|
-
})();
|
|
967
|
-
claimPort(roomsPort, 'the Rooms console');
|
|
968
|
-
|
|
969
|
-
// WHICH DAEMON. `undefined` means "leave whatever is there alone" — the answer for an
|
|
970
|
-
// existing embedded install nobody asked to migrate.
|
|
971
|
-
const wasEmbedded = coworkDaemonMode(existingRooms) === 'embedded';
|
|
972
|
-
const hadConfig = existsSync(cfgPath);
|
|
973
|
-
// Ask the BUILD, not the channel: this runs after the install above, so the version
|
|
974
|
-
// read here is the one actually on the machine.
|
|
975
|
-
const coworkVersion = globalVersion('@ours.network/cowork');
|
|
976
|
-
const externalSupported = coworkSupportsExternalDaemon(coworkVersion);
|
|
977
|
-
let roomsDaemon;
|
|
978
|
-
let roomsDaemonLabel;
|
|
979
|
-
if (!externalSupported) {
|
|
980
|
-
// The build we just installed predates cowork's external-daemon mode. Its config
|
|
981
|
-
// is a strict document and its boot fails closed, so writing a selection it cannot
|
|
982
|
-
// honour would break Rooms rather than degrade it.
|
|
983
|
-
line(info(`This Rooms build${coworkVersion ? ` (${coworkVersion})` : ''} hosts its own daemon; pointing it at the shared`));
|
|
984
|
-
line(info(`one needs ${COWORK_EXTERNAL_MIN_VERSION} or newer. Re-run with ${c.cyan('OURS_CHANNEL=nightly')} to get it.`));
|
|
985
|
-
roomsDaemonLabel = 'embedded';
|
|
986
|
-
} else if (hadConfig && wasEmbedded && !interactive) {
|
|
987
|
-
// Fail-closed boot makes this migration a real risk; never do it unasked.
|
|
988
|
-
line(info('Rooms already runs its own embedded daemon — leaving that alone.'));
|
|
989
|
-
line(info(`To point it at this install's daemon, re-run ${c.cyan('ours-install')} in a terminal.`));
|
|
990
|
-
roomsDaemonLabel = 'embedded (unchanged)';
|
|
991
|
-
} else {
|
|
992
|
-
line('');
|
|
993
|
-
const roomsMode = askDaemonMode('Rooms');
|
|
994
|
-
if (roomsMode === 'dedicated') {
|
|
995
|
-
const instance = DEDICATED_INSTANCES.rooms;
|
|
996
|
-
const suggested = suggestPort(chosenPort + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
|
|
997
|
-
const port = askDaemonPort('Which local port should the Rooms daemon use?', suggested);
|
|
998
|
-
claimPort(port, 'the dedicated Rooms daemon');
|
|
999
|
-
const provisioned = await provisionDedicatedDaemon({ instance, port, label: 'Rooms' });
|
|
1000
|
-
roomsDaemon = { endpoint: provisioned.endpoint, stateDir: provisioned.stateDir };
|
|
1001
|
-
roomsDaemonLabel = `dedicated daemon ${port}`;
|
|
1002
|
-
} else {
|
|
1003
|
-
roomsDaemon = { endpoint: daemonEndpoint(chosenPort), stateDir: daemonStateDir() };
|
|
1004
|
-
roomsDaemonLabel = `common daemon ${chosenPort}`;
|
|
1005
|
-
line(ok(`Rooms will use the shared daemon on port ${chosenPort}.`));
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
const roomsPlan = planCoworkConfig(existingRooms, {
|
|
1010
|
-
brokerUrl: sharedBroker(),
|
|
1011
|
-
stateDir: roomsStateDir,
|
|
1012
|
-
restPort: roomsPort,
|
|
1013
|
-
daemon: roomsDaemon,
|
|
1014
|
-
});
|
|
1015
|
-
if (roomsPlan.error) {
|
|
1016
|
-
// Only reachable if a daemon selection lost half of itself; a half-written block
|
|
1017
|
-
// would fail closed at cowork's boot, so refuse rather than write it.
|
|
1018
|
-
line(warn(`not changing the Rooms daemon selection — ${roomsPlan.error}.`));
|
|
1019
|
-
} else if (roomsPlan.changed) {
|
|
1020
|
-
await act(`write ${cfgPath} (console port ${roomsPort}, state ${roomsStateDir}${roomsDaemon ? `, daemon ${roomsDaemon.endpoint}` : ''})`, async () => {
|
|
1021
|
-
atomicWriteConfig(cfgPath, roomsPlan.text);
|
|
1022
|
-
return { ok: true };
|
|
1023
|
-
});
|
|
1024
|
-
if (roomsDaemon) line(ok(`Rooms configured to use ${roomsDaemonLabel} (${roomsDaemon.endpoint}, state ${roomsDaemon.stateDir}).`));
|
|
1025
|
-
} else {
|
|
1026
|
-
line(ok(`Rooms is already configured for this deployment (console port ${roomsPort}) — no change.`));
|
|
1027
|
-
}
|
|
1028
|
-
const svc = await act('ours-cowork install-service (starts on boot)', async () => run('ours-cowork', ['install-service']));
|
|
1029
|
-
if (svc.ok) {
|
|
1030
|
-
line(ok(`Rooms ready — console at ${c.cyan(`http://127.0.0.1:${roomsPort}/`)}, sharing your broker. No problems.`));
|
|
1031
|
-
} else {
|
|
1032
|
-
line(warn(`Rooms installed, but its service didn't start — retry '${c.cyan('ours-cowork install-service')}'.`));
|
|
1033
|
-
line(info(`You can also run it in the foreground: '${c.cyan('ours-cowork web')}'.`));
|
|
1034
|
-
}
|
|
1035
|
-
record({
|
|
1036
|
-
key: 'rooms',
|
|
1037
|
-
label: 'Rooms (ours-cowork)',
|
|
1038
|
-
state: svc.ok ? 'installed' : 'failed',
|
|
1039
|
-
version: coworkVersion,
|
|
1040
|
-
note: svc.ok ? `console ${roomsPort} · ${roomsDaemonLabel}` : 'ours-cowork install-service failed',
|
|
1041
|
-
});
|
|
1042
|
-
} else {
|
|
1043
|
-
line(info('skipped cleanly — re-run ours-install any time to add it.'));
|
|
1044
|
-
record({ key: 'rooms', label: 'Rooms (ours-cowork)', state: 'skipped' });
|
|
1045
|
-
}
|
|
1046
|
-
cont(goRooms);
|
|
1047
|
-
|
|
1048
|
-
// Last guard on the whole topology: no two daemons in this install may share a port.
|
|
1049
|
-
// Each answer was validated as it was given, but only the finished plan proves the set.
|
|
1050
|
-
const portPlan = planPorts(topology);
|
|
1051
|
-
if (!portPlan.ok) {
|
|
1052
|
-
for (const d of portPlan.duplicates) {
|
|
1053
|
-
line(warn(`port ${d.port} ended up claimed by both ${d.labels[0]} and ${d.labels[1]} — one of them will fail to bind.`));
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
return endScreen({ ttyFd, summary, chosenPort, chosenBroker });
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
// --- never-dead-end messaging (owner edit #3) --------------------------------------------------
|
|
1061
|
-
function manualClaude(h) {
|
|
1062
|
-
if (h.status === 'alias') {
|
|
1063
|
-
line(warn('Heads-up: on your machine, "claude" is installed as an alias, not the real command,'));
|
|
1064
|
-
line(info('so I can\'t drive it safely. To fix it: run ' + c.cyan('type claude') + ' , remove/rename that'));
|
|
1065
|
-
line(info('alias in your shell config, open a new terminal, and re-run ours-install.'));
|
|
1066
|
-
} else {
|
|
1067
|
-
line(warn('I couldn\'t safely drive the "claude" command on this machine.'));
|
|
1068
|
-
}
|
|
1069
|
-
line(info('You can still install the plugin yourself — inside Claude Code, run these two:'));
|
|
1070
|
-
line(' ' + c.cyan(`/plugin marketplace add ${CLAUDE_MARKET}`));
|
|
1071
|
-
line(' ' + c.cyan('/plugin install ours'));
|
|
1072
|
-
}
|
|
1073
|
-
function failClaude() {
|
|
1074
|
-
line(warn('Couldn\'t install the Claude Code plugin automatically (network or plugin cache).'));
|
|
1075
|
-
line(info('Install it by hand — inside Claude Code, run these two, then re-run ours-install:'));
|
|
1076
|
-
line(' ' + c.cyan(`/plugin marketplace add ${CLAUDE_MARKET}`));
|
|
1077
|
-
line(' ' + c.cyan('/plugin install ours'));
|
|
1078
|
-
line(info('Your daemon and other steps are intact. Continuing.'));
|
|
1079
|
-
}
|
|
1080
|
-
function manualCodex(h) {
|
|
1081
|
-
if (h.status === 'alias') {
|
|
1082
|
-
line(warn('Heads-up: on your machine, "codex" is installed as an alias, not the real command,'));
|
|
1083
|
-
line(info('so I can\'t drive it safely. To fix it: run ' + c.cyan('type codex') + ' , remove/rename that'));
|
|
1084
|
-
line(info('alias in your shell config, open a new terminal, and re-run ours-install.'));
|
|
1085
|
-
} else {
|
|
1086
|
-
line(warn('I couldn\'t safely drive the "codex" command on this machine.'));
|
|
1087
|
-
}
|
|
1088
|
-
line(info('You can still install it yourself — run these three in your terminal:'));
|
|
1089
|
-
line(' ' + c.cyan(`codex plugin marketplace add ${CODEX_MARKET}`));
|
|
1090
|
-
line(' ' + c.cyan('codex plugin add ours@ours-codex-marketplace'));
|
|
1091
|
-
line(' ' + c.cyan('npm i -g @ours.network/codex') + c.gray(' (adds the ours-codex live launcher)'));
|
|
1092
|
-
}
|
|
1093
|
-
function failCodex() {
|
|
1094
|
-
line(warn('Couldn\'t install the Codex plugin automatically (network or plugin cache).'));
|
|
1095
|
-
line(info('Install it by hand — run these three, then re-run ours-install:'));
|
|
1096
|
-
line(' ' + c.cyan(`codex plugin marketplace add ${CODEX_MARKET}`));
|
|
1097
|
-
line(' ' + c.cyan('codex plugin add ours@ours-codex-marketplace'));
|
|
1098
|
-
line(' ' + c.cyan('npm i -g @ours.network/codex'));
|
|
1099
|
-
line(info('Your daemon and other steps are intact. Continuing.'));
|
|
1100
|
-
}
|
|
1101
|
-
function failHermes() {
|
|
1102
|
-
line(warn('Couldn\'t install the Hermes plugin automatically (network or npm).'));
|
|
1103
|
-
line(info('Install it by hand — run these two, then run /reload-mcp in Hermes:'));
|
|
1104
|
-
line(' ' + c.cyan('npm i -g @ours.network/hermes'));
|
|
1105
|
-
line(' ' + c.cyan('ours-hermes-install'));
|
|
1106
|
-
line(info('Your daemon and other steps are intact. Continuing.'));
|
|
1107
|
-
}
|
|
1108
|
-
// --- final summary + copy-paste hand-off -------------------------------------------------------
|
|
1109
|
-
function endScreen({ ttyFd, summary, chosenPort, chosenBroker }) {
|
|
1110
|
-
line('');
|
|
1111
|
-
line(' ' + c.cyan('═'.repeat(64)));
|
|
1112
|
-
line(' ' + c.bold('ours.network — install complete'));
|
|
1113
|
-
line(' ' + c.gray(`Daemon port: ${chosenPort} • Broker: ${chosenBroker ? 'custom' : 'standard'}`));
|
|
1114
|
-
line(' ' + c.cyan('═'.repeat(64)));
|
|
1115
|
-
for (const row of summary) {
|
|
1116
|
-
const m = row.state === 'failed' ? c.red('✗') : row.state === 'skipped' ? c.gray('·') : c.green('✓');
|
|
1117
|
-
const label = row.label.padEnd(26);
|
|
1118
|
-
const ver = (row.version ? `v${row.version}` : '').padEnd(9);
|
|
1119
|
-
const state = (row.state === 'installed' || row.state === 'current') ? (row.note || 'ready')
|
|
1120
|
-
: row.state === 'skipped' ? c.gray('skipped' + (row.note ? ` (${row.note})` : ''))
|
|
1121
|
-
: c.red('needs attention' + (row.note ? ` — ${row.note}` : ''));
|
|
1122
|
-
line(` ${m} ${label}${ver}${state}`);
|
|
1123
|
-
}
|
|
1124
|
-
const anyFail = summary.some((r) => r.state === 'failed');
|
|
1125
|
-
line('');
|
|
1126
|
-
line(anyFail
|
|
1127
|
-
? ' ' + c.yellow('Some pieces need a hand — see the notes above; re-run ours-install after fixing.')
|
|
1128
|
-
: ' ' + c.green('Everything installed cleanly. No problems.'));
|
|
1129
|
-
|
|
1130
|
-
// The literal copy-paste hand-off — skipped/failed components drop out. The human identity is
|
|
1131
|
-
// normally created in-install, so its step appears ONLY as a fallback when that didn't succeed.
|
|
1132
|
-
const has = (k) => summary.some((r) => r.key === k && (r.state === 'installed' || r.state === 'current'));
|
|
1133
|
-
if (has('core')) {
|
|
1134
|
-
const identityDone = has('identity');
|
|
1135
|
-
const { text, empty } = buildHandoffPrompt({
|
|
1136
|
-
identity: !identityDone, fleet: has('fleet'), telegram: has('telegram'), rooms: has('rooms'),
|
|
1137
|
-
});
|
|
1138
|
-
if (empty) {
|
|
1139
|
-
// Nothing left to finish (identity created in-install, no fleet/Telegram). Don't show an empty box.
|
|
1140
|
-
line('');
|
|
1141
|
-
line(' ' + c.green("You're all set — open Claude Code or Codex and just start talking to your agent."));
|
|
1142
|
-
} else {
|
|
1143
|
-
line('');
|
|
1144
|
-
line(' ' + c.gray('─'.repeat(64)));
|
|
1145
|
-
line(' ' + c.bold('ONE LAST STEP') + ' — copy the prompt below and paste it into Claude Code');
|
|
1146
|
-
line(' (or Codex). Your agent walks you through the rest, conversationally.');
|
|
1147
|
-
line(' ' + c.gray('─'.repeat(64)));
|
|
1148
|
-
line('');
|
|
1149
|
-
line(box(text.split('\n'), 'paste this into your agent'));
|
|
1150
|
-
copyToClipboard(text);
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
line('');
|
|
1154
|
-
line(' ' + c.gray('Re-run ') + c.cyan('ours-install') + c.gray(' any time to add a skipped piece or update.'));
|
|
1155
|
-
line(' ' + c.cyan('═'.repeat(64)));
|
|
1156
|
-
finish(ttyFd);
|
|
1157
|
-
// Exit CLEANLY right after the summary — never leave the user at a hung installer (the tty is
|
|
1158
|
-
// closed above; a lingering clipboard child or signal listener must not keep us alive).
|
|
1159
|
-
process.exit(0);
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
// Best-effort clipboard copy (pbcopy/wl-copy/xclip/clip). Silent when unsupported. A hard timeout
|
|
1163
|
-
// keeps a lingering/blocking clipboard helper (e.g. xclip holding the selection) from ever stalling
|
|
1164
|
-
// the exit.
|
|
1165
|
-
function copyToClipboard(text) {
|
|
1166
|
-
if (DRY) return;
|
|
1167
|
-
const tools = [['pbcopy', []], ['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['clip.exe', []]];
|
|
1168
|
-
for (const [bin, args] of tools) {
|
|
1169
|
-
try {
|
|
1170
|
-
const r = spawnSync(bin, args, { input: text, timeout: 2000 });
|
|
1171
|
-
if (!r.error && !r.timedOut && (r.status === 0 || r.status == null)) { line(' ' + c.gray('(copied to your clipboard.)')); return; }
|
|
1172
|
-
} catch { /* try the next one */ }
|
|
5
|
+
import { realEffects } from './lib/effects.mjs';
|
|
6
|
+
import { runInstall } from './lib/orchestrate.mjs';
|
|
7
|
+
import { closeSync, makeWriter, openTty } from './lib/ui.mjs';
|
|
8
|
+
|
|
9
|
+
const version = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
|
|
10
|
+
const ttyFd = openTty();
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const code = await runInstall(process.argv.slice(2), realEffects({
|
|
14
|
+
write: makeWriter(ttyFd),
|
|
15
|
+
ttyFd,
|
|
16
|
+
env: process.env,
|
|
17
|
+
version,
|
|
18
|
+
}));
|
|
19
|
+
process.exitCode = code;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
process.stderr.write(`ours-install: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
} finally {
|
|
24
|
+
if (ttyFd != null) {
|
|
25
|
+
try { closeSync(ttyFd); } catch { /* already closed */ }
|
|
1173
26
|
}
|
|
1174
27
|
}
|
|
1175
|
-
|
|
1176
|
-
function finish(ttyFd) {
|
|
1177
|
-
if (ttyFd != null) { try { closeSync(ttyFd); } catch { /* ignore */ } }
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
main().catch((e) => {
|
|
1181
|
-
// A Ctrl+C thrown out of a blocked prompt read → the clean cancel path (exit 130).
|
|
1182
|
-
if (isCancel(e)) { if (cancelHandler) return cancelHandler(); process.exit(130); }
|
|
1183
|
-
// Otherwise: degrade, don't crash — one honest line, non-zero exit.
|
|
1184
|
-
say(`unexpected error: ${String(e)}`);
|
|
1185
|
-
process.exitCode = 1;
|
|
1186
|
-
});
|