@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.
- package/README.md +24 -217
- package/install.mjs +80 -459
- package/lib/logic.mjs +21 -419
- package/package.json +1 -1
- package/uninstall.mjs +2 -15
- package/lib/components.mjs +0 -300
- package/lib/effects.mjs +0 -331
- package/lib/extras.mjs +0 -357
- package/lib/journal.mjs +0 -113
- package/lib/nightly-install.mjs +0 -716
- package/lib/nightly-uninstall.mjs +0 -373
- package/lib/orchestrate-uninstall.mjs +0 -293
- package/lib/orchestrate.mjs +0 -884
- package/lib/plan.mjs +0 -238
- package/lib/profiles.mjs +0 -501
- package/lib/rerun.mjs +0 -119
- package/lib/target.mjs +0 -353
- package/lib/uninstall.mjs +0 -439
- package/lib/usage.mjs +0 -47
|
@@ -1,373 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,293 +0,0 @@
|
|
|
1
|
-
// ours-uninstall v3 — the orchestrator.
|
|
2
|
-
//
|
|
3
|
-
// ours-uninstall [--state-dir PATH] [--purge] [--dry-run]
|
|
4
|
-
//
|
|
5
|
-
// Same shape as lib/orchestrate.mjs: every side effect arrives through one
|
|
6
|
-
// injected `effects` object, every DECISION comes from lib/uninstall.mjs, which
|
|
7
|
-
// is pure and separately tested.
|
|
8
|
-
//
|
|
9
|
-
// This file removes things, so two properties matter more here than anywhere
|
|
10
|
-
// else in the installer:
|
|
11
|
-
//
|
|
12
|
-
// NOTHING IS REMOVED AFTER A REFUSAL. Step 1 refuses before the first
|
|
13
|
-
// mutation, so a run that stops leaves the daemon exactly as it was rather
|
|
14
|
-
// than half-dismantled.
|
|
15
|
-
//
|
|
16
|
-
// THE DESTRUCTIVE STEP IS LAST AND SEPARATELY GATED. --purge runs after
|
|
17
|
-
// everything else has succeeded, needs four gates open, and is the only step
|
|
18
|
-
// here that cannot be undone by re-running the installer.
|
|
19
|
-
|
|
20
|
-
import { join } from 'node:path';
|
|
21
|
-
import { parseInstallArgs, InstallUsageError } from './target.mjs';
|
|
22
|
-
import { planUninstall, planComponentDetach, planStatePurge, stripManagedBlock } from './uninstall.mjs';
|
|
23
|
-
import { tgConfigPath, coworkConfigPath } from './components.mjs';
|
|
24
|
-
import { configJournal, reportRollback } from './journal.mjs';
|
|
25
|
-
import { UNINSTALL_USAGE } from './usage.mjs';
|
|
26
|
-
import { ok, info, warn, heading } from './ui.mjs';
|
|
27
|
-
|
|
28
|
-
export const EXIT_OK = 0;
|
|
29
|
-
export const EXIT_REFUSED = 2;
|
|
30
|
-
|
|
31
|
-
async function perform(effects, dryRun, label, thunk) {
|
|
32
|
-
if (dryRun) {
|
|
33
|
-
effects.out(info(`[dry-run] would: ${label}`));
|
|
34
|
-
return { performed: false };
|
|
35
|
-
}
|
|
36
|
-
await thunk();
|
|
37
|
-
effects.out(ok(label));
|
|
38
|
-
return { performed: true };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Which components are being removed alongside this daemon. Asked once, before
|
|
43
|
-
* anything is touched, so the §8 step 1 refusal can be resolved in the same run
|
|
44
|
-
* rather than sending the operator away and back.
|
|
45
|
-
*
|
|
46
|
-
* Non-interactively the answer is NO — assume-yes never consents to removing
|
|
47
|
-
* something on the operator's behalf, and step 1 then refuses, which is the
|
|
48
|
-
* correct outcome for an unattended run pointed at a daemon still in use.
|
|
49
|
-
*/
|
|
50
|
-
export async function confirmComponentRemoval(pointing, { assumeYes, effects }) {
|
|
51
|
-
if (pointing.length === 0 || assumeYes) return [];
|
|
52
|
-
const confirmed = [];
|
|
53
|
-
for (const component of pointing) {
|
|
54
|
-
const answer = await effects.ask(
|
|
55
|
-
`${component.key} points at this daemon (${component.config}). Remove its attachment too?`,
|
|
56
|
-
false,
|
|
57
|
-
);
|
|
58
|
-
if (answer) confirmed.push(component.key);
|
|
59
|
-
}
|
|
60
|
-
return confirmed;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export async function runUninstall(argv, effects) {
|
|
64
|
-
let args;
|
|
65
|
-
try {
|
|
66
|
-
args = parseInstallArgs(argv.filter((a) => a !== '--purge'), effects.env, { home: effects.home });
|
|
67
|
-
} catch (error) {
|
|
68
|
-
if (error instanceof InstallUsageError) {
|
|
69
|
-
effects.out(warn(`ours: ${error.message}`));
|
|
70
|
-
return EXIT_REFUSED;
|
|
71
|
-
}
|
|
72
|
-
throw error;
|
|
73
|
-
}
|
|
74
|
-
if (args.help) { effects.out(UNINSTALL_USAGE); return EXIT_OK; }
|
|
75
|
-
if (args.version) { effects.out(`ours-uninstall v${effects.version ?? '?'}`); return EXIT_OK; }
|
|
76
|
-
const purge = argv.includes('--purge');
|
|
77
|
-
const dir = args.stateDir;
|
|
78
|
-
const config = effects.readJson(join(dir, 'config.json'));
|
|
79
|
-
const endpoint = `http://127.0.0.1:${typeof config?.port === 'number' ? config.port : 3050}`;
|
|
80
|
-
|
|
81
|
-
effects.out(heading(`ours-uninstall --state-dir ${dir}`));
|
|
82
|
-
if (args.dryRun) effects.out(info('dry-run: nothing will be removed or stopped'));
|
|
83
|
-
|
|
84
|
-
// Ask first, mutate second. The question is about resolving the step-1
|
|
85
|
-
// refusal, so it has to come before the plan that would refuse.
|
|
86
|
-
// `readText` is passed alongside `readJson` so the planner can tell an ABSENT
|
|
87
|
-
// component config from a CORRUPT one. Without it, effects.readJson's null
|
|
88
|
-
// stands for both, and a file that will not parse reads as "no connector points
|
|
89
|
-
// here" — which removes the daemon out from under a live connector.
|
|
90
|
-
const probe = planUninstall({ home: effects.home, env: effects.env, endpoint, stateDir: dir, readJson: effects.readJson, readText: effects.readText });
|
|
91
|
-
if (probe.action === 'refuse' && probe.reason === 'component-config-unreadable') {
|
|
92
|
-
effects.out(warn(`ours: ${probe.message}`));
|
|
93
|
-
return EXIT_REFUSED;
|
|
94
|
-
}
|
|
95
|
-
const pointing = probe.action === 'refuse' ? probe.components : [];
|
|
96
|
-
const confirmedComponents = await confirmComponentRemoval(pointing, { assumeYes: args.assumeYes, effects });
|
|
97
|
-
|
|
98
|
-
const plan = planUninstall({
|
|
99
|
-
home: effects.home,
|
|
100
|
-
env: effects.env,
|
|
101
|
-
endpoint,
|
|
102
|
-
stateDir: dir,
|
|
103
|
-
purge,
|
|
104
|
-
assumeYes: args.assumeYes,
|
|
105
|
-
confirmedComponents,
|
|
106
|
-
readJson: effects.readJson,
|
|
107
|
-
readText: effects.readText,
|
|
108
|
-
exists: effects.exists,
|
|
109
|
-
cliStartedIt: effects.readJson(join(dir, 'ours-cli-daemon.json')) !== null,
|
|
110
|
-
otherStateDirsWithConfig: effects.knownStateDirs(),
|
|
111
|
-
typedConfirmation: null,
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
if (plan.action === 'refuse') {
|
|
115
|
-
effects.out(warn(`ours: ${plan.message}`));
|
|
116
|
-
return EXIT_REFUSED;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// 2. Component services and configs. The FILE is kept — it also holds the
|
|
120
|
-
// operator's bot token and settings, which were never ours.
|
|
121
|
-
//
|
|
122
|
-
// THE UNIT OF WORK HERE SPANS STEPS 2 THROUGH 4, and that is what makes it
|
|
123
|
-
// different from the install-side journals. A detached connector's stripped
|
|
124
|
-
// config says "no longer attached to this daemon", and only the daemon's actual
|
|
125
|
-
// removal makes that true. If step 3 or 4 fails, the operator is left with a
|
|
126
|
-
// stopped, detached connector NEXT TO A DAEMON THAT IS STILL THERE — a world the
|
|
127
|
-
// bytes no longer describe.
|
|
128
|
-
const journal = configJournal(effects, { dryRun: args.dryRun });
|
|
129
|
-
const detached = [];
|
|
130
|
-
for (const component of plan.detach) {
|
|
131
|
-
const path = component.key === 'tg' ? tgConfigPath(effects.home, effects.env) : coworkConfigPath(effects.home, effects.env);
|
|
132
|
-
const detach = planComponentDetach(component.key, effects.readJson(path));
|
|
133
|
-
if (detach.behaviourChange) effects.out(warn(`${component.key}: ${detach.behaviourChange}`));
|
|
134
|
-
await perform(effects, args.dryRun, `${component.service.join(' ')}`, () => effects.run(component.service[0], component.service.slice(1)));
|
|
135
|
-
// Recorded whether or not its config changed: the SERVICE was stopped either
|
|
136
|
-
// way, so a rollback owes it a re-apply either way.
|
|
137
|
-
detached.push({ key: component.key, path, service: [component.service[0], 'install-service'] });
|
|
138
|
-
if (detach.removed.length > 0) {
|
|
139
|
-
journal.snapshot(path);
|
|
140
|
-
await perform(effects, args.dryRun, `remove ${detach.removed.join(', ')} from ${path} (file kept)`, () => effects.writeJson(path, `${JSON.stringify(detach.config, null, 2)}\n`));
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// 3-4. The boot service, then the daemon. Both delegate their refusals.
|
|
145
|
-
try {
|
|
146
|
-
for (const step of plan.daemon) {
|
|
147
|
-
if (step.command === null) {
|
|
148
|
-
effects.out(info(`${step.note} — nothing signalled`));
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
await perform(effects, args.dryRun, step.command.join(' '), () => effects.run(step.command[0], step.command.slice(1)));
|
|
152
|
-
}
|
|
153
|
-
} catch (error) {
|
|
154
|
-
await rollBackDetach(effects, journal, detached, args);
|
|
155
|
-
throw error;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// 5. The harness plugins the installer wrote.
|
|
159
|
-
await runPluginPhase(plan.plugins, { args, effects });
|
|
160
|
-
|
|
161
|
-
// 6. State. Last, because it is the only irreversible thing here.
|
|
162
|
-
await runPurgePhase({ dir, purge, args, effects });
|
|
163
|
-
|
|
164
|
-
// 7. Global packages, only when this was the last daemon.
|
|
165
|
-
if (plan.packages.action === 'keep') {
|
|
166
|
-
effects.out(info(`@ours.network/cli kept — ${plan.packages.reason}`));
|
|
167
|
-
} else {
|
|
168
|
-
for (const pkg of plan.packages.packages) {
|
|
169
|
-
await perform(effects, args.dryRun, `npm rm -g ${pkg}`, () => effects.run('npm', ['rm', '-g', pkg]));
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
return EXIT_OK;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Undo a detach when the daemon it was detaching FROM did not go away.
|
|
177
|
-
*
|
|
178
|
-
* THIS ONE NEEDS A COMMAND, NOT JUST BYTES, and that is the whole reason it is a
|
|
179
|
-
* separate function from the install side's rollback. The detach stopped the
|
|
180
|
-
* connector's service before stripping its config, so restoring the bytes under a
|
|
181
|
-
* stopped service is a HALF rollback — and half-states are exactly what this
|
|
182
|
-
* feature exists to eliminate. The config goes back and then `install-service` is
|
|
183
|
-
* re-applied, which is what the nightly uninstaller does
|
|
184
|
-
* (lib/nightly-uninstall.mjs `rollbackConnectorLifecycles`), rather than a second
|
|
185
|
-
* approach invented here.
|
|
186
|
-
*
|
|
187
|
-
* Every failure inside the recovery is REPORTED and none is thrown: the caller is
|
|
188
|
-
* already on a failure path, and a recovery failure must never be what the
|
|
189
|
-
* operator sees instead of the real fault. The original error is what propagates.
|
|
190
|
-
*/
|
|
191
|
-
export async function rollBackDetach(effects, journal, detached, args) {
|
|
192
|
-
if (args.dryRun || detached.length === 0) return { restored: [], reapplied: [], failed: [] };
|
|
193
|
-
effects.out(warn('the daemon was not removed, so the connectors are still attached to it — putting them back'));
|
|
194
|
-
const outcome = journal.restoreAll();
|
|
195
|
-
const reapplied = [];
|
|
196
|
-
const failed = [];
|
|
197
|
-
for (const component of detached.slice().reverse()) {
|
|
198
|
-
try {
|
|
199
|
-
await effects.run(component.service[0], component.service.slice(1));
|
|
200
|
-
effects.out(ok(`${component.service.join(' ')} — ${component.key} is attached and running again`));
|
|
201
|
-
reapplied.push(component.key);
|
|
202
|
-
} catch (error) {
|
|
203
|
-
failed.push(component.key);
|
|
204
|
-
effects.out(warn(`could NOT re-apply ${component.key}'s service: ${error instanceof Error ? error.message : String(error)} — its config is back but the service is down; run '${component.service.join(' ')}' yourself`));
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
reportRollback(effects, outcome, { packagesInstalled: false });
|
|
208
|
-
return { ...outcome, reapplied, failed };
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* The harness plugins: the managed config blocks, the ours skills directories,
|
|
213
|
-
* and the plugin launchers on npm.
|
|
214
|
-
*
|
|
215
|
-
* Without this, a v3 uninstall is a capability REGRESSION against the v2 one —
|
|
216
|
-
* it would remove the daemon and leave every harness still advertising ours
|
|
217
|
-
* tools that no longer resolve.
|
|
218
|
-
*
|
|
219
|
-
* Two rules, both inherited rather than invented. A config file is edited only
|
|
220
|
-
* when both our sentinels are found, and an unterminated block is REPORTED and
|
|
221
|
-
* left alone rather than truncated to end-of-file (which is what v2 did, and it
|
|
222
|
-
* would take everything the user wrote after our block with it). And the whole
|
|
223
|
-
* phase is skipped while another daemon is still on this machine, because its
|
|
224
|
-
* harnesses still need these plugins — the same condition that keeps the global
|
|
225
|
-
* packages, decided once.
|
|
226
|
-
*/
|
|
227
|
-
export async function runPluginPhase(plugins, { args, effects }) {
|
|
228
|
-
effects.out(heading('Harness plugins'));
|
|
229
|
-
for (const step of plugins.manual) {
|
|
230
|
-
// Never a dead end, and never a claim: Claude Code's plugin is not ours to
|
|
231
|
-
// remove, so the run says so and prints the two commands that do it.
|
|
232
|
-
effects.out(info(`${step.label} — ${step.reason}. Inside Claude Code, run:`));
|
|
233
|
-
for (const command of step.steps) effects.out(info(` ${command}`));
|
|
234
|
-
}
|
|
235
|
-
if (plugins.action === 'keep') {
|
|
236
|
-
effects.out(info(`harness plugins kept — ${plugins.reason}`));
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
if (plugins.harnesses.length === 0) {
|
|
240
|
-
effects.out(info('no Hermes or Codex plugin files found — nothing of ours to remove'));
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
for (const harness of plugins.harnesses) {
|
|
245
|
-
for (const block of harness.blocks) {
|
|
246
|
-
const before = effects.readText(block.path);
|
|
247
|
-
if (before === null) continue;
|
|
248
|
-
const stripped = stripManagedBlock(before, block.markers);
|
|
249
|
-
if (stripped.action === 'absent') {
|
|
250
|
-
effects.out(info(`${block.path} carries no ours block — left untouched`));
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
if (stripped.action === 'refuse') {
|
|
254
|
-
effects.out(warn(`${block.path}: ${stripped.reason}. Remove it by hand.`));
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
await perform(effects, args.dryRun, `remove the ours managed block from ${block.path} (file kept)`, () => effects.writeText(block.path, stripped.text));
|
|
258
|
-
}
|
|
259
|
-
for (const dir of harness.dirs) {
|
|
260
|
-
if (!effects.exists(dir)) continue;
|
|
261
|
-
await perform(effects, args.dryRun, `remove ${dir}`, () => effects.removeDir(dir));
|
|
262
|
-
}
|
|
263
|
-
for (const file of harness.files) {
|
|
264
|
-
if (!effects.exists(file)) continue;
|
|
265
|
-
await perform(effects, args.dryRun, `remove ${file}`, () => effects.removeFile(file));
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* --purge, gated four ways and asked for by typing the full path.
|
|
272
|
-
*
|
|
273
|
-
* The typed answer is compared by the pure planner, not here, so the comparison
|
|
274
|
-
* cannot drift from the one the tests pin. A wrong or empty answer keeps the
|
|
275
|
-
* state directory — there is no retry loop, because a second chance at deleting
|
|
276
|
-
* identity keys is not a kindness.
|
|
277
|
-
*/
|
|
278
|
-
export async function runPurgePhase({ dir, purge, args, effects }) {
|
|
279
|
-
const asked = planStatePurge({ stateDir: dir, purge, assumeYes: args.assumeYes, exists: effects.exists });
|
|
280
|
-
if (asked.action === 'keep') {
|
|
281
|
-
effects.out(info(`state ${dir} kept — ${asked.reason}`));
|
|
282
|
-
if (!purge) effects.out(info(asked.hint));
|
|
283
|
-
return { purged: false };
|
|
284
|
-
}
|
|
285
|
-
const typed = await effects.askLine(asked.prompt);
|
|
286
|
-
const decided = planStatePurge({ stateDir: dir, purge, assumeYes: args.assumeYes, exists: effects.exists, typedConfirmation: typed });
|
|
287
|
-
if (decided.action !== 'purge') {
|
|
288
|
-
effects.out(info(`state ${dir} kept — the typed path did not match`));
|
|
289
|
-
return { purged: false };
|
|
290
|
-
}
|
|
291
|
-
await perform(effects, args.dryRun, `delete ${dir} and everything in it`, () => effects.removeDir(dir));
|
|
292
|
-
return { purged: !args.dryRun };
|
|
293
|
-
}
|