@ours.network/install 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +98 -0
- package/README.md +136 -0
- package/install.mjs +604 -0
- package/install.sh +92 -0
- package/lib/logic.mjs +192 -0
- package/lib/prompt.mjs +139 -0
- package/lib/ui.mjs +126 -0
- package/package.json +33 -0
- package/uninstall.mjs +198 -0
- package/uninstall.sh +71 -0
package/install.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
|
+
}
|