@ours.network/install 0.17.0-nightly.2 → 0.17.0-nightly.4

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.
@@ -0,0 +1,501 @@
1
+ import {
2
+ chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync,
3
+ unlinkSync, writeFileSync,
4
+ } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { basename, dirname, isAbsolute, join, normalize, resolve } from 'node:path';
7
+
8
+ export const PROFILE_REGISTRY_VERSION = 1;
9
+ export const HARNESS_APPLICATIONS = ['claude-code', 'codex', 'hermes'];
10
+ export const DEFAULT_PROFILE_ID = 'default';
11
+ export const DEFAULT_PROFILE_PORT = 3050;
12
+ const PROFILE_ID_RE = /^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,30}[A-Za-z0-9])?$/;
13
+ const SECRET_KEYS = /token|secret|password|credential|apiKey/i;
14
+
15
+ export class ProfileRegistryError extends Error {
16
+ constructor(message, code = 'INVALID_REGISTRY') {
17
+ super(message);
18
+ this.name = 'ProfileRegistryError';
19
+ this.code = code;
20
+ }
21
+ }
22
+
23
+ export function profilesPath(env = process.env, home = homedir()) {
24
+ return env.OURS_INSTALL_PROFILES
25
+ ? normalizeProfilePath(env.OURS_INSTALL_PROFILES, 'OURS_INSTALL_PROFILES')
26
+ : join(home, '.ours', 'installer-profiles.json');
27
+ }
28
+
29
+ export function emptyRegistry() {
30
+ return { version: PROFILE_REGISTRY_VERSION, profiles: {}, harnessAssociations: {} };
31
+ }
32
+
33
+ export function normalizeLoopbackHost(raw) {
34
+ const value = String(raw ?? '').trim().toLowerCase();
35
+ if (value === 'localhost' || value === '127.0.0.1') return '127.0.0.1';
36
+ throw new ProfileRegistryError(
37
+ `host must be localhost or 127.0.0.1 (got ${JSON.stringify(raw)})`,
38
+ 'UNSAFE_HOST',
39
+ );
40
+ }
41
+
42
+ export function normalizeProfileId(raw, { allowDefault = true } = {}) {
43
+ const value = typeof raw === 'string' ? raw.trim() : '';
44
+ if (!value) throw new ProfileRegistryError('profile id is required', 'INVALID_PROFILE_ID');
45
+ if (!allowDefault && value === DEFAULT_PROFILE_ID) {
46
+ throw new ProfileRegistryError('profile id "default" is reserved', 'INVALID_PROFILE_ID');
47
+ }
48
+ if (value.length > 32 || !PROFILE_ID_RE.test(value)) {
49
+ throw new ProfileRegistryError(
50
+ 'profile id must be 1–32 letters, digits, hyphens or underscores, starting and ending with a letter or digit',
51
+ 'INVALID_PROFILE_ID',
52
+ );
53
+ }
54
+ return value;
55
+ }
56
+
57
+ export function normalizeProfilePath(raw, field) {
58
+ if (typeof raw !== 'string' || !raw.trim()) {
59
+ throw new ProfileRegistryError(`${field} must be a non-empty absolute path`, 'UNSAFE_PATH');
60
+ }
61
+ const value = raw.trim();
62
+ if (value.includes('\0') || !isAbsolute(value) || normalize(value) !== value || value === '/') {
63
+ throw new ProfileRegistryError(`${field} must be a normalized absolute path below the filesystem root`, 'UNSAFE_PATH');
64
+ }
65
+ return value;
66
+ }
67
+
68
+ export function normalizeServiceName(raw) {
69
+ const value = typeof raw === 'string' ? raw.trim() : '';
70
+ if (!value) return '';
71
+ if (value.length > 32 || !PROFILE_ID_RE.test(value)) {
72
+ throw new ProfileRegistryError(
73
+ 'serviceName must be empty or use the 1–32 character daemon instance-name format',
74
+ 'INVALID_SERVICE_NAME',
75
+ );
76
+ }
77
+ return value;
78
+ }
79
+
80
+ function boolOwnership(value, field) {
81
+ if (typeof value !== 'boolean') {
82
+ throw new ProfileRegistryError(`ownership.${field} must be boolean`, 'INVALID_OWNERSHIP');
83
+ }
84
+ return value;
85
+ }
86
+
87
+ function assertNoSecrets(value, where = 'registry') {
88
+ if (!value || typeof value !== 'object') return;
89
+ for (const [key, nested] of Object.entries(value)) {
90
+ if (SECRET_KEYS.test(key)) {
91
+ throw new ProfileRegistryError(`${where} must never contain secret field ${JSON.stringify(key)}`, 'SECRET_IN_REGISTRY');
92
+ }
93
+ if (nested && typeof nested === 'object') assertNoSecrets(nested, `${where}.${key}`);
94
+ }
95
+ }
96
+
97
+ function assertOnlyKeys(value, allowed, where) {
98
+ for (const key of Object.keys(value)) {
99
+ if (!allowed.includes(key)) {
100
+ throw new ProfileRegistryError(`${where} contains unsupported field ${JSON.stringify(key)}`, 'INVALID_SCHEMA');
101
+ }
102
+ }
103
+ }
104
+
105
+ export function normalizeProfile(id, input) {
106
+ const profileId = normalizeProfileId(id);
107
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
108
+ throw new ProfileRegistryError(`profile ${profileId} must be an object`, 'INVALID_PROFILE');
109
+ }
110
+ assertNoSecrets(input, `profiles.${profileId}`);
111
+ assertOnlyKeys(input, ['label', 'host', 'port', 'configPath', 'stateDir', 'serviceName', 'ownership'], `profiles.${profileId}`);
112
+ const label = typeof input.label === 'string' ? input.label.trim() : '';
113
+ if (!label || label.length > 120 || /[\r\n\0]/.test(label)) {
114
+ throw new ProfileRegistryError(`profile ${profileId} label must be 1–120 printable characters`, 'INVALID_PROFILE');
115
+ }
116
+ const port = Number(input.port);
117
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
118
+ throw new ProfileRegistryError(`profile ${profileId} port must be an integer from 1 to 65535`, 'INVALID_PORT');
119
+ }
120
+ const configPath = normalizeProfilePath(input.configPath, `profiles.${profileId}.configPath`);
121
+ const stateDir = normalizeProfilePath(input.stateDir, `profiles.${profileId}.stateDir`);
122
+ if (configPath === stateDir || dirname(configPath) === configPath) {
123
+ throw new ProfileRegistryError(`profile ${profileId} configPath and stateDir must be distinct`, 'UNSAFE_PATH');
124
+ }
125
+ const ownership = input.ownership;
126
+ if (!ownership || typeof ownership !== 'object' || Array.isArray(ownership)) {
127
+ throw new ProfileRegistryError(`profile ${profileId} requires explicit ownership`, 'INVALID_OWNERSHIP');
128
+ }
129
+ assertOnlyKeys(ownership, ['config', 'service', 'state'], `profiles.${profileId}.ownership`);
130
+ const serviceName = normalizeServiceName(input.serviceName);
131
+ if (profileId !== DEFAULT_PROFILE_ID && !serviceName) {
132
+ throw new ProfileRegistryError(
133
+ `profile ${profileId} requires a named service instance; the empty serviceName is reserved for profile "default"`,
134
+ 'INVALID_SERVICE_NAME',
135
+ );
136
+ }
137
+ return {
138
+ label,
139
+ host: normalizeLoopbackHost(input.host),
140
+ port,
141
+ configPath,
142
+ stateDir,
143
+ serviceName,
144
+ ownership: {
145
+ config: boolOwnership(ownership.config, 'config'),
146
+ service: boolOwnership(ownership.service, 'service'),
147
+ state: boolOwnership(ownership.state, 'state'),
148
+ },
149
+ };
150
+ }
151
+
152
+ export function validateRegistry(input) {
153
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
154
+ throw new ProfileRegistryError('profile registry must be an object');
155
+ }
156
+ assertNoSecrets(input);
157
+ assertOnlyKeys(input, ['version', 'profiles', 'harnessAssociations'], 'registry');
158
+ if (input.version !== PROFILE_REGISTRY_VERSION) {
159
+ throw new ProfileRegistryError(
160
+ `unsupported profile registry version ${JSON.stringify(input.version)}; expected ${PROFILE_REGISTRY_VERSION}`,
161
+ 'UNSUPPORTED_VERSION',
162
+ );
163
+ }
164
+ if (!input.profiles || typeof input.profiles !== 'object' || Array.isArray(input.profiles)) {
165
+ throw new ProfileRegistryError('profile registry profiles must be an object');
166
+ }
167
+ if (!input.harnessAssociations || typeof input.harnessAssociations !== 'object' || Array.isArray(input.harnessAssociations)) {
168
+ throw new ProfileRegistryError('profile registry harnessAssociations must be an object');
169
+ }
170
+ const profiles = {};
171
+ for (const [id, value] of Object.entries(input.profiles)) profiles[normalizeProfileId(id)] = normalizeProfile(id, value);
172
+ const harnessAssociations = {};
173
+ for (const [application, profileId] of Object.entries(input.harnessAssociations)) {
174
+ if (!HARNESS_APPLICATIONS.includes(application)) {
175
+ throw new ProfileRegistryError(`unknown harness association ${JSON.stringify(application)}`, 'INVALID_ASSOCIATION');
176
+ }
177
+ if (typeof profileId !== 'string' || !profiles[profileId]) {
178
+ throw new ProfileRegistryError(`association ${application} names missing profile ${JSON.stringify(profileId)}`, 'INVALID_ASSOCIATION');
179
+ }
180
+ harnessAssociations[application] = profileId;
181
+ }
182
+ const normalized = { version: PROFILE_REGISTRY_VERSION, profiles, harnessAssociations };
183
+ assertNoProfileCollisions(normalized.profiles);
184
+ return normalized;
185
+ }
186
+
187
+ export function readRegistry(path = profilesPath(), { allowMissing = true } = {}) {
188
+ let text;
189
+ try {
190
+ text = readFileSync(path, 'utf8');
191
+ } catch (error) {
192
+ if (allowMissing && error?.code === 'ENOENT') return emptyRegistry();
193
+ throw new ProfileRegistryError(`cannot read profile registry ${path}: ${error?.message || error}`, 'READ_FAILED');
194
+ }
195
+ let parsed;
196
+ try { parsed = JSON.parse(text); }
197
+ catch (error) { throw new ProfileRegistryError(`profile registry ${path} is corrupt JSON: ${error.message}`, 'CORRUPT_JSON'); }
198
+ return validateRegistry(parsed);
199
+ }
200
+
201
+ export function registryText(registry) {
202
+ return JSON.stringify(validateRegistry(registry), null, 2) + '\n';
203
+ }
204
+
205
+ export function writeRegistry(path, registry, { rename = renameSync } = {}) {
206
+ const text = registryText(registry);
207
+ try {
208
+ if (existsSync(path) && readFileSync(path, 'utf8') === text) {
209
+ chmodSync(path, 0o600);
210
+ return { changed: false, path };
211
+ }
212
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
213
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
214
+ try {
215
+ writeFileSync(tmp, text, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
216
+ chmodSync(tmp, 0o600);
217
+ rename(tmp, path);
218
+ chmodSync(path, 0o600);
219
+ } catch (error) {
220
+ try { unlinkSync(tmp); } catch { /* absent or renamed */ }
221
+ throw error;
222
+ }
223
+ return { changed: true, path };
224
+ } catch (error) {
225
+ if (error instanceof ProfileRegistryError) throw error;
226
+ throw new ProfileRegistryError(`cannot atomically write profile registry ${path}: ${error?.message || error}`, 'WRITE_FAILED');
227
+ }
228
+ }
229
+
230
+ export function endpointKey(profile) {
231
+ return `${normalizeLoopbackHost(profile.host)}:${Number(profile.port)}`;
232
+ }
233
+
234
+ export function canonicalStateDir(path, realpath = realpathSync) {
235
+ const normalized = normalizeProfilePath(path, 'stateDir');
236
+ try { return realpath(normalized); } catch { return normalized; }
237
+ }
238
+
239
+ function canonicalStateForDependency(path, realpath) {
240
+ const normalized = resolve(path);
241
+ const suffix = [];
242
+ let existingAncestor = normalized;
243
+ while (true) {
244
+ try {
245
+ const canonicalAncestor = realpath(existingAncestor);
246
+ return { known: true, path: resolve(canonicalAncestor, ...suffix.reverse()) };
247
+ } catch (error) {
248
+ // A nonexistent leaf is deterministic: canonicalize its nearest existing
249
+ // ancestor so symlinked parents and lexical missing paths still compare.
250
+ // Permission/I/O failures are ambiguous and must remain fail-closed.
251
+ if (error?.code !== 'ENOENT') return { known: false };
252
+ const parent = dirname(existingAncestor);
253
+ if (parent === existingAncestor) return { known: false };
254
+ suffix.push(basename(existingAncestor));
255
+ existingAncestor = parent;
256
+ }
257
+ }
258
+ }
259
+
260
+ export function compareDependencyStateDirs(left, right, { realpath = realpathSync } = {}) {
261
+ const normalizedLeft = resolve(left);
262
+ const normalizedRight = resolve(right);
263
+ if (normalizedLeft === normalizedRight) return 'equal';
264
+ const canonicalLeft = canonicalStateForDependency(normalizedLeft, realpath);
265
+ const canonicalRight = canonicalStateForDependency(normalizedRight, realpath);
266
+ if (!canonicalLeft.known || !canonicalRight.known) return 'unknown';
267
+ return canonicalLeft.path === canonicalRight.path ? 'equal' : 'different';
268
+ }
269
+
270
+ export function profileConflicts(profiles, id, candidate, { realpath = realpathSync } = {}) {
271
+ const normalized = normalizeProfile(id, candidate);
272
+ const endpoint = endpointKey(normalized);
273
+ const state = canonicalStateDir(normalized.stateDir, realpath);
274
+ const conflicts = [];
275
+ for (const [otherId, otherValue] of Object.entries(profiles || {})) {
276
+ if (otherId === id) continue;
277
+ const other = normalizeProfile(otherId, otherValue);
278
+ if (endpointKey(other) === endpoint) conflicts.push({ field: 'endpoint', profileId: otherId, value: endpoint });
279
+ if (canonicalStateDir(other.stateDir, realpath) === state) conflicts.push({ field: 'stateDir', profileId: otherId, value: state });
280
+ if (other.configPath === normalized.configPath) conflicts.push({ field: 'configPath', profileId: otherId, value: normalized.configPath });
281
+ if (other.serviceName === normalized.serviceName) conflicts.push({ field: 'serviceName', profileId: otherId, value: normalized.serviceName });
282
+ }
283
+ return conflicts;
284
+ }
285
+
286
+ export function assertNoProfileCollisions(profiles, options) {
287
+ for (const [id, profile] of Object.entries(profiles || {})) {
288
+ const conflicts = profileConflicts(profiles, id, profile, options);
289
+ if (conflicts.length) {
290
+ const c = conflicts[0];
291
+ throw new ProfileRegistryError(
292
+ `profile ${id} collides with ${c.profileId} on ${c.field} ${JSON.stringify(c.value)}`,
293
+ 'PROFILE_COLLISION',
294
+ );
295
+ }
296
+ }
297
+ }
298
+
299
+ export function upsertProfile(registry, id, profile, options) {
300
+ const current = validateRegistry(registry);
301
+ const profileId = normalizeProfileId(id);
302
+ const nextProfile = normalizeProfile(profileId, profile);
303
+ const conflicts = profileConflicts(current.profiles, profileId, nextProfile, options);
304
+ if (conflicts.length) {
305
+ const c = conflicts[0];
306
+ throw new ProfileRegistryError(
307
+ `profile ${profileId} collides with ${c.profileId} on ${c.field} ${JSON.stringify(c.value)}`,
308
+ 'PROFILE_COLLISION',
309
+ );
310
+ }
311
+ return { ...current, profiles: { ...current.profiles, [profileId]: nextProfile } };
312
+ }
313
+
314
+ export function associateHarness(registry, application, profileId, { allowReassign = false } = {}) {
315
+ const current = validateRegistry(registry);
316
+ if (!HARNESS_APPLICATIONS.includes(application)) {
317
+ throw new ProfileRegistryError(`unknown harness ${JSON.stringify(application)}`, 'INVALID_ASSOCIATION');
318
+ }
319
+ if (!current.profiles[profileId]) {
320
+ throw new ProfileRegistryError(`cannot associate ${application} with missing profile ${JSON.stringify(profileId)}`, 'INVALID_ASSOCIATION');
321
+ }
322
+ const previous = current.harnessAssociations[application];
323
+ if (previous && previous !== profileId && !allowReassign) {
324
+ throw new ProfileRegistryError(
325
+ `${application} is already associated with profile ${previous}; explicit reassignment confirmation is required`,
326
+ 'REASSIGN_CONFIRMATION_REQUIRED',
327
+ );
328
+ }
329
+ return {
330
+ registry: {
331
+ ...current,
332
+ harnessAssociations: { ...current.harnessAssociations, [application]: profileId },
333
+ },
334
+ changed: previous !== profileId,
335
+ previous,
336
+ };
337
+ }
338
+
339
+ export function removeHarnessAssociation(registry, application) {
340
+ const current = validateRegistry(registry);
341
+ const previous = current.harnessAssociations[application];
342
+ if (!previous) return { registry: current, changed: false };
343
+ const harnessAssociations = { ...current.harnessAssociations };
344
+ delete harnessAssociations[application];
345
+ return { registry: { ...current, harnessAssociations }, changed: true, previous };
346
+ }
347
+
348
+ function externalDaemon(value) {
349
+ if (!value || typeof value !== 'object') return null;
350
+ const endpoint = value.endpoint || value.daemonUrl;
351
+ const stateDir = value.stateDir || value.daemonStateDir;
352
+ if (typeof endpoint !== 'string' || typeof stateDir !== 'string') return null;
353
+ let url;
354
+ try { url = new URL(endpoint); } catch { return null; }
355
+ if (url.protocol !== 'http:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) return null;
356
+ let host;
357
+ try { host = normalizeLoopbackHost(url.hostname); } catch { return null; }
358
+ const port = Number(url.port || 80);
359
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
360
+ return { host, port, stateDir: resolve(stateDir) };
361
+ }
362
+
363
+ export function reverseApplicationIndex(registry, { telegramConfig, roomsConfig } = {}, options = {}) {
364
+ const current = validateRegistry(registry);
365
+ const index = Object.fromEntries(Object.keys(current.profiles).map((id) => [id, []]));
366
+ for (const [application, profileId] of Object.entries(current.harnessAssociations)) index[profileId].push(application);
367
+ const connectorTargets = [
368
+ ['telegram', externalDaemon(telegramConfig)],
369
+ ['rooms', externalDaemon(roomsConfig?.daemon)],
370
+ ];
371
+ for (const [application, target] of connectorTargets) {
372
+ if (!target) continue;
373
+ for (const [id, profile] of Object.entries(current.profiles)) {
374
+ if (endpointKey(profile) !== `${target.host}:${target.port}`) continue;
375
+ const stateComparison = compareDependencyStateDirs(profile.stateDir, target.stateDir, options);
376
+ // Unknown canonicalization is a dependency for deletion safety. Operators
377
+ // can still proceed through an explicit connector lifecycle action.
378
+ if (stateComparison !== 'different') {
379
+ index[id].push(application);
380
+ }
381
+ }
382
+ }
383
+ for (const applications of Object.values(index)) applications.sort();
384
+ return index;
385
+ }
386
+
387
+ function candidateFromProfile(id, profile, origin) {
388
+ const value = normalizeProfile(id, {
389
+ label: profile.label,
390
+ host: profile.host,
391
+ port: profile.port,
392
+ configPath: profile.configPath,
393
+ stateDir: profile.stateDir,
394
+ serviceName: profile.serviceName,
395
+ ownership: profile.ownership,
396
+ });
397
+ return { id, ...value, origin, configured: true, reachable: false };
398
+ }
399
+
400
+ export function dedupeCandidates(candidates, { realpath = realpathSync } = {}) {
401
+ const accepted = [];
402
+ for (const raw of candidates) {
403
+ const id = normalizeProfileId(raw.id);
404
+ const candidate = { ...candidateFromProfile(id, raw, raw.origin || 'candidate'), ...raw, id };
405
+ const endpoint = endpointKey(candidate);
406
+ const state = canonicalStateDir(candidate.stateDir, realpath);
407
+ const exact = accepted.find((item) => endpointKey(item) === endpoint && canonicalStateDir(item.stateDir, realpath) === state);
408
+ if (exact) {
409
+ exact.origins = [...new Set([...(exact.origins || [exact.origin]), ...(candidate.origins || [candidate.origin])])];
410
+ exact.reachable ||= candidate.reachable;
411
+ continue;
412
+ }
413
+ const endpointConflict = accepted.find((item) => endpointKey(item) === endpoint);
414
+ if (endpointConflict) {
415
+ throw new ProfileRegistryError(
416
+ `candidate endpoint ${endpoint} names different state directories (${endpointConflict.stateDir} and ${candidate.stateDir})`,
417
+ 'DISCOVERY_COLLISION',
418
+ );
419
+ }
420
+ const stateConflict = accepted.find((item) => canonicalStateDir(item.stateDir, realpath) === state);
421
+ if (stateConflict) {
422
+ throw new ProfileRegistryError(
423
+ `candidate state directory ${state} names different endpoints (${endpointKey(stateConflict)} and ${endpoint})`,
424
+ 'DISCOVERY_COLLISION',
425
+ );
426
+ }
427
+ accepted.push({ ...candidate, origins: candidate.origins || [candidate.origin] });
428
+ }
429
+ return accepted;
430
+ }
431
+
432
+ export async function probeProfileCandidate(candidate, { fetch: fetchImpl = globalThis.fetch, timeoutMs = 1500 } = {}) {
433
+ const normalized = candidateFromProfile(candidate.id, candidate, candidate.origin || 'candidate');
434
+ const base = `http://${normalized.host}:${normalized.port}`;
435
+ const request = async (path) => {
436
+ const controller = new AbortController();
437
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
438
+ try { return await fetchImpl(`${base}${path}`, { signal: controller.signal }); }
439
+ finally { clearTimeout(timer); }
440
+ };
441
+ try {
442
+ const infoResponse = await request('/info');
443
+ if (!infoResponse.ok) return { ...normalized, reachable: true, compatible: false, error: `HTTP ${infoResponse.status} from /info` };
444
+ const info = await infoResponse.json();
445
+ if (info?.name !== 'ours' || typeof info?.stateDir !== 'string') {
446
+ return { ...normalized, reachable: true, compatible: false, error: 'endpoint is not an ours daemon' };
447
+ }
448
+ if (canonicalStateDir(info.stateDir) !== canonicalStateDir(normalized.stateDir)) {
449
+ return { ...normalized, reachable: true, compatible: false, drift: true, error: `daemon reports stateDir ${info.stateDir}` };
450
+ }
451
+ let versionResponse;
452
+ try {
453
+ versionResponse = await request('/version');
454
+ } catch (error) {
455
+ return { ...normalized, reachable: true, compatible: false, info, error: `/version failed: ${String(error?.message || error)}` };
456
+ }
457
+ if (!versionResponse.ok) {
458
+ return { ...normalized, reachable: true, compatible: false, info, error: `HTTP ${versionResponse.status} from /version` };
459
+ }
460
+ let version = '';
461
+ try { version = String((await versionResponse.json())?.version || ''); }
462
+ catch { return { ...normalized, reachable: true, compatible: false, info, error: '/version returned invalid JSON' }; }
463
+ if (!version) return { ...normalized, reachable: true, compatible: false, info, error: '/version did not report a version' };
464
+ if (typeof info.version === 'string' && info.version && info.version !== version) {
465
+ return { ...normalized, reachable: true, compatible: false, info, version, error: `/info and /version disagree (${info.version} vs ${version})` };
466
+ }
467
+ return { ...normalized, reachable: true, compatible: true, info, version };
468
+ } catch (error) {
469
+ return { ...normalized, reachable: false, compatible: null, error: error?.name === 'AbortError' ? 'probe timed out' : String(error?.message || error) };
470
+ }
471
+ }
472
+
473
+ export async function discoverProfileCandidates({
474
+ registry = emptyRegistry(), defaultCandidate, legacyCandidates = [], connectorCandidates = [],
475
+ serviceCandidates = [], manualCandidate, probe = probeProfileCandidate, realpath = realpathSync,
476
+ } = {}) {
477
+ const current = validateRegistry(registry);
478
+ const ordered = [];
479
+ for (const [id, profile] of Object.entries(current.profiles)) ordered.push(candidateFromProfile(id, profile, 'registry'));
480
+ if (defaultCandidate) ordered.push({ ...defaultCandidate, origin: 'default-config' });
481
+ for (const candidate of legacyCandidates) ordered.push({ ...candidate, origin: candidate.origin || 'legacy-config' });
482
+ for (const candidate of connectorCandidates) ordered.push({ ...candidate, origin: candidate.origin || 'connector-config' });
483
+ for (const candidate of serviceCandidates) ordered.push({ ...candidate, origin: candidate.origin || 'known-service' });
484
+ if (manualCandidate) ordered.push({ ...manualCandidate, origin: 'manual' });
485
+ const deduped = dedupeCandidates(ordered, { realpath });
486
+ const out = [];
487
+ for (const candidate of deduped) out.push(await probe(candidate));
488
+ return out;
489
+ }
490
+
491
+ export function defaultHistoricalProfile(home = homedir()) {
492
+ return {
493
+ label: 'Default ours daemon', host: '127.0.0.1', port: DEFAULT_PROFILE_PORT,
494
+ configPath: join(home, '.ours', 'config.json'), stateDir: join(home, '.ours'), serviceName: '',
495
+ ownership: { config: true, service: true, state: true },
496
+ };
497
+ }
498
+
499
+ export function fileMode(path) {
500
+ return statSync(path).mode & 0o777;
501
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "0.17.0-nightly.2",
3
+ "version": "0.17.0-nightly.4",
4
4
  "private": false,
5
5
  "description": "The unified ours.network stack installer (ours-install): one guided ~3-minute flow for ours core (the daemon) + the harness plugins (Claude Code / Codex) + ours-fleet + the Telegram connector, then a single copy-paste hand-off prompt. Self-contained (Node built-ins only); run as `ours-install` or via curl|bash (install.sh).",
6
6
  "type": "module",
package/uninstall.mjs CHANGED
@@ -19,7 +19,8 @@ import { homedir } from 'node:os';
19
19
  import { join } from 'node:path';
20
20
  import { banner, heading, c, openTty, makeWriter, closeSync } from './lib/ui.mjs';
21
21
  import { askLine, checkboxSelect } from './lib/prompt.mjs';
22
- import { canonHarnesses } from './lib/logic.mjs';
22
+ import { canonHarnesses, resolveChannel } from './lib/logic.mjs';
23
+ import { runNightlyUninstaller } from './lib/nightly-uninstall.mjs';
23
24
 
24
25
  const NPM = process.env.OURS_NPM || 'npm';
25
26
  const HOME = homedir();
@@ -28,6 +29,11 @@ const CODEX_DIR = process.env.CODEX_DIR || join(HOME, '.codex');
28
29
  const SKILLS_DIR = process.env.SKILLS_DIR || join(HOME, '.agents', 'skills');
29
30
  const OURS_STATE_DIR = process.env.OURS_STATE_DIR || join(HOME, '.ours');
30
31
  const ASSUME_YES = !!process.env.OURS_ASSUME_YES;
32
+ const packageVersion = (() => {
33
+ try { return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version || ''; }
34
+ catch { return ''; }
35
+ })();
36
+ const CHANNEL = resolveChannel(process.env.OURS_CHANNEL || process.env.OURS_INSTALL_CHANNEL, packageVersion);
31
37
 
32
38
  const say = (s) => process.stdout.write(`ours: ${s}\n`);
33
39
  const line = (s = '') => process.stdout.write(`${s}\n`);
@@ -38,7 +44,10 @@ const YAML_END = '# <<< ours.network plugin';
38
44
  const MD_START = '<!-- >>> ours.network plugin (managed block) -->';
39
45
  const MD_END = '<!-- <<< ours.network plugin -->';
40
46
 
41
- const run = (bin, args) => spawnSync(bin, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
47
+ const run = (bin, args, { env } = {}) => spawnSync(bin, args, {
48
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
49
+ env: env ? { ...process.env, ...env } : process.env,
50
+ });
42
51
 
43
52
  // --- safe removal helpers ----------------------------------------------------------------------
44
53
  function rmDir(d) { if (d && existsSync(d)) { rmSync(d, { recursive: true, force: true }); say(` removed ${d}`); } }
@@ -115,6 +124,10 @@ function main() {
115
124
  line(c.bold(' ours.network uninstaller'));
116
125
  say('Removes only what the ours installers created. Nothing is removed until you confirm.');
117
126
 
127
+ if (CHANNEL === 'nightly') {
128
+ return runNightlyUninstaller({ ttyFd, write, run, npm: NPM, assumeYes: ASSUME_YES, finish });
129
+ }
130
+
118
131
  // --- 1) choose what to remove ----------------------------------------------------------------
119
132
  let selected = [];
120
133
  let wantData = false;