@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/lib/target.mjs ADDED
@@ -0,0 +1,390 @@
1
+ // ours-install v3 — argument handling and daemon detection.
2
+ //
3
+ // Spec: installer-spec-v3 §§1-3. Everything here is PURE: the orchestrator
4
+ // injects the probe, the file reads and the port check, so the whole decision
5
+ // table is testable without a socket, a daemon or a filesystem.
6
+ //
7
+ // THE ONE IDEA. A daemon is identified by its STATE DIRECTORY, not by its port.
8
+ // `--state-dir` is the key; `--port` is derived from whatever daemon already
9
+ // owns that directory, and is only searched when this run creates one. Every
10
+ // function below follows from that.
11
+
12
+ import { resolve, join } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+
15
+ export const DEFAULT_STATE_DIR_NAME = '.ours';
16
+ export const INSTALL_DEFAULT_PORT = 3050;
17
+ export const FREE_PORT_FLOOR = 3050;
18
+ export const FREE_PORT_SPAN = 1000;
19
+
20
+ // Ports that are other components' DEFAULTS, not facts about the machine: 3051
21
+ // is the Telegram connector's, 3052 is cowork's loopback console. The free-port
22
+ // search skips them; an explicit --port is still honoured as typed (spec §2).
23
+ //
24
+ // NOTE, deliberately not silently reconciled: lib/logic.mjs's RESERVED_PORTS is
25
+ // [3051] on this branch, while spec §2 states [3051, 3052] citing the
26
+ // dev4/version-plumbing branch. This constant follows the spec. The two should
27
+ // be merged once someone decides whether cowork's 3052 belongs in the shipped
28
+ // list — flagged rather than assumed.
29
+ export const INSTALL_RESERVED_PORTS = [3051, 3052];
30
+
31
+ // The CLI-owned PID record that proves a daemon belongs to a state directory
32
+ // even when nothing is recorded in its config (spec §1).
33
+ export const CLI_PID_RECORD = 'ours-cli-daemon.json';
34
+ // The SAME record, written by a different daemon. `ours daemon start` writes
35
+ // ours-cli-daemon.json; prerelease ours-mcp wrote daemon.pid (packages/core
36
+ // cli.ts PID_PATH). BOTH are read, and neither replaces the other: a machine
37
+ // installed yesterday has the first and a machine installed today has the
38
+ // second, and detection that goes blind on either one concludes "no daemon
39
+ // here" and creates a SECOND daemon on a state directory that already has one.
40
+ // Two writers on one state_data.bin is the corruption case this lookup exists
41
+ // to prevent, so the lookup has to know both spellings.
42
+ export const MCP_PID_RECORD = 'daemon.pid';
43
+ export const PID_RECORDS = [CLI_PID_RECORD, MCP_PID_RECORD];
44
+
45
+ /**
46
+ * The port a PID record names, whichever daemon wrote it.
47
+ *
48
+ * ours-cli-daemon.json is JSON with a `port`. daemon.pid is a bare pid — no
49
+ * port — so it proves a daemon EXISTS for this state directory without saying
50
+ * where. That distinction is the whole reason this returns both fields.
51
+ */
52
+ export function readPidRecords(target, readJson, readText) {
53
+ const record = readJson(join(target, CLI_PID_RECORD));
54
+ const port = record && typeof record.port === 'number' && Number.isFinite(record.port) ? record.port : null;
55
+ const rawPid = typeof readText === 'function' ? readText(join(target, MCP_PID_RECORD)) : null;
56
+ const pid = rawPid !== null && rawPid !== undefined && /^\s*\d+\s*$/.test(String(rawPid))
57
+ ? Number.parseInt(String(rawPid).trim(), 10)
58
+ : null;
59
+ return { port, pid };
60
+ }
61
+ export const DAEMON_CONFIG = 'config.json';
62
+
63
+ export class InstallUsageError extends Error {
64
+ constructor(message) {
65
+ super(message);
66
+ this.name = 'InstallUsageError';
67
+ this.exitCode = 2;
68
+ }
69
+ }
70
+
71
+ // Lexical path comparison, matching how the SDK compares a reported state
72
+ // directory against a selected one. Resolving symlinks would mean touching the
73
+ // filesystem before validation, which is what this comparison exists to avoid.
74
+ export function samePath(a, b) {
75
+ if (typeof a !== 'string' || typeof b !== 'string' || !a || !b) return false;
76
+ return resolve(a) === resolve(b);
77
+ }
78
+
79
+ // -----------------------------------------------------------------------------
80
+ // §2 — arguments
81
+ // -----------------------------------------------------------------------------
82
+
83
+ const VALUE_FLAGS = new Set(['--state-dir', '--port']);
84
+ const BOOLEAN_FLAGS = new Set(['--dry-run', '--help', '--version']);
85
+ const ALIASES = new Map([['-h', '--help'], ['-V', '--version']]);
86
+
87
+ /**
88
+ * `ours-install [--state-dir PATH] [--port N] [--dry-run] [--help] [--version]`
89
+ *
90
+ * Returns the run's target with both values already resolved, so the opening
91
+ * screen can echo exactly what the run will act on. An unknown flag, a missing
92
+ * value or an out-of-range port is an InstallUsageError (exit 2) — the installer
93
+ * never guesses what an operator meant.
94
+ *
95
+ * `port` is null when not given. That is meaningful: "not given" lets the port be
96
+ * derived from an existing daemon, whereas an explicit value must be honoured or
97
+ * refused, never quietly moved.
98
+ */
99
+ export function parseInstallArgs(argv = [], env = {}, { home = homedir() } = {}) {
100
+ const out = {
101
+ stateDir: null,
102
+ port: null,
103
+ // `stateDir` is always populated (it defaults to ~/.ours), so "was it given?"
104
+ // needs its own flag — the selection screen has to tell an explicit target from
105
+ // a defaulted one, and a defaulted value looks identical to a chosen one.
106
+ stateDirExplicit: false,
107
+ portExplicit: false,
108
+ dryRun: env.OURS_INSTALL_DRY_RUN === '1',
109
+ assumeYes: env.OURS_ASSUME_YES === '1',
110
+ help: false,
111
+ version: false,
112
+ };
113
+ const seen = new Set();
114
+ for (let i = 0; i < argv.length; i += 1) {
115
+ const raw = String(argv[i]);
116
+ const equal = raw.indexOf('=');
117
+ const name = ALIASES.get(equal < 0 ? raw : raw.slice(0, equal)) ?? (equal < 0 ? raw : raw.slice(0, equal));
118
+ if (BOOLEAN_FLAGS.has(name)) {
119
+ if (equal >= 0) throw new InstallUsageError(`${name} does not take a value`);
120
+ if (seen.has(name)) throw new InstallUsageError(`${name} may be given only once`);
121
+ seen.add(name);
122
+ if (name === '--dry-run') out.dryRun = true;
123
+ if (name === '--help') out.help = true;
124
+ if (name === '--version') out.version = true;
125
+ continue;
126
+ }
127
+ if (!VALUE_FLAGS.has(name)) throw new InstallUsageError(`unknown option: ${name}`);
128
+ if (seen.has(name)) throw new InstallUsageError(`${name} may be given only once`);
129
+ seen.add(name);
130
+ const value = equal >= 0 ? raw.slice(equal + 1) : argv[++i];
131
+ if (value === undefined || value === '') throw new InstallUsageError(`${name} requires a value`);
132
+ if (name === '--state-dir') { out.stateDir = resolve(String(value)); out.stateDirExplicit = true; }
133
+ if (name === '--port') {
134
+ if (!/^[0-9]+$/.test(String(value).trim())) throw new InstallUsageError('--port must be an integer');
135
+ const n = Number.parseInt(String(value).trim(), 10);
136
+ if (!Number.isInteger(n) || n < 1 || n > 65535) throw new InstallUsageError('--port must be between 1 and 65535');
137
+ out.port = n;
138
+ out.portExplicit = true;
139
+ }
140
+ }
141
+ if (out.stateDir === null) out.stateDir = resolve(join(home, DEFAULT_STATE_DIR_NAME));
142
+ return out;
143
+ }
144
+
145
+ // -----------------------------------------------------------------------------
146
+ // §§1, 3 — is there a daemon at this state directory?
147
+ // -----------------------------------------------------------------------------
148
+
149
+ /**
150
+ * The port to probe first: the one recorded in <state-dir>/config.json, else the
151
+ * built-in default. An explicit --port does NOT change where we look — the
152
+ * question is which daemon owns this directory, and that is answered by the
153
+ * directory's own record, not by what the operator typed (spec §2 step 2).
154
+ */
155
+ export function candidatePort(config) {
156
+ const recorded = config && typeof config.port === 'number' && Number.isFinite(config.port) ? config.port : null;
157
+ return recorded ?? INSTALL_DEFAULT_PORT;
158
+ }
159
+
160
+ /**
161
+ * Classify one probe result against the target state directory (spec §3).
162
+ *
163
+ * present — an ours daemon answered and reports THIS state directory
164
+ * foreign — something answered, but it is not an ours daemon, or it is one
165
+ * owning a different state directory
166
+ * absent — nothing answered
167
+ *
168
+ * `probe` is `{ ok: true, stateDir }` for a daemon that answered `/state-dir`,
169
+ * or `{ ok: false, reason }` for no answer / non-JSON / HTTP error.
170
+ */
171
+ export function classifyProbe(probe, targetStateDir) {
172
+ if (!probe || probe.ok !== true) return { kind: 'absent', reason: probe?.reason ?? 'no answer' };
173
+ if (typeof probe.stateDir !== 'string' || !probe.stateDir) {
174
+ return { kind: 'foreign', reason: 'answered but did not report a state directory' };
175
+ }
176
+ if (!samePath(probe.stateDir, targetStateDir)) {
177
+ return { kind: 'foreign', reason: 'daemon owns a different state directory', stateDir: resolve(probe.stateDir) };
178
+ }
179
+ return { kind: 'present', stateDir: resolve(probe.stateDir) };
180
+ }
181
+
182
+ /**
183
+ * Find the daemon that owns this state directory, if any.
184
+ *
185
+ * Two lookups, in order, and the second is the one the spec did not have:
186
+ *
187
+ * 1. The port recorded in <state-dir>/config.json (else the default).
188
+ * 2. **The port in <state-dir>/ours-cli-daemon.json.** DO NOT REMOVE THIS AS
189
+ * REDUNDANT. It exists because "the port RECORDED for a state directory" and
190
+ * "the port the daemon is ACTUALLY on" are two different things, and lookup 1
191
+ * only knows the first. Two ways they diverge, both real:
192
+ * - a daemon started by hand with OURS_PORT=3060 records nothing in
193
+ * config.json at all;
194
+ * - an operator edits config.json's port and does not restart, so the file
195
+ * says one thing and the running daemon another.
196
+ * In both cases lookup 1 misses and the caller would conclude "no daemon here"
197
+ * and create a SECOND daemon on the SAME state directory. There is no
198
+ * cross-process lock on a state directory, and two writers on one
199
+ * state_data.bin is a corruption case. So any daemon reporting this state
200
+ * directory on ANY port we can learn about counts as present — and a
201
+ * disagreeing --port is then refused against the port the daemon is really on,
202
+ * not against the stale one in the file.
203
+ *
204
+ * A PID record whose port does not answer is STALE, not present — reported as
205
+ * such so the caller can say why it is creating a daemon.
206
+ *
207
+ * Injected IO: `probe(port)`, `readJson(path)`.
208
+ */
209
+ /*
210
+ * ASYNC, AND THAT IS AMENDING #56 TOO. The injected `probe` reaches a socket:
211
+ * lib/effects.mjs implements it with fetch, so it returns a PROMISE. Called
212
+ * synchronously, classifyProbe saw a Promise, found no `.ok` on it, and read
213
+ * every daemon in the world as absent — so the real installer never detected an
214
+ * existing daemon at all and took the create path every single time. The pure
215
+ * tests could not see it, because a fake probe returns a plain object and a
216
+ * plain object is exactly what synchronous code needs.
217
+ *
218
+ * Awaiting an injected function costs the purity of this module nothing: there
219
+ * is still no I/O here, and `await` on a non-promise is the same value back, so
220
+ * every existing fake keeps working unchanged.
221
+ */
222
+ export async function findDaemon({ stateDir, probe, readJson, readText }) {
223
+ const target = resolve(stateDir);
224
+ const config = readJson(join(target, DAEMON_CONFIG));
225
+ const recordedPort = config && typeof config.port === 'number' && Number.isFinite(config.port) ? config.port : null;
226
+ const first = recordedPort ?? INSTALL_DEFAULT_PORT;
227
+ // Did this directory TELL us that port, or did we guess it? The whole
228
+ // treatment of a foreign answer turns on the difference.
229
+ const guessed = recordedPort === null;
230
+ const byConfig = classifyProbe(await probe(first), target);
231
+ if (byConfig.kind === 'present') return { ...byConfig, port: first, via: 'config', config };
232
+ if (byConfig.kind === 'foreign' && !guessed) return { ...byConfig, port: first, via: 'config', config };
233
+
234
+ const records = readPidRecords(target, readJson, readText);
235
+ const recorded = records.port;
236
+ if (recorded !== null && recorded !== first) {
237
+ const byRecord = classifyProbe(await probe(recorded), target);
238
+ if (byRecord.kind === 'present') return { ...byRecord, port: recorded, via: 'pid-record', config };
239
+ // A foreign daemon on the PID record's port says nothing about OUR daemon —
240
+ // the record is simply stale. Do not refuse the run over it.
241
+ return { kind: 'absent', reason: 'stale PID record', stalePidRecord: recorded, port: first, via: 'config', config };
242
+ }
243
+ // A FOREIGN DAEMON ON A PORT WE GUESSED IS NOT A REASON TO REFUSE.
244
+ //
245
+ // AMENDS #56. As first written, any foreign answer on the candidate port
246
+ // refused the run. But a state directory with no recorded port has told us
247
+ // nothing, so the candidate is the built-in default — which, on any machine
248
+ // that already runs a daemon, is where the FIRST daemon answers. The result
249
+ // was that a second daemon could never be created while the first was up:
250
+ // §7 coexistence was unreachable, and the refusal's own advice ("re-run with
251
+ // --port for a free port") could not work either, because an explicit --port
252
+ // deliberately does not change where we look.
253
+ //
254
+ // This is the same argument the stale-PID-record branch above already makes,
255
+ // one layer over: an answer on a port we guessed says nothing about whether a
256
+ // daemon owns THIS directory. A foreign answer on a port the directory
257
+ // actually RECORDED still refuses, because there the operator's own file said
258
+ // the daemon was there and something else is.
259
+ // A bare pid record with no port still says a daemon OWNS this directory. It
260
+ // cannot say where, so this is not "present" — but it is a reason to report a
261
+ // stale record rather than silently create a second daemon beside it.
262
+ if (records.pid !== null && byConfig.kind !== 'present') {
263
+ return { kind: 'absent', reason: 'a daemon pid record exists but nothing answers', stalePidRecord: first, port: first, via: 'config', config };
264
+ }
265
+ if (byConfig.kind === 'foreign') {
266
+ return {
267
+ kind: 'absent',
268
+ reason: 'another daemon holds the default port',
269
+ port: first,
270
+ via: 'config',
271
+ config,
272
+ defaultPortHeldBy: { port: first, stateDir: byConfig.stateDir ?? null },
273
+ };
274
+ }
275
+ return { kind: 'absent', reason: byConfig.reason, port: first, via: 'config', config };
276
+ }
277
+
278
+ // -----------------------------------------------------------------------------
279
+ // §2 — the derived-port rule
280
+ // -----------------------------------------------------------------------------
281
+
282
+ /**
283
+ * Decide what this run does about the daemon at `stateDir`. One of:
284
+ *
285
+ * { action: 'update', port } — a daemon already owns this directory
286
+ * { action: 'create', port } — none does; this run creates one
287
+ * { action: 'refuse', exitCode: 2, reason, … } — write nothing
288
+ *
289
+ * Refusals, both following the SDK's precedent of refusing an incoherent
290
+ * selection rather than silently correcting it:
291
+ *
292
+ * - an explicit `--port` that disagrees with the port the existing daemon is
293
+ * actually on. Ignoring it would leave the operator believing they addressed
294
+ * the daemon on the port they typed while the run touched a different one.
295
+ * - something answering the candidate port that is not this directory's daemon.
296
+ *
297
+ * When creating: an explicit `--port` is used exactly as given and NEVER moved —
298
+ * if it is occupied that is a refusal, not a reason to shift. Only a derived port
299
+ * is searched, from 3050 upward, skipping the reserved defaults.
300
+ */
301
+ export async function resolveTarget({ stateDir, port = null, portExplicit = false, probe, readJson, readText, isTaken }) {
302
+ const target = resolve(stateDir);
303
+ const found = await findDaemon({ stateDir: target, probe, readJson, readText });
304
+
305
+ if (found.kind === 'foreign') {
306
+ return {
307
+ action: 'refuse',
308
+ exitCode: 2,
309
+ reason: 'foreign-daemon',
310
+ port: found.port,
311
+ stateDir: target,
312
+ otherStateDir: found.stateDir ?? null,
313
+ message: found.stateDir
314
+ ? `port ${found.port} answers, but that daemon owns state directory ${found.stateDir}, not ${target}`
315
+ : `port ${found.port} answers, but it is not an ours daemon`,
316
+ };
317
+ }
318
+
319
+ if (found.kind === 'present') {
320
+ if (portExplicit && port !== found.port) {
321
+ return {
322
+ action: 'refuse',
323
+ exitCode: 2,
324
+ reason: 'port-mismatch',
325
+ port: found.port,
326
+ stateDir: target,
327
+ message: `--port ${port} disagrees with port ${found.port}, where the daemon for ${target} is actually running`,
328
+ };
329
+ }
330
+ return { action: 'update', port: found.port, stateDir: target, config: found.config };
331
+ }
332
+
333
+ // Creating. Only now is a port chosen.
334
+ if (portExplicit) {
335
+ if (isTaken(port)) {
336
+ return {
337
+ action: 'refuse',
338
+ exitCode: 2,
339
+ reason: 'port-occupied',
340
+ port,
341
+ stateDir: target,
342
+ message: `port ${port} is already in use, and an explicit --port is never moved`,
343
+ };
344
+ }
345
+ return {
346
+ action: 'create',
347
+ port,
348
+ stateDir: target,
349
+ stalePidRecord: found.stalePidRecord ?? null,
350
+ defaultPortHeldBy: found.defaultPortHeldBy ?? null,
351
+ // Purely informational: honoured as typed, but the operator should see it.
352
+ reservedNotice: INSTALL_RESERVED_PORTS.includes(port) ? port : null,
353
+ };
354
+ }
355
+ const free = searchFreePort(isTaken);
356
+ if (free === null) {
357
+ // Consistent with every other decision here: refuse an impossible selection
358
+ // rather than widen the band, loop, or reuse a bound port.
359
+ return {
360
+ action: 'refuse',
361
+ exitCode: 2,
362
+ reason: 'no-free-port',
363
+ port: null,
364
+ stateDir: target,
365
+ searched: { from: FREE_PORT_FLOOR, to: FREE_PORT_FLOOR + FREE_PORT_SPAN - 1 },
366
+ message: `no free port between ${FREE_PORT_FLOOR} and ${FREE_PORT_FLOOR + FREE_PORT_SPAN - 1}; pass --port explicitly to name one`,
367
+ };
368
+ }
369
+ return {
370
+ action: 'create',
371
+ port: free,
372
+ stateDir: target,
373
+ stalePidRecord: found.stalePidRecord ?? null,
374
+ defaultPortHeldBy: found.defaultPortHeldBy ?? null,
375
+ reservedNotice: null,
376
+ };
377
+ }
378
+
379
+ /**
380
+ * First free port from 3050 upward, skipping other components' defaults. Returns
381
+ * null when the band is exhausted rather than looping or reusing a bound port —
382
+ * the caller reports it.
383
+ */
384
+ export function searchFreePort(isTaken, { floor = FREE_PORT_FLOOR, reserved = INSTALL_RESERVED_PORTS, span = FREE_PORT_SPAN } = {}) {
385
+ for (let p = floor; p < floor + span; p += 1) {
386
+ if (reserved.includes(p)) continue;
387
+ if (!isTaken(p)) return p;
388
+ }
389
+ return null;
390
+ }