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