@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/lib/profiles.mjs DELETED
@@ -1,524 +0,0 @@
1
- // ─────────────────────────────────────────────────────────────────────────────
2
- // NO LONGER REACHED BY ANY CODE PATH, DELIBERATELY AND TEMPORARILY.
3
- //
4
- // The nightly channel now runs the v3 installer end to end (owner ruling
5
- // 2026-08-17: v3 SUBSUMES the nightly flow), so the two dispatch sites that led
6
- // here — install.mjs and uninstall.mjs — were removed. This file is retained on
7
- // purpose rather than deleted in the same commit.
8
- //
9
- // WHY IT IS STILL HERE. The behaviour inventory
10
- // (/home/fleet/work/dev1-installer-notes/NIGHTLY-BEHAVIOUR-INVENTORY.md) lists
11
- // what this flow does that v3 does not, and that list is not finished being
12
- // carried across. Deleting the implementation and its tests before the one-for-one
13
- // replacement exists is how a test that was covering something real gets removed
14
- // alongside the ones that were not. Its tests still run and still pass, because
15
- // they exercise this module directly.
16
- //
17
- // The retirement — removing this file and retiring each of its tests against a
18
- // named v3 equivalent — is its own piece of work. Until then this is ORPHANED AND
19
- // SAID SO, which is the opposite of the failure the staging existed to prevent:
20
- // seven commits of feature reachable by no code path, with git reporting no
21
- // conflict and nothing saying it had happened.
22
- // ─────────────────────────────────────────────────────────────────────────────
23
-
24
- import {
25
- chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync,
26
- unlinkSync, writeFileSync,
27
- } from 'node:fs';
28
- import { homedir } from 'node:os';
29
- import { basename, dirname, isAbsolute, join, normalize, resolve } from 'node:path';
30
-
31
- export const PROFILE_REGISTRY_VERSION = 1;
32
- export const HARNESS_APPLICATIONS = ['claude-code', 'codex', 'hermes'];
33
- export const DEFAULT_PROFILE_ID = 'default';
34
- export const DEFAULT_PROFILE_PORT = 3050;
35
- const PROFILE_ID_RE = /^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,30}[A-Za-z0-9])?$/;
36
- const SECRET_KEYS = /token|secret|password|credential|apiKey/i;
37
-
38
- export class ProfileRegistryError extends Error {
39
- constructor(message, code = 'INVALID_REGISTRY') {
40
- super(message);
41
- this.name = 'ProfileRegistryError';
42
- this.code = code;
43
- }
44
- }
45
-
46
- export function profilesPath(env = process.env, home = homedir()) {
47
- return env.OURS_INSTALL_PROFILES
48
- ? normalizeProfilePath(env.OURS_INSTALL_PROFILES, 'OURS_INSTALL_PROFILES')
49
- : join(home, '.ours', 'installer-profiles.json');
50
- }
51
-
52
- export function emptyRegistry() {
53
- return { version: PROFILE_REGISTRY_VERSION, profiles: {}, harnessAssociations: {} };
54
- }
55
-
56
- export function normalizeLoopbackHost(raw) {
57
- const value = String(raw ?? '').trim().toLowerCase();
58
- if (value === 'localhost' || value === '127.0.0.1') return '127.0.0.1';
59
- throw new ProfileRegistryError(
60
- `host must be localhost or 127.0.0.1 (got ${JSON.stringify(raw)})`,
61
- 'UNSAFE_HOST',
62
- );
63
- }
64
-
65
- export function normalizeProfileId(raw, { allowDefault = true } = {}) {
66
- const value = typeof raw === 'string' ? raw.trim() : '';
67
- if (!value) throw new ProfileRegistryError('profile id is required', 'INVALID_PROFILE_ID');
68
- if (!allowDefault && value === DEFAULT_PROFILE_ID) {
69
- throw new ProfileRegistryError('profile id "default" is reserved', 'INVALID_PROFILE_ID');
70
- }
71
- if (value.length > 32 || !PROFILE_ID_RE.test(value)) {
72
- throw new ProfileRegistryError(
73
- 'profile id must be 1–32 letters, digits, hyphens or underscores, starting and ending with a letter or digit',
74
- 'INVALID_PROFILE_ID',
75
- );
76
- }
77
- return value;
78
- }
79
-
80
- export function normalizeProfilePath(raw, field) {
81
- if (typeof raw !== 'string' || !raw.trim()) {
82
- throw new ProfileRegistryError(`${field} must be a non-empty absolute path`, 'UNSAFE_PATH');
83
- }
84
- const value = raw.trim();
85
- if (value.includes('\0') || !isAbsolute(value) || normalize(value) !== value || value === '/') {
86
- throw new ProfileRegistryError(`${field} must be a normalized absolute path below the filesystem root`, 'UNSAFE_PATH');
87
- }
88
- return value;
89
- }
90
-
91
- export function normalizeServiceName(raw) {
92
- const value = typeof raw === 'string' ? raw.trim() : '';
93
- if (!value) return '';
94
- if (value.length > 32 || !PROFILE_ID_RE.test(value)) {
95
- throw new ProfileRegistryError(
96
- 'serviceName must be empty or use the 1–32 character daemon instance-name format',
97
- 'INVALID_SERVICE_NAME',
98
- );
99
- }
100
- return value;
101
- }
102
-
103
- function boolOwnership(value, field) {
104
- if (typeof value !== 'boolean') {
105
- throw new ProfileRegistryError(`ownership.${field} must be boolean`, 'INVALID_OWNERSHIP');
106
- }
107
- return value;
108
- }
109
-
110
- function assertNoSecrets(value, where = 'registry') {
111
- if (!value || typeof value !== 'object') return;
112
- for (const [key, nested] of Object.entries(value)) {
113
- if (SECRET_KEYS.test(key)) {
114
- throw new ProfileRegistryError(`${where} must never contain secret field ${JSON.stringify(key)}`, 'SECRET_IN_REGISTRY');
115
- }
116
- if (nested && typeof nested === 'object') assertNoSecrets(nested, `${where}.${key}`);
117
- }
118
- }
119
-
120
- function assertOnlyKeys(value, allowed, where) {
121
- for (const key of Object.keys(value)) {
122
- if (!allowed.includes(key)) {
123
- throw new ProfileRegistryError(`${where} contains unsupported field ${JSON.stringify(key)}`, 'INVALID_SCHEMA');
124
- }
125
- }
126
- }
127
-
128
- export function normalizeProfile(id, input) {
129
- const profileId = normalizeProfileId(id);
130
- if (!input || typeof input !== 'object' || Array.isArray(input)) {
131
- throw new ProfileRegistryError(`profile ${profileId} must be an object`, 'INVALID_PROFILE');
132
- }
133
- assertNoSecrets(input, `profiles.${profileId}`);
134
- assertOnlyKeys(input, ['label', 'host', 'port', 'configPath', 'stateDir', 'serviceName', 'ownership'], `profiles.${profileId}`);
135
- const label = typeof input.label === 'string' ? input.label.trim() : '';
136
- if (!label || label.length > 120 || /[\r\n\0]/.test(label)) {
137
- throw new ProfileRegistryError(`profile ${profileId} label must be 1–120 printable characters`, 'INVALID_PROFILE');
138
- }
139
- const port = Number(input.port);
140
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
141
- throw new ProfileRegistryError(`profile ${profileId} port must be an integer from 1 to 65535`, 'INVALID_PORT');
142
- }
143
- const configPath = normalizeProfilePath(input.configPath, `profiles.${profileId}.configPath`);
144
- const stateDir = normalizeProfilePath(input.stateDir, `profiles.${profileId}.stateDir`);
145
- if (configPath === stateDir || dirname(configPath) === configPath) {
146
- throw new ProfileRegistryError(`profile ${profileId} configPath and stateDir must be distinct`, 'UNSAFE_PATH');
147
- }
148
- const ownership = input.ownership;
149
- if (!ownership || typeof ownership !== 'object' || Array.isArray(ownership)) {
150
- throw new ProfileRegistryError(`profile ${profileId} requires explicit ownership`, 'INVALID_OWNERSHIP');
151
- }
152
- assertOnlyKeys(ownership, ['config', 'service', 'state'], `profiles.${profileId}.ownership`);
153
- const serviceName = normalizeServiceName(input.serviceName);
154
- if (profileId !== DEFAULT_PROFILE_ID && !serviceName) {
155
- throw new ProfileRegistryError(
156
- `profile ${profileId} requires a named service instance; the empty serviceName is reserved for profile "default"`,
157
- 'INVALID_SERVICE_NAME',
158
- );
159
- }
160
- return {
161
- label,
162
- host: normalizeLoopbackHost(input.host),
163
- port,
164
- configPath,
165
- stateDir,
166
- serviceName,
167
- ownership: {
168
- config: boolOwnership(ownership.config, 'config'),
169
- service: boolOwnership(ownership.service, 'service'),
170
- state: boolOwnership(ownership.state, 'state'),
171
- },
172
- };
173
- }
174
-
175
- export function validateRegistry(input) {
176
- if (!input || typeof input !== 'object' || Array.isArray(input)) {
177
- throw new ProfileRegistryError('profile registry must be an object');
178
- }
179
- assertNoSecrets(input);
180
- assertOnlyKeys(input, ['version', 'profiles', 'harnessAssociations'], 'registry');
181
- if (input.version !== PROFILE_REGISTRY_VERSION) {
182
- throw new ProfileRegistryError(
183
- `unsupported profile registry version ${JSON.stringify(input.version)}; expected ${PROFILE_REGISTRY_VERSION}`,
184
- 'UNSUPPORTED_VERSION',
185
- );
186
- }
187
- if (!input.profiles || typeof input.profiles !== 'object' || Array.isArray(input.profiles)) {
188
- throw new ProfileRegistryError('profile registry profiles must be an object');
189
- }
190
- if (!input.harnessAssociations || typeof input.harnessAssociations !== 'object' || Array.isArray(input.harnessAssociations)) {
191
- throw new ProfileRegistryError('profile registry harnessAssociations must be an object');
192
- }
193
- const profiles = {};
194
- for (const [id, value] of Object.entries(input.profiles)) profiles[normalizeProfileId(id)] = normalizeProfile(id, value);
195
- const harnessAssociations = {};
196
- for (const [application, profileId] of Object.entries(input.harnessAssociations)) {
197
- if (!HARNESS_APPLICATIONS.includes(application)) {
198
- throw new ProfileRegistryError(`unknown harness association ${JSON.stringify(application)}`, 'INVALID_ASSOCIATION');
199
- }
200
- if (typeof profileId !== 'string' || !profiles[profileId]) {
201
- throw new ProfileRegistryError(`association ${application} names missing profile ${JSON.stringify(profileId)}`, 'INVALID_ASSOCIATION');
202
- }
203
- harnessAssociations[application] = profileId;
204
- }
205
- const normalized = { version: PROFILE_REGISTRY_VERSION, profiles, harnessAssociations };
206
- assertNoProfileCollisions(normalized.profiles);
207
- return normalized;
208
- }
209
-
210
- export function readRegistry(path = profilesPath(), { allowMissing = true } = {}) {
211
- let text;
212
- try {
213
- text = readFileSync(path, 'utf8');
214
- } catch (error) {
215
- if (allowMissing && error?.code === 'ENOENT') return emptyRegistry();
216
- throw new ProfileRegistryError(`cannot read profile registry ${path}: ${error?.message || error}`, 'READ_FAILED');
217
- }
218
- let parsed;
219
- try { parsed = JSON.parse(text); }
220
- catch (error) { throw new ProfileRegistryError(`profile registry ${path} is corrupt JSON: ${error.message}`, 'CORRUPT_JSON'); }
221
- return validateRegistry(parsed);
222
- }
223
-
224
- export function registryText(registry) {
225
- return JSON.stringify(validateRegistry(registry), null, 2) + '\n';
226
- }
227
-
228
- export function writeRegistry(path, registry, { rename = renameSync } = {}) {
229
- const text = registryText(registry);
230
- try {
231
- if (existsSync(path) && readFileSync(path, 'utf8') === text) {
232
- chmodSync(path, 0o600);
233
- return { changed: false, path };
234
- }
235
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
236
- const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
237
- try {
238
- writeFileSync(tmp, text, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
239
- chmodSync(tmp, 0o600);
240
- rename(tmp, path);
241
- chmodSync(path, 0o600);
242
- } catch (error) {
243
- try { unlinkSync(tmp); } catch { /* absent or renamed */ }
244
- throw error;
245
- }
246
- return { changed: true, path };
247
- } catch (error) {
248
- if (error instanceof ProfileRegistryError) throw error;
249
- throw new ProfileRegistryError(`cannot atomically write profile registry ${path}: ${error?.message || error}`, 'WRITE_FAILED');
250
- }
251
- }
252
-
253
- export function endpointKey(profile) {
254
- return `${normalizeLoopbackHost(profile.host)}:${Number(profile.port)}`;
255
- }
256
-
257
- export function canonicalStateDir(path, realpath = realpathSync) {
258
- const normalized = normalizeProfilePath(path, 'stateDir');
259
- try { return realpath(normalized); } catch { return normalized; }
260
- }
261
-
262
- function canonicalStateForDependency(path, realpath) {
263
- const normalized = resolve(path);
264
- const suffix = [];
265
- let existingAncestor = normalized;
266
- while (true) {
267
- try {
268
- const canonicalAncestor = realpath(existingAncestor);
269
- return { known: true, path: resolve(canonicalAncestor, ...suffix.reverse()) };
270
- } catch (error) {
271
- // A nonexistent leaf is deterministic: canonicalize its nearest existing
272
- // ancestor so symlinked parents and lexical missing paths still compare.
273
- // Permission/I/O failures are ambiguous and must remain fail-closed.
274
- if (error?.code !== 'ENOENT') return { known: false };
275
- const parent = dirname(existingAncestor);
276
- if (parent === existingAncestor) return { known: false };
277
- suffix.push(basename(existingAncestor));
278
- existingAncestor = parent;
279
- }
280
- }
281
- }
282
-
283
- export function compareDependencyStateDirs(left, right, { realpath = realpathSync } = {}) {
284
- const normalizedLeft = resolve(left);
285
- const normalizedRight = resolve(right);
286
- if (normalizedLeft === normalizedRight) return 'equal';
287
- const canonicalLeft = canonicalStateForDependency(normalizedLeft, realpath);
288
- const canonicalRight = canonicalStateForDependency(normalizedRight, realpath);
289
- if (!canonicalLeft.known || !canonicalRight.known) return 'unknown';
290
- return canonicalLeft.path === canonicalRight.path ? 'equal' : 'different';
291
- }
292
-
293
- export function profileConflicts(profiles, id, candidate, { realpath = realpathSync } = {}) {
294
- const normalized = normalizeProfile(id, candidate);
295
- const endpoint = endpointKey(normalized);
296
- const state = canonicalStateDir(normalized.stateDir, realpath);
297
- const conflicts = [];
298
- for (const [otherId, otherValue] of Object.entries(profiles || {})) {
299
- if (otherId === id) continue;
300
- const other = normalizeProfile(otherId, otherValue);
301
- if (endpointKey(other) === endpoint) conflicts.push({ field: 'endpoint', profileId: otherId, value: endpoint });
302
- if (canonicalStateDir(other.stateDir, realpath) === state) conflicts.push({ field: 'stateDir', profileId: otherId, value: state });
303
- if (other.configPath === normalized.configPath) conflicts.push({ field: 'configPath', profileId: otherId, value: normalized.configPath });
304
- if (other.serviceName === normalized.serviceName) conflicts.push({ field: 'serviceName', profileId: otherId, value: normalized.serviceName });
305
- }
306
- return conflicts;
307
- }
308
-
309
- export function assertNoProfileCollisions(profiles, options) {
310
- for (const [id, profile] of Object.entries(profiles || {})) {
311
- const conflicts = profileConflicts(profiles, id, profile, options);
312
- if (conflicts.length) {
313
- const c = conflicts[0];
314
- throw new ProfileRegistryError(
315
- `profile ${id} collides with ${c.profileId} on ${c.field} ${JSON.stringify(c.value)}`,
316
- 'PROFILE_COLLISION',
317
- );
318
- }
319
- }
320
- }
321
-
322
- export function upsertProfile(registry, id, profile, options) {
323
- const current = validateRegistry(registry);
324
- const profileId = normalizeProfileId(id);
325
- const nextProfile = normalizeProfile(profileId, profile);
326
- const conflicts = profileConflicts(current.profiles, profileId, nextProfile, options);
327
- if (conflicts.length) {
328
- const c = conflicts[0];
329
- throw new ProfileRegistryError(
330
- `profile ${profileId} collides with ${c.profileId} on ${c.field} ${JSON.stringify(c.value)}`,
331
- 'PROFILE_COLLISION',
332
- );
333
- }
334
- return { ...current, profiles: { ...current.profiles, [profileId]: nextProfile } };
335
- }
336
-
337
- export function associateHarness(registry, application, profileId, { allowReassign = false } = {}) {
338
- const current = validateRegistry(registry);
339
- if (!HARNESS_APPLICATIONS.includes(application)) {
340
- throw new ProfileRegistryError(`unknown harness ${JSON.stringify(application)}`, 'INVALID_ASSOCIATION');
341
- }
342
- if (!current.profiles[profileId]) {
343
- throw new ProfileRegistryError(`cannot associate ${application} with missing profile ${JSON.stringify(profileId)}`, 'INVALID_ASSOCIATION');
344
- }
345
- const previous = current.harnessAssociations[application];
346
- if (previous && previous !== profileId && !allowReassign) {
347
- throw new ProfileRegistryError(
348
- `${application} is already associated with profile ${previous}; explicit reassignment confirmation is required`,
349
- 'REASSIGN_CONFIRMATION_REQUIRED',
350
- );
351
- }
352
- return {
353
- registry: {
354
- ...current,
355
- harnessAssociations: { ...current.harnessAssociations, [application]: profileId },
356
- },
357
- changed: previous !== profileId,
358
- previous,
359
- };
360
- }
361
-
362
- export function removeHarnessAssociation(registry, application) {
363
- const current = validateRegistry(registry);
364
- const previous = current.harnessAssociations[application];
365
- if (!previous) return { registry: current, changed: false };
366
- const harnessAssociations = { ...current.harnessAssociations };
367
- delete harnessAssociations[application];
368
- return { registry: { ...current, harnessAssociations }, changed: true, previous };
369
- }
370
-
371
- function externalDaemon(value) {
372
- if (!value || typeof value !== 'object') return null;
373
- const endpoint = value.endpoint || value.daemonUrl;
374
- const stateDir = value.stateDir || value.daemonStateDir;
375
- if (typeof endpoint !== 'string' || typeof stateDir !== 'string') return null;
376
- let url;
377
- try { url = new URL(endpoint); } catch { return null; }
378
- if (url.protocol !== 'http:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) return null;
379
- let host;
380
- try { host = normalizeLoopbackHost(url.hostname); } catch { return null; }
381
- const port = Number(url.port || 80);
382
- if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
383
- return { host, port, stateDir: resolve(stateDir) };
384
- }
385
-
386
- export function reverseApplicationIndex(registry, { telegramConfig, roomsConfig } = {}, options = {}) {
387
- const current = validateRegistry(registry);
388
- const index = Object.fromEntries(Object.keys(current.profiles).map((id) => [id, []]));
389
- for (const [application, profileId] of Object.entries(current.harnessAssociations)) index[profileId].push(application);
390
- const connectorTargets = [
391
- ['telegram', externalDaemon(telegramConfig)],
392
- ['rooms', externalDaemon(roomsConfig?.daemon)],
393
- ];
394
- for (const [application, target] of connectorTargets) {
395
- if (!target) continue;
396
- for (const [id, profile] of Object.entries(current.profiles)) {
397
- if (endpointKey(profile) !== `${target.host}:${target.port}`) continue;
398
- const stateComparison = compareDependencyStateDirs(profile.stateDir, target.stateDir, options);
399
- // Unknown canonicalization is a dependency for deletion safety. Operators
400
- // can still proceed through an explicit connector lifecycle action.
401
- if (stateComparison !== 'different') {
402
- index[id].push(application);
403
- }
404
- }
405
- }
406
- for (const applications of Object.values(index)) applications.sort();
407
- return index;
408
- }
409
-
410
- function candidateFromProfile(id, profile, origin) {
411
- const value = normalizeProfile(id, {
412
- label: profile.label,
413
- host: profile.host,
414
- port: profile.port,
415
- configPath: profile.configPath,
416
- stateDir: profile.stateDir,
417
- serviceName: profile.serviceName,
418
- ownership: profile.ownership,
419
- });
420
- return { id, ...value, origin, configured: true, reachable: false };
421
- }
422
-
423
- export function dedupeCandidates(candidates, { realpath = realpathSync } = {}) {
424
- const accepted = [];
425
- for (const raw of candidates) {
426
- const id = normalizeProfileId(raw.id);
427
- const candidate = { ...candidateFromProfile(id, raw, raw.origin || 'candidate'), ...raw, id };
428
- const endpoint = endpointKey(candidate);
429
- const state = canonicalStateDir(candidate.stateDir, realpath);
430
- const exact = accepted.find((item) => endpointKey(item) === endpoint && canonicalStateDir(item.stateDir, realpath) === state);
431
- if (exact) {
432
- exact.origins = [...new Set([...(exact.origins || [exact.origin]), ...(candidate.origins || [candidate.origin])])];
433
- exact.reachable ||= candidate.reachable;
434
- continue;
435
- }
436
- const endpointConflict = accepted.find((item) => endpointKey(item) === endpoint);
437
- if (endpointConflict) {
438
- throw new ProfileRegistryError(
439
- `candidate endpoint ${endpoint} names different state directories (${endpointConflict.stateDir} and ${candidate.stateDir})`,
440
- 'DISCOVERY_COLLISION',
441
- );
442
- }
443
- const stateConflict = accepted.find((item) => canonicalStateDir(item.stateDir, realpath) === state);
444
- if (stateConflict) {
445
- throw new ProfileRegistryError(
446
- `candidate state directory ${state} names different endpoints (${endpointKey(stateConflict)} and ${endpoint})`,
447
- 'DISCOVERY_COLLISION',
448
- );
449
- }
450
- accepted.push({ ...candidate, origins: candidate.origins || [candidate.origin] });
451
- }
452
- return accepted;
453
- }
454
-
455
- export async function probeProfileCandidate(candidate, { fetch: fetchImpl = globalThis.fetch, timeoutMs = 1500 } = {}) {
456
- const normalized = candidateFromProfile(candidate.id, candidate, candidate.origin || 'candidate');
457
- const base = `http://${normalized.host}:${normalized.port}`;
458
- const request = async (path) => {
459
- const controller = new AbortController();
460
- const timer = setTimeout(() => controller.abort(), timeoutMs);
461
- try { return await fetchImpl(`${base}${path}`, { signal: controller.signal }); }
462
- finally { clearTimeout(timer); }
463
- };
464
- try {
465
- const infoResponse = await request('/info');
466
- if (!infoResponse.ok) return { ...normalized, reachable: true, compatible: false, error: `HTTP ${infoResponse.status} from /info` };
467
- const info = await infoResponse.json();
468
- if (info?.name !== 'ours' || typeof info?.stateDir !== 'string') {
469
- return { ...normalized, reachable: true, compatible: false, error: 'endpoint is not an ours daemon' };
470
- }
471
- if (canonicalStateDir(info.stateDir) !== canonicalStateDir(normalized.stateDir)) {
472
- return { ...normalized, reachable: true, compatible: false, drift: true, error: `daemon reports stateDir ${info.stateDir}` };
473
- }
474
- let versionResponse;
475
- try {
476
- versionResponse = await request('/version');
477
- } catch (error) {
478
- return { ...normalized, reachable: true, compatible: false, info, error: `/version failed: ${String(error?.message || error)}` };
479
- }
480
- if (!versionResponse.ok) {
481
- return { ...normalized, reachable: true, compatible: false, info, error: `HTTP ${versionResponse.status} from /version` };
482
- }
483
- let version = '';
484
- try { version = String((await versionResponse.json())?.version || ''); }
485
- catch { return { ...normalized, reachable: true, compatible: false, info, error: '/version returned invalid JSON' }; }
486
- if (!version) return { ...normalized, reachable: true, compatible: false, info, error: '/version did not report a version' };
487
- if (typeof info.version === 'string' && info.version && info.version !== version) {
488
- return { ...normalized, reachable: true, compatible: false, info, version, error: `/info and /version disagree (${info.version} vs ${version})` };
489
- }
490
- return { ...normalized, reachable: true, compatible: true, info, version };
491
- } catch (error) {
492
- return { ...normalized, reachable: false, compatible: null, error: error?.name === 'AbortError' ? 'probe timed out' : String(error?.message || error) };
493
- }
494
- }
495
-
496
- export async function discoverProfileCandidates({
497
- registry = emptyRegistry(), defaultCandidate, legacyCandidates = [], connectorCandidates = [],
498
- serviceCandidates = [], manualCandidate, probe = probeProfileCandidate, realpath = realpathSync,
499
- } = {}) {
500
- const current = validateRegistry(registry);
501
- const ordered = [];
502
- for (const [id, profile] of Object.entries(current.profiles)) ordered.push(candidateFromProfile(id, profile, 'registry'));
503
- if (defaultCandidate) ordered.push({ ...defaultCandidate, origin: 'default-config' });
504
- for (const candidate of legacyCandidates) ordered.push({ ...candidate, origin: candidate.origin || 'legacy-config' });
505
- for (const candidate of connectorCandidates) ordered.push({ ...candidate, origin: candidate.origin || 'connector-config' });
506
- for (const candidate of serviceCandidates) ordered.push({ ...candidate, origin: candidate.origin || 'known-service' });
507
- if (manualCandidate) ordered.push({ ...manualCandidate, origin: 'manual' });
508
- const deduped = dedupeCandidates(ordered, { realpath });
509
- const out = [];
510
- for (const candidate of deduped) out.push(await probe(candidate));
511
- return out;
512
- }
513
-
514
- export function defaultHistoricalProfile(home = homedir()) {
515
- return {
516
- label: 'Default ours daemon', host: '127.0.0.1', port: DEFAULT_PROFILE_PORT,
517
- configPath: join(home, '.ours', 'config.json'), stateDir: join(home, '.ours'), serviceName: '',
518
- ownership: { config: true, service: true, state: true },
519
- };
520
- }
521
-
522
- export function fileMode(path) {
523
- return statSync(path).mode & 0o777;
524
- }
package/lib/rerun.mjs DELETED
@@ -1,119 +0,0 @@
1
- // ours-install v3 — re-running, and a second daemon alongside the first.
2
- //
3
- // Spec: installer-spec-v3 §§6-7. Pure, like the earlier stages.
4
- //
5
- // Two properties this file exists to make checkable rather than hoped for:
6
- //
7
- // IDEMPOTENCE (§6). Running the installer again with the same answers changes
8
- // nothing except refreshed npm packages. Not "changes little" — nothing: no
9
- // config written, no unit rewritten, no systemctl run, no daemon restarted.
10
- //
11
- // COEXISTENCE (§7). Two daemons share no per-daemon artefact. Everything keyed
12
- // to a daemon is derived from its state directory, so two state directories
13
- // produce two of everything.
14
-
15
- import { join, resolve } from 'node:path';
16
- import { unitNameForStateDir } from './plan.mjs';
17
-
18
- /**
19
- * Everything that belongs to ONE daemon, derived from its state directory (spec
20
- * §7's table). Listing them in one place is what makes "these two daemons share
21
- * nothing" a property a test can check instead of a claim in a document.
22
- *
23
- * `port` is included because it is per-daemon, but note it is NOT what identifies
24
- * one: it is a fact about a daemon, not its name.
25
- */
26
- export function perDaemonArtefacts(stateDir, port) {
27
- const dir = resolve(stateDir);
28
- const unit = unitNameForStateDir(dir);
29
- return {
30
- stateDir: dir,
31
- port,
32
- config: join(dir, 'config.json'),
33
- token: join(dir, 'daemon-token'),
34
- pidRecord: join(dir, 'ours-cli-daemon.json'),
35
- log: join(dir, 'ours-cli-daemon.log'),
36
- unit: unit.ok ? unit.unit : null,
37
- };
38
- }
39
-
40
- /**
41
- * Do two daemons collide anywhere?
42
- *
43
- * Returns the list of colliding fields, empty when they are fully independent.
44
- * The unit name is checked like everything else, and it is the ONE field that can
45
- * collide for two legitimately different state directories — `~/.ours-tg` and
46
- * `/srv/ours-tg` both derive `ours-tg.service`. That is not a bug in the
47
- * derivation, it is the price of a readable unit name, and it is closed one layer
48
- * down: the CLI refuses to overwrite a CLI-managed unit whose baked
49
- * OURS_STATE_DIR is a different daemon's. This function surfaces it so the
50
- * installer can say so before the CLI has to.
51
- */
52
- export function daemonCollisions(a, b) {
53
- const fields = ['stateDir', 'port', 'config', 'token', 'pidRecord', 'log', 'unit'];
54
- return fields.filter((f) => a[f] !== null && a[f] !== undefined && a[f] === b[f]);
55
- }
56
-
57
- /**
58
- * The per-component coexistence rule (spec §7), stated so the screen can never
59
- * imply something the design does not do.
60
- *
61
- * mcp — coexists naturally. Each harness registration carries its own
62
- * OURS_CONFIG, so one harness can point at ~/.ours and another at
63
- * ~/.ours-tg.
64
- * tg — ONE config file with ONE daemon pair and ONE unit. Pointing it at a
65
- * second daemon MOVES it. Running two connectors at once needs a
66
- * second config file and a second unit, which is outside what this
67
- * installer does — stated here so no screen implies otherwise.
68
- * cowork — the same, for its single `daemon` block.
69
- */
70
- export function componentCoexistence(key) {
71
- if (key === 'mcp') {
72
- return { key, coexists: true, why: 'each harness registration carries its own OURS_CONFIG' };
73
- }
74
- return {
75
- key,
76
- coexists: false,
77
- why: 'one config file with one daemon pair and one unit — pointing it elsewhere moves it rather than adding a second',
78
- outOfScope: 'running two at once needs a second config file and a second unit, which this installer does not do',
79
- };
80
- }
81
-
82
- /**
83
- * Summarise a run for the screen, and decide whether it changed anything.
84
- *
85
- * `steps` are the outcomes the orchestrator collected, each
86
- * `{ id, changed: boolean, reason?: string, packageRefresh?: boolean }`.
87
- *
88
- * A repeated run must come back `changedAnything: false` with a reason recorded
89
- * against every step, because "nothing happened" is only trustworthy when the run
90
- * can say why for each thing it did not do. Package refreshes are counted
91
- * separately: `npm i -g` is not idempotent in the same sense and re-running it is
92
- * the one thing a repeat run is allowed to do.
93
- */
94
- export function summarizeRun(steps) {
95
- const changed = steps.filter((s) => s.changed === true && s.packageRefresh !== true);
96
- const refreshed = steps.filter((s) => s.packageRefresh === true).map((s) => s.id);
97
- const noops = steps.filter((s) => s.changed !== true).map((s) => ({ id: s.id, reason: s.reason ?? 'unchanged' }));
98
- return {
99
- changedAnything: changed.length > 0,
100
- changed: changed.map((s) => s.id),
101
- refreshedPackages: refreshed,
102
- noops,
103
- // Every no-op carries a reason: a silent "nothing happened" is
104
- // indistinguishable from a step that was skipped by accident.
105
- allNoopsExplained: noops.every((n) => typeof n.reason === 'string' && n.reason.length > 0),
106
- };
107
- }
108
-
109
- /**
110
- * Did a re-run leave the daemon alone? Spec §3(a): update never deletes state,
111
- * never moves a port, and never creates a second daemon.
112
- */
113
- export function assertUpdateLeftDaemonAlone({ before, after }) {
114
- const problems = [];
115
- if (before.stateDir !== after.stateDir) problems.push('state directory changed');
116
- if (before.port !== after.port) problems.push('port moved');
117
- if (after.created === true) problems.push('a second daemon was created');
118
- return { ok: problems.length === 0, problems };
119
- }