@ours.network/install 0.17.0-nightly.9 → 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/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
- }