@ours.network/install 0.17.0-nightly.9 → 0.17.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/lib/effects.mjs DELETED
@@ -1,331 +0,0 @@
1
- // ours-install v3 — the real side effects.
2
- //
3
- // Every mutation the installer can perform lives here and nowhere else, behind
4
- // the same contract lib/orchestrate.mjs is tested against. Keeping them in one
5
- // small file is the point: it is the only place to audit for "does this touch
6
- // the machine", and it is what makes the fake used in the tests a faithful
7
- // stand-in rather than an approximation.
8
- //
9
- // NOTE: nothing here runs systemctl. systemd is reached ONLY through
10
- // `ours daemon install-service`, which owns the marker check, the baked
11
- // state-directory guard and the enable/reload. The installer never touches a
12
- // unit file or the service manager directly.
13
-
14
- import { spawnSync, execFileSync } from 'node:child_process';
15
- import { existsSync, readFileSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
- import { homedir, userInfo, platform as osPlatform, release as osRelease } from 'node:os';
17
- import { dirname, join, resolve } from 'node:path';
18
- import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
19
- import { askYesNo, askLine as askLineOnTty } from './prompt.mjs';
20
- import { classifyHarnessProbe } from './logic.mjs';
21
-
22
- /** GET http://127.0.0.1:<port>/state-dir — the unauthenticated identity probe. */
23
- async function probePort(port, { timeoutMs = 1500 } = {}) {
24
- const controller = new AbortController();
25
- const timer = setTimeout(() => controller.abort(), timeoutMs);
26
- try {
27
- const res = await fetch(`http://127.0.0.1:${port}/state-dir`, { signal: controller.signal });
28
- if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };
29
- const body = await res.json();
30
- if (typeof body?.stateDir !== 'string') return { ok: false, reason: 'no stateDir in reply' };
31
- return { ok: true, stateDir: body.stateDir };
32
- } catch (error) {
33
- return { ok: false, reason: error?.name === 'AbortError' ? 'timed out' : String(error?.message ?? error) };
34
- } finally {
35
- clearTimeout(timer);
36
- }
37
- }
38
-
39
- /**
40
- * Is this port bound? Probed in a throwaway child so a bind attempt cannot leave
41
- * a listener behind in this process — the same technique the existing installer
42
- * uses (install.mjs portTakenSync).
43
- */
44
- function portTakenSync(port) {
45
- const src = `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))});`;
46
- return spawnSync(process.execPath, ['-e', src], { stdio: 'ignore' }).status === 3;
47
- }
48
-
49
- function readJsonFile(path) {
50
- try {
51
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
52
- return parsed && typeof parsed === 'object' ? parsed : null;
53
- } catch {
54
- return null;
55
- }
56
- }
57
-
58
- function readTextFile(path) {
59
- try {
60
- return readFileSync(path, 'utf8');
61
- } catch {
62
- return null;
63
- }
64
- }
65
-
66
- function installedVersionOf(pkg) {
67
- try {
68
- const out = execFileSync('npm', ['ls', '-g', '--depth', '0', '--json', pkg], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
69
- return JSON.parse(out)?.dependencies?.[pkg]?.version ?? null;
70
- } catch {
71
- // Unreadable is NOT "new enough": the cowork gate fails closed on null.
72
- return null;
73
- }
74
- }
75
-
76
- /**
77
- * A read-only command probe that NEVER throws and NEVER inherits stdio.
78
- *
79
- * Separate from `run` on purpose. `run` is for mutations and throws on a
80
- * non-zero exit, because a failed mutation is news. Detection is the opposite:
81
- * a non-zero exit IS the answer, and a hung wrapper must be killed rather than
82
- * waited on. Mixing the two would mean either detection crashes a run or a
83
- * failed install passes silently.
84
- */
85
- function capture(cmd, args, { timeout, env = process.env } = {}) {
86
- const r = spawnSync(cmd, args, { encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'], env });
87
- const timedOut = !!(r.error && (r.error.code === 'ETIMEDOUT' || r.signal === 'SIGTERM'));
88
- return {
89
- ok: !r.error && r.status === 0,
90
- code: r.status ?? -1,
91
- stdout: r.stdout ?? '',
92
- stderr: r.stderr ?? '',
93
- timedOut,
94
- };
95
- }
96
-
97
- // The two harnesses that are DRIVEN CLIs. The `name` is the one lib/extras.mjs
98
- // plans against; the `command` is what actually lives on PATH.
99
- export const DRIVEN_HARNESSES = [
100
- { name: 'claude-code', command: 'claude', label: 'Claude Code' },
101
- { name: 'codex', command: 'codex', label: 'Codex' },
102
- ];
103
-
104
- /**
105
- * Alias-safety, unchanged from v2: three read-only observations, then the pure
106
- * classifier decides. The harness is NEVER called in a way that can hang —
107
- * `--version` is spawned directly (no shell, so a real PATH binary) under a hard
108
- * timeout, and the shell `type` lookup is timeout-guarded too.
109
- */
110
- function detectDrivenHarness({ name, command, label }, env) {
111
- const onPath = capture('bash', ['-c', `command -v ${command}`], { env }).ok;
112
- const probe = capture(command, ['--version'], { timeout: 6000, env });
113
- const versionOk = probe.ok && /\d+\.\d+/.test(probe.stdout);
114
- const shell = env.SHELL || '/bin/bash';
115
- const typeProbe = capture(shell, ['-ic', `type -t ${command} 2>/dev/null`], { timeout: 4000, env });
116
- const verdict = classifyHarnessProbe({
117
- onPath, versionOk, timedOut: probe.timedOut, shellType: (typeProbe.stdout || '').trim(),
118
- });
119
- return { name, command, label, ...verdict };
120
- }
121
-
122
- /**
123
- * Hermes is detected DIFFERENTLY, and it is not an inconsistency. Its ours
124
- * plugin never calls a `hermes` binary — `ours-hermes-install` writes
125
- * ~/.hermes/config.yaml and the skills — so "can we drive it?" is the wrong
126
- * question. Per the plugin's own prerequisites, presence IS the config
127
- * directory. The CLI probe still runs, purely to enrich detection.
128
- */
129
- function detectHermesHarness(env, home) {
130
- const dir = env.HERMES_DIR || join(home, '.hermes');
131
- const dirPresent = existsSync(dir);
132
- const cli = detectDrivenHarness({ name: 'hermes', command: 'hermes', label: 'Hermes' }, env);
133
- return {
134
- name: 'hermes',
135
- command: 'hermes',
136
- label: 'Hermes',
137
- status: dirPresent || cli.status === 'ok' ? 'ok' : 'absent',
138
- detail: dirPresent ? `config dir ${dir} present` : cli.detail,
139
- };
140
- }
141
-
142
- /**
143
- * Best-effort clipboard copy (pbcopy / wl-copy / xclip / clip.exe). The hard
144
- * timeout is load-bearing: xclip holds the selection and would otherwise keep
145
- * the installer alive after its own summary.
146
- */
147
- function copyToClipboard(text) {
148
- const tools = [['pbcopy', []], ['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['clip.exe', []]];
149
- for (const [bin, args] of tools) {
150
- try {
151
- const r = spawnSync(bin, args, { input: text, timeout: 2000 });
152
- if (!r.error && (r.status === 0 || r.status == null)) return true;
153
- } catch { /* try the next one */ }
154
- }
155
- return false;
156
- }
157
-
158
- /**
159
- * Every state directory on this machine that still has a daemon config.
160
- *
161
- * `ours-uninstall` asks this exactly once, to answer one question: are the
162
- * GLOBAL packages still needed by somebody else? Getting it wrong the optimistic
163
- * way (reporting none) uninstalls the CLI out from under a second daemon that is
164
- * still running, so the search is deliberately conservative — it looks only where
165
- * a state directory can actually be, and an unreadable home means "there might be
166
- * others", not "there are none".
167
- *
168
- * Where they can be: the default `~/.ours`, plus any `~/.ours*` sibling, which is
169
- * the shape every other part of this installer uses for a second daemon. A state
170
- * directory somewhere else entirely will not be found, and that is a KNOWN limit
171
- * rather than a claim — the failure is keeping a global package that could have
172
- * been removed, which is the harmless direction.
173
- */
174
- function knownStateDirsIn(home) {
175
- const found = [];
176
- const consider = (dir) => { if (existsSync(join(dir, 'config.json'))) found.push(dir); };
177
- consider(join(home, '.ours'));
178
- try {
179
- for (const entry of readdirSync(home, { withFileTypes: true })) {
180
- if (!entry.isDirectory() || !entry.name.startsWith('.ours') || entry.name === '.ours') continue;
181
- consider(join(home, entry.name));
182
- }
183
- } catch { /* an unreadable home is not evidence that there are no others */ }
184
- return found;
185
- }
186
-
187
- /**
188
- * Build the real effects. `write` and `ttyFd` come from the caller's UI layer so
189
- * the orchestrator never reaches for a terminal itself.
190
- */
191
- export function realEffects({ write, ttyFd, env = process.env, home = homedir(), out, version = null } = {}) {
192
- return {
193
- home,
194
- env,
195
- version,
196
- // Preflight reads the machine rather than asking the orchestrator to.
197
- platform: { platform: osPlatform(), release: osRelease() },
198
- nodeVersion: process.versions.node,
199
- exists: (path) => existsSync(path),
200
- knownStateDirs: () => knownStateDirsIn(home),
201
- // The only irreversible effect in this package, and the reason it takes no
202
- // pattern and no parent: the caller passes ONE resolved directory that the
203
- // pure planner already gated four ways, and this deletes exactly that.
204
- removeDir: (path) => { rmSync(resolve(path), { recursive: true, force: true }); },
205
- removeFile: (path) => { rmSync(resolve(path), { force: true }); },
206
- // Rewrites a config file we do NOT own, so it keeps the file's own mode
207
- // rather than imposing 0600: tightening the permissions of somebody else's
208
- // ~/.codex/config.toml is a side effect nobody asked this to have.
209
- //
210
- // DO NOT "IMPROVE" THIS TO 0600. It looks like a security improvement, which
211
- // is exactly why someone will try — but this file is the operator's, not
212
- // ours, and the only thing we were invited to do to it is remove our own
213
- // block. Changing its mode on the way past is an uninvited change to a file
214
- // we happened to be holding, and a tool that does that once is a tool you
215
- // cannot let near your configs.
216
- writeText: (path, text) => {
217
- const mode = (() => { try { return statSync(path).mode & 0o777; } catch { return 0o644; } })();
218
- const temp = `${path}.tmp-${process.pid}`;
219
- writeFileSync(temp, text, { encoding: 'utf8', mode });
220
- renameSync(temp, path);
221
- },
222
- username: () => { try { return userInfo().username || 'me'; } catch { return 'me'; } },
223
- detectHarnesses: () => [
224
- ...DRIVEN_HARNESSES.map((h) => detectDrivenHarness(h, env)),
225
- detectHermesHarness(env, home),
226
- ],
227
- clipboard: (text) => copyToClipboard(text),
228
- brokerUrl: env.OURS_BROKER_URL ?? 'wss://broker1.ours.network',
229
- now: () => Date.now(),
230
- probe: (port) => probePort(port),
231
- isTaken: (port) => portTakenSync(port),
232
- readJson: readJsonFile,
233
- readText: readTextFile,
234
- writeJson: (path, text) => {
235
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
236
- atomicWriteConfig(path, text);
237
- },
238
- // The two halves of a config rollback (lib/journal.mjs). Deliberately the
239
- // SAME pair the nightly installer uses, from lib/config.mjs, rather than a
240
- // second implementation: `snapshot` records bytes and mode, and `restore`
241
- // either writes those bytes back at their original mode or DELETES a file
242
- // that did not exist before this run. Both are ordinary reads and writes of a
243
- // file this installer was already writing — no new class of side effect
244
- // enters the package here.
245
- snapshot: (path) => snapshotConfig(path),
246
- restore: (path, snapshot) => restoreConfig(path, snapshot),
247
- // `extraEnv` is the daemon pair (see daemonEnv). It is applied to THIS
248
- // invocation only and never to the installer's own process: a state
249
- // directory selected by one run must not leak into anything the operator
250
- // starts afterwards.
251
- run: async (cmd, args, { env: extraEnv = null } = {}) => {
252
- // Always built from this layer's OWN env rather than left to spawnSync's
253
- // implicit inheritance, so what a child receives is a property of the
254
- // effects object a caller constructed and not of whatever ambient shell
255
- // the installer happened to start in.
256
- const childEnv = { ...env, ...(extraEnv ?? {}) };
257
- const r = spawnSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: childEnv });
258
- if (r.status !== 0) {
259
- const detail = (r.stderr || r.stdout || '').trim().split('\n').slice(-3).join('; ');
260
- throw new Error(`${cmd} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
261
- }
262
- return { ok: true, code: r.status, stdout: r.stdout ?? '' };
263
- },
264
- // The ONE invocation that must keep the user's terminal: `ours-mcp
265
- // voice-setup` is an interactive command with its own masked prompts, and
266
- // piping its stdio would hang it forever waiting on input nobody can type.
267
- // It is otherwise the same contract as `run` — including the environment,
268
- // so an interactive command reaches the same daemon a piped one would.
269
- runInteractive: async (cmd, args, { env: extraEnv = null } = {}) => {
270
- const r = spawnSync(cmd, args, { stdio: 'inherit', env: { ...env, ...(extraEnv ?? {}) } });
271
- return { ok: !r.error && r.status === 0, code: r.status ?? -1 };
272
- },
273
- installedVersion: installedVersionOf,
274
- out: out ?? ((line) => process.stdout.write(`${line}\n`)),
275
- // Never called when assumeYes: the orchestrator takes the default itself.
276
- ask: async (prompt, def = false) => (ttyFd == null ? def : askYesNo(write, ttyFd, ` ${prompt} `, def)),
277
- askLine: async (prompt, def = '') => (ttyFd == null ? def : askLineOnTty(write, ttyFd, ` ${prompt} `, def)),
278
- };
279
- }
280
-
281
- export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, installedVersionOf };
282
-
283
- // -----------------------------------------------------------------------------
284
- // THE PAIR
285
- // -----------------------------------------------------------------------------
286
-
287
- /**
288
- * The environment that names ONE daemon, for a single child invocation.
289
- *
290
- * Spec §2's rule is that a state directory and an endpoint always travel
291
- * together; "endpoint selected, state directory defaulted" must be unreachable.
292
- * Every consumer downstream — ours-mcp's proxy, ours-fleet's per-role resolver,
293
- * ours-hermes-install — reads these three names and falls back to `~/.ours` for
294
- * whichever one is missing. So a HALF pair does not fail: it silently attaches
295
- * to the default daemon while the operator was told a different one was chosen.
296
- *
297
- * That is why this is a function and not three assignments at the call sites.
298
- * There is exactly one place a daemon environment can be built, it takes both
299
- * halves as arguments, and it refuses rather than emit a partial one.
300
- *
301
- * The three names, not two, are deliberate: OURS_CONFIG alone would leave the
302
- * port to whatever config.json happens to say, which is exactly the stale-file
303
- * divergence lib/target.mjs's second lookup exists to survive.
304
- */
305
- export const DAEMON_ENV_KEYS = ['OURS_CONFIG', 'OURS_STATE_DIR', 'OURS_PORT'];
306
-
307
- export function daemonEnv(stateDir, port) {
308
- const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
309
- if (!dir) throw new Error('daemonEnv requires a state directory: refusing to build half of the daemon pair');
310
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
311
- throw new Error('daemonEnv requires a port between 1 and 65535: refusing to build half of the daemon pair');
312
- }
313
- const resolved = resolve(dir);
314
- return {
315
- OURS_CONFIG: join(resolved, 'config.json'),
316
- OURS_STATE_DIR: resolved,
317
- OURS_PORT: String(port),
318
- };
319
- }
320
-
321
- /** Is this environment a whole pair (or nothing at all)? Never one half. */
322
- export function isWholeDaemonEnv(env) {
323
- if (env == null) return true;
324
- const present = DAEMON_ENV_KEYS.filter((k) => typeof env[k] === 'string' && env[k] !== '');
325
- if (present.length === 0) return true;
326
- if (present.length !== DAEMON_ENV_KEYS.length) return false;
327
- return env.OURS_CONFIG === join(resolve(env.OURS_STATE_DIR), 'config.json');
328
- }
329
-
330
- /** The state directory a default run targets, for callers that need it early. */
331
- export const defaultStateDir = (home = homedir()) => join(home, '.ours');