@ours.network/install 0.17.0-nightly.8 → 0.17.0

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