@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/install.sh ADDED
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env bash
2
+ # ours.network — unified stack installer bootstrap (the `ours-install` experience).
3
+ #
4
+ # PREFERRED install is npm (persistent, versioned, integrity-checked command on PATH):
5
+ # npm i -g @ours.network/install && ours-install # then re-run any time with: ours-install
6
+ # npx @ours.network/install # one-off, no global install
7
+ #
8
+ # This curl|bash bootstrap is the FALLBACK for machines without npm set up (least secure — it
9
+ # pipes a script into your shell). It just gets Node.js/npm sorted, then does the same
10
+ # `npm i -g @ours.network/install` and runs `ours-install`. Meant to be run as:
11
+ #
12
+ # curl -fsSL https://raw.githubusercontent.com/adapt-toolkit/ours-mcp/main/packages/installer/install.sh | bash
13
+ #
14
+ # This file is a THIN bootstrap: it checks that Node.js + npm are present (and prints friendly,
15
+ # per-OS guidance if not), then runs the real experience — the Node installer (install.mjs) that
16
+ # guides the WHOLE stack in ~3 minutes: pre-flight (platform / node / harness alias-safety),
17
+ # config-first (broker + port), then four consent-gated steps — ours core (the daemon), the harness
18
+ # plugins (Claude Code + Codex), ours-fleet, and the Telegram connector — ending in ONE copy-paste
19
+ # hand-off prompt. All the real work lives in the Node installer.
20
+ #
21
+ # From a CLONE (a sibling install.mjs is present) it runs that directly. Piped as `curl … | bash`
22
+ # it installs the published command globally — `npm i -g @ours.network/install` — then runs
23
+ # `ours-install`. Idempotent: a re-run updates to @latest and runs again.
24
+ #
25
+ # Non-interactive env overrides (all optional) — consumed by the Node installer:
26
+ # OURS_ASSUME_YES=1 accept every default; never prompt (no tty needed)
27
+ # OURS_INSTALL_DRY_RUN=1 walk the whole flow WITHOUT installing/changing anything
28
+ # (prints exactly what it WOULD do — safe on any machine)
29
+ # OURS_NPM="npm" npm binary to use
30
+ # OURS_CONFIG=/path/config.json daemon config file (default ~/.ours/config.json)
31
+ # OURS_INSTALLER_MJS=/path/install.mjs run this Node installer directly (dev/testing)
32
+ # OURS_INSTALL_PKG=@ours.network/install the package to install for the command (override for dev)
33
+ set -euo pipefail
34
+
35
+ say(){ printf 'ours: %s\n' "$1"; }
36
+ PKG="${OURS_INSTALL_PKG:-@ours.network/install}"
37
+ NPM_BIN="${OURS_NPM:-npm}"
38
+
39
+ # --- 1) Node.js + npm check + friendly guidance ------------------------------------------------
40
+ # The installer needs Node.js ≥ 20 (and npm to fetch the command). If missing, don't fail
41
+ # cryptically: explain what Node is and how to get it for this OS, point at nodejs.org, and exit
42
+ # cleanly (0) so a piped run ends gracefully rather than with a scary non-zero error.
43
+ if ! command -v node >/dev/null 2>&1 || ! command -v "$NPM_BIN" >/dev/null 2>&1; then
44
+ os="$(uname -s 2>/dev/null || echo unknown)"
45
+ printf '\n'
46
+ say "ours needs Node.js (version 20 or newer, which includes npm) — it isn't installed yet."
47
+ say "Node.js is a common, free runtime; here's how to get it:"
48
+ case "$os" in
49
+ Darwin)
50
+ say " • macOS (Homebrew): brew install node"
51
+ say " • or nvm: https://github.com/nvm-sh/nvm then nvm install --lts" ;;
52
+ Linux)
53
+ say " • Debian/Ubuntu: https://github.com/nodesource/distributions (NodeSource)"
54
+ say " • or nvm: https://github.com/nvm-sh/nvm then nvm install --lts" ;;
55
+ *)
56
+ say " • Windows/WSL: install Node.js in WSL, or from https://nodejs.org" ;;
57
+ esac
58
+ say " • Or download the installer for any OS: https://nodejs.org"
59
+ say "Once Node.js is installed, re-run this command and you're set."
60
+ printf '\n'
61
+ exit 0
62
+ fi
63
+
64
+ # --- 2) DEV / LOCAL path: run the sibling install.mjs directly ---------------------------------
65
+ # From a clone (or an explicit OURS_INSTALLER_MJS), skip npm entirely and run the local installer.
66
+ MJS=""
67
+ if [ -n "${OURS_INSTALLER_MJS:-}" ] && [ -f "${OURS_INSTALLER_MJS}" ]; then
68
+ MJS="${OURS_INSTALLER_MJS}"
69
+ else
70
+ SELF="${BASH_SOURCE[0]:-$0}"
71
+ DIR="$(cd "$(dirname "$SELF")" 2>/dev/null && pwd || true)"
72
+ [ -n "$DIR" ] && [ -f "$DIR/install.mjs" ] && MJS="$DIR/install.mjs"
73
+ fi
74
+ if [ -n "$MJS" ]; then
75
+ exec node "$MJS" "$@"
76
+ fi
77
+
78
+ # --- 3) PIPED path (curl … | bash): install the command globally, then run it ------------------
79
+ # Canonical entry: install (or update) @ours.network/install, then invoke `ours-install`.
80
+ # Idempotent — a re-run updates to @latest and runs again.
81
+ say "installing the ours installer ($PKG)…"
82
+ if ! "$NPM_BIN" i -g "${PKG}@latest" >/dev/null 2>&1; then
83
+ say "couldn't install $PKG from npm. Check your connection (and npm permissions), then retry:"
84
+ say " $NPM_BIN i -g $PKG && ours-install"
85
+ exit 1
86
+ fi
87
+ if ! command -v ours-install >/dev/null 2>&1; then
88
+ say "installed $PKG but 'ours-install' isn't on your PATH. Add your npm global bin to PATH, then run: ours-install"
89
+ say " (npm bin -g shows the directory to add)"
90
+ exit 1
91
+ fi
92
+ exec ours-install "$@"
package/lib/logic.mjs ADDED
@@ -0,0 +1,192 @@
1
+ // Pure, dependency-free logic for the ours.network installer — everything here is a plain
2
+ // function with no I/O so it can be unit-tested directly (no subprocess, no tty). The install.mjs
3
+ // orchestrator wires these to real npm/ours-mcp/tty calls; the integration tests drive that via
4
+ // install.sh. Keeping the decisions here means the tricky bits (harness canon, port-conflict,
5
+ // config merge, version parsing) are covered by fast, hermetic tests.
6
+
7
+ // canonHarnesses: normalize a free-form selection (numbers, names, or "all") into canonical
8
+ // harness names, de-duped, order-preserving. Faithful port of install.sh's canon_harnesses.
9
+ // Returns { names: string[], unknown: string[] } — unknown tokens are reported, not fatal.
10
+ export function canonHarnesses(raw) {
11
+ const names = [];
12
+ const unknown = [];
13
+ const push = (n) => { if (!names.includes(n)) names.push(n); };
14
+ const toks = String(raw || '').toLowerCase().replace(/,/g, ' ').split(/\s+/).filter(Boolean);
15
+ for (const tok of toks) {
16
+ switch (tok) {
17
+ case 'all': case 'a':
18
+ return { names: ['claude-code', 'codex', 'hermes'], unknown };
19
+ case '1': case 'claude-code': case 'claude': case 'cc': push('claude-code'); break;
20
+ case '2': case 'codex': push('codex'); break;
21
+ case '3': case 'hermes': push('hermes'); break;
22
+ case 'none': case 'skip': case '0': break;
23
+ default: unknown.push(tok);
24
+ }
25
+ }
26
+ return { names, unknown };
27
+ }
28
+
29
+ // The Telegram connector owns 3051 — the installer must never hand a daemon that port.
30
+ export const RESERVED_PORTS = [3051];
31
+ export const DEFAULT_PORT = 3050;
32
+ export const DEFAULT_BROKER = 'wss://broker1.ours.network';
33
+
34
+ // suggestPort: pick a usable HTTP port. If `desired` is free and not reserved, keep it. Otherwise
35
+ // scan upward from 3060 (the brief's suggested alternate band) for the first free, non-reserved
36
+ // port. `isTaken(port)` is injected so this stays pure and testable (real caller probes a bind).
37
+ export function suggestPort(desired, isTaken, { reserved = RESERVED_PORTS, floor = 3060 } = {}) {
38
+ const taken = (p) => reserved.includes(p) || isTaken(p);
39
+ if (!taken(desired)) return desired;
40
+ for (let p = Math.max(floor, desired + 1); p < desired + 1000; p++) {
41
+ if (!taken(p)) return p;
42
+ }
43
+ return desired; // exhausted — let the caller surface it; never silently loop forever
44
+ }
45
+
46
+ // A port string is valid if it's an integer in the ephemeral-safe user range.
47
+ export function parsePort(input, fallback = DEFAULT_PORT) {
48
+ const n = Number.parseInt(String(input).trim(), 10);
49
+ if (!Number.isInteger(n) || n < 1 || n > 65535) return { ok: false, port: fallback };
50
+ return { ok: true, port: n };
51
+ }
52
+
53
+ // Basic sanity for a broker address: must look like a ws:// or wss:// URL. Empty → keep default
54
+ // (handled by caller). We don't hard-fail on odd input, just report so the caller can warn.
55
+ export function validateBroker(input) {
56
+ const s = String(input).trim();
57
+ if (!s) return { ok: true, value: '', empty: true };
58
+ const ok = /^wss?:\/\/[^\s]+$/i.test(s);
59
+ return { ok, value: s, empty: false };
60
+ }
61
+
62
+ // mergeConfig: take the existing parsed config.json object and a patch of only the keys the user
63
+ // changed, returning the pretty-printed strict-JSON text to write (stable key handling, trailing
64
+ // newline). Never drops unrelated keys the daemon or user added.
65
+ export function mergeConfig(existing, patch) {
66
+ const base = existing && typeof existing === 'object' ? existing : {};
67
+ const out = { ...base };
68
+ for (const [k, v] of Object.entries(patch)) {
69
+ if (v === undefined) continue;
70
+ out[k] = v;
71
+ }
72
+ return JSON.stringify(out, null, 2) + '\n';
73
+ }
74
+
75
+ // parseVersion: pull the first x.y.z out of a version string (e.g. `ours-mcp v0.9.9`), matching
76
+ // install.sh's `grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1`. Returns '' when none is present.
77
+ export function parseVersion(text) {
78
+ const m = String(text || '').match(/[0-9]+\.[0-9]+\.[0-9]+/);
79
+ return m ? m[0] : '';
80
+ }
81
+
82
+ // parseStatus: read the daemon's RESOLVED broker + port out of `ours-mcp status` output, so we
83
+ // prompt with what the daemon is actually using rather than a hardcoded guess. Returns
84
+ // { broker, port } with either field null when the line isn't present (daemon stopped / older
85
+ // build). Lines look like: " broker: wss://broker1.ours.network" and
86
+ // " url: http://localhost:3050/mcp (reachable)".
87
+ export function parseStatus(text) {
88
+ const s = String(text || '');
89
+ const bm = s.match(/^\s*broker:\s*(\S+)/m);
90
+ const pm = s.match(/url:\s*https?:\/\/[^:\s/]+:(\d+)/i);
91
+ return {
92
+ broker: bm ? bm[1] : null,
93
+ port: pm ? Number.parseInt(pm[1], 10) : null,
94
+ };
95
+ }
96
+
97
+ // ===============================================================================================
98
+ // v2 unified-installer logic (all pure — the orchestrator injects the real probe/uname/env).
99
+ // ===============================================================================================
100
+
101
+ // detectPlatform: classify the host from process.platform + the kernel release string + env.
102
+ // - darwin → macOS (supported)
103
+ // - linux + microsoft/WSL in release, or WSL_* env → WSL (supported)
104
+ // - linux → Linux (supported)
105
+ // - win32 → Windows (NOT supported in v1 — the caller prints a WSL pointer + exits)
106
+ // - anything else → unknown (unsupported)
107
+ // Returns { os, label, supported }.
108
+ export function detectPlatform({ platform, release = '', env = {} } = {}) {
109
+ const rel = String(release).toLowerCase();
110
+ const isWsl = !!(env.WSL_DISTRO_NAME || env.WSL_INTEROP) || /microsoft|wsl/.test(rel);
111
+ switch (platform) {
112
+ case 'darwin': return { os: 'macos', label: 'macOS', supported: true };
113
+ case 'linux':
114
+ return isWsl
115
+ ? { os: 'wsl', label: 'Windows (WSL)', supported: true }
116
+ : { os: 'linux', label: 'Linux', supported: true };
117
+ case 'win32': return { os: 'windows', label: 'Windows (native)', supported: false };
118
+ default: return { os: 'unknown', label: platform || 'unknown', supported: false };
119
+ }
120
+ }
121
+
122
+ // classifyHarnessProbe: turn what we observed about a harness command into a safety verdict,
123
+ // WITHOUT ever having called it unsafely. Inputs (all gathered by the orchestrator):
124
+ // onPath — `command -v <name>` found an executable on PATH
125
+ // versionOk — a non-interactive `<name> --version` returned 0 promptly with sane output
126
+ // timedOut — that probe had to be killed (an interactive/hanging wrapper — NEVER call it)
127
+ // shellType — `type -t <name>` in the user's shell: 'alias' | 'function' | 'file' | ''
128
+ // Verdict.status:
129
+ // 'ok' — a real binary that answers --version → safe to drive
130
+ // 'alias' — shadowed by a shell alias/function (or a wrapper that hangs) → do NOT call it,
131
+ // tell the user plainly + how to fix, and ALWAYS still offer a manual path
132
+ // 'unsafe' — on PATH but the probe failed/looked wrong → don't auto-drive; offer manual path
133
+ // 'absent' — genuinely not installed → this harness is skipped (with a note)
134
+ // The golden rule (owner edit #3): 'alias'/'unsafe'/'absent' NEVER dead-end — the caller always
135
+ // prints a manual-install path so the component still gets installed.
136
+ export function classifyHarnessProbe({ onPath, versionOk, timedOut, shellType = '' } = {}) {
137
+ if (versionOk) return { status: 'ok', detail: 'real program' };
138
+ if (timedOut) return { status: 'alias', detail: 'a wrapper that did not answer --version' };
139
+ const t = String(shellType).toLowerCase();
140
+ if (t === 'alias' || t === 'function') {
141
+ return { status: 'alias', detail: `a shell ${t}, not the real command` };
142
+ }
143
+ if (onPath) return { status: 'unsafe', detail: 'found, but did not answer --version' };
144
+ return { status: 'absent', detail: 'not installed' };
145
+ }
146
+
147
+ // harnessAvailable: a harness we can safely DRIVE headlessly right now.
148
+ export function harnessAvailable(status) { return status === 'ok'; }
149
+
150
+ // buildHandoffPrompt: the literal copy-paste hand-off text (delta #1861). Steps for components
151
+ // that were NOT installed drop out and the remaining steps renumber, so the user never sees an
152
+ // instruction for a piece they don't have. The human identity is normally created DURING install,
153
+ // so its step is included ONLY as a fallback (identity: true) when in-install creation was skipped
154
+ // or failed. Returns { text, empty } — empty is true when there is nothing left to finish.
155
+ export function buildHandoffPrompt({ identity = false, fleet = false, telegram = false } = {}) {
156
+ const steps = [];
157
+ if (identity) {
158
+ steps.push(
159
+ 'Create my Ours human identity — this is me, the human; my agents act on\n' +
160
+ ' my behalf. Ask me what name others should see, then create it.',
161
+ );
162
+ }
163
+ if (fleet) {
164
+ steps.push(
165
+ "Set up ours-fleet: confirm it's ready and show me how to spawn a\n" +
166
+ ' temporary agent, then spawn one so I can see it.',
167
+ );
168
+ }
169
+ if (telegram) {
170
+ steps.push(
171
+ 'Set up my Telegram bot: ask me for my bot\'s name and its token from\n' +
172
+ ' @BotFather, register the bot, create a chat↔agent connection, and\n' +
173
+ ' give me the invite link to send.',
174
+ );
175
+ }
176
+ if (steps.length === 0) return { text: '', empty: true };
177
+ const numbered = steps.map((s, i) => `${i + 1}. ${s}`).join('\n');
178
+ const text =
179
+ 'I just installed the ours.network stack. Please help me finish setup, one\n' +
180
+ 'step at a time, explaining as you go:\n\n' +
181
+ numbered + '\n\n' +
182
+ 'Do these in order, wait for my answers, and tell me if you need anything\n' +
183
+ "from me. Don't assume — ask.";
184
+ return { text, empty: false };
185
+ }
186
+
187
+ // summarizeComponent: normalize one component's outcome into a summary row the final screen and
188
+ // the report share. state ∈ 'installed'|'skipped'|'failed'|'current'. Pure formatting only.
189
+ export function summarizeComponent({ key, label, state, version = '', note = '' }) {
190
+ const mark = state === 'failed' ? '✗' : state === 'skipped' ? '·' : '✓';
191
+ return { key, label, state, version, note, mark };
192
+ }
package/lib/prompt.mjs ADDED
@@ -0,0 +1,139 @@
1
+ // Interactive prompts drawn on the controlling terminal (/dev/tty), so they work under
2
+ // `curl | bash` (where stdin/stdout are the pipe). Synchronous, dependency-free: line prompts via
3
+ // fs.readSync, and a raw-mode checkbox multi-select. When there is no tty or OURS_ASSUME_YES is
4
+ // set, every prompt returns its default without reading — the headless/CI path never blocks.
5
+ import { readSync } from 'node:fs';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { c } from './ui.mjs';
8
+
9
+ const ASSUME_YES = () => !!process.env.OURS_ASSUME_YES;
10
+
11
+ // A Ctrl+C at any prompt must abort cleanly — never the old "^C^C^C… and keeps going" bug. Two
12
+ // things conspire: (1) the process must have a SIGINT handler (installed by the orchestrator) so
13
+ // Node doesn't just hard-kill, and (2) that handler can't run while a synchronous fs.readSync
14
+ // blocks the event loop. So readByte turns an interrupted read (EINTR) — and a literal Ctrl+C byte
15
+ // 0x03, in case the tty's signal generation is off — into this sentinel error, which unwinds the
16
+ // blocked read; the orchestrator catches it (or its own SIGINT handler fires) and exits(130).
17
+ export class InstallCancelled extends Error {
18
+ constructor() { super('SIGINT'); this.code = 'OURS_SIGINT'; }
19
+ }
20
+ export const isCancel = (e) => !!e && e.code === 'OURS_SIGINT';
21
+
22
+ // Read raw bytes one at a time from the tty fd. Returns the byte (0-255) or null at EOF/error.
23
+ function readByte(fd) {
24
+ const buf = Buffer.alloc(1);
25
+ try {
26
+ const n = readSync(fd, buf, 0, 1, null);
27
+ return n === 1 ? buf[0] : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ // askLine: print `prompt` and read a line. We read in RAW mode with signal generation OFF (stty
34
+ // -icanon -echo -isig) so Ctrl+C arrives as the byte 0x03 that we turn into a clean InstallCancelled
35
+ // — rather than a SIGINT that a blocked synchronous readSync would defer forever (the old
36
+ // "^C^C^C… and keeps going" bug). We echo/backspace ourselves since -echo is off. If stty isn't
37
+ // available (not a real tty), fall back to a plain cooked read. Throws InstallCancelled on Ctrl+C.
38
+ export function askLine(write, fd, prompt, def = '') {
39
+ if (fd == null || ASSUME_YES()) return def;
40
+ write(prompt);
41
+
42
+ const saved = spawnSync('stty', ['-g'], { stdio: [fd, 'pipe', 'ignore'], encoding: 'utf8' });
43
+ const rawOk = saved.status === 0
44
+ && spawnSync('stty', ['-icanon', '-echo', '-isig', 'min', '1', 'time', '0'], { stdio: [fd, 'ignore', 'ignore'] }).status === 0;
45
+ const restore = () => { if (rawOk) spawnSync('stty', (saved.stdout || '').trim() ? [(saved.stdout || '').trim()] : ['sane'], { stdio: [fd, 'ignore', 'ignore'] }); };
46
+
47
+ let s = '';
48
+ try {
49
+ for (;;) {
50
+ const b = readByte(fd);
51
+ if (b === null || b === 0x04) break; // EOF / Ctrl+D → take the default
52
+ if (b === 0x03) { restore(); write('^C'); throw new InstallCancelled(); } // Ctrl+C
53
+ if (b === 0x0a || b === 0x0d) { if (rawOk) write('\n'); break; } // Enter
54
+ if (b === 0x7f || b === 0x08) { if (s.length) { s = s.slice(0, -1); if (rawOk) write('\b \b'); } continue; } // backspace
55
+ if (b < 0x20) continue; // ignore other control bytes
56
+ s += String.fromCharCode(b);
57
+ if (rawOk) write(String.fromCharCode(b)); // manual echo
58
+ }
59
+ } finally {
60
+ restore();
61
+ }
62
+ const ans = s.trim();
63
+ return ans === '' ? def : ans;
64
+ }
65
+
66
+ // askYesNo: y/n with a default shown in caps. Returns boolean.
67
+ export function askYesNo(write, fd, prompt, def = false) {
68
+ if (fd == null || ASSUME_YES()) return def;
69
+ const hint = def ? '[Y/n]' : '[y/N]';
70
+ const ans = askLine(write, fd, `${prompt} ${hint} `, def ? 'y' : 'n');
71
+ return /^y/i.test(ans);
72
+ }
73
+
74
+ // checkboxSelect: raw-mode multi-select drawn on the tty. ↑/↓ (or k/j) move, Space toggles, Enter
75
+ // confirms, a/n = all/none, q cancels (selects nothing). Returns the selected names in order.
76
+ // `specs` are { name, label } (optionally { checked }). No-tty → returns pre-checked defaults.
77
+ export function checkboxSelect(write, fd, specs, { title } = {}) {
78
+ const names = specs.map((s) => s.name);
79
+ const labels = specs.map((s) => s.label);
80
+ const sel = specs.map((s) => (s.checked ? 1 : 0));
81
+ const n = specs.length;
82
+ const chosen = () => names.filter((_, i) => sel[i] === 1);
83
+
84
+ if (fd == null) return chosen();
85
+
86
+ // Enter raw mode via `stty` on the tty fd (NOT tty.ReadStream.setRawMode, which would flip the
87
+ // fd to non-blocking and break our fs.readSync). Save the current settings and restore them
88
+ // after. If stty is unavailable / this isn't a real tty, fall back to the pre-checked defaults.
89
+ const saved = spawnSync('stty', ['-g'], { stdio: [fd, 'pipe', 'ignore'], encoding: 'utf8' });
90
+ if (saved.status !== 0) return chosen();
91
+ const savedMode = (saved.stdout || '').trim();
92
+ const setRaw = spawnSync('stty', ['-echo', '-icanon', 'min', '1', 'time', '0'], { stdio: [fd, 'ignore', 'ignore'] });
93
+ if (setRaw.status !== 0) return chosen();
94
+ const restore = () => spawnSync('stty', savedMode ? [savedMode] : ['sane'], { stdio: [fd, 'ignore', 'ignore'] });
95
+
96
+ let cur = 0;
97
+ let drawn = 0;
98
+
99
+ const redraw = () => {
100
+ if (drawn) write(`\x1b[${n}A`); // rewind over the previous frame
101
+ drawn = 1;
102
+ for (let i = 0; i < n; i++) {
103
+ const box = sel[i] ? c.green('[x]') : '[ ]';
104
+ const point = i === cur ? c.cyan('> ') : ' ';
105
+ write(`\r\x1b[K ${point}${box} ${labels[i]}\n`);
106
+ }
107
+ };
108
+
109
+ write(` ${title || 'Choose — ' + c.bold('↑/↓') + ' move, ' + c.bold('Space') + ' toggle, ' + c.bold('Enter') + ' confirm (a=all, n=none)'}:\n`);
110
+ redraw();
111
+
112
+ loop: for (;;) {
113
+ const b = readByte(fd);
114
+ if (b === null) break;
115
+ switch (b) {
116
+ case 0x1b: { // ESC — arrow key sequence \x1b [ A/B
117
+ const b1 = readByte(fd);
118
+ if (b1 === 0x5b || b1 === 0x4f) { // '[' or 'O'
119
+ const b2 = readByte(fd);
120
+ if (b2 === 0x41) cur = (cur - 1 + n) % n; // up
121
+ else if (b2 === 0x42) cur = (cur + 1) % n; // down
122
+ }
123
+ break;
124
+ }
125
+ case 0x6b: case 0x4b: cur = (cur - 1 + n) % n; break; // k/K
126
+ case 0x6a: case 0x4a: cur = (cur + 1) % n; break; // j/J
127
+ case 0x20: sel[cur] = 1 - sel[cur]; break; // space
128
+ case 0x61: case 0x41: for (let i = 0; i < n; i++) sel[i] = 1; break; // a/A
129
+ case 0x6e: case 0x4e: for (let i = 0; i < n; i++) sel[i] = 0; break; // n/N
130
+ case 0x71: case 0x51: for (let i = 0; i < n; i++) sel[i] = 0; break loop; // q/Q cancel
131
+ case 0x0d: case 0x0a: break loop; // enter
132
+ default: break;
133
+ }
134
+ redraw();
135
+ }
136
+
137
+ restore();
138
+ return chosen();
139
+ }
package/lib/ui.mjs ADDED
@@ -0,0 +1,126 @@
1
+ // Shared UI toolkit for the ours.network Node installer + uninstaller: colors that degrade
2
+ // gracefully, the ASCII banner, and /dev/tty-aware prompts (so `curl | bash` still works and
3
+ // true-headless runs fall back to env vars). No dependencies — pure Node, ESM.
4
+ import { openSync, closeSync, writeSync } from 'node:fs';
5
+
6
+ // --- colors -----------------------------------------------------------------------------------
7
+ // Enabled only for a real terminal and when NO_COLOR is unset (https://no-color.org). Everything
8
+ // routes through c()/style helpers so a no-color run is plain ASCII, never raw escape codes.
9
+ const COLOR = !process.env.NO_COLOR && (process.env.OURS_FORCE_COLOR === '1' || process.stdout.isTTY);
10
+ const E = {
11
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
12
+ cyan: '\x1b[36m', green: '\x1b[32m', yellow: '\x1b[33m',
13
+ red: '\x1b[31m', blue: '\x1b[34m', magenta: '\x1b[35m', gray: '\x1b[90m',
14
+ };
15
+ function wrap(code, s) { return COLOR ? code + s + E.reset : String(s); }
16
+ export const c = {
17
+ bold: (s) => wrap(E.bold, s), dim: (s) => wrap(E.dim, s), gray: (s) => wrap(E.gray, s),
18
+ cyan: (s) => wrap(E.cyan, s), green: (s) => wrap(E.green, s), yellow: (s) => wrap(E.yellow, s),
19
+ red: (s) => wrap(E.red, s), blue: (s) => wrap(E.blue, s), magenta: (s) => wrap(E.magenta, s),
20
+ };
21
+
22
+ // The theme's gradient ramp (256-color cyan → blue → violet), one shade per banner row. Plain
23
+ // text under NO_COLOR — same degrade contract as c.*.
24
+ const GRAD = [51, 45, 39, 33, 63, 99];
25
+ const grad = (s, i) => (COLOR ? `\x1b[38;5;${GRAD[Math.min(i, GRAD.length - 1)]}m${s}\x1b[0m` : String(s));
26
+
27
+ // --- banner -----------------------------------------------------------------------------------
28
+ // The block-letter "ours" logo with a top-to-bottom gradient, the wordmark, a mesh accent and
29
+ // the website tagline. Straight monospace blocks (no slanted ASCII art — that reads crooked),
30
+ // kept well under 80 columns so it never wraps on a default terminal. Plain under NO_COLOR.
31
+ export function banner() {
32
+ const logo = [
33
+ ' ██████ ██ ██ ██████ ███████',
34
+ '██ ██ ██ ██ ██ ██ ██ ',
35
+ '██ ██ ██ ██ ██████ ███████',
36
+ '██ ██ ██ ██ ██ ██ ██',
37
+ ' ██████ ██████ ██ ██ ███████',
38
+ ];
39
+ const dot = c.gray('·');
40
+ const mesh = `${c.cyan('◇')}${dot}${c.cyan('◇')}${dot}${c.cyan('◇')}`;
41
+ const out = [];
42
+ out.push('');
43
+ logo.forEach((l, i) => out.push(' ' + grad(l, i)));
44
+ out.push('');
45
+ out.push(' ' + c.bold(c.cyan('ours')) + c.gray('.network') + ' ' + mesh);
46
+ out.push(' ' + c.dim('The space where humans and AI agents collaborate'));
47
+ out.push('');
48
+ return out.join('\n');
49
+ }
50
+
51
+ // --- section + status helpers -----------------------------------------------------------------
52
+ // Sections read as ruled chapter headers: `── 3 · broker address ────────…` (fixed overall
53
+ // width so the rules line up down the whole run).
54
+ const SECTION_WIDTH = 64;
55
+ export function section(n, title) {
56
+ const rule = '─'.repeat(Math.max(2, SECTION_WIDTH - `── ${n} · ${title} `.length));
57
+ return '\n' + c.cyan('──') + ' ' + c.bold(c.cyan(String(n))) + c.gray(' · ') + c.bold(title) + ' ' + c.gray(rule);
58
+ }
59
+ export function heading(title) { return '\n' + c.bold(c.cyan('◆ ' + title)); }
60
+ export const ok = (s) => ` ${c.green('✓')} ${s}`;
61
+ export const step = (s) => ` ${c.dim('…')} ${s}`;
62
+ export const info = (s) => ` ${c.gray('•')} ${s}`;
63
+ export const warn = (s) => ` ${c.yellow('!')} ${s}`;
64
+ export const why = (s) => ` ${c.gray('why: ' + s)}`;
65
+
66
+ // --- framed panel -------------------------------------------------------------------------------
67
+ // A boxed block for the one moment that must stand out (the post-first-install next steps).
68
+ // Content lines must be PLAIN strings (no ANSI) so the width math stays honest — only the frame
69
+ // is coloured, which degrades to plain box-drawing under NO_COLOR.
70
+ export function box(lines, title = '') {
71
+ const inner = Math.max(...lines.map((l) => l.length), title.length + 2) + 2;
72
+ const bar = (s) => c.cyan(s);
73
+ const top = title
74
+ ? bar('┌─') + ' ' + c.bold(title) + ' ' + bar('─'.repeat(inner - title.length - 3) + '┐')
75
+ : bar('┌' + '─'.repeat(inner) + '┐');
76
+ const row = (l) => bar('│') + ' ' + l + ' '.repeat(inner - l.length - 1) + bar('│');
77
+ const bottom = bar('└' + '─'.repeat(inner) + '┘');
78
+ return [top, ...lines.map(row), bottom].map((l) => ' ' + l).join('\n');
79
+ }
80
+
81
+ // --- spinner ------------------------------------------------------------------------------------
82
+ // Animate a braille spinner next to `label` while the (async) thunk runs, then clear the line so
83
+ // the caller prints the real ✓/! outcome. Animation only on a real color tty — a piped or
84
+ // NO_COLOR run gets one static `… label` line instead, so logs and tests stay clean.
85
+ export async function withSpinner(label, thunk) {
86
+ const animate = COLOR && !!process.stdout.isTTY;
87
+ if (!animate) {
88
+ process.stdout.write(` … ${label}\n`);
89
+ return thunk();
90
+ }
91
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
92
+ let i = 0;
93
+ const draw = () => process.stdout.write(`\r\x1b[K ${c.cyan(frames[i = (i + 1) % frames.length])} ${label}`);
94
+ draw();
95
+ const timer = setInterval(draw, 80);
96
+ try {
97
+ return await thunk();
98
+ } finally {
99
+ clearInterval(timer);
100
+ process.stdout.write('\r\x1b[K');
101
+ }
102
+ }
103
+
104
+ // --- /dev/tty-aware prompting ------------------------------------------------------------------
105
+ // Probe by OPENING /dev/tty read-write: the device node is world-rw, so a permission check passes
106
+ // even with no controlling terminal (headless/CI). open() fails (ENXIO) when there is genuinely no
107
+ // terminal — only then do we truly have no tty. Mirrors install.sh's bash probe exactly.
108
+ export function openTty() {
109
+ try {
110
+ const fd = openSync('/dev/tty', 'r+');
111
+ return fd;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ // Write to the tty (falls back to stdout if no tty fd). Used for prompts drawn on the controlling
118
+ // terminal so they survive `curl | bash` (where stdout may be the far end of a pipe). We use a
119
+ // blocking fs.writeSync — NOT a tty.WriteStream — because wrapping the fd in a tty stream flips it
120
+ // to non-blocking, which would make our fs.readSync-based prompt reads throw EAGAIN.
121
+ export function makeWriter(ttyFd) {
122
+ if (ttyFd == null) return (s) => process.stdout.write(s);
123
+ return (s) => { try { writeSync(ttyFd, s); } catch { /* ignore */ } };
124
+ }
125
+
126
+ export { closeSync };
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@ours.network/install",
3
+ "version": "0.11.0",
4
+ "private": false,
5
+ "description": "The unified ours.network stack installer (ours-install): one guided ~3-minute flow for ours core (the daemon) + the harness plugins (Claude Code / Codex) + ours-fleet + the Telegram connector, then a single copy-paste hand-off prompt. Self-contained (Node built-ins only); run as `ours-install` or via curl|bash (install.sh).",
6
+ "type": "module",
7
+ "bin": {
8
+ "ours-install": "install.mjs"
9
+ },
10
+ "files": [
11
+ "install.mjs",
12
+ "install.sh",
13
+ "lib",
14
+ "uninstall.mjs",
15
+ "uninstall.sh",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "license": "FSL-1.1-Apache-2.0",
20
+ "author": "Adapt Toolkit",
21
+ "homepage": "https://github.com/adapt-toolkit/ours-mcp/tree/main/packages/installer#readme",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/adapt-toolkit/ours-mcp.git",
25
+ "directory": "packages/installer"
26
+ },
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "scripts": {
31
+ "test": "node --test"
32
+ }
33
+ }