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