@ours.network/install 0.17.0-nightly.8 → 0.17.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.
@@ -1,300 +0,0 @@
1
- // ours-install v3 — component selection and attachment.
2
- //
3
- // Spec: installer-spec-v3 §5 (and the repoint half of §7, which cannot be
4
- // separated from attaching the connector without making a silent move possible).
5
- // Pure, like target.mjs and plan.mjs: the caller injects the current file
6
- // contents and the installed versions, and every function returns a plan.
7
- //
8
- // The three components are the MCP server, the Telegram connector and cowork.
9
- // None of them IS the daemon; all three attach to one. The messenger is out of
10
- // scope by the owner's instruction, and ours-fleet sits on top and is not
11
- // installed here.
12
-
13
- import { join, resolve } from 'node:path';
14
- import { pkgSpec, resolveChannel } from './logic.mjs';
15
-
16
- // `pkg` is the package's IDENTITY — bare, no dist-tag — because that is what
17
- // `npm ls -g --json <pkg>` needs to read an installed version back, and what a
18
- // component is keyed by in every summary. `specKey` is the same package's name
19
- // in lib/logic.mjs's channel map, and `componentSpec` below is the only thing
20
- // that turns the two into an `npm i -g` argument.
21
- //
22
- // THE TWO MUST STAY SEPARATE. Putting the tag in `pkg` would make
23
- // effects.installedVersion('@ours.network/mcp@nightly') return null forever,
24
- // which fails the cowork version floor CLOSED and blanks the version column —
25
- // a silent regression that looks like "cowork is too old".
26
- export const COMPONENTS = [
27
- { key: 'mcp', label: 'MCP server', pkg: '@ours.network/mcp', specKey: 'mcp', default: true },
28
- { key: 'tg', label: 'Telegram connector', pkg: '@ours.network/tg-connector', specKey: 'tg-connector', default: false },
29
- { key: 'cowork', label: 'cowork', pkg: '@ours.network/cowork', specKey: 'cowork', default: false },
30
- ];
31
-
32
- /**
33
- * The `npm i -g` argument for one component on one channel.
34
- *
35
- * WHY THIS EXISTS AT ALL. `args.channel` was resolved in the orchestrator and
36
- * then reached only lib/extras.mjs's two planners, while these three packages
37
- * were installed by their bare names. So `CHANNEL=nightly` installed the NIGHTLY
38
- * Codex/Hermes plugins and NIGHTLY ours-fleet beside a STABLE MCP server — the
39
- * split-brain deployment the channel exists to prevent, and the same class of
40
- * bug the extras.mjs channel-map correction fixed for ours-fleet one package
41
- * over. Every install path for these three now goes through here.
42
- *
43
- * All three publish a real `nightly` dist-tag (verified against the registry,
44
- * 2026-08-17), so none of these pins can 404. `pkgSpec` falls back to `latest`
45
- * for anything unmapped rather than inventing a tag, so an unknown component
46
- * degrades to today's behaviour instead of failing.
47
- *
48
- * ON THE STABLE CHANNEL THIS RETURNS THE BARE NAME, byte for byte what shipped
49
- * before. `npm i -g pkg` and `npm i -g pkg@latest` are the same install, so
50
- * appending the tag would have bought nothing and changed every stable screen
51
- * line and assertion. The nightly channel is the case that was broken; it is the
52
- * only case that changes.
53
- */
54
- export const componentByKey = (key) => COMPONENTS.find((c) => c.key === key);
55
-
56
- export function componentSpec(component, channel = 'latest') {
57
- const key = typeof component === 'string' ? component : component?.specKey ?? component?.key;
58
- const name = typeof component === 'string' ? null : component?.pkg ?? null;
59
- if (resolveChannel(channel) === 'latest') return name ?? `@ours.network/${String(key).replace(/^@ours\.network\//, '')}`;
60
- return pkgSpec(key, channel);
61
- }
62
-
63
- // cowork must be at least this build to understand an external-daemon block.
64
- export const COWORK_DAEMON_FLOOR = '0.4.1-nightly.20260816.4aaf940';
65
-
66
- // -----------------------------------------------------------------------------
67
- // THE REGISTRY THAT IS NOT OURS TO TOUCH
68
- // -----------------------------------------------------------------------------
69
-
70
- /**
71
- * The Telegram connector keeps its OWN registry of routes, and the installer must
72
- * never write into it.
73
- *
74
- * ~/.ours-telegram/bots.json the bot registry
75
- * ~/.ours-telegram/<route>/ one directory per route, each holding
76
- * identity.key, state_data.bin, connection.json
77
- *
78
- * (tg-connector `src/connector.ts:35-42,170-173`, `src/config.ts:39`.)
79
- *
80
- * WHY THIS IS STATED AS A CONSTANT AND ASSERTED BY A TEST. The connector
81
- * distinguishes its identities by walking its own state directory, NOT by asking
82
- * the daemon: a daemon's identity list is FLAT and carries no attribution to the
83
- * app that created it. So there is no way to reconstruct this registry from the
84
- * daemon side, and anything the installer clears here is gone. The installer's
85
- * entire business with the connector is three keys in one config file.
86
- *
87
- * This also keeps the door open for the route migration the owner is considering
88
- * — moving each route's packet into the shared daemon. That migration has to read
89
- * this registry to know which daemon identity corresponds to which route; an
90
- * installer that had trampled it would have destroyed the mapping.
91
- */
92
- export const TG_STATE_DIR_NAME = '.ours-telegram';
93
- export const TG_REGISTRY_FILES = ['bots.json'];
94
- export const TG_ROUTE_FILES = ['identity.key', 'state_data.bin', 'connection.json'];
95
-
96
- export const tgConfigPath = (home, env = {}) => env.OURS_TG_CONFIG ?? join(home, TG_STATE_DIR_NAME, 'config.json');
97
- export const coworkConfigPath = (home, env = {}) => env.OURS_COWORK_CONFIG ?? join(home, '.ours-cowork', 'config.json');
98
-
99
- // -----------------------------------------------------------------------------
100
- // §5 — selection
101
- // -----------------------------------------------------------------------------
102
-
103
- /**
104
- * Which components this run installs. Defaults are MCP server yes, connector no,
105
- * cowork no — the same answers a non-interactive run takes, so
106
- * `OURS_ASSUME_YES=1 ours-install` produces a daemon plus the MCP server and
107
- * nothing else (spec §9).
108
- *
109
- * `installed` marks a component already present so the question reads "keep it?"
110
- * — and DECLINING AN ALREADY-INSTALLED COMPONENT NEVER UNINSTALLS IT. Removal is
111
- * `ours-uninstall`. A "no" here means "do not add", never "take it away".
112
- */
113
- export function planComponentSelection({ answers = {}, installed = {}, assumeYes = false } = {}) {
114
- return COMPONENTS.map((component) => {
115
- const already = installed[component.key] === true;
116
- const answer = assumeYes ? component.default : answers[component.key];
117
- const wanted = answer === undefined ? (already || component.default) : answer === true;
118
- return {
119
- ...component,
120
- already,
121
- action: already ? (wanted ? 'keep' : 'leave-alone') : (wanted ? 'install' : 'skip'),
122
- };
123
- });
124
- }
125
-
126
- // -----------------------------------------------------------------------------
127
- // §5 — the MCP server
128
- // -----------------------------------------------------------------------------
129
-
130
- /**
131
- * The MCP server runs per session, not as a daemon: the harness spawns
132
- * `ours-mcp proxy` over stdio. No systemd unit is installed for it, and the only
133
- * thing written to disk is the harness's MCP registration.
134
- *
135
- * For a non-default state directory the registration carries
136
- * `OURS_CONFIG=<state-dir>/config.json`, so the endpoint and the state directory
137
- * travel together — which is what keeps "endpoint given, state directory
138
- * defaulted" unreachable. Several harnesses can each point at a different daemon
139
- * precisely because the pair lives in each registration.
140
- */
141
- export function planMcpAttachment({ stateDir, isDefaultStateDir, channel = 'latest' }) {
142
- const dir = resolve(stateDir);
143
- return {
144
- key: 'mcp',
145
- install: ['npm', 'i', '-g', componentSpec(componentByKey('mcp'), channel)],
146
- service: null, // deliberate: per-session stdio proxy, never a unit
147
- harnessEnv: isDefaultStateDir ? {} : { OURS_CONFIG: join(dir, 'config.json') },
148
- writes: ['the harness MCP registration'],
149
- };
150
- }
151
-
152
- // -----------------------------------------------------------------------------
153
- // §5 / §7 — the Telegram connector
154
- // -----------------------------------------------------------------------------
155
-
156
- /**
157
- * Attach the connector to this daemon by setting exactly three keys in its config
158
- * file, preserving every other key in it — the file also holds the operator's bot
159
- * token and STT settings. If all three already match, the file is not touched.
160
- *
161
- * Both daemon keys are ALWAYS written together. A half-formed pair is what makes
162
- * "endpoint selected, state directory defaulted" reachable downstream, and the
163
- * SDK refuses that before it opens a socket.
164
- *
165
- * Returns `action: 'confirm-repoint'` when the connector is currently attached to
166
- * a DIFFERENT daemon. There is exactly one `daemonUrl`/`daemonStateDir` pair and
167
- * one unit, so pointing it elsewhere MOVES the connector; it does not add a
168
- * second one. That is not something to do silently, and unlike the legacy-unit
169
- * case it is not recoverable by re-running: the operator's routes would be
170
- * talking to a daemon they did not choose.
171
- */
172
- export function planTgAttachment({ existing, endpoint, stateDir, brokerUrl, assumeYes = false, channel = 'latest' }) {
173
- const dir = resolve(stateDir);
174
- const base = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
175
- const desired = { daemonUrl: endpoint, daemonStateDir: dir, brokerUrl };
176
-
177
- const current = typeof base.daemonUrl === 'string' || typeof base.daemonStateDir === 'string'
178
- ? { daemonUrl: base.daemonUrl ?? null, daemonStateDir: base.daemonStateDir ?? null }
179
- : null;
180
- const pointsElsewhere = current !== null
181
- && (current.daemonStateDir === null || resolve(current.daemonStateDir) !== dir);
182
-
183
- const merged = { ...base };
184
- const changes = [];
185
- for (const [key, value] of Object.entries(desired)) {
186
- if (value === undefined || value === null) continue;
187
- if (merged[key] === value) continue;
188
- changes.push(key);
189
- merged[key] = value;
190
- }
191
- const plan = {
192
- key: 'tg',
193
- install: ['npm', 'i', '-g', componentSpec(componentByKey('tg'), channel)],
194
- changed: changes.length > 0,
195
- changes,
196
- config: merged,
197
- // Written BEFORE the service: `ours-tg-connector install-service` bakes the
198
- // resolved values into the unit as environment, and environment outranks the
199
- // config file afterwards.
200
- service: ['ours-tg-connector', 'install-service'],
201
- untouched: [...TG_REGISTRY_FILES, ...TG_ROUTE_FILES],
202
- };
203
- if (!pointsElsewhere) return { ...plan, action: plan.changed ? 'attach' : 'unchanged' };
204
- const repoint = {
205
- ...plan,
206
- action: 'confirm-repoint',
207
- from: current,
208
- to: { daemonUrl: endpoint, daemonStateDir: dir },
209
- prompt: `The Telegram connector currently uses ${current.daemonUrl ?? 'an unrecorded daemon'} (${current.daemonStateDir ?? 'unrecorded state directory'}).\nPoint it at ${endpoint} (${dir}) instead? This MOVES the connector; it does not add a second one.`,
210
- };
211
- // Never repointed without a human, in any mode (spec §9).
212
- return assumeYes ? { ...repoint, action: 'skip-repoint', reason: 'never repointed non-interactively' } : repoint;
213
- }
214
-
215
- // -----------------------------------------------------------------------------
216
- // §5 — cowork
217
- // -----------------------------------------------------------------------------
218
-
219
- /**
220
- * Compare an installed version against a floor. Prerelease-aware and CONSERVATIVE
221
- * by design: anything unparseable is "too old", because the failure it guards is
222
- * handing a daemon block to a build whose config is strict and whose boot is
223
- * fail-closed. Being wrong in the cautious direction leaves cowork embedded;
224
- * being wrong the other way stops it starting.
225
- */
226
- export function atLeastVersion(actual, floor) {
227
- const parse = (v) => {
228
- const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(String(v ?? '').trim());
229
- return m ? { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null } : null;
230
- };
231
- const a = parse(actual);
232
- const b = parse(floor);
233
- if (!a || !b) return false;
234
- for (let i = 0; i < 3; i += 1) {
235
- if (a.nums[i] !== b.nums[i]) return a.nums[i] > b.nums[i];
236
- }
237
- if (a.pre === b.pre) return true;
238
- if (a.pre === null) return true; // a release outranks any prerelease of the same version
239
- if (b.pre === null) return false;
240
- return a.pre >= b.pre;
241
- }
242
-
243
- /**
244
- * cowork's external-daemon block. Both halves are required: a half-formed block
245
- * is REFUSED rather than written, because cowork's boot is fail-closed.
246
- *
247
- * The top-level `stateDir` in that file is cowork's OWN private state and is left
248
- * alone; only `daemon.stateDir` is the ours daemon's. Confusing the two fails
249
- * closed at boot, which is why they are never touched in the same write.
250
- */
251
- export function planCoworkAttachment({ existing, endpoint, stateDir, installedVersion, channel = 'latest' }) {
252
- const dir = resolve(stateDir);
253
- if (!endpoint || !stateDir) {
254
- return { key: 'cowork', action: 'refuse', reason: 'half-formed-block', message: 'a cowork daemon block needs both an endpoint and a state directory; refusing to write half of one' };
255
- }
256
- if (!atLeastVersion(installedVersion, COWORK_DAEMON_FLOOR)) {
257
- return {
258
- key: 'cowork',
259
- action: 'leave-embedded',
260
- reason: 'version-floor',
261
- installedVersion: installedVersion ?? null,
262
- message: `the installed cowork (${installedVersion ?? 'version unreadable'}) predates ${COWORK_DAEMON_FLOOR}, which is the first build that understands an external-daemon block; leaving cowork embedded`,
263
- };
264
- }
265
- const base = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
266
- const daemon = { mode: 'external', endpoint, stateDir: dir };
267
- const unchanged = base.daemon
268
- && base.daemon.mode === 'external'
269
- && base.daemon.endpoint === endpoint
270
- && typeof base.daemon.stateDir === 'string'
271
- && resolve(base.daemon.stateDir) === dir;
272
- return {
273
- key: 'cowork',
274
- action: unchanged ? 'unchanged' : 'attach',
275
- install: ['npm', 'i', '-g', componentSpec(componentByKey('cowork'), channel)],
276
- changed: !unchanged,
277
- // The top-level stateDir is cowork's own; it is copied through untouched.
278
- config: { ...base, daemon },
279
- service: ['ours-cowork', 'install-service'],
280
- };
281
- }
282
-
283
- // -----------------------------------------------------------------------------
284
- // §5 — one component failing does not stop the others
285
- // -----------------------------------------------------------------------------
286
-
287
- /**
288
- * A component that fails is reported with its reason and the exact manual
289
- * command, and the run continues to the next. The daemon and the
290
- * already-installed components stay as they are — a failed component is never a
291
- * reason to undo a successful one.
292
- */
293
- export function summarizeComponentRun(results) {
294
- return {
295
- installed: results.filter((r) => r.state === 'installed').map((r) => r.key),
296
- failed: results.filter((r) => r.state === 'failed').map((r) => ({ key: r.key, reason: r.reason, retry: r.retry ?? null })),
297
- skipped: results.filter((r) => r.state === 'skipped').map((r) => r.key),
298
- continued: true,
299
- };
300
- }
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');