@ours.network/install 0.17.0-nightly.7 → 0.17.0-nightly.8
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/components.mjs +300 -0
- package/lib/effects.mjs +331 -0
- package/lib/extras.mjs +357 -0
- package/lib/journal.mjs +113 -0
- package/lib/orchestrate-uninstall.mjs +293 -0
- package/lib/orchestrate.mjs +884 -0
- package/lib/plan.mjs +238 -0
- package/lib/rerun.mjs +119 -0
- package/lib/target.mjs +353 -0
- package/lib/uninstall.mjs +439 -0
- package/lib/usage.mjs +47 -0
- package/package.json +1 -1
|
@@ -0,0 +1,300 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
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');
|