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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,716 +0,0 @@
1
- import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
2
- import { homedir, userInfo } from 'node:os';
3
- import { join, resolve } from 'node:path';
4
- import { heading, ok, info, warn, c } from './ui.mjs';
5
- import {
6
- DEFAULT_BROKER, DEFAULT_PORT, COWORK_DEFAULT_PORT, COWORK_EXTERNAL_MIN_VERSION,
7
- coworkSupportsExternalDaemon, daemonEndpoint, mergeConfig,
8
- planCoworkConfig, planTgDaemonConfig, pkgSpec, tgConfigPath, coworkConfigPath,
9
- validateBroker, validateDaemonPort,
10
- } from './logic.mjs';
11
- import {
12
- associateHarness, defaultHistoricalProfile, discoverProfileCandidates, emptyRegistry,
13
- normalizeLoopbackHost, normalizeProfile, normalizeProfileId, profilesPath, readRegistry,
14
- probeProfileCandidate, reverseApplicationIndex, upsertProfile, writeRegistry,
15
- } from './profiles.mjs';
16
- import { atomicWriteConfig, restoreConfig, snapshotConfig } from './config.mjs';
17
-
18
- const line = (text = '') => process.stdout.write(`${text}\n`);
19
- const say = (text) => line(`ours: ${text}`);
20
- const readObject = (path, { missing = {} } = {}) => {
21
- if (!existsSync(path)) return missing;
22
- let value;
23
- try { value = JSON.parse(readFileSync(path, 'utf8')); }
24
- catch (error) { throw new Error(`${path} is corrupt JSON: ${error.message}`); }
25
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${path} must contain a JSON object`);
26
- return value;
27
- };
28
-
29
- const canonicalPath = (path) => {
30
- try { return realpathSync(path); } catch { return resolve(path); }
31
- };
32
-
33
- // Mirror core's runtime-config-values + loadConfig contract for endpoint fields.
34
- // The standalone installer cannot import the daemon package, so every installer
35
- // discovery/validation path shares this one equivalent resolver: service env
36
- // uses core's envInt/nullish precedence, file values keep only finite numbers
37
- // and strings, and the historical defaults never depend on a service name.
38
- function resolveRuntimeEndpoint(config, home, env = {}) {
39
- let environmentPort;
40
- if (env.OURS_PORT !== undefined) {
41
- const parsed = parseInt(env.OURS_PORT, 10);
42
- if (!Number.isNaN(parsed)) environmentPort = parsed;
43
- }
44
- const filePort = typeof config.port === 'number' && Number.isFinite(config.port)
45
- ? config.port
46
- : undefined;
47
- const stateValue = env.OURS_STATE_DIR !== undefined
48
- ? env.OURS_STATE_DIR
49
- : typeof config.stateDir === 'string'
50
- ? config.stateDir
51
- : join(home, '.ours');
52
- return {
53
- port: environmentPort ?? filePort ?? DEFAULT_PORT,
54
- stateDir: resolve(stateValue),
55
- };
56
- }
57
-
58
- // Keep this check aligned with the runtime association resolvers: a profile-selected
59
- // client reads this exact config with the historical 3050 / ~/.ours defaults. A manual
60
- // profile that merely reaches the requested daemon is still unusable when its client
61
- // config resolves another port or state directory, so reject it before any mutation.
62
- function assertClientConfigMatchesProfile(profile, config, home) {
63
- const configured = resolveRuntimeEndpoint(config, home);
64
- if (configured.port !== profile.port) {
65
- throw new Error(`client config resolves port ${configured.port}, not selected port ${profile.port}`);
66
- }
67
- if (canonicalPath(configured.stateDir) !== canonicalPath(profile.stateDir)) {
68
- throw new Error('client config resolves a different state directory than the selected daemon');
69
- }
70
- }
71
-
72
- function candidateFromConfig(id, label, configPath, home, { serviceName = '', ownership } = {}) {
73
- if (!existsSync(configPath)) return null;
74
- const config = readObject(configPath);
75
- const { stateDir, port } = resolveRuntimeEndpoint(config, home);
76
- return {
77
- id, label, host: '127.0.0.1', port, configPath: resolve(configPath), stateDir,
78
- serviceName: typeof config.serviceName === 'string' ? config.serviceName : serviceName,
79
- ownership: ownership || { config: false, service: false, state: false },
80
- };
81
- }
82
-
83
- function persistedProfile(candidate) {
84
- return {
85
- label: candidate.label,
86
- host: candidate.host,
87
- port: candidate.port,
88
- configPath: candidate.configPath,
89
- stateDir: candidate.stateDir,
90
- serviceName: candidate.serviceName,
91
- ownership: candidate.ownership,
92
- };
93
- }
94
-
95
- function candidateFromExternalConfig(id, label, daemon, home) {
96
- if (!daemon || typeof daemon !== 'object') return null;
97
- const endpoint = daemon.endpoint || daemon.daemonUrl;
98
- const stateDir = daemon.stateDir || daemon.daemonStateDir;
99
- if (typeof endpoint !== 'string' || typeof stateDir !== 'string') return null;
100
- let url;
101
- try { url = new URL(endpoint); } catch { throw new Error(`${label} names an invalid daemon endpoint`); }
102
- if (url.protocol !== 'http:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
103
- throw new Error(`${label} daemon endpoint must be a credential-free loopback HTTP endpoint`);
104
- }
105
- const host = normalizeLoopbackHost(url.hostname);
106
- const port = Number(url.port || 80);
107
- const configPath = join(resolve(stateDir), 'config.json');
108
- return {
109
- id, label, host, port, configPath, stateDir: resolve(stateDir), serviceName: id,
110
- ownership: { config: false, service: false, state: false },
111
- };
112
- }
113
-
114
- function parseServiceFile(path, id, home) {
115
- let text;
116
- try { text = readFileSync(path, 'utf8'); } catch { return null; }
117
- const env = {};
118
- for (const key of ['OURS_CONFIG', 'OURS_PORT', 'OURS_STATE_DIR', 'OURS_SERVICE_NAME']) {
119
- if (path.endsWith('.plist')) {
120
- const marker = `<key>${key}</key>`;
121
- if (!text.includes(marker)) continue;
122
- const match = text.match(new RegExp(`<key>${key}<\\/key>\\s*<string>([^<]*)<\\/string>`));
123
- if (!match) throw new Error(`${path} has malformed XML text for ${key}`);
124
- const undecoded = match[1];
125
- if (undecoded.replace(/&(?:amp|lt|gt|quot|apos);/g, '').includes('&')) {
126
- throw new Error(`${path} has malformed XML entity in ${key}`);
127
- }
128
- env[key] = undecoded.replace(/&(?:amp|lt|gt|quot|apos);/g, (entity) => ({
129
- '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&apos;': "'",
130
- })[entity]);
131
- continue;
132
- }
133
- const match = text.match(new RegExp(`(?:Environment=|<key>)${key}(?:=|<\\/key>\\s*<string>)([^\\n<]*)`));
134
- if (match) env[key] = match[1].replace(/^['"]|['"]$/g, '').trim();
135
- }
136
- const serviceName = env.OURS_SERVICE_NAME || (id === 'default' ? '' : id);
137
- const configPath = resolve(env.OURS_CONFIG || join(home, '.ours', 'config.json'));
138
- const configured = existsSync(configPath) ? readObject(configPath) : {};
139
- const { stateDir, port } = resolveRuntimeEndpoint(configured, home, env);
140
- return {
141
- id, label: id === 'default' ? 'Default ours service' : `ours service ${id}`,
142
- host: '127.0.0.1', port, configPath, stateDir, serviceName,
143
- ownership: { config: false, service: false, state: false }, origin: 'known-service',
144
- };
145
- }
146
-
147
- // The instance-name rule, byte-for-byte core's INSTANCE_RE (packages/core/src/
148
- // service-instance.ts): 1–32 characters, starting AND ENDING with a letter or
149
- // digit. Discovery used a looser pattern that also accepted a trailing hyphen or
150
- // underscore, which meant it recognised unit filenames core itself refuses to
151
- // produce and profiles.mjs then refuses to normalize — see the guard below.
152
- const SERVICE_INSTANCE_FILE = {
153
- systemd: /^ours-([A-Za-z0-9](?:[A-Za-z0-9_-]{0,30}[A-Za-z0-9])?)\.service$/,
154
- launchd: /^solutions\.adaptframework\.ours\.([A-Za-z0-9](?:[A-Za-z0-9_-]{0,30}[A-Za-z0-9])?)\.plist$/,
155
- };
156
-
157
- function knownServiceCandidates(home) {
158
- const candidates = [];
159
- const dirs = [
160
- join(home, '.config', 'systemd', 'user'),
161
- join(home, 'Library', 'LaunchAgents'),
162
- ];
163
- for (const dir of dirs) {
164
- let files = [];
165
- try { files = readdirSync(dir).sort(); } catch { continue; }
166
- for (const file of files) {
167
- let id = null;
168
- if (file === 'ours.service' || file === 'solutions.adaptframework.ours.plist') id = 'default';
169
- else id = SERVICE_INSTANCE_FILE.systemd.exec(file)?.[1] ?? SERVICE_INSTANCE_FILE.launchd.exec(file)?.[1] ?? null;
170
- if (!id) continue;
171
- // A file whose name matches is CLAIMED, and any fault reading its contents
172
- // stays FATAL on purpose. That is not the same question as the one above:
173
- // once a definition is ours, a malformed entity or corrupt config could
174
- // otherwise be treated as literal path bytes and point discovery at the
175
- // wrong state directory, so it must fail closed before anything is probed
176
- // or mutated (see the launchd malformed-entity test). What the pattern
177
- // above fixes is the file that was never ours to claim.
178
- const candidate = parseServiceFile(join(dir, file), id, home);
179
- if (candidate) candidates.push(candidate);
180
- }
181
- }
182
- return candidates;
183
- }
184
-
185
- function discoveryInputs(registry, home, env) {
186
- const defaultPath = join(home, '.ours', 'config.json');
187
- const defaultCandidate = candidateFromConfig('default', 'Default ours daemon', defaultPath, home);
188
- const legacyCandidates = [];
189
- for (const [id, dir] of [['tg', '.ours-tg'], ['rooms', '.ours-rooms']]) {
190
- const candidate = candidateFromConfig(id, `Legacy ${id} daemon`, join(home, dir, 'config.json'), home, { serviceName: id });
191
- if (candidate) legacyCandidates.push(candidate);
192
- }
193
- const connectorCandidates = [];
194
- const tg = readObject(tgConfigPath(env, home));
195
- const tgCandidate = candidateFromExternalConfig('tg-config', 'Telegram configured daemon', tg, home);
196
- if (tgCandidate) connectorCandidates.push(tgCandidate);
197
- const rooms = readObject(coworkConfigPath(env, home));
198
- const roomsCandidate = candidateFromExternalConfig('rooms-config', 'Rooms configured daemon', rooms.daemon, home);
199
- if (roomsCandidate) connectorCandidates.push(roomsCandidate);
200
- return {
201
- registry, defaultCandidate, legacyCandidates, connectorCandidates,
202
- serviceCandidates: knownServiceCandidates(home),
203
- };
204
- }
205
-
206
- function exactEnv(profile) {
207
- const env = {
208
- OURS_CONFIG: profile.configPath,
209
- OURS_PORT: String(profile.port),
210
- OURS_STATE_DIR: profile.stateDir,
211
- OURS_AUTOSTART: '0',
212
- };
213
- if (profile.serviceName) env.OURS_SERVICE_NAME = profile.serviceName;
214
- return env;
215
- }
216
-
217
- function serviceDefinitionPath(profile, home, platform = process.platform) {
218
- if (platform === 'linux') {
219
- const unit = profile.serviceName ? `ours-${profile.serviceName}.service` : 'ours.service';
220
- return join(home, '.config', 'systemd', 'user', unit);
221
- }
222
- if (platform === 'darwin') {
223
- const label = profile.serviceName
224
- ? `solutions.adaptframework.ours.${profile.serviceName}.plist`
225
- : 'solutions.adaptframework.ours.plist';
226
- return join(home, 'Library', 'LaunchAgents', label);
227
- }
228
- return '';
229
- }
230
-
231
- function ownerToken(profile, config, env) {
232
- if (env.OURS_API_TOKEN?.trim()) return env.OURS_API_TOKEN.trim();
233
- if (typeof config.apiToken === 'string' && config.apiToken.trim()) return config.apiToken.trim();
234
- try { return readFileSync(join(profile.stateDir, 'daemon-token'), 'utf8').trim(); } catch { return ''; }
235
- }
236
-
237
- async function verifyProtected(profile, { fetch: fetchImpl = globalThis.fetch, env = process.env } = {}) {
238
- const config = readObject(profile.configPath);
239
- const base = `http://127.0.0.1:${profile.port}`;
240
- const request = async (path, headers) => {
241
- const controller = new AbortController();
242
- const timer = setTimeout(() => controller.abort(), 2000);
243
- try { return await fetchImpl(`${base}${path}`, { headers, signal: controller.signal }); }
244
- finally { clearTimeout(timer); }
245
- };
246
- let infoResponse;
247
- try { infoResponse = await request('/info'); }
248
- catch (error) { throw new Error(`daemon ${base} is unreachable: ${error.message}`); }
249
- if (!infoResponse.ok) throw new Error(`daemon ${base} /info returned HTTP ${infoResponse.status}`);
250
- const daemonInfo = await infoResponse.json();
251
- if (daemonInfo?.name !== 'ours' || resolve(String(daemonInfo.stateDir || '')) !== resolve(profile.stateDir)) {
252
- throw new Error(`daemon ${base} does not report the selected state directory ${profile.stateDir}`);
253
- }
254
- const versionResponse = await request('/version');
255
- if (!versionResponse.ok) throw new Error(`daemon ${base} /version returned HTTP ${versionResponse.status}`);
256
- let runningVersion = '';
257
- try { runningVersion = String((await versionResponse.json())?.version || ''); }
258
- catch { throw new Error(`daemon ${base} /version returned invalid JSON`); }
259
- if (!runningVersion) throw new Error(`daemon ${base} /version did not report a version`);
260
- const token = ownerToken(profile, config, env);
261
- const auth = await request('/identities', token ? { 'x-ours-api-token': token } : undefined);
262
- if (auth.status === 401 || auth.status === 403) throw new Error(`daemon ${base} rejected the selected authentication`);
263
- if (!auth.ok) throw new Error(`daemon ${base} protected API returned HTTP ${auth.status}`);
264
- return daemonInfo;
265
- }
266
-
267
- function readChoice(ask, prompt, def) {
268
- return String(ask(prompt, def) ?? def).trim();
269
- }
270
-
271
- function newProfileSurvey({ ask, home, claimed, defaults = {} }) {
272
- const id = normalizeProfileId(readChoice(ask, ' Profile id: ', defaults.id || 'default'));
273
- const label = readChoice(ask, ' Profile label: ', defaults.label || (id === 'default' ? 'Default ours daemon' : `ours daemon ${id}`));
274
- const host = normalizeLoopbackHost(readChoice(ask, ' Host (localhost only): ', defaults.host || '127.0.0.1'));
275
- const rawPort = readChoice(ask, ' Port: ', String(defaults.port || DEFAULT_PORT));
276
- const checked = validateDaemonPort(rawPort, { fallback: DEFAULT_PORT, taken: claimed, isTaken: () => false });
277
- if (!checked.ok) throw new Error(checked.reason);
278
- const root = id === 'default' ? join(home, '.ours') : join(home, `.ours-${id}`);
279
- const stateDir = resolve(readChoice(ask, ' State directory: ', defaults.stateDir || root));
280
- const configPath = resolve(readChoice(ask, ' Config path: ', defaults.configPath || join(root, 'config.json')));
281
- const serviceName = readChoice(ask, ' Service instance name (empty only for default): ', defaults.serviceName ?? (id === 'default' ? '' : id));
282
- const brokerRaw = readChoice(ask, ' Broker URL: ', defaults.brokerUrl || DEFAULT_BROKER);
283
- const broker = validateBroker(brokerRaw);
284
- if (!broker.ok || broker.empty) throw new Error('broker must be a ws:// or wss:// URL');
285
- const profile = normalizeProfile(id, {
286
- label, host, port: checked.port, configPath, stateDir, serviceName,
287
- ownership: { config: true, service: true, state: true },
288
- });
289
- return { id, profile, brokerUrl: broker.value, kind: 'new' };
290
- }
291
-
292
- function manualProfileSurvey({ ask, home, claimed }) {
293
- const id = normalizeProfileId(readChoice(ask, ' Profile id: ', 'existing'));
294
- const label = readChoice(ask, ' Profile label: ', `Existing ours daemon ${id}`);
295
- const host = normalizeLoopbackHost(readChoice(ask, ' Host (localhost only): ', '127.0.0.1'));
296
- const rawPort = readChoice(ask, ' Port: ', String(DEFAULT_PORT));
297
- const checked = validateDaemonPort(rawPort, { fallback: DEFAULT_PORT, taken: claimed, isTaken: () => false });
298
- if (!checked.ok) throw new Error(checked.reason);
299
- const stateDir = resolve(readChoice(ask, ' Exact state directory: ', join(home, `.ours-${id}`)));
300
- const configPath = resolve(readChoice(ask, ' Existing/client config path: ', join(stateDir, 'config.json')));
301
- const serviceName = readChoice(ask, ' Service instance name: ', id === 'default' ? '' : id);
302
- const configExists = existsSync(configPath);
303
- const profile = normalizeProfile(id, {
304
- label, host, port: checked.port, configPath, stateDir, serviceName,
305
- ownership: { config: !configExists, service: false, state: false },
306
- });
307
- return { id, profile, kind: 'manual', clientConfigNeeded: !configExists };
308
- }
309
-
310
- function planAssociations(registry, selectedId, selectedApplications, yes) {
311
- let next = registry;
312
- const changed = [];
313
- for (const application of selectedApplications) {
314
- const previous = next.harnessAssociations[application];
315
- let allowReassign = false;
316
- if (previous && previous !== selectedId) {
317
- allowReassign = yes(` ${application} currently uses profile ${previous}. Reassign it to ${selectedId}?`, false);
318
- if (!allowReassign) continue;
319
- }
320
- const result = associateHarness(next, application, selectedId, { allowReassign });
321
- next = result.registry;
322
- if (result.changed) changed.push({ application, previous });
323
- }
324
- return { registry: next, changed };
325
- }
326
-
327
- function renderProfiles(candidates, appIndex) {
328
- line(heading('Nightly daemon profiles'));
329
- if (!candidates.length) {
330
- line(info('No configured local ours daemons were discovered. The historical local default will be offered.'));
331
- return;
332
- }
333
- candidates.forEach((candidate, index) => {
334
- const apps = appIndex[candidate.id] || [];
335
- const reach = candidate.reachable
336
- ? `reachable${candidate.versionBefore ? `, running v${candidate.versionBefore}` : ''}`
337
- : 'configured/stopped';
338
- const owner = Object.entries(candidate.ownership).filter(([, value]) => value).map(([key]) => key).join(', ') || 'external';
339
- line(` ${index + 1}) ${c.bold(candidate.label)} [${candidate.id}] — 127.0.0.1:${candidate.port} (${reach})`);
340
- line(` state ${candidate.stateDir}`);
341
- line(` config ${candidate.configPath} · service ${candidate.serviceName || '(default)'} · owns ${owner}`);
342
- line(` applications ${apps.length ? apps.join(', ') : '(none)'}`);
343
- });
344
- }
345
-
346
- function selectedHarnessApplications(harnesses, registry, selectedId, yes, assumeYes) {
347
- const out = [];
348
- for (const harness of harnesses) {
349
- if (harness.status === 'absent') continue;
350
- const application = harness.name === 'claude' ? 'claude-code' : harness.name;
351
- const current = registry.harnessAssociations[application];
352
- const defaultAnswer = current ? current === selectedId : true;
353
- if (assumeYes ? (!current || current === selectedId) : yes(` Use profile ${selectedId} for ${application}?`, defaultAnswer)) out.push(application);
354
- }
355
- return out;
356
- }
357
-
358
- async function installHarness(application, deps, profile) {
359
- const { run, runAsync, act, actSpin, npm } = deps;
360
- if (application === 'claude-code') {
361
- const add = await act('claude plugin marketplace add adapt-toolkit/ours-claude-marketplace', async () => run('claude', ['plugin', 'marketplace', 'add', 'adapt-toolkit/ours-claude-marketplace'], { capture: true }));
362
- return add.ok ? act('claude plugin install ours@ours.network', async () => run('claude', ['plugin', 'install', 'ours@ours.network'], { capture: true })) : add;
363
- }
364
- if (application === 'codex') {
365
- const add = await act('codex plugin marketplace add adapt-toolkit/ours-codex-marketplace', async () => run('codex', ['plugin', 'marketplace', 'add', 'adapt-toolkit/ours-codex-marketplace'], { capture: true }));
366
- const plugin = add.ok ? await act('codex plugin add ours@ours-codex-marketplace', async () => run('codex', ['plugin', 'add', 'ours@ours-codex-marketplace'], { capture: true })) : add;
367
- return plugin.ok ? actSpin('installing ours-codex nightly…', `npm i -g ${pkgSpec('codex', 'nightly')}`, () => runAsync(npm, ['i', '-g', pkgSpec('codex', 'nightly')])) : plugin;
368
- }
369
- const pkg = await actSpin('installing Hermes ours plugin nightly…', `npm i -g ${pkgSpec('hermes', 'nightly')}`, () => runAsync(npm, ['i', '-g', pkgSpec('hermes', 'nightly')]));
370
- return pkg.ok ? act(`ours-hermes-install --skip-daemon for profile ${profile.id}`, async () => run('ours-hermes-install', ['--skip-daemon'], { capture: true, env: exactEnv(profile) })) : pkg;
371
- }
372
-
373
- function rollbackSnapshots(snapshots) {
374
- for (const [path, snapshot, preserveOnFailure] of snapshots.reverse()) {
375
- if (preserveOnFailure) continue;
376
- try { restoreConfig(path, snapshot); } catch { /* report partial state at caller */ }
377
- }
378
- }
379
-
380
- export async function runNightlyInstaller(deps) {
381
- const { harnesses, ttyFd, interactive, yes, ask, dry, npm, run, runAsync, act, actSpin, finish } = deps;
382
- const home = process.env.HOME || homedir();
383
- const assumeYes = !!process.env.OURS_ASSUME_YES;
384
- const registryFile = profilesPath(process.env, home);
385
- let registry;
386
- try { registry = readRegistry(registryFile); }
387
- catch (error) {
388
- line(warn(`Nightly profile registry is unusable: ${error.message}`));
389
- line(info('No changes were made. Repair or move the corrupt registry, then re-run the Nightly installer.'));
390
- finish(ttyFd); return;
391
- }
392
- let tgExisting;
393
- let roomsExisting;
394
- try {
395
- tgExisting = readObject(tgConfigPath(process.env, home));
396
- roomsExisting = readObject(coworkConfigPath(process.env, home));
397
- } catch (error) {
398
- line(warn(error.message)); finish(ttyFd); return;
399
- }
400
- const appIndex = reverseApplicationIndex(registry, { telegramConfig: tgExisting, roomsConfig: roomsExisting });
401
- let candidates;
402
- try {
403
- candidates = await discoverProfileCandidates({
404
- ...discoveryInputs(registry, home, process.env),
405
- probe: deps.probe || (dry
406
- ? async (candidate) => ({ ...candidate, reachable: true, compatible: true, info: { name: 'ours', stateDir: candidate.stateDir } })
407
- : undefined),
408
- });
409
- } catch (error) {
410
- line(warn(`Nightly discovery stopped on a collision or unsafe candidate: ${error.message}`));
411
- line(info('No changes were made. Resolve the conflicting config/service definitions and re-run.'));
412
- finish(ttyFd); return;
413
- }
414
- // Discovery queried /version before any global npm change. Keep the running
415
- // value diagnostic-only: it is displayed for comparison and never persisted.
416
- for (const candidate of candidates) {
417
- candidate.versionBefore = candidate.reachable && candidate.compatible ? candidate.version : '';
418
- }
419
- renderProfiles(candidates, appIndex);
420
-
421
- const defaultIndex = Math.max(0, candidates.findIndex((candidate) => candidate.id === 'default'));
422
- let selection;
423
- try {
424
- if (assumeYes && !candidates.length) {
425
- selection = newProfileSurvey({ ask, home, claimed: [], defaults: { id: 'default' } });
426
- } else {
427
- const choice = readChoice(
428
- ask,
429
- ` Choose profile 1-${candidates.length || 0}, (m)anual existing, or (n)ew: `,
430
- candidates.length ? String(defaultIndex + 1) : 'n',
431
- ).toLowerCase();
432
- if (choice === 'm' || choice === 'manual') selection = manualProfileSurvey({ ask, home, claimed: candidates.map((c) => c.port) });
433
- else if (choice === 'n' || choice === 'new' || !candidates.length) selection = newProfileSurvey({ ask, home, claimed: candidates.map((c) => c.port) });
434
- else {
435
- const index = Number(choice) - 1;
436
- if (!Number.isInteger(index) || !candidates[index]) throw new Error('that profile selection does not exist');
437
- const candidate = candidates[index];
438
- if (candidate.reachable && !candidate.compatible) {
439
- throw new Error(`profile ${candidate.id} failed daemon validation: ${candidate.error || 'incompatible endpoint'}`);
440
- }
441
- const update = !assumeYes && readChoice(ask, ' Press Enter to use as-is, or type u to update/repair this profile: ', '') === 'u';
442
- selection = {
443
- id: candidate.id,
444
- profile: normalizeProfile(candidate.id, persistedProfile(candidate)),
445
- kind: update ? 'update' : 'existing',
446
- reachable: candidate.reachable,
447
- };
448
- }
449
- }
450
- } catch (error) {
451
- line(warn(`Cannot use that daemon profile: ${error.message}`)); finish(ttyFd); return;
452
- }
453
-
454
- if ((selection.kind === 'manual' || selection.kind === 'existing') && !selection.clientConfigNeeded) {
455
- try {
456
- assertClientConfigMatchesProfile(selection.profile, readObject(selection.profile.configPath), home);
457
- } catch (error) {
458
- line(warn(`Selected client config does not match the selected daemon: ${error.message}`));
459
- line(info('No package, service, harness, identity, or registry changes were made.'));
460
- finish(ttyFd); return;
461
- }
462
- }
463
-
464
- if (selection.kind === 'new' && !dry) {
465
- if (existsSync(selection.profile.configPath)) {
466
- line(warn(`New profile config path already exists: ${selection.profile.configPath}. It will not be overwritten.`));
467
- line(info('Choose the discovered/manual-existing flow, or choose a new config path. No changes were made.'));
468
- finish(ttyFd); return;
469
- }
470
- const stateExisted = existsSync(selection.profile.stateDir);
471
- if (stateExisted) {
472
- let entries = [];
473
- try { entries = readdirSync(selection.profile.stateDir); } catch { entries = ['unreadable']; }
474
- if (entries.length) {
475
- line(warn(`New profile state directory is not empty: ${selection.profile.stateDir}. It will not be reused.`));
476
- line(info('Choose manual existing for that state, or choose a new empty path. No changes were made.'));
477
- finish(ttyFd); return;
478
- }
479
- }
480
- // An explicitly accepted empty directory may be used, but it remains operator-owned.
481
- // Only a state directory absent at preflight can become installer-owned.
482
- selection.profile = normalizeProfile(selection.id, {
483
- ...selection.profile,
484
- ownership: { ...selection.profile.ownership, state: !stateExisted },
485
- });
486
- const servicePath = serviceDefinitionPath(selection.profile, home);
487
- if (servicePath && existsSync(servicePath)) {
488
- line(warn(`New profile service definition already exists: ${servicePath}. It will not be overwritten.`));
489
- line(info('Choose the discovered/update flow, or choose another service instance name. No changes were made.'));
490
- finish(ttyFd); return;
491
- }
492
- const exactProbe = await probeProfileCandidate({ id: selection.id, ...selection.profile }, { fetch: deps.fetch });
493
- if (exactProbe.reachable) {
494
- line(warn(exactProbe.compatible
495
- ? `An ours daemon already answers on 127.0.0.1:${selection.profile.port}; it will not be overwritten.`
496
- : `A service already answers on 127.0.0.1:${selection.profile.port}; it is incompatible or has different state.`));
497
- line(info('Choose “manual existing” and verify its exact state/config, or choose another port. No changes were made.'));
498
- finish(ttyFd); return;
499
- }
500
- }
501
-
502
- let plannedRegistry;
503
- try { plannedRegistry = upsertProfile(registry, selection.id, selection.profile); }
504
- catch (error) { line(warn(`Profile collision: ${error.message}`)); finish(ttyFd); return; }
505
-
506
- if (selection.kind === 'manual' && !dry) {
507
- try {
508
- const checked = await probeProfileCandidate({ id: selection.id, ...selection.profile }, { fetch: deps.fetch });
509
- if (!checked.reachable || !checked.compatible) throw new Error(checked.error || 'daemon is unreachable');
510
- await verifyProtected(selection.profile, { fetch: deps.fetch });
511
- selection.reachable = true;
512
- }
513
- catch (error) { line(warn(`Manual daemon verification failed: ${error.message}`)); line(info('No registry entry was written.')); finish(ttyFd); return; }
514
- }
515
-
516
- line(heading('Applications for this exact daemon'));
517
- const applications = selectedHarnessApplications(harnesses, registry, selection.id, yes, assumeYes);
518
- let associationPlan;
519
- try { associationPlan = planAssociations(plannedRegistry, selection.id, applications, yes); }
520
- catch (error) { line(warn(error.message)); finish(ttyFd); return; }
521
- plannedRegistry = associationPlan.registry;
522
- const wantFleet = assumeYes ? true : yes(' Install/update ours-fleet (roles inherit their harness association)?', true);
523
- const wantTelegram = assumeYes ? false : yes(` Point Telegram at profile ${selection.id}?`, false);
524
- const wantRooms = assumeYes ? false : yes(` Point Rooms/cowork at profile ${selection.id}?`, false);
525
- let identityName = 'me';
526
- try { identityName = userInfo().username || identityName; } catch { /* fallback */ }
527
- identityName = readChoice(ask, ' Human identity name (created only if this daemon has none): ', identityName);
528
-
529
- line(heading('Review Nightly topology'));
530
- line(` profile ${c.bold(selection.profile.label)} [${selection.id}]`);
531
- line(` endpoint http://127.0.0.1:${selection.profile.port} · state ${selection.profile.stateDir}`);
532
- line(` config ${selection.profile.configPath} · service ${selection.profile.serviceName || '(default)'}`);
533
- line(` action ${selection.kind} · harnesses ${applications.join(', ') || '(unchanged)'}`);
534
- line(` Telegram ${wantTelegram ? 'use selected profile' : 'unchanged'} · Rooms ${wantRooms ? 'use selected profile' : 'unchanged'} · fleet ${wantFleet ? 'installed' : 'unchanged'}`);
535
- if (!yes(' Apply this Nightly plan?', true)) {
536
- line(info('Cancelled before changes.')); finish(ttyFd); return;
537
- }
538
-
539
- const snapshots = [];
540
- const snap = (path, { preserveOnFailure = false } = {}) => {
541
- if (!snapshots.some(([p]) => p === path)) snapshots.push([path, snapshotConfig(path), preserveOnFailure]);
542
- };
543
- let partial = false;
544
- const newArtifactBaseline = selection.kind === 'new' && !dry ? {
545
- config: existsSync(selection.profile.configPath),
546
- state: existsSync(selection.profile.stateDir),
547
- servicePath: serviceDefinitionPath(selection.profile, home),
548
- service: !!serviceDefinitionPath(selection.profile, home) && existsSync(serviceDefinitionPath(selection.profile, home)),
549
- } : null;
550
- let daemonReady = !!selection.reachable;
551
- const appliedApps = [];
552
- try {
553
- // One global package installation/update, regardless of profile count.
554
- if (selection.kind === 'new' || selection.kind === 'update') {
555
- const ensured = await actSpin(`ensuring ${pkgSpec('mcp', 'nightly')} once…`, `npm i -g ${pkgSpec('mcp', 'nightly')}`, () => runAsync(npm, ['i', '-g', pkgSpec('mcp', 'nightly')]));
556
- if (!ensured.ok) throw new Error('global Nightly daemon package update failed');
557
- partial = true; // npm cannot be rolled back
558
- }
559
-
560
- if (selection.kind === 'new' || selection.clientConfigNeeded || (selection.kind === 'update' && selection.profile.ownership.config)) {
561
- snap(selection.profile.configPath, { preserveOnFailure: selection.kind === 'new' });
562
- const existing = existsSync(selection.profile.configPath) ? readObject(selection.profile.configPath) : {};
563
- const config = mergeConfig(existing, {
564
- port: selection.profile.port, stateDir: selection.profile.stateDir,
565
- serviceName: selection.profile.serviceName || undefined,
566
- brokerUrl: selection.brokerUrl || existing.brokerUrl || DEFAULT_BROKER,
567
- autoStart: false,
568
- });
569
- await act(`write selected daemon config ${selection.profile.configPath}`, async () => {
570
- atomicWriteConfig(selection.profile.configPath, config); return { ok: true };
571
- });
572
- }
573
-
574
- if (selection.kind === 'new') {
575
- const selectedEnv = exactEnv(selection.profile);
576
- const started = await act(`start selected profile ${selection.id}`, async () => run('ours-mcp', ['start'], { env: selectedEnv }));
577
- if (!started.ok) throw new Error(`selected profile ${selection.id} failed to start`);
578
- const service = await act(`install exact service for profile ${selection.id}`, async () => run('ours-mcp', ['install-service'], { env: selectedEnv }));
579
- if (!service.ok && !dry) {
580
- // install-service can stop the daemon before failing; recover foreground state.
581
- const recovered = run('ours-mcp', ['start'], { env: selectedEnv });
582
- if (!recovered.ok) throw new Error(`service apply failed and profile ${selection.id} could not be restarted`);
583
- throw new Error(`service apply failed for profile ${selection.id}; daemon was recovered and no association was committed`);
584
- }
585
- daemonReady = true;
586
- } else if (selection.kind === 'update' && selection.profile.ownership.service) {
587
- const selectedEnv = exactEnv(selection.profile);
588
- const service = await act(`repair/reinstall only selected installer-owned service ${selection.id}`, async () => run('ours-mcp', ['install-service'], { env: selectedEnv }));
589
- if (!service.ok && !dry) {
590
- const recovered = run('ours-mcp', ['start'], { env: selectedEnv });
591
- if (!recovered.ok) throw new Error(`selected profile ${selection.id} service repair failed and daemon recovery failed`);
592
- throw new Error(`selected profile ${selection.id} service repair failed; daemon was recovered`);
593
- }
594
- daemonReady = true;
595
- }
596
-
597
- if (!dry) {
598
- try { await verifyProtected(selection.profile, { fetch: deps.fetch }); daemonReady = true; }
599
- catch (error) { throw new Error(`selected daemon validation failed before association commit: ${error.message}`); }
600
- }
601
- if (!daemonReady && !dry) throw new Error('selected daemon is not reachable; associations were not committed');
602
-
603
- for (const application of applications) {
604
- const installed = await installHarness(application, deps, { ...selection.profile, id: selection.id });
605
- if (installed.ok) appliedApps.push(application);
606
- else throw new Error(`${application} installation failed`);
607
- }
608
- if (wantFleet) {
609
- const fleetPkg = await actSpin('installing ours-fleet Nightly…', `npm i -g ${pkgSpec('fleet', 'nightly')}`, () => runAsync(npm, ['i', '-g', pkgSpec('fleet', 'nightly')]));
610
- if (!fleetPkg.ok) throw new Error('ours-fleet package installation failed');
611
- // ours-fleet resolves its daemon from OURS_CONFIG / OURS_PORT / OURS_STATE_DIR,
612
- // falling back to ~/.ours and 3050. It has no concept of this registry, so an
613
- // init run without the selected profile's environment points every role at the
614
- // historical default daemon — which, when the selected profile is not the
615
- // default, is a daemon the user may not even have. Hand it the same exact
616
- // environment every other selected-profile command here gets.
617
- const initialized = await act(`ours-fleet init for profile ${selection.id}`, async () => run('ours-fleet', ['init'], { env: exactEnv(selection.profile) }));
618
- if (!initialized.ok) throw new Error('ours-fleet init failed');
619
- }
620
-
621
- if (wantTelegram) {
622
- const path = tgConfigPath(process.env, home);
623
- const configPlan = planTgDaemonConfig(tgExisting, {
624
- daemonUrl: daemonEndpoint(selection.profile.port), daemonStateDir: selection.profile.stateDir,
625
- brokerUrl: selection.brokerUrl || DEFAULT_BROKER,
626
- });
627
- const pkg = await actSpin('installing Telegram connector Nightly…', `npm i -g ${pkgSpec('tg-connector', 'nightly')}`, () => runAsync(npm, ['i', '-g', pkgSpec('tg-connector', 'nightly')]));
628
- if (!pkg.ok) throw new Error('Telegram package installation failed');
629
- if (configPlan.changed) {
630
- snap(path);
631
- await act(`write Telegram config before service apply`, async () => { atomicWriteConfig(path, configPlan.text); return { ok: true }; });
632
- }
633
- const service = await act('reinstall Telegram service with selected daemon snapshot', async () => run('ours-tg-connector', ['install-service']));
634
- if (!service.ok) throw new Error('Telegram service apply failed');
635
- }
636
-
637
- if (wantRooms) {
638
- const path = coworkConfigPath(process.env, home);
639
- const pkg = await actSpin('installing Rooms Nightly…', `npm i -g ${pkgSpec('cowork', 'nightly')}`, () => runAsync(npm, ['i', '-g', pkgSpec('cowork', 'nightly')]));
640
- if (!pkg.ok) throw new Error('Rooms package installation failed');
641
- if (!dry) {
642
- const installed = run(npm, ['ls', '-g', '@ours.network/cowork', '--json'], { capture: true });
643
- let version = '';
644
- try { version = JSON.parse(installed.out || '{}')?.dependencies?.['@ours.network/cowork']?.version || ''; }
645
- catch { /* fail closed below */ }
646
- if (!coworkSupportsExternalDaemon(version)) {
647
- throw new Error(`Rooms build ${version || '(unknown)'} cannot use an external daemon; requires ${COWORK_EXTERNAL_MIN_VERSION} or newer`);
648
- }
649
- }
650
- const roomsPlan = planCoworkConfig(roomsExisting, {
651
- brokerUrl: selection.brokerUrl || DEFAULT_BROKER,
652
- stateDir: roomsExisting.stateDir || join(home, '.ours-cowork'),
653
- restPort: roomsExisting.rest?.port || COWORK_DEFAULT_PORT,
654
- daemon: { endpoint: daemonEndpoint(selection.profile.port), stateDir: selection.profile.stateDir },
655
- });
656
- if (roomsPlan.error) throw new Error(roomsPlan.error);
657
- if (roomsPlan.changed) {
658
- snap(path);
659
- await act('write Rooms external daemon config before service apply', async () => { atomicWriteConfig(path, roomsPlan.text); return { ok: true }; });
660
- }
661
- const service = await act('reinstall Rooms service with selected daemon snapshot', async () => run('ours-cowork', ['install-service']));
662
- if (!service.ok) throw new Error('Rooms service apply failed');
663
- }
664
-
665
- if (!dry) {
666
- const identity = run('ours-mcp', ['create-root', identityName], { capture: true, env: exactEnv(selection.profile) });
667
- if (!identity.ok) throw new Error(`human identity creation failed for selected profile ${selection.id}`);
668
- } else {
669
- line(` ${c.dim(`[dry-run] would: create human identity on profile ${selection.id}`)}`);
670
- }
671
-
672
- snap(registryFile);
673
- await act(`commit profile registry ${registryFile}`, async () => { writeRegistry(registryFile, plannedRegistry); return { ok: true }; });
674
- } catch (error) {
675
- if (!dry) rollbackSnapshots(snapshots);
676
- let recovery = '';
677
- if (newArtifactBaseline) {
678
- const ownership = {
679
- config: !newArtifactBaseline.config && existsSync(selection.profile.configPath),
680
- service: !!newArtifactBaseline.servicePath && !newArtifactBaseline.service && existsSync(newArtifactBaseline.servicePath),
681
- state: !newArtifactBaseline.state && existsSync(selection.profile.stateDir),
682
- };
683
- if (Object.values(ownership).some(Boolean)) {
684
- try {
685
- const recoveredProfile = normalizeProfile(selection.id, { ...selection.profile, ownership });
686
- const recoveredRegistry = upsertProfile(registry, selection.id, recoveredProfile);
687
- writeRegistry(registryFile, recoveredRegistry);
688
- recovery = ` Recovery metadata recorded installer ownership for: ${Object.entries(ownership).filter(([, owned]) => owned).map(([name]) => name).join(', ')}.`;
689
- } catch (recoveryError) {
690
- recovery = ` Recovery metadata could not be recorded: ${recoveryError.message}.`;
691
- }
692
- }
693
- }
694
- line(warn(`Nightly plan did not complete: ${error.message}`));
695
- line(info(`Snapshotted config/registry bytes were rolled back${partial ? '; completed package/plugin installs were not rolled back' : ''}.`));
696
- line(info(`New daemon artifacts and identities were retained for recovery; inspect the selected service before re-running.${recovery}`));
697
- finish(ttyFd); return;
698
- }
699
-
700
- const finalIndex = reverseApplicationIndex(plannedRegistry, {
701
- telegramConfig: wantTelegram ? { daemonUrl: daemonEndpoint(selection.profile.port), daemonStateDir: selection.profile.stateDir } : tgExisting,
702
- roomsConfig: wantRooms ? { daemon: { endpoint: daemonEndpoint(selection.profile.port), stateDir: selection.profile.stateDir } } : roomsExisting,
703
- });
704
- line(heading('Nightly install complete'));
705
- line(ok(`${selection.profile.label} [${selection.id}] — http://127.0.0.1:${selection.profile.port}`));
706
- for (const application of finalIndex[selection.id] || []) {
707
- const restart = application === 'claude-code' ? 'restart Claude Code'
708
- : application === 'codex' ? 'start a new Codex/ours-codex session'
709
- : application === 'hermes' ? 'run /reload-mcp in Hermes'
710
- : application === 'telegram' ? 'restart ours-tg-connector' : 'restart ours-cowork';
711
- line(` ${c.green('✓')} ${application} → ${selection.profile.label} (${restart})`);
712
- }
713
- if (!appliedApps.length && applications.length) line(info('Existing harness associations were kept; no duplicate MCP registrations were created.'));
714
- say(`Registry: ${registryFile} (schema v1, mode 0600; no tokens/status/pids stored).`);
715
- finish(ttyFd);
716
- }