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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,396 +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 { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
25
- import { homedir } from 'node:os';
26
- import { join } from 'node:path';
27
- import { heading, c } from './ui.mjs';
28
- import { askLine, checkboxSelect } from './prompt.mjs';
29
- import { canonHarnesses, coworkConfigPath, daemonEndpoint, tgConfigPath } from './logic.mjs';
30
- import {
31
- atomicWriteConfig, restoreConfig, snapshotConfig, transactionalConfigUpdate,
32
- } from './config.mjs';
33
- import {
34
- readRegistry, profilesPath, removeHarnessAssociation, reverseApplicationIndex,
35
- validateRegistry, writeRegistry,
36
- } from './profiles.mjs';
37
-
38
- const YAML_START = '# >>> ours.network plugin (managed block)';
39
- const YAML_END = '# <<< ours.network plugin';
40
- const MD_START = '<!-- >>> ours.network plugin (managed block) -->';
41
- const MD_END = '<!-- <<< ours.network plugin -->';
42
- const line = (text = '') => process.stdout.write(`${text}\n`);
43
- const say = (text) => line(`ours: ${text}`);
44
-
45
- function readObject(path) {
46
- if (!existsSync(path)) return {};
47
- try {
48
- const value = JSON.parse(readFileSync(path, 'utf8'));
49
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('expected a JSON object');
50
- return value;
51
- } catch (error) { throw new Error(`${path} is corrupt or unsafe to inspect: ${error.message}`); }
52
- }
53
-
54
- function stripBlock(file, start, end) {
55
- if (!existsSync(file)) return false;
56
- const text = readFileSync(file, 'utf8');
57
- if (!text.includes(start)) return false;
58
- const lines = text.split('\n');
59
- const out = [];
60
- let skipping = false;
61
- for (const value of lines) {
62
- if (!skipping && value.includes(start)) { skipping = true; continue; }
63
- if (skipping) { if (value.includes(end)) skipping = false; continue; }
64
- out.push(value);
65
- }
66
- writeFileSync(file, out.join('\n'));
67
- return true;
68
- }
69
-
70
- export function planNightlyUninstall(
71
- registry,
72
- {
73
- removeHarnesses = [], profileId, forgetProfile = false, removeDaemon = false,
74
- deleteData = false, connectorActions = {},
75
- } = {},
76
- connectorConfigs = {},
77
- ) {
78
- let next = validateRegistry(registry);
79
- const removedAssociations = [];
80
- for (const application of removeHarnesses) {
81
- const result = removeHarnessAssociation(next, application);
82
- next = result.registry;
83
- if (result.changed) removedAssociations.push({ application, profileId: result.previous });
84
- }
85
- const index = reverseApplicationIndex(next, connectorConfigs);
86
- const profile = profileId ? next.profiles[profileId] : undefined;
87
- if (profileId && !profile) throw new Error(`unknown daemon profile ${JSON.stringify(profileId)}`);
88
- const originalDependencies = profileId ? (index[profileId] || []) : [];
89
- const connectorPlans = [];
90
- const releasedConnectors = new Set();
91
- if (forgetProfile || removeDaemon) {
92
- for (const connector of ['telegram', 'rooms']) {
93
- if (!originalDependencies.includes(connector)) continue;
94
- const requested = connectorActions[connector];
95
- if (!requested) continue;
96
- const mode = requested.action;
97
- if (!['detach', 'uninstall', 'reassign'].includes(mode)) {
98
- throw new Error(`unknown ${connector} lifecycle action ${JSON.stringify(mode)}`);
99
- }
100
- let target;
101
- if (mode === 'reassign') {
102
- const toProfileId = requested.profileId;
103
- target = next.profiles[toProfileId];
104
- if (!target || toProfileId === profileId) {
105
- throw new Error(`${connector} reassignment requires a different retained profile`);
106
- }
107
- connectorPlans.push({
108
- type: 'connector-lifecycle', connector, mode, fromProfileId: profileId,
109
- toProfileId, profile: target,
110
- });
111
- } else {
112
- connectorPlans.push({ type: 'connector-lifecycle', connector, mode, fromProfileId: profileId });
113
- }
114
- releasedConnectors.add(connector);
115
- }
116
- }
117
- const dependencies = originalDependencies.filter((application) => !releasedConnectors.has(application));
118
- if ((forgetProfile || removeDaemon) && dependencies.length) {
119
- throw new Error(`profile ${profileId} is still required by: ${dependencies.join(', ')}`);
120
- }
121
- if (deleteData && !removeDaemon) throw new Error('data deletion requires selecting daemon removal');
122
- if (forgetProfile && removeDaemon) throw new Error('choose either metadata-only forgetting or daemon removal, not both');
123
- if (deleteData && !profile?.ownership.state) throw new Error(`profile ${profileId} state is external; the installer will not delete it`);
124
- const actions = [...connectorPlans];
125
- if (removeDaemon) {
126
- if (profile.ownership.service) actions.push({ type: 'uninstall-service', profileId, profile });
127
- else actions.push({ type: 'external-service-kept', profileId, profile });
128
- if (deleteData) actions.push({ type: 'delete-state', profileId, path: profile.stateDir });
129
- const profiles = { ...next.profiles };
130
- delete profiles[profileId];
131
- next = { ...next, profiles };
132
- } else if (forgetProfile) {
133
- actions.push({ type: 'forget-metadata', profileId, profile });
134
- const profiles = { ...next.profiles };
135
- delete profiles[profileId];
136
- next = { ...next, profiles };
137
- }
138
- const remainingIndex = reverseApplicationIndex(next, connectorConfigs);
139
- const retainedApplications = Object.values(remainingIndex).flat()
140
- .filter((application) => !releasedConnectors.has(application));
141
- const removeGlobalMcp = Object.keys(next.profiles).length === 0 && retainedApplications.length === 0;
142
- return { registry: next, removedAssociations, dependencies, actions, removeGlobalMcp };
143
- }
144
-
145
- const commandOk = (result) => result?.ok === true || result?.status === 0;
146
-
147
- function parseConnectorLifecycle(raw, connector) {
148
- const value = String(raw || '').trim();
149
- if (!value) return null;
150
- if (/^detach$/i.test(value)) return { action: 'detach' };
151
- if (/^(?:uninstall|remove)$/i.test(value)) return { action: 'uninstall' };
152
- const reassign = value.match(/^(?:reassign|profile|move)(?::|\s+)([A-Za-z0-9][A-Za-z0-9_-]{0,31})$/i);
153
- if (reassign) return { action: 'reassign', profileId: reassign[1] };
154
- throw new Error(`${connector} action must be detach, uninstall, or reassign:<profile-id>`);
155
- }
156
-
157
- function connectorRuntime(connector, home) {
158
- return connector === 'telegram'
159
- ? { path: tgConfigPath(process.env, home), bin: 'ours-tg-connector', pkg: '@ours.network/tg-connector' }
160
- : { path: coworkConfigPath(process.env, home), bin: 'ours-cowork', pkg: '@ours.network/cowork' };
161
- }
162
-
163
- function connectorConfigText(connector, current, action) {
164
- const next = { ...current };
165
- if (action.mode === 'reassign') {
166
- if (connector === 'telegram') {
167
- next.daemonUrl = daemonEndpoint(action.profile.port);
168
- next.daemonStateDir = action.profile.stateDir;
169
- } else {
170
- next.daemon = {
171
- ...(current.daemon && typeof current.daemon === 'object' ? current.daemon : {}),
172
- mode: 'external', endpoint: daemonEndpoint(action.profile.port), stateDir: action.profile.stateDir,
173
- };
174
- }
175
- } else if (connector === 'telegram') {
176
- delete next.daemonUrl;
177
- delete next.daemonStateDir;
178
- } else {
179
- delete next.daemon;
180
- }
181
- return `${JSON.stringify(next, null, 2)}\n`;
182
- }
183
-
184
- function rollbackConnectorLifecycles(applied, run) {
185
- let ok = true;
186
- for (const item of [...applied].reverse()) {
187
- try { restoreConfig(item.runtime.path, item.before); }
188
- catch { ok = false; continue; }
189
- const restored = commandOk(run(item.runtime.bin, ['install-service']));
190
- ok &&= restored;
191
- }
192
- return ok;
193
- }
194
-
195
- function applyConnectorLifecycle(action, current, { home, run }) {
196
- const runtime = connectorRuntime(action.connector, home);
197
- const before = snapshotConfig(runtime.path);
198
- const text = connectorConfigText(action.connector, current, action);
199
- if (action.mode === 'reassign') {
200
- const applied = transactionalConfigUpdate(runtime.path, text, () => ({
201
- ok: commandOk(run(runtime.bin, ['install-service'])),
202
- }));
203
- return applied.ok
204
- ? { ok: true, runtime, before }
205
- : { ok: false, error: `${action.connector} reassignment failed during ${applied.stage}` };
206
- }
207
- const stopped = run(runtime.bin, ['uninstall-service']);
208
- if (!commandOk(stopped)) return { ok: false, error: `${action.connector} service detach failed` };
209
- try { atomicWriteConfig(runtime.path, text); }
210
- catch (error) {
211
- run(runtime.bin, ['install-service']);
212
- return { ok: false, error: `${action.connector} config detach failed: ${error.message}` };
213
- }
214
- return { ok: true, runtime, before };
215
- }
216
-
217
- function removeHarnessArtifacts(application, { run, npm, home }) {
218
- if (application === 'claude-code') {
219
- const plugin = run('claude', ['plugin', 'uninstall', 'ours@ours.network']);
220
- if (!commandOk(plugin)) return false;
221
- const market = run('claude', ['plugin', 'marketplace', 'remove', 'ours.network']);
222
- return commandOk(market);
223
- }
224
- if (application === 'codex') {
225
- const plugin = run('codex', ['plugin', 'remove', 'ours@ours-codex-marketplace']);
226
- if (!commandOk(plugin)) return false;
227
- const market = run('codex', ['plugin', 'marketplace', 'remove', 'ours-codex-marketplace']);
228
- if (!commandOk(market)) return false;
229
- const codex = process.env.CODEX_DIR || join(home, '.codex');
230
- const skills = process.env.SKILLS_DIR || join(home, '.agents', 'skills');
231
- stripBlock(join(codex, 'config.toml'), YAML_START, YAML_END);
232
- stripBlock(join(codex, 'AGENTS.md'), MD_START, MD_END);
233
- for (const path of [join(skills, 'ours'), join(skills, 'writing-agent-bios')]) {
234
- if (existsSync(path)) rmSync(path, { recursive: true, force: true });
235
- }
236
- return commandOk(run(npm, ['rm', '-g', '@ours.network/codex']));
237
- }
238
- if (application === 'hermes') {
239
- const hermes = process.env.HERMES_DIR || join(home, '.hermes');
240
- stripBlock(join(hermes, 'config.yaml'), YAML_START, YAML_END);
241
- for (const path of [
242
- join(hermes, 'skills', 'communication', 'ours'),
243
- join(hermes, 'skills', 'communication', 'writing-agent-bios'),
244
- ]) if (existsSync(path)) rmSync(path, { recursive: true, force: true });
245
- return commandOk(run(npm, ['rm', '-g', '@ours.network/hermes']));
246
- }
247
- return false;
248
- }
249
-
250
- function exactServiceEnv(profile) {
251
- return {
252
- OURS_CONFIG: profile.configPath,
253
- OURS_PORT: String(profile.port),
254
- OURS_STATE_DIR: profile.stateDir,
255
- ...(profile.serviceName ? { OURS_SERVICE_NAME: profile.serviceName } : {}),
256
- };
257
- }
258
-
259
- export function runNightlyUninstaller({ ttyFd, write, run, npm, assumeYes, finish }) {
260
- const home = process.env.HOME || homedir();
261
- const registryFile = profilesPath(process.env, home);
262
- let registry;
263
- try { registry = readRegistry(registryFile, { allowMissing: false }); }
264
- catch (error) { say(`Nightly profile registry is unavailable: ${error.message}`); finish(ttyFd); return; }
265
- let telegramConfig;
266
- let roomsConfig;
267
- try {
268
- telegramConfig = readObject(tgConfigPath(process.env, home));
269
- roomsConfig = readObject(coworkConfigPath(process.env, home));
270
- } catch (error) {
271
- say(`Refusing Nightly uninstall: ${error.message}`);
272
- finish(ttyFd); return;
273
- }
274
- line(heading('Nightly profile-aware uninstall'));
275
- const envHarnesses = process.env.OURS_UNINSTALL;
276
- let removeHarnesses;
277
- if (envHarnesses != null) removeHarnesses = canonHarnesses(envHarnesses).names;
278
- else if (ttyFd != null) removeHarnesses = checkboxSelect(write, ttyFd, [
279
- { name: 'claude-code', label: 'Claude Code plugin + its profile association' },
280
- { name: 'codex', label: 'Codex plugin + its profile association' },
281
- { name: 'hermes', label: 'Hermes plugin + its profile association' },
282
- ], { title: 'Choose harness plugins to remove' });
283
- else removeHarnesses = [];
284
-
285
- const ids = Object.keys(registry.profiles);
286
- let profileId = process.env.OURS_UNINSTALL_PROFILE || '';
287
- if (!profileId && ttyFd != null && ids.length) {
288
- line(' Profiles: ' + ids.map((id, index) => `${index + 1}) ${id}`).join(' · '));
289
- const choice = askLine(write, ttyFd, ' Profile to forget/remove (Enter keeps all): ', '');
290
- if (/^\d+$/.test(choice)) profileId = ids[Number(choice) - 1] || '';
291
- else profileId = choice;
292
- }
293
- const forgetProfile = !!profileId && (process.env.OURS_UNINSTALL_FORGET_PROFILE === 'yes' || (!assumeYes && ttyFd != null && askLine(write, ttyFd, ` Type 'forget ${profileId}' to remove metadata only: `, '') === `forget ${profileId}`));
294
- const removeDaemon = !!profileId && (process.env.OURS_UNINSTALL_DAEMON === 'yes' || (!assumeYes && ttyFd != null && askLine(write, ttyFd, ` Type 'remove ${profileId}' to remove its installer-owned service: `, '') === `remove ${profileId}`));
295
- let deleteData = false;
296
- if (removeDaemon && process.env.OURS_UNINSTALL_DATA === 'yes') deleteData = true;
297
- else if (removeDaemon && ttyFd != null) {
298
- const path = registry.profiles[profileId]?.stateDir;
299
- if (path) {
300
- line(c.red(` Data deletion permanently destroys identities and private keys at ${path}.`));
301
- deleteData = askLine(write, ttyFd, ` Type the exact path ${path} to confirm key loss, or Enter to keep data: `, '') === path;
302
- }
303
- }
304
-
305
- const connectorActions = {};
306
- if (profileId && (forgetProfile || removeDaemon)) {
307
- const dependencies = reverseApplicationIndex(registry, { telegramConfig, roomsConfig })[profileId] || [];
308
- for (const connector of ['telegram', 'rooms']) {
309
- if (!dependencies.includes(connector)) continue;
310
- const envName = connector === 'telegram' ? 'OURS_UNINSTALL_TELEGRAM' : 'OURS_UNINSTALL_ROOMS';
311
- let raw = process.env[envName] || '';
312
- if (!raw && !assumeYes && ttyFd != null) {
313
- raw = askLine(
314
- write, ttyFd,
315
- ` ${connector} uses ${profileId}. Type detach, uninstall, or reassign:<retained-profile-id>: `,
316
- '',
317
- );
318
- }
319
- try {
320
- const parsed = parseConnectorLifecycle(raw, connector);
321
- if (parsed) connectorActions[connector] = parsed;
322
- } catch (error) {
323
- say(`Refusing Nightly uninstall: ${error.message}`); finish(ttyFd); return;
324
- }
325
- }
326
- }
327
-
328
- let plan;
329
- try {
330
- plan = planNightlyUninstall(registry, {
331
- removeHarnesses, profileId: profileId || undefined, forgetProfile, removeDaemon,
332
- deleteData, connectorActions,
333
- }, { telegramConfig, roomsConfig });
334
- } catch (error) { say(`Refusing uninstall plan: ${error.message}`); finish(ttyFd); return; }
335
-
336
- for (const application of removeHarnesses) {
337
- if (!removeHarnessArtifacts(application, { run, npm, home })) {
338
- say(`Could not remove ${application} artifacts; its registry association was left unchanged.`);
339
- finish(ttyFd); return;
340
- }
341
- }
342
- const connectorConfigs = { telegram: telegramConfig, rooms: roomsConfig };
343
- const appliedConnectors = [];
344
- for (const action of plan.actions) {
345
- if (action.type !== 'connector-lifecycle') continue;
346
- const result = applyConnectorLifecycle(action, connectorConfigs[action.connector], { home, run });
347
- if (!result.ok) {
348
- const recovered = rollbackConnectorLifecycles(appliedConnectors, run);
349
- say(`${result.error}; daemon/profile registry left unchanged.${recovered ? '' : ' Earlier connector rollback also failed; inspect those services.'}`);
350
- finish(ttyFd); return;
351
- }
352
- appliedConnectors.push({ ...result, action });
353
- }
354
- const removedServices = [];
355
- for (const action of plan.actions) {
356
- if (action.type === 'uninstall-service') {
357
- const result = run('ours-mcp', ['uninstall-service'], { env: exactServiceEnv(action.profile) });
358
- if (!commandOk(result)) {
359
- const recovered = rollbackConnectorLifecycles(appliedConnectors, run);
360
- say(`Could not uninstall the exact service for ${action.profileId}; registry left unchanged.${recovered ? '' : ' Connector rollback also failed; inspect those services.'}`);
361
- finish(ttyFd); return;
362
- }
363
- removedServices.push(action);
364
- }
365
- if (action.type === 'external-service-kept') say(`Profile ${action.profileId} is external; its service/config/state were left untouched.`);
366
- }
367
- try { writeRegistry(registryFile, plan.registry); }
368
- catch (error) {
369
- let recovered = true;
370
- for (const action of removedServices.reverse()) {
371
- const restored = commandOk(run('ours-mcp', ['install-service'], { env: exactServiceEnv(action.profile) }));
372
- recovered &&= restored;
373
- }
374
- const connectorsRecovered = rollbackConnectorLifecycles(appliedConnectors, run);
375
- recovered &&= connectorsRecovered;
376
- say(`Registry commit failed: ${error.message}. ${recovered ? 'Removed services/connectors were restored.' : 'Service recovery also failed; inspect the exact profile and connector services.'}`);
377
- finish(ttyFd); return;
378
- }
379
- for (const action of plan.actions) {
380
- if (action.type === 'delete-state') {
381
- if (existsSync(action.path)) rmSync(action.path, { recursive: true, force: true });
382
- say(`Permanently removed ${action.path}; identities/keys there are not recoverable.`);
383
- }
384
- if (action.type === 'forget-metadata') say(`Forgot external profile ${action.profileId}; no daemon artifacts were changed.`);
385
- if (action.type === 'connector-lifecycle' && action.mode === 'uninstall') {
386
- const runtime = connectorRuntime(action.connector, home);
387
- if (!commandOk(run(npm, ['rm', '-g', runtime.pkg]))) {
388
- say(`${action.connector} was safely detached, but its global package could not be removed.`);
389
- }
390
- }
391
- }
392
- if (plan.removeGlobalMcp && (removeDaemon || forgetProfile)) run(npm, ['rm', '-g', '@ours.network/mcp']);
393
- else say('Kept the global @ours.network/mcp package because retained profiles/applications still need it.');
394
- say('Nightly uninstall complete.');
395
- finish(ttyFd);
396
- }