@ours.network/install 0.17.0 → 0.18.0-nightly.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/README.md +37 -122
- package/install.mjs +23 -790
- package/lib/components.mjs +360 -0
- package/lib/detect.mjs +169 -0
- package/lib/effects.mjs +349 -0
- package/lib/extras.mjs +320 -0
- package/lib/journal.mjs +158 -0
- package/lib/logic.mjs +351 -25
- package/lib/orchestrate-uninstall.mjs +379 -0
- package/lib/orchestrate.mjs +1051 -0
- package/lib/plan.mjs +270 -0
- package/lib/rerun.mjs +119 -0
- package/lib/target.mjs +390 -0
- package/lib/uninstall.mjs +736 -0
- package/lib/usage.mjs +47 -0
- package/package.json +1 -1
- package/uninstall.mjs +23 -194
- package/uninstall.sh +13 -4
package/lib/effects.mjs
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
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
|
+
import { classifyStateDir } from './detect.mjs';
|
|
22
|
+
|
|
23
|
+
/** GET http://127.0.0.1:<port>/state-dir — the unauthenticated identity probe. */
|
|
24
|
+
async function probePort(port, { timeoutMs = 1500 } = {}) {
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(`http://127.0.0.1:${port}/state-dir`, { signal: controller.signal });
|
|
29
|
+
if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };
|
|
30
|
+
const body = await res.json();
|
|
31
|
+
if (typeof body?.stateDir !== 'string') return { ok: false, reason: 'no stateDir in reply' };
|
|
32
|
+
return { ok: true, stateDir: body.stateDir };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return { ok: false, reason: error?.name === 'AbortError' ? 'timed out' : String(error?.message ?? error) };
|
|
35
|
+
} finally {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Is this port bound? Probed in a throwaway child so a bind attempt cannot leave
|
|
42
|
+
* a listener behind in this process — the same technique the existing installer
|
|
43
|
+
* uses (install.mjs portTakenSync).
|
|
44
|
+
*/
|
|
45
|
+
function portTakenSync(port) {
|
|
46
|
+
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))});`;
|
|
47
|
+
return spawnSync(process.execPath, ['-e', src], { stdio: 'ignore' }).status === 3;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readJsonFile(path) {
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
53
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readTextFile(path) {
|
|
60
|
+
try {
|
|
61
|
+
return readFileSync(path, 'utf8');
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function installedVersionOf(pkg) {
|
|
68
|
+
try {
|
|
69
|
+
const out = execFileSync('npm', ['ls', '-g', '--depth', '0', '--json', pkg], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
70
|
+
return JSON.parse(out)?.dependencies?.[pkg]?.version ?? null;
|
|
71
|
+
} catch {
|
|
72
|
+
// Unreadable is NOT "new enough": the cowork gate fails closed on null.
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A read-only command probe that NEVER throws and NEVER inherits stdio.
|
|
79
|
+
*
|
|
80
|
+
* Separate from `run` on purpose. `run` is for mutations and throws on a
|
|
81
|
+
* non-zero exit, because a failed mutation is news. Detection is the opposite:
|
|
82
|
+
* a non-zero exit IS the answer, and a hung wrapper must be killed rather than
|
|
83
|
+
* waited on. Mixing the two would mean either detection crashes a run or a
|
|
84
|
+
* failed install passes silently.
|
|
85
|
+
*/
|
|
86
|
+
function capture(cmd, args, { timeout, env = process.env } = {}) {
|
|
87
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'], env });
|
|
88
|
+
const timedOut = !!(r.error && (r.error.code === 'ETIMEDOUT' || r.signal === 'SIGTERM'));
|
|
89
|
+
return {
|
|
90
|
+
ok: !r.error && r.status === 0,
|
|
91
|
+
code: r.status ?? -1,
|
|
92
|
+
stdout: r.stdout ?? '',
|
|
93
|
+
stderr: r.stderr ?? '',
|
|
94
|
+
timedOut,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The two harnesses that are DRIVEN CLIs. The `name` is the one lib/extras.mjs
|
|
99
|
+
// plans against; the `command` is what actually lives on PATH.
|
|
100
|
+
export const DRIVEN_HARNESSES = [
|
|
101
|
+
{ name: 'claude-code', command: 'claude', label: 'Claude Code' },
|
|
102
|
+
{ name: 'codex', command: 'codex', label: 'Codex' },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Alias-safety, unchanged from v2: three read-only observations, then the pure
|
|
107
|
+
* classifier decides. The harness is NEVER called in a way that can hang —
|
|
108
|
+
* `--version` is spawned directly (no shell, so a real PATH binary) under a hard
|
|
109
|
+
* timeout, and the shell `type` lookup is timeout-guarded too.
|
|
110
|
+
*/
|
|
111
|
+
function detectDrivenHarness({ name, command, label }, env) {
|
|
112
|
+
const onPath = capture('bash', ['-c', `command -v ${command}`], { env }).ok;
|
|
113
|
+
const probe = capture(command, ['--version'], { timeout: 6000, env });
|
|
114
|
+
const versionOk = probe.ok && /\d+\.\d+/.test(probe.stdout);
|
|
115
|
+
const shell = env.SHELL || '/bin/bash';
|
|
116
|
+
const typeProbe = capture(shell, ['-ic', `type -t ${command} 2>/dev/null`], { timeout: 4000, env });
|
|
117
|
+
const verdict = classifyHarnessProbe({
|
|
118
|
+
onPath, versionOk, timedOut: probe.timedOut, shellType: (typeProbe.stdout || '').trim(),
|
|
119
|
+
});
|
|
120
|
+
return { name, command, label, ...verdict };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Hermes is detected DIFFERENTLY, and it is not an inconsistency. Its ours
|
|
125
|
+
* plugin never calls a `hermes` binary — `ours-hermes-install` writes
|
|
126
|
+
* ~/.hermes/config.yaml and the skills — so "can we drive it?" is the wrong
|
|
127
|
+
* question. Per the plugin's own prerequisites, presence IS the config
|
|
128
|
+
* directory. The CLI probe still runs, purely to enrich detection.
|
|
129
|
+
*/
|
|
130
|
+
function detectHermesHarness(env, home) {
|
|
131
|
+
const dir = env.HERMES_DIR || join(home, '.hermes');
|
|
132
|
+
const dirPresent = existsSync(dir);
|
|
133
|
+
const cli = detectDrivenHarness({ name: 'hermes', command: 'hermes', label: 'Hermes' }, env);
|
|
134
|
+
return {
|
|
135
|
+
name: 'hermes',
|
|
136
|
+
command: 'hermes',
|
|
137
|
+
label: 'Hermes',
|
|
138
|
+
status: dirPresent || cli.status === 'ok' ? 'ok' : 'absent',
|
|
139
|
+
detail: dirPresent ? `config dir ${dir} present` : cli.detail,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Best-effort clipboard copy (pbcopy / wl-copy / xclip / clip.exe). The hard
|
|
145
|
+
* timeout is load-bearing: xclip holds the selection and would otherwise keep
|
|
146
|
+
* the installer alive after its own summary.
|
|
147
|
+
*/
|
|
148
|
+
function copyToClipboard(text) {
|
|
149
|
+
const tools = [['pbcopy', []], ['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['clip.exe', []]];
|
|
150
|
+
for (const [bin, args] of tools) {
|
|
151
|
+
try {
|
|
152
|
+
const r = spawnSync(bin, args, { input: text, timeout: 2000 });
|
|
153
|
+
if (!r.error && (r.status === 0 || r.status == null)) return true;
|
|
154
|
+
} catch { /* try the next one */ }
|
|
155
|
+
}
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Every DAEMON state directory on this machine.
|
|
161
|
+
*
|
|
162
|
+
* ONE DEFINITION OF WHAT A DAEMON IS, and this function is why that matters.
|
|
163
|
+
*
|
|
164
|
+
* It used to count any `~/.ours*` directory containing a config.json. Two of those
|
|
165
|
+
* are not daemons on a perfectly normal machine: `~/.ours-telegram/config.json` is
|
|
166
|
+
* the Telegram connector's and `~/.ours-cowork/config.json` is cowork's. So the
|
|
167
|
+
* uninstaller reported "@ours.network/cli kept — still used by the daemon at
|
|
168
|
+
* ~/.ours-telegram", and two things followed silently:
|
|
169
|
+
*
|
|
170
|
+
* · planGlobalPackages kept cli, mcp and the plugin packages FOREVER on any
|
|
171
|
+
* machine with the connector installed, naming a connector's config directory
|
|
172
|
+
* as a daemon;
|
|
173
|
+
* · worse, planPluginRemoval's `lastDaemon` went false, so the whole harness
|
|
174
|
+
* plugin phase was skipped — with a reason that was not true. A plain
|
|
175
|
+
* interactive `ours-uninstall` on a machine with the connector removed no
|
|
176
|
+
* plugins at all.
|
|
177
|
+
*
|
|
178
|
+
* The selection screen had already closed exactly this: config.json is the one
|
|
179
|
+
* piece of evidence that is AMBIGUOUS, so it cannot be the test. That predicate
|
|
180
|
+
* lives in lib/detect.mjs and this now calls it rather than keeping a second,
|
|
181
|
+
* naive copy that drifted. A daemon is identified by an artefact only a daemon
|
|
182
|
+
* writes, or by a config whose SHAPE is a daemon's.
|
|
183
|
+
*
|
|
184
|
+
* Still deliberately conservative about WHERE it looks: only `~/.ours` and its
|
|
185
|
+
* `~/.ours*` siblings. A state directory somewhere else is not found, and an
|
|
186
|
+
* unreadable home means "there might be others", not "there are none" — because
|
|
187
|
+
* the caller uses this to decide whether a GLOBAL package is still needed, and
|
|
188
|
+
* being wrong the optimistic way uninstalls the CLI out from under a running
|
|
189
|
+
* daemon.
|
|
190
|
+
*/
|
|
191
|
+
function knownStateDirsIn(home) {
|
|
192
|
+
const found = [];
|
|
193
|
+
const io = { exists: existsSync, readJson: readJsonFile };
|
|
194
|
+
const consider = (dir) => { if (classifyStateDir(dir, io).isDaemon) found.push(dir); };
|
|
195
|
+
consider(join(home, '.ours'));
|
|
196
|
+
try {
|
|
197
|
+
for (const entry of readdirSync(home, { withFileTypes: true })) {
|
|
198
|
+
if (!entry.isDirectory() || !entry.name.startsWith('.ours') || entry.name === '.ours') continue;
|
|
199
|
+
consider(join(home, entry.name));
|
|
200
|
+
}
|
|
201
|
+
} catch { /* an unreadable home is not evidence that there are no others */ }
|
|
202
|
+
return found;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Build the real effects. `write` and `ttyFd` come from the caller's UI layer so
|
|
207
|
+
* the orchestrator never reaches for a terminal itself.
|
|
208
|
+
*/
|
|
209
|
+
export function realEffects({ write, ttyFd, env = process.env, home = homedir(), out, version = null } = {}) {
|
|
210
|
+
return {
|
|
211
|
+
home,
|
|
212
|
+
env,
|
|
213
|
+
version,
|
|
214
|
+
// Preflight reads the machine rather than asking the orchestrator to.
|
|
215
|
+
platform: { platform: osPlatform(), release: osRelease() },
|
|
216
|
+
nodeVersion: process.versions.node,
|
|
217
|
+
exists: (path) => existsSync(path),
|
|
218
|
+
knownStateDirs: () => knownStateDirsIn(home),
|
|
219
|
+
// The only irreversible effect in this package, and the reason it takes no
|
|
220
|
+
// pattern and no parent: the caller passes ONE resolved directory that the
|
|
221
|
+
// pure planner already gated four ways, and this deletes exactly that.
|
|
222
|
+
removeDir: (path) => { rmSync(resolve(path), { recursive: true, force: true }); },
|
|
223
|
+
removeFile: (path) => { rmSync(resolve(path), { force: true }); },
|
|
224
|
+
// Rewrites a config file we do NOT own, so it keeps the file's own mode
|
|
225
|
+
// rather than imposing 0600: tightening the permissions of somebody else's
|
|
226
|
+
// ~/.codex/config.toml is a side effect nobody asked this to have.
|
|
227
|
+
//
|
|
228
|
+
// DO NOT "IMPROVE" THIS TO 0600. It looks like a security improvement, which
|
|
229
|
+
// is exactly why someone will try — but this file is the operator's, not
|
|
230
|
+
// ours, and the only thing we were invited to do to it is remove our own
|
|
231
|
+
// block. Changing its mode on the way past is an uninvited change to a file
|
|
232
|
+
// we happened to be holding, and a tool that does that once is a tool you
|
|
233
|
+
// cannot let near your configs.
|
|
234
|
+
writeText: (path, text) => {
|
|
235
|
+
const mode = (() => { try { return statSync(path).mode & 0o777; } catch { return 0o644; } })();
|
|
236
|
+
const temp = `${path}.tmp-${process.pid}`;
|
|
237
|
+
writeFileSync(temp, text, { encoding: 'utf8', mode });
|
|
238
|
+
renameSync(temp, path);
|
|
239
|
+
},
|
|
240
|
+
username: () => { try { return userInfo().username || 'me'; } catch { return 'me'; } },
|
|
241
|
+
detectHarnesses: () => [
|
|
242
|
+
...DRIVEN_HARNESSES.map((h) => detectDrivenHarness(h, env)),
|
|
243
|
+
detectHermesHarness(env, home),
|
|
244
|
+
],
|
|
245
|
+
clipboard: (text) => copyToClipboard(text),
|
|
246
|
+
brokerUrl: env.OURS_BROKER_URL ?? 'wss://broker1.ours.network',
|
|
247
|
+
now: () => Date.now(),
|
|
248
|
+
probe: (port) => probePort(port),
|
|
249
|
+
isTaken: (port) => portTakenSync(port),
|
|
250
|
+
readJson: readJsonFile,
|
|
251
|
+
readText: readTextFile,
|
|
252
|
+
writeJson: (path, text) => {
|
|
253
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
254
|
+
atomicWriteConfig(path, text);
|
|
255
|
+
},
|
|
256
|
+
// The two halves of a config rollback (lib/journal.mjs). Deliberately the
|
|
257
|
+
// SAME pair the nightly installer uses, from lib/config.mjs, rather than a
|
|
258
|
+
// second implementation: `snapshot` records bytes and mode, and `restore`
|
|
259
|
+
// either writes those bytes back at their original mode or DELETES a file
|
|
260
|
+
// that did not exist before this run. Both are ordinary reads and writes of a
|
|
261
|
+
// file this installer was already writing — no new class of side effect
|
|
262
|
+
// enters the package here.
|
|
263
|
+
snapshot: (path) => snapshotConfig(path),
|
|
264
|
+
restore: (path, snapshot) => restoreConfig(path, snapshot),
|
|
265
|
+
// `extraEnv` is the daemon pair (see daemonEnv). It is applied to THIS
|
|
266
|
+
// invocation only and never to the installer's own process: a state
|
|
267
|
+
// directory selected by one run must not leak into anything the operator
|
|
268
|
+
// starts afterwards.
|
|
269
|
+
run: async (cmd, args, { env: extraEnv = null } = {}) => {
|
|
270
|
+
// Always built from this layer's OWN env rather than left to spawnSync's
|
|
271
|
+
// implicit inheritance, so what a child receives is a property of the
|
|
272
|
+
// effects object a caller constructed and not of whatever ambient shell
|
|
273
|
+
// the installer happened to start in.
|
|
274
|
+
const childEnv = { ...env, ...(extraEnv ?? {}) };
|
|
275
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: childEnv });
|
|
276
|
+
if (r.status !== 0) {
|
|
277
|
+
const detail = (r.stderr || r.stdout || '').trim().split('\n').slice(-3).join('; ');
|
|
278
|
+
throw new Error(`${cmd} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
|
|
279
|
+
}
|
|
280
|
+
return { ok: true, code: r.status, stdout: r.stdout ?? '' };
|
|
281
|
+
},
|
|
282
|
+
// The ONE invocation that must keep the user's terminal: `ours-mcp
|
|
283
|
+
// voice-setup` is an interactive command with its own masked prompts, and
|
|
284
|
+
// piping its stdio would hang it forever waiting on input nobody can type.
|
|
285
|
+
// It is otherwise the same contract as `run` — including the environment,
|
|
286
|
+
// so an interactive command reaches the same daemon a piped one would.
|
|
287
|
+
runInteractive: async (cmd, args, { env: extraEnv = null } = {}) => {
|
|
288
|
+
const r = spawnSync(cmd, args, { stdio: 'inherit', env: { ...env, ...(extraEnv ?? {}) } });
|
|
289
|
+
return { ok: !r.error && r.status === 0, code: r.status ?? -1 };
|
|
290
|
+
},
|
|
291
|
+
installedVersion: installedVersionOf,
|
|
292
|
+
out: out ?? ((line) => process.stdout.write(`${line}\n`)),
|
|
293
|
+
// Never called when assumeYes: the orchestrator takes the default itself.
|
|
294
|
+
ask: async (prompt, def = false) => (ttyFd == null ? def : askYesNo(write, ttyFd, ` ${prompt} `, def)),
|
|
295
|
+
askLine: async (prompt, def = '') => (ttyFd == null ? def : askLineOnTty(write, ttyFd, ` ${prompt} `, def)),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, installedVersionOf, knownStateDirsIn };
|
|
300
|
+
|
|
301
|
+
// -----------------------------------------------------------------------------
|
|
302
|
+
// THE PAIR
|
|
303
|
+
// -----------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The environment that names ONE daemon, for a single child invocation.
|
|
307
|
+
*
|
|
308
|
+
* Spec §2's rule is that a state directory and an endpoint always travel
|
|
309
|
+
* together; "endpoint selected, state directory defaulted" must be unreachable.
|
|
310
|
+
* Every consumer downstream — ours-mcp's proxy, ours-fleet's per-role resolver,
|
|
311
|
+
* ours-hermes-install — reads these three names and falls back to `~/.ours` for
|
|
312
|
+
* whichever one is missing. So a HALF pair does not fail: it silently attaches
|
|
313
|
+
* to the default daemon while the operator was told a different one was chosen.
|
|
314
|
+
*
|
|
315
|
+
* That is why this is a function and not three assignments at the call sites.
|
|
316
|
+
* There is exactly one place a daemon environment can be built, it takes both
|
|
317
|
+
* halves as arguments, and it refuses rather than emit a partial one.
|
|
318
|
+
*
|
|
319
|
+
* The three names, not two, are deliberate: OURS_CONFIG alone would leave the
|
|
320
|
+
* port to whatever config.json happens to say, which is exactly the stale-file
|
|
321
|
+
* divergence lib/target.mjs's second lookup exists to survive.
|
|
322
|
+
*/
|
|
323
|
+
export const DAEMON_ENV_KEYS = ['OURS_CONFIG', 'OURS_STATE_DIR', 'OURS_PORT'];
|
|
324
|
+
|
|
325
|
+
export function daemonEnv(stateDir, port) {
|
|
326
|
+
const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
327
|
+
if (!dir) throw new Error('daemonEnv requires a state directory: refusing to build half of the daemon pair');
|
|
328
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
329
|
+
throw new Error('daemonEnv requires a port between 1 and 65535: refusing to build half of the daemon pair');
|
|
330
|
+
}
|
|
331
|
+
const resolved = resolve(dir);
|
|
332
|
+
return {
|
|
333
|
+
OURS_CONFIG: join(resolved, 'config.json'),
|
|
334
|
+
OURS_STATE_DIR: resolved,
|
|
335
|
+
OURS_PORT: String(port),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Is this environment a whole pair (or nothing at all)? Never one half. */
|
|
340
|
+
export function isWholeDaemonEnv(env) {
|
|
341
|
+
if (env == null) return true;
|
|
342
|
+
const present = DAEMON_ENV_KEYS.filter((k) => typeof env[k] === 'string' && env[k] !== '');
|
|
343
|
+
if (present.length === 0) return true;
|
|
344
|
+
if (present.length !== DAEMON_ENV_KEYS.length) return false;
|
|
345
|
+
return env.OURS_CONFIG === join(resolve(env.OURS_STATE_DIR), 'config.json');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The state directory a default run targets, for callers that need it early. */
|
|
349
|
+
export const defaultStateDir = (home = homedir()) => join(home, '.ours');
|
package/lib/extras.mjs
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// ours-install v3 — the four retained extras, re-pointed at the v3 arrangement.
|
|
2
|
+
//
|
|
3
|
+
// The v3 installer keeps harness plugins, ours-fleet, voice setup and the
|
|
4
|
+
// copy-paste hand-off prompt; spec v3's silence about them was an oversight.
|
|
5
|
+
//
|
|
6
|
+
// The shared daemon belongs to the operator CLI. ours-mcp is only a per-session
|
|
7
|
+
// stdio adapter with no unit, and these extra phases preserve that boundary.
|
|
8
|
+
//
|
|
9
|
+
// Pure, like target.mjs / plan.mjs / components.mjs: no I/O, no subprocess, no
|
|
10
|
+
// terminal. Every function takes what was observed and returns a plan; the
|
|
11
|
+
// orchestrator (a later PR) is what performs it.
|
|
12
|
+
|
|
13
|
+
import { join, resolve } from 'node:path';
|
|
14
|
+
import { pkgSpec } from './logic.mjs';
|
|
15
|
+
|
|
16
|
+
const cfgPath = (stateDir) => join(resolve(stateDir), 'config.json');
|
|
17
|
+
|
|
18
|
+
// -----------------------------------------------------------------------------
|
|
19
|
+
// §5 — harness plugins
|
|
20
|
+
// -----------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export const CLAUDE_MARKET = 'adapt-toolkit/ours-claude-marketplace';
|
|
23
|
+
export const CODEX_MARKET = 'adapt-toolkit/ours-codex-marketplace';
|
|
24
|
+
|
|
25
|
+
export const HARNESSES = [
|
|
26
|
+
{ name: 'claude-code', label: 'Claude Code' },
|
|
27
|
+
{ name: 'codex', label: 'Codex' },
|
|
28
|
+
{ name: 'hermes', label: 'Hermes' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* SPEC §5 PROMISES SOMETHING TWO OF THE THREE REGISTRATIONS CANNOT DO.
|
|
33
|
+
*
|
|
34
|
+
* §5: "For any other state directory the installer registers the harness MCP
|
|
35
|
+
* entry with OURS_CONFIG=<state-dir>/config.json in its environment, so the pair
|
|
36
|
+
* travels together." planMcpAttachment already returns exactly that harnessEnv —
|
|
37
|
+
* and the orchestrator only PRINTS it. That is not an oversight to be fixed by
|
|
38
|
+
* wiring it up harder; none of the three registrations can carry a value:
|
|
39
|
+
*
|
|
40
|
+
* Claude Code the marketplace plugin's mcpServers.ours is command+args, with
|
|
41
|
+
* no env key, and `claude plugin install` injects nothing per
|
|
42
|
+
* install.
|
|
43
|
+
* Codex .mcp.json's env_vars is an allowlist of NAMES, not a value map
|
|
44
|
+
* (pinned by packages/codex/test/plugin-package.test.mjs). The
|
|
45
|
+
* value must already be in the ambient environment.
|
|
46
|
+
* Hermes renderConfigBlock is OUR writer, and now emits an `env:` block
|
|
47
|
+
* carrying OURS_CONFIG — so for Hermes the pair is real.
|
|
48
|
+
*
|
|
49
|
+
* So §5's guarantee is ALREADY unmet today for every non-default state
|
|
50
|
+
* directory, silently: the harness attaches to ~/.ours while the operator was
|
|
51
|
+
* told the run targeted somewhere else. The shape is:
|
|
52
|
+
*
|
|
53
|
+
* default state directory today's behaviour, byte for byte.
|
|
54
|
+
* Hermes, non-default real: the pair is handed to ours-hermes-install's
|
|
55
|
+
* invocation and written into ~/.hermes/config.yaml
|
|
56
|
+
* as the ours server's own env block.
|
|
57
|
+
* Claude / Codex, non-def install the plugin (it is still the right plugin)
|
|
58
|
+
* and PRINT the exact line the operator must add.
|
|
59
|
+
* Never claim §5's guarantee in the screen text.
|
|
60
|
+
*
|
|
61
|
+
* Deliberately NOT done: registering a second, user-scoped `ours` MCP server via
|
|
62
|
+
* `claude mcp add --env`. Two `ours` servers in front of one harness, and which
|
|
63
|
+
* wins is not something anyone here has verified.
|
|
64
|
+
*/
|
|
65
|
+
export const HARNESS_ENV_SUPPORT = {
|
|
66
|
+
// 'applied' — the pair is genuinely carried into the registration.
|
|
67
|
+
// 'printed' — the operator is told the exact line and nothing is claimed.
|
|
68
|
+
'claude-code': 'printed',
|
|
69
|
+
codex: 'printed',
|
|
70
|
+
hermes: 'applied',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const manualSteps = {
|
|
74
|
+
'claude-code': (channel) => [
|
|
75
|
+
`/plugin marketplace add ${CLAUDE_MARKET}`,
|
|
76
|
+
'/plugin install ours',
|
|
77
|
+
],
|
|
78
|
+
codex: (channel) => [
|
|
79
|
+
`codex plugin marketplace add ${CODEX_MARKET}`,
|
|
80
|
+
'codex plugin add ours@ours-codex-marketplace',
|
|
81
|
+
`npm i -g ${pkgSpec('codex', channel)}`,
|
|
82
|
+
],
|
|
83
|
+
hermes: (channel) => [
|
|
84
|
+
`npm i -g ${pkgSpec('hermes', channel)}`,
|
|
85
|
+
'ours-hermes-install',
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const driveSteps = {
|
|
90
|
+
'claude-code': (channel) => [
|
|
91
|
+
['claude', 'plugin', 'marketplace', 'add', CLAUDE_MARKET],
|
|
92
|
+
['claude', 'plugin', 'install', 'ours@ours.network'],
|
|
93
|
+
],
|
|
94
|
+
codex: (channel) => [
|
|
95
|
+
['codex', 'plugin', 'marketplace', 'add', CODEX_MARKET],
|
|
96
|
+
['codex', 'plugin', 'add', 'ours@ours-codex-marketplace'],
|
|
97
|
+
// Owner-mandated in v2 and kept: choosing the Codex plugin also installs the
|
|
98
|
+
// ours-codex live launcher, in the same step.
|
|
99
|
+
['npm', 'i', '-g', pkgSpec('codex', channel)],
|
|
100
|
+
],
|
|
101
|
+
// Hermes has no driven CLI: nothing here ever calls a `hermes` binary. Its
|
|
102
|
+
// plugin install is npm + ours-hermes-install, which writes ~/.hermes.
|
|
103
|
+
// --skip-daemon because in v3 the daemon is emphatically not ours-mcp's.
|
|
104
|
+
hermes: (channel) => [
|
|
105
|
+
['npm', 'i', '-g', pkgSpec('hermes', channel)],
|
|
106
|
+
['ours-hermes-install', '--skip-daemon'],
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One plan per harness the caller observed.
|
|
112
|
+
*
|
|
113
|
+
* `harnesses` is [{ name, status }] where status is classifyHarnessProbe's
|
|
114
|
+
* verdict ('ok' | 'alias' | 'unsafe' | 'absent'). Hermes is detected by its
|
|
115
|
+
* config directory rather than a CLI, which is the caller's business; this only
|
|
116
|
+
* consumes the verdict.
|
|
117
|
+
*
|
|
118
|
+
* The v2 golden rule is kept intact: 'alias' / 'unsafe' NEVER dead-end. A
|
|
119
|
+
* harness we cannot safely drive still gets its manual steps printed, so the
|
|
120
|
+
* plugin is still installable.
|
|
121
|
+
*
|
|
122
|
+
* `env` is what an invocation must carry, and it is EMPTY unless the harness can
|
|
123
|
+
* genuinely apply it. `envLine` is what the operator is told. `claimsPair` is
|
|
124
|
+
* false whenever the pair is only printed — the screen text renderer reads it so
|
|
125
|
+
* §5's guarantee cannot be claimed where it does not hold.
|
|
126
|
+
*/
|
|
127
|
+
export function planHarnessPlugins({
|
|
128
|
+
harnesses = [],
|
|
129
|
+
stateDir,
|
|
130
|
+
isDefaultStateDir,
|
|
131
|
+
channel = 'latest',
|
|
132
|
+
assumeYes = false,
|
|
133
|
+
answers = {},
|
|
134
|
+
} = {}) {
|
|
135
|
+
const config = stateDir ? cfgPath(stateDir) : null;
|
|
136
|
+
return harnesses.map((h) => {
|
|
137
|
+
const name = String(h?.name ?? '');
|
|
138
|
+
const known = HARNESSES.find((k) => k.name === name);
|
|
139
|
+
const label = known?.label ?? name;
|
|
140
|
+
const status = String(h?.status ?? 'absent');
|
|
141
|
+
const support = HARNESS_ENV_SUPPORT[name] ?? 'printed';
|
|
142
|
+
const applies = !isDefaultStateDir && support === 'applied';
|
|
143
|
+
|
|
144
|
+
const base = {
|
|
145
|
+
name,
|
|
146
|
+
label,
|
|
147
|
+
status,
|
|
148
|
+
// Default state directory → today's behaviour, byte for byte: no env
|
|
149
|
+
// anywhere, nothing extra printed, nothing claimed.
|
|
150
|
+
envSupport: isDefaultStateDir ? 'none' : support,
|
|
151
|
+
env: applies ? { OURS_CONFIG: config } : {},
|
|
152
|
+
envLine: isDefaultStateDir || applies ? null : `export OURS_CONFIG=${config}`,
|
|
153
|
+
claimsPair: isDefaultStateDir ? true : applies,
|
|
154
|
+
manual: manualSteps[name] ? manualSteps[name](channel) : [],
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
if (!known) return { ...base, action: 'skip', reason: 'unknown harness' };
|
|
158
|
+
if (status === 'absent') return { ...base, action: 'skip', reason: 'not installed' };
|
|
159
|
+
|
|
160
|
+
const wanted = assumeYes ? true : answers[name] !== false;
|
|
161
|
+
if (!wanted) return { ...base, action: 'skip', reason: 'declined', offerOnRerun: true };
|
|
162
|
+
|
|
163
|
+
if (status === 'ok') return { ...base, action: 'drive', steps: driveSteps[name](channel) };
|
|
164
|
+
return {
|
|
165
|
+
...base,
|
|
166
|
+
action: 'manual',
|
|
167
|
+
reason: status === 'alias' ? 'installed as an alias, not the real command' : 'found, but did not answer --version',
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// -----------------------------------------------------------------------------
|
|
173
|
+
// §5 — what the operator has to do BEFORE any of this works
|
|
174
|
+
// -----------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The restart each harness needs before its new plugin is live.
|
|
178
|
+
*
|
|
179
|
+
* A CORRECTNESS PROBLEM WEARING A COSMETIC COSTUME. The ours MCP server is spawned
|
|
180
|
+
* BY the harness, once per session (`ours-mcp proxy` over stdio), so a harness that
|
|
181
|
+
* was already running when its plugin was installed has no ours tools and will not
|
|
182
|
+
* get them until it restarts. v3 said nothing at all about this: the screen read
|
|
183
|
+
* "Everything installed cleanly", the user went back to a running Claude Code,
|
|
184
|
+
* found no ours tools, and concluded the install had failed. The nightly installer
|
|
185
|
+
* prints these hints and v3 dropped them.
|
|
186
|
+
*
|
|
187
|
+
* Derived from what THIS RUN installed rather than from a registry — v3 already
|
|
188
|
+
* knows, and its own summary is a better source than a persisted file that can go
|
|
189
|
+
* stale against reality.
|
|
190
|
+
*
|
|
191
|
+
* The connectors are deliberately absent. The installer runs their
|
|
192
|
+
* `install-service` itself, so their new configuration is already applied; telling
|
|
193
|
+
* someone to restart something that was just restarted for them is noise, and noise
|
|
194
|
+
* in this list is what stops the real lines being read.
|
|
195
|
+
*/
|
|
196
|
+
export const HARNESS_RESTART = {
|
|
197
|
+
'claude-code': 'restart Claude Code',
|
|
198
|
+
codex: 'start a new Codex session (or `ours-codex`)',
|
|
199
|
+
hermes: 'run /reload-mcp in Hermes',
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export function restartHints(summary = []) {
|
|
203
|
+
const live = (row) => row && (row.state === 'installed' || row.state === 'current');
|
|
204
|
+
const hints = [];
|
|
205
|
+
for (const [name, action] of Object.entries(HARNESS_RESTART)) {
|
|
206
|
+
const row = summary.find((r) => r.key === name);
|
|
207
|
+
if (live(row)) hints.push({ key: name, action });
|
|
208
|
+
}
|
|
209
|
+
// Nothing to restart if no harness got a plugin this run. The MCP server on its
|
|
210
|
+
// own changes nothing a running harness can see, so an "install the MCP server
|
|
211
|
+
// and restart everything" line would be advice with no reason behind it.
|
|
212
|
+
return hints;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// -----------------------------------------------------------------------------
|
|
216
|
+
// ours-fleet — the one that needs zero code
|
|
217
|
+
// -----------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* ours-fleet needs NO change in either repo, and the installer configures
|
|
221
|
+
* nothing in it.
|
|
222
|
+
*
|
|
223
|
+
* `ours-fleet init` takes no daemon argument of any kind and never reads a
|
|
224
|
+
* daemon config: it makes its directories, installs its own units and prints a
|
|
225
|
+
* next step. Fleet resolves the ours daemon the same way the MCP client does —
|
|
226
|
+
* OURS_CONFIG ?? ~/.ours/config.json, then OURS_PORT / OURS_STATE_DIR /
|
|
227
|
+
* OURS_API_TOKEN — and it does so PER ROLE, through
|
|
228
|
+
* resolveEndpoint({ ...process.env, ...role.env }). Different roles can already
|
|
229
|
+
* target different daemons. Temp supervisors inherit the same four names.
|
|
230
|
+
*
|
|
231
|
+
* So for a non-default state directory the installer's ONLY job is to say the
|
|
232
|
+
* one fleet.yaml line that points a role at the daemon this run created. Saying
|
|
233
|
+
* it is the whole feature; anything more would be configuring a tool that is
|
|
234
|
+
* already correct.
|
|
235
|
+
*/
|
|
236
|
+
export function planFleet({ stateDir, isDefaultStateDir, wanted = true, channel = 'latest' } = {}) {
|
|
237
|
+
const config = stateDir ? cfgPath(stateDir) : null;
|
|
238
|
+
const plan = {
|
|
239
|
+
key: 'fleet',
|
|
240
|
+
label: 'ours-fleet',
|
|
241
|
+
// FOLLOWS THE CHANNEL, and the correction matters more than it looks.
|
|
242
|
+
//
|
|
243
|
+
// This comment used to say the opposite — that ours-fleet lives in its own
|
|
244
|
+
// repo and publishes no nightly tag, so pkgSpec pinned it to @latest. That
|
|
245
|
+
// was true when v3 was written against `main` and it is FALSE here: fleet
|
|
246
|
+
// does publish a nightly dist-tag, and the nightly stack needs the fleet
|
|
247
|
+
// build carrying the SDK integration. A nightly installer that quietly
|
|
248
|
+
// installs stable fleet is precisely the split-brain deployment the channel
|
|
249
|
+
// exists to prevent — the same architecture boundary that made a mixed
|
|
250
|
+
// tg-connector fatal. lib/logic.mjs is the single source of that mapping and
|
|
251
|
+
// this defers to it rather than restating it.
|
|
252
|
+
install: ['npm', 'i', '-g', pkgSpec('fleet', channel)],
|
|
253
|
+
init: ['ours-fleet', 'init'],
|
|
254
|
+
// Stated as data so a test can pin it: this feature writes no fleet config.
|
|
255
|
+
writes: [],
|
|
256
|
+
roleEnv: isDefaultStateDir ? {} : { OURS_CONFIG: config },
|
|
257
|
+
instruction: isDefaultStateDir
|
|
258
|
+
? null
|
|
259
|
+
: `fleet roles that should use this daemon need one line in fleet.yaml:\n env: { OURS_CONFIG: ${config} }`,
|
|
260
|
+
};
|
|
261
|
+
return wanted ? { ...plan, action: 'install' } : { ...plan, action: 'skip', offerOnRerun: true };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// -----------------------------------------------------------------------------
|
|
265
|
+
// the copy-paste hand-off prompt
|
|
266
|
+
// -----------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* buildHandoffPrompt is already pure, already renumbers and already drops steps
|
|
270
|
+
* for components that were not installed. Re-pointing it is one preamble.
|
|
271
|
+
*
|
|
272
|
+
* DEFAULT STATE DIRECTORY → THE TEXT IS UNCHANGED, BYTE FOR BYTE. That is the
|
|
273
|
+
* overwhelming majority case and it is pinned by a test, because the agent on
|
|
274
|
+
* the other end of this prompt configures fleet roles and harness environments,
|
|
275
|
+
* and a stray line about a state directory the user never chose is a worse
|
|
276
|
+
* outcome than no line at all.
|
|
277
|
+
*/
|
|
278
|
+
export function buildHandoffPromptV3({
|
|
279
|
+
identity = false,
|
|
280
|
+
fleet = false,
|
|
281
|
+
telegram = false,
|
|
282
|
+
stateDir = null,
|
|
283
|
+
isDefaultStateDir = true,
|
|
284
|
+
} = {}) {
|
|
285
|
+
const steps = [];
|
|
286
|
+
if (identity) {
|
|
287
|
+
steps.push(
|
|
288
|
+
'Create my Ours human identity — this is me, the human; my agents act on\n'
|
|
289
|
+
+ ' my behalf. Ask me what name others should see, then create it.',
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
if (fleet) {
|
|
293
|
+
steps.push(
|
|
294
|
+
'Set up my ours-fleet: ask me what agents I want in my fleet for\n'
|
|
295
|
+
+ ' PERMANENT use (a name + role/purpose for each), then create and\n'
|
|
296
|
+
+ ' configure those permanent fleet agents for me.',
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (telegram) {
|
|
300
|
+
steps.push(
|
|
301
|
+
'Set up my Telegram bot: ask me for my bot\'s name and its token from\n'
|
|
302
|
+
+ ' @BotFather, register the bot, create a chat↔agent connection, and\n'
|
|
303
|
+
+ ' give me the invite link to send.',
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
if (steps.length === 0) return { text: '', empty: true };
|
|
307
|
+
|
|
308
|
+
const preamble = isDefaultStateDir || !stateDir
|
|
309
|
+
? ''
|
|
310
|
+
: `My ours daemon uses the state directory ${resolve(stateDir)} (config\n${cfgPath(stateDir)}). When you configure anything for me — fleet roles,\nharness environments — set OURS_CONFIG to that path.\n\n`;
|
|
311
|
+
|
|
312
|
+
const numbered = steps.map((s, i) => `${i + 1}. ${s}`).join('\n');
|
|
313
|
+
const text = preamble
|
|
314
|
+
+ 'I just installed the ours.network stack. Please help me finish setup, one\n'
|
|
315
|
+
+ 'step at a time, explaining as you go:\n\n'
|
|
316
|
+
+ numbered + '\n\n'
|
|
317
|
+
+ 'Do these in order, wait for my answers, and tell me if you need anything\n'
|
|
318
|
+
+ "from me. Don't assume — ask.";
|
|
319
|
+
return { text, empty: false };
|
|
320
|
+
}
|