@ours.network/install 0.17.0 → 0.18.0-nightly.2
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 +72 -120
- package/install.mjs +23 -790
- package/lib/components.mjs +361 -0
- package/lib/detect.mjs +169 -0
- package/lib/effects.mjs +349 -0
- package/lib/extras.mjs +351 -0
- package/lib/journal.mjs +158 -0
- package/lib/logic.mjs +351 -25
- package/lib/orchestrate-uninstall.mjs +379 -0
- package/lib/orchestrate.mjs +984 -0
- package/lib/plan.mjs +270 -0
- package/lib/rerun.mjs +119 -0
- package/lib/target.mjs +390 -0
- package/lib/ui.mjs +15 -0
- package/lib/uninstall.mjs +736 -0
- package/lib/usage.mjs +48 -0
- package/package.json +2 -2
- package/uninstall.mjs +23 -194
- package/uninstall.sh +13 -4
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
// ours-uninstall v3 — removing ONE daemon, and the non-interactive contract.
|
|
2
|
+
//
|
|
3
|
+
// Spec: installer-spec-v3 §§8-9. Pure, like every other stage: the caller
|
|
4
|
+
// injects the file reads and this returns a plan.
|
|
5
|
+
//
|
|
6
|
+
// ours-uninstall [--state-dir PATH] [--purge] [--dry-run]
|
|
7
|
+
//
|
|
8
|
+
// `--state-dir` selects WHICH daemon to remove and defaults to ~/.ours. The
|
|
9
|
+
// uninstaller only ever touches that daemon and the components pointing at it.
|
|
10
|
+
//
|
|
11
|
+
// THE BIAS OF THIS WHOLE FILE IS TOWARD KEEPING THINGS. State is kept by default,
|
|
12
|
+
// a component's config file is kept even when its daemon keys are removed, and
|
|
13
|
+
// global packages are kept while any other daemon still needs them. The one
|
|
14
|
+
// destructive operation, --purge, is separately gated (§8 step 5) because
|
|
15
|
+
// it deletes identity keys, and no other step here is irreversible.
|
|
16
|
+
|
|
17
|
+
import { join, resolve } from 'node:path';
|
|
18
|
+
import { unitNameForStateDir } from './plan.mjs';
|
|
19
|
+
import { tgConfigPath, coworkConfigPath } from './components.mjs';
|
|
20
|
+
import { canonHarnesses } from './logic.mjs';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Does this directory even look like an ours state directory?
|
|
24
|
+
*
|
|
25
|
+
* WHY THIS EXISTS. The owner removed the "created by an installer run" gate —
|
|
26
|
+
* purge means purge, on any state directory. That was a deliberate ruling and
|
|
27
|
+
* this does not reintroduce it: this asks "is this a state directory at all",
|
|
28
|
+
* not "is it ours". With provenance gone, the typed path would otherwise be the
|
|
29
|
+
* only thing between `ours-uninstall --state-dir ~ --purge` and a deleted home
|
|
30
|
+
* directory, and a typed path is no protection against a path typed exactly as
|
|
31
|
+
* intended but meant differently.
|
|
32
|
+
*
|
|
33
|
+
* A directory qualifies if it carries any of the artefacts only a daemon writes.
|
|
34
|
+
* Absent all of them, purge refuses and says why — the operator can still delete
|
|
35
|
+
* the directory themselves, which is the right place for that decision.
|
|
36
|
+
*/
|
|
37
|
+
export const STATE_DIR_EVIDENCE = ['config.json', 'daemon-token', 'ours-cli-daemon.json', 'root.json'];
|
|
38
|
+
export function looksLikeStateDir(stateDir, exists) {
|
|
39
|
+
return STATE_DIR_EVIDENCE.some((name) => exists(join(resolve(stateDir), name)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A component config file, read so that ABSENT and CORRUPT are different answers.
|
|
44
|
+
*
|
|
45
|
+
* WHY THIS IS NOT `readJson`. `effects.readJson` swallows every failure into
|
|
46
|
+
* `null` (lib/effects.mjs), which is the right shape for the installer — an
|
|
47
|
+
* unreadable daemon config there means "nothing recorded", and the run proceeds
|
|
48
|
+
* to write one. On the UNINSTALL side that same `null` is a fail-open:
|
|
49
|
+
* `componentsPointingHere` reads it as "no connector points at this daemon",
|
|
50
|
+
* step 1's refusal never fires, and the daemon is removed out from under a
|
|
51
|
+
* connector that may still be using it. The nightly uninstaller refuses here
|
|
52
|
+
* (`lib/nightly-uninstall.mjs:22,244`) and has a test pinning that it does.
|
|
53
|
+
*
|
|
54
|
+
* So this takes `readText` — already on the effects contract — and does the parse
|
|
55
|
+
* itself, which keeps the whole decision pure and testable:
|
|
56
|
+
*
|
|
57
|
+
* absent — no file (readText returned null). Nothing to point anywhere.
|
|
58
|
+
* ok — a JSON object.
|
|
59
|
+
* corrupt — the file exists and is not a readable JSON object. An EXISTING file
|
|
60
|
+
* we cannot parse is the case that must stop the run: we cannot prove
|
|
61
|
+
* it does not name this daemon, and "cannot prove" is not "does not".
|
|
62
|
+
*
|
|
63
|
+
* An empty existing file is CORRUPT, not absent — same as the nightly reader,
|
|
64
|
+
* which parses whatever `existsSync` says is there.
|
|
65
|
+
*
|
|
66
|
+
* THIS DECIDES NOTHING ABOUT CONTENTS. `componentsPointingHere` still reads its
|
|
67
|
+
* values through `readJson`, unchanged — deliberately, so this addition cannot
|
|
68
|
+
* alter which components are found. The only new outcome is `corrupt`, and the
|
|
69
|
+
* only thing that consumes it is a refusal.
|
|
70
|
+
*/
|
|
71
|
+
export function inspectComponentConfig(path, { readText } = {}) {
|
|
72
|
+
if (typeof readText !== 'function') return { state: 'unknown', reason: 'no text reader injected' };
|
|
73
|
+
const text = readText(path);
|
|
74
|
+
if (text === null || text === undefined) return { state: 'absent' };
|
|
75
|
+
let parsed;
|
|
76
|
+
try {
|
|
77
|
+
parsed = JSON.parse(String(text));
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return { state: 'corrupt', reason: error instanceof Error ? error.message : String(error) };
|
|
80
|
+
}
|
|
81
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
82
|
+
return { state: 'corrupt', reason: 'expected a JSON object' };
|
|
83
|
+
}
|
|
84
|
+
return { state: 'ok' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* §8 step 1 — refuse if a component still points at this daemon.
|
|
89
|
+
*
|
|
90
|
+
* Read the connector's and cowork's config files; if either names this daemon's
|
|
91
|
+
* endpoint or its state directory, list them and stop. Exit 2, nothing removed.
|
|
92
|
+
* The operator either repoints them or confirms their removal in the same run.
|
|
93
|
+
*
|
|
94
|
+
* Matching on EITHER the endpoint or the state directory is deliberate: a
|
|
95
|
+
* half-written pair should still be caught, and the whole point of the pair is
|
|
96
|
+
* that neither half alone is trustworthy.
|
|
97
|
+
*/
|
|
98
|
+
export function componentsPointingHere({ home, env = {}, endpoint, stateDir, readJson }) {
|
|
99
|
+
const dir = resolve(stateDir);
|
|
100
|
+
const found = [];
|
|
101
|
+
const tg = readJson(tgConfigPath(home, env));
|
|
102
|
+
if (tg && (tg.daemonUrl === endpoint || (typeof tg.daemonStateDir === 'string' && resolve(tg.daemonStateDir) === dir))) {
|
|
103
|
+
found.push({ key: 'tg', config: tgConfigPath(home, env), endpoint: tg.daemonUrl ?? null, stateDir: tg.daemonStateDir ?? null });
|
|
104
|
+
}
|
|
105
|
+
const cowork = readJson(coworkConfigPath(home, env));
|
|
106
|
+
const block = cowork && typeof cowork.daemon === 'object' && cowork.daemon !== null ? cowork.daemon : null;
|
|
107
|
+
if (block && (block.endpoint === endpoint || (typeof block.stateDir === 'string' && resolve(block.stateDir) === dir))) {
|
|
108
|
+
found.push({ key: 'cowork', config: coworkConfigPath(home, env), endpoint: block.endpoint ?? null, stateDir: block.stateDir ?? null });
|
|
109
|
+
}
|
|
110
|
+
return found;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The other half of step 1, and the reason it is a SEPARATE question.
|
|
115
|
+
*
|
|
116
|
+
* `componentsPointingHere` answers "does a component point here?". This answers
|
|
117
|
+
* "is there a component config I cannot read at all?" — and the two must not be
|
|
118
|
+
* collapsed, because they have opposite resolutions. A component that points here
|
|
119
|
+
* can be confirmed for removal in the same run; a config that will not parse
|
|
120
|
+
* cannot be confirmed away by anybody, because nothing about its contents is
|
|
121
|
+
* known. The operator has to fix or move the file first.
|
|
122
|
+
*
|
|
123
|
+
* Returns [] when no reader capable of telling absent from corrupt was injected.
|
|
124
|
+
* That is not a silent pass: planUninstall reports the limit in the plan itself.
|
|
125
|
+
*/
|
|
126
|
+
export function unreadableComponentConfigs({ home, env = {}, readText }) {
|
|
127
|
+
if (typeof readText !== 'function') return [];
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const [key, path] of [['tg', tgConfigPath(home, env)], ['cowork', coworkConfigPath(home, env)]]) {
|
|
130
|
+
const read = inspectComponentConfig(path, { readText });
|
|
131
|
+
if (read.state === 'corrupt') out.push({ key, config: path, reason: read.reason });
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Strip a component's daemon keys while KEEPING the file. It also holds the
|
|
138
|
+
* operator's bot token and settings, and those are not ours to delete.
|
|
139
|
+
*
|
|
140
|
+
* For cowork, removing the `daemon` block returns it to embedded mode. That is a
|
|
141
|
+
* real behaviour change, not a cleanup, so the plan carries `behaviourChange` for
|
|
142
|
+
* the screen to state BEFORE it happens.
|
|
143
|
+
*/
|
|
144
|
+
export function planComponentDetach(key, existing) {
|
|
145
|
+
const base = existing && typeof existing === 'object' && !Array.isArray(existing) ? { ...existing } : {};
|
|
146
|
+
if (key === 'tg') {
|
|
147
|
+
const removed = ['daemonUrl', 'daemonStateDir'].filter((k) => k in base);
|
|
148
|
+
for (const k of removed) delete base[k];
|
|
149
|
+
return { key, removed, config: base, keepsFile: true, behaviourChange: null };
|
|
150
|
+
}
|
|
151
|
+
const had = 'daemon' in base;
|
|
152
|
+
delete base.daemon;
|
|
153
|
+
return {
|
|
154
|
+
key,
|
|
155
|
+
removed: had ? ['daemon'] : [],
|
|
156
|
+
config: base,
|
|
157
|
+
keepsFile: true,
|
|
158
|
+
behaviourChange: had ? 'cowork returns to embedded mode' : null,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* §8 steps 3-4 — the boot service, then the daemon itself.
|
|
164
|
+
*
|
|
165
|
+
* Both delegate their refusals rather than reimplementing them: `ours daemon
|
|
166
|
+
* uninstall-service` refuses to remove a unit not marked as CLI-managed, and
|
|
167
|
+
* `ours daemon stop` refuses to signal a daemon it did not start. In the second
|
|
168
|
+
* case the screen names the external launcher and the run CONTINUES — a daemon
|
|
169
|
+
* someone else supervises is not a failure of this uninstall.
|
|
170
|
+
*/
|
|
171
|
+
export function planDaemonRemoval({ stateDir, cliStartedIt }) {
|
|
172
|
+
const dir = resolve(stateDir);
|
|
173
|
+
const unit = unitNameForStateDir(dir);
|
|
174
|
+
const service = {
|
|
175
|
+
id: 'service',
|
|
176
|
+
unit: unit.ok ? unit.unit : null,
|
|
177
|
+
command: ['ours', 'daemon', 'uninstall-service', '--yes', '--state-dir', dir, '--config', join(dir, 'config.json')],
|
|
178
|
+
note: 'removes the unit managed by the ours CLI',
|
|
179
|
+
};
|
|
180
|
+
return [
|
|
181
|
+
service,
|
|
182
|
+
cliStartedIt
|
|
183
|
+
? { id: 'stop', command: ['ours', 'daemon', 'stop', '--state-dir', dir, '--config', join(dir, 'config.json')] }
|
|
184
|
+
: { id: 'stop-external', command: null, continues: true, note: 'this daemon was not started by the CLI; naming its launcher and continuing' },
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* §8 step 5 — state. Kept unless every gate opens.
|
|
190
|
+
*
|
|
191
|
+
* --purge given — never the default; deleting identity keys is opt-in.
|
|
192
|
+
* interactive — an unattended run never deletes state (§9).
|
|
193
|
+
* looks like a state directory — see looksLikeStateDir.
|
|
194
|
+
* typed confirmation — the full path, not a y/N. The owner removed the
|
|
195
|
+
* provenance condition, not the deliberateness, and with
|
|
196
|
+
* provenance gone this is the last thing standing between
|
|
197
|
+
* a mistyped command and someone's identity keys.
|
|
198
|
+
*
|
|
199
|
+
* Returns the exact directory, never a glob or a parent, and only when every gate
|
|
200
|
+
* is satisfied.
|
|
201
|
+
*/
|
|
202
|
+
export function planStatePurge({ stateDir, purge = false, assumeYes = false, exists = () => true, typedConfirmation = null }) {
|
|
203
|
+
const dir = resolve(stateDir);
|
|
204
|
+
const keep = (reason) => ({ action: 'keep', stateDir: dir, reason, hint: 're-run with --purge to delete identities and history' });
|
|
205
|
+
if (!purge) return keep('state is kept by default');
|
|
206
|
+
if (assumeYes) return keep('state is never deleted non-interactively');
|
|
207
|
+
if (!looksLikeStateDir(dir, exists)) {
|
|
208
|
+
return keep(`${dir} does not look like an ours state directory (none of ${STATE_DIR_EVIDENCE.join(', ')}); refusing to delete it`);
|
|
209
|
+
}
|
|
210
|
+
if (typedConfirmation !== dir) {
|
|
211
|
+
return {
|
|
212
|
+
action: 'confirm-typed',
|
|
213
|
+
stateDir: dir,
|
|
214
|
+
expected: dir,
|
|
215
|
+
prompt: `This permanently deletes ${dir} and everything in it, including the identity keys and message history of every identity there. Those keys exist nowhere else and no peer can give them back.\nType the full path to confirm:`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return { action: 'purge', stateDir: dir, paths: [dir] };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* §8 step 6 — global packages are shared. Remove them only when no OTHER state
|
|
223
|
+
* directory on this machine still has a daemon config; otherwise keep them and
|
|
224
|
+
* say which daemon still needs them.
|
|
225
|
+
*/
|
|
226
|
+
export const CONNECTOR_PACKAGES = { tg: '@ours.network/tg-connector', cowork: '@ours.network/cowork' };
|
|
227
|
+
|
|
228
|
+
export function planGlobalPackages({ stateDir, otherStateDirsWithConfig = [], pluginPackages = [], detachedComponents = [] }) {
|
|
229
|
+
const dir = resolve(stateDir);
|
|
230
|
+
const others = otherStateDirsWithConfig.map((d) => resolve(d)).filter((d) => d !== dir);
|
|
231
|
+
if (others.length > 0) {
|
|
232
|
+
return { action: 'keep', packages: [], stillNeededBy: others, reason: `still used by the daemon at ${others[0]}` };
|
|
233
|
+
}
|
|
234
|
+
// The harness-plugin launchers follow the SAME rule, not a second one: they
|
|
235
|
+
// speak to a daemon through ours-mcp, so a second daemon still on the machine
|
|
236
|
+
// still needs them, and the one condition above already decides that.
|
|
237
|
+
// A connector's package goes only when BOTH conditions hold: the operator
|
|
238
|
+
// confirmed removing its attachment in THIS run, and this was the last daemon —
|
|
239
|
+
// the same condition already decided once above.
|
|
240
|
+
//
|
|
241
|
+
// WHY NOT NIGHTLY'S THREE-WAY CHOICE. The nightly uninstaller asks per connector
|
|
242
|
+
// for detach / uninstall / reassign:<profile> and removes the package only on
|
|
243
|
+
// `uninstall`. v3 has no such lifecycle question and inventing one here would be
|
|
244
|
+
// deciding scope in a patch. What v3 DOES have is the operator's explicit yes to
|
|
245
|
+
// "remove its attachment too", which is a narrower thing than nightly's
|
|
246
|
+
// `uninstall` — so this stays behind the last-daemon condition rather than
|
|
247
|
+
// standing on the confirmation alone. A connector detached while another daemon
|
|
248
|
+
// survives keeps its package, because that daemon may still be using it.
|
|
249
|
+
const connectors = detachedComponents
|
|
250
|
+
.map((key) => CONNECTOR_PACKAGES[key])
|
|
251
|
+
.filter(Boolean);
|
|
252
|
+
return {
|
|
253
|
+
action: 'remove',
|
|
254
|
+
packages: ['@ours.network/cli', '@ours.network/mcp', ...pluginPackages, ...connectors],
|
|
255
|
+
stillNeededBy: [],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// -----------------------------------------------------------------------------
|
|
260
|
+
// §8 — the harness plugins the installer wrote
|
|
261
|
+
// -----------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The sentinels the plugin installers stamp around everything they write. They
|
|
265
|
+
* are the ONLY thing that makes a block removable: without them we would be
|
|
266
|
+
* editing a config file on a guess.
|
|
267
|
+
*/
|
|
268
|
+
export const YAML_BLOCK = { start: '# >>> ours.network plugin (managed block)', end: '# <<< ours.network plugin' };
|
|
269
|
+
export const MD_BLOCK = { start: '<!-- >>> ours.network plugin (managed block) -->', end: '<!-- <<< ours.network plugin -->' };
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Remove OUR block from a config file, or refuse.
|
|
273
|
+
*
|
|
274
|
+
* SAME IDENTIFICATION DISCIPLINE AS THE SYSTEMD UNIT, and for the same reason.
|
|
275
|
+
* The installer refuses to overwrite a unit it cannot positively identify rather
|
|
276
|
+
* than guessing; this refuses to edit a config it cannot positively identify.
|
|
277
|
+
* Three outcomes, and the third is the one that matters:
|
|
278
|
+
*
|
|
279
|
+
* absent — no start sentinel. The file is not ours to edit; left untouched.
|
|
280
|
+
* strip — both sentinels present. Exactly the delimited span is removed.
|
|
281
|
+
* refuse — a start sentinel with NO end. v2 deleted to end-of-file here,
|
|
282
|
+
* which would take everything the user added after our block with
|
|
283
|
+
* it. An unterminated block is damage we cannot bound, so it is
|
|
284
|
+
* reported and left alone.
|
|
285
|
+
*/
|
|
286
|
+
export function stripManagedBlock(text, markers) {
|
|
287
|
+
if (typeof text !== 'string' || !text.includes(markers.start)) {
|
|
288
|
+
return { action: 'absent', reason: 'no ours managed block in this file' };
|
|
289
|
+
}
|
|
290
|
+
const lines = text.split('\n');
|
|
291
|
+
const out = [];
|
|
292
|
+
let inside = false;
|
|
293
|
+
let closed = false;
|
|
294
|
+
for (const line of lines) {
|
|
295
|
+
if (!inside && line.includes(markers.start)) { inside = true; continue; }
|
|
296
|
+
if (inside) {
|
|
297
|
+
if (line.includes(markers.end)) { inside = false; closed = true; }
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
out.push(line);
|
|
301
|
+
}
|
|
302
|
+
if (!closed) {
|
|
303
|
+
return {
|
|
304
|
+
action: 'refuse',
|
|
305
|
+
reason: 'the ours managed block has no closing marker; refusing to guess where it ends',
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
return { action: 'strip', text: out.join('\n') };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The three harnesses this uninstaller knows, in the order they are offered.
|
|
313
|
+
* Same names and same order as the nightly picker and as `canonHarnesses`, so a
|
|
314
|
+
* selection written for one is a selection for the other.
|
|
315
|
+
*/
|
|
316
|
+
export const HARNESS_ORDER = ['claude-code', 'codex', 'hermes'];
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* What the harness-plugin half of an uninstall removes.
|
|
320
|
+
*
|
|
321
|
+
* `lastDaemon` is the same condition planGlobalPackages decides on, passed in
|
|
322
|
+
* rather than recomputed: while another daemon is still on this machine its
|
|
323
|
+
* harnesses still need their plugins, so nothing here is touched at all.
|
|
324
|
+
*
|
|
325
|
+
* `explicitSelection` OVERRIDES that keep, and only that. The keep is a guess
|
|
326
|
+
* made on the operator's behalf — "another daemon is here, so you probably still
|
|
327
|
+
* want these". When the operator has named the harnesses themselves (the picker,
|
|
328
|
+
* or OURS_UNINSTALL), the guess has been answered and must not outrank the
|
|
329
|
+
* answer. Nothing else about the plan changes: WHICH of the discovered harnesses
|
|
330
|
+
* are then acted on is decided by the caller, not here, so this stays the single
|
|
331
|
+
* description of what exists on disk.
|
|
332
|
+
*
|
|
333
|
+
* Claude Code is deliberately manual-only. Its plugin lives in the in-app
|
|
334
|
+
* marketplace, and there is no file on disk we own — so this prints the two
|
|
335
|
+
* commands and claims nothing, which is the same never-dead-end contract the
|
|
336
|
+
* installer applies to a harness it cannot drive.
|
|
337
|
+
*
|
|
338
|
+
* Every path is EXACT — the precise file or directory the plugin installer
|
|
339
|
+
* writes. Nothing here is a glob, and nothing walks a tree looking for matches.
|
|
340
|
+
*/
|
|
341
|
+
export function planPluginRemoval({ home, env = {}, exists = () => false, lastDaemon = true, explicitSelection = false } = {}) {
|
|
342
|
+
const hermesDir = env.HERMES_DIR || join(home, '.hermes');
|
|
343
|
+
const codexDir = env.CODEX_DIR || join(home, '.codex');
|
|
344
|
+
const skillsDir = env.SKILLS_DIR || join(home, '.agents', 'skills');
|
|
345
|
+
|
|
346
|
+
const manual = {
|
|
347
|
+
key: 'claude-code',
|
|
348
|
+
label: 'Claude Code plugin',
|
|
349
|
+
action: 'manual',
|
|
350
|
+
reason: "its plugin lives in Claude Code's in-app marketplace, so nothing on disk is ours to remove",
|
|
351
|
+
steps: ['/plugin uninstall ours', '/plugin marketplace remove adapt-toolkit/ours-claude-marketplace'],
|
|
352
|
+
};
|
|
353
|
+
if (!lastDaemon && !explicitSelection) {
|
|
354
|
+
return {
|
|
355
|
+
action: 'keep',
|
|
356
|
+
reason: 'another daemon on this machine still uses these plugins',
|
|
357
|
+
harnesses: [],
|
|
358
|
+
manual: [manual],
|
|
359
|
+
packages: [],
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const harnesses = [];
|
|
364
|
+
const hermesConfig = join(hermesDir, 'config.yaml');
|
|
365
|
+
if (exists(hermesConfig) || exists(hermesDir)) {
|
|
366
|
+
harnesses.push({
|
|
367
|
+
key: 'hermes',
|
|
368
|
+
label: 'Hermes plugin',
|
|
369
|
+
blocks: [{ path: hermesConfig, markers: YAML_BLOCK }],
|
|
370
|
+
dirs: [
|
|
371
|
+
join(hermesDir, 'skills', 'communication', 'ours'),
|
|
372
|
+
join(hermesDir, 'skills', 'communication', 'writing-agent-bios'),
|
|
373
|
+
],
|
|
374
|
+
files: [join(hermesDir, 'ours-connector.env'), join(hermesDir, 'ours-connector.log')],
|
|
375
|
+
pkg: '@ours.network/hermes',
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
if (exists(join(codexDir, 'config.toml')) || exists(codexDir)) {
|
|
379
|
+
harnesses.push({
|
|
380
|
+
key: 'codex',
|
|
381
|
+
label: 'Codex plugin',
|
|
382
|
+
blocks: [
|
|
383
|
+
{ path: join(codexDir, 'config.toml'), markers: YAML_BLOCK },
|
|
384
|
+
{ path: join(codexDir, 'AGENTS.md'), markers: MD_BLOCK },
|
|
385
|
+
],
|
|
386
|
+
dirs: [join(skillsDir, 'ours'), join(skillsDir, 'writing-agent-bios')],
|
|
387
|
+
files: [],
|
|
388
|
+
pkg: '@ours.network/codex',
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
action: 'remove',
|
|
393
|
+
harnesses,
|
|
394
|
+
manual: [manual],
|
|
395
|
+
packages: harnesses.map((h) => h.pkg),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* §8 — WHICH harnesses to detach (inventory item 9.5).
|
|
401
|
+
*
|
|
402
|
+
* The nightly uninstaller let the operator choose: a `checkboxSelect` picker on a
|
|
403
|
+
* terminal, `OURS_UNINSTALL` without one, and NOTHING removed when it had
|
|
404
|
+
* neither. v3 had no choice at all — it removed every plugin artefact it found
|
|
405
|
+
* whenever this was the last daemon. A user who wanted to detach one harness had
|
|
406
|
+
* no way to say so, and an unattended run removed plugins that leaving
|
|
407
|
+
* `OURS_UNINSTALL` unset used to protect.
|
|
408
|
+
*
|
|
409
|
+
* This decides the three cases; the orchestrator only does the asking, so the
|
|
410
|
+
* rule and the terminal I/O stay apart.
|
|
411
|
+
*
|
|
412
|
+
* explicit — a selection was given (OURS_UNINSTALL, or the answers to the
|
|
413
|
+
* per-harness questions). Exactly those, intersected with what is
|
|
414
|
+
* actually there. A name for a harness that is not installed is
|
|
415
|
+
* reported as `ignored`, not silently dropped.
|
|
416
|
+
* keep — unattended with no selection. Nothing is removed, and the caller
|
|
417
|
+
* says how to select. This is the conservative side: an uninstall
|
|
418
|
+
* nobody is watching does not decide on its own that a harness
|
|
419
|
+
* should lose its plugin.
|
|
420
|
+
* ask — a terminal and no selection. One question per harness, DEFAULTING
|
|
421
|
+
* TO YES, because the daemon these plugins talk to is going away
|
|
422
|
+
* and a plugin left behind advertises tools that no longer resolve.
|
|
423
|
+
* An operator who just presses Enter gets exactly what v3 does
|
|
424
|
+
* today; the only new thing is that they can now say no.
|
|
425
|
+
*
|
|
426
|
+
* A checkbox picker would match nightly's chrome more closely, but the effects
|
|
427
|
+
* contract this uninstaller runs on has `ask`, not `checkboxSelect` — and the
|
|
428
|
+
* question here is which harnesses, not which widget.
|
|
429
|
+
*/
|
|
430
|
+
export function planHarnessSelection(plugins, { selection = null, assumeYes = false } = {}) {
|
|
431
|
+
const offered = [...(plugins.manual ?? []), ...(plugins.harnesses ?? [])]
|
|
432
|
+
.map((h) => ({ key: h.key, label: h.label }))
|
|
433
|
+
.sort((a, b) => HARNESS_ORDER.indexOf(a.key) - HARNESS_ORDER.indexOf(b.key));
|
|
434
|
+
if (selection !== null) {
|
|
435
|
+
const keys = offered.map((o) => o.key);
|
|
436
|
+
return {
|
|
437
|
+
mode: 'explicit',
|
|
438
|
+
offered,
|
|
439
|
+
chosen: keys.filter((k) => selection.includes(k)),
|
|
440
|
+
ignored: selection.filter((k) => !keys.includes(k)),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
if (assumeYes) {
|
|
444
|
+
return {
|
|
445
|
+
mode: 'keep',
|
|
446
|
+
offered,
|
|
447
|
+
chosen: [],
|
|
448
|
+
ignored: [],
|
|
449
|
+
reason: 'no OURS_UNINSTALL was set and there is nobody to ask',
|
|
450
|
+
hint: `set OURS_UNINSTALL="${HARNESS_ORDER.join(' ')}" (or a subset, or "all") to remove harness plugins unattended`,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
return { mode: 'ask', offered, chosen: null, ignored: [] };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Narrow a plugin plan to the chosen harnesses. Pure, so the orchestrator never
|
|
458
|
+
* decides what "chosen" means to a plan — it only supplies the answers.
|
|
459
|
+
*
|
|
460
|
+
* `packages` follows the harnesses it narrows to: a plugin package belongs to
|
|
461
|
+
* the harness that was removed, so a harness that was kept keeps its package.
|
|
462
|
+
*/
|
|
463
|
+
export function selectHarnesses(plugins, chosen) {
|
|
464
|
+
const keep = (h) => chosen.includes(h.key);
|
|
465
|
+
const harnesses = (plugins.harnesses ?? []).filter(keep);
|
|
466
|
+
return {
|
|
467
|
+
...plugins,
|
|
468
|
+
harnesses,
|
|
469
|
+
manual: (plugins.manual ?? []).filter(keep),
|
|
470
|
+
packages: harnesses.map((h) => h.pkg),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The whole §8 order, refusing at step 1 rather than starting and stopping
|
|
476
|
+
* half-way.
|
|
477
|
+
*/
|
|
478
|
+
export function planUninstall({ home, env = {}, endpoint, stateDir, purge = false, assumeYes = false, confirmedComponents = [], readJson, readText, exists = () => true, cliStartedIt = true, otherStateDirsWithConfig = [], typedConfirmation = null, explicitHarnessSelection = false, platform = 'linux' }) {
|
|
479
|
+
const dir = resolve(stateDir);
|
|
480
|
+
const lastDaemon = otherStateDirsWithConfig.map((d) => resolve(d)).filter((d) => d !== dir).length === 0;
|
|
481
|
+
const plugins = planPluginRemoval({ home, env, exists, lastDaemon, explicitSelection: explicitHarnessSelection });
|
|
482
|
+
|
|
483
|
+
// BEFORE the pointing question, and not resolvable by confirming anything: a
|
|
484
|
+
// component config that will not parse cannot be proven not to name this
|
|
485
|
+
// daemon. Fail closed, name the file, remove nothing. This mirrors the nightly
|
|
486
|
+
// uninstaller's refusal (lib/nightly-uninstall.mjs:244) rather than inventing a
|
|
487
|
+
// second wording for the same event.
|
|
488
|
+
const unreadable = unreadableComponentConfigs({ home, env, readText });
|
|
489
|
+
if (unreadable.length > 0) {
|
|
490
|
+
return {
|
|
491
|
+
action: 'refuse',
|
|
492
|
+
exitCode: 2,
|
|
493
|
+
reason: 'component-config-unreadable',
|
|
494
|
+
components: unreadable,
|
|
495
|
+
removed: [],
|
|
496
|
+
message: `${unreadable.map((c) => `${c.config} is corrupt or unsafe to inspect${c.reason ? `: ${c.reason}` : ''}`).join('; ')}. Refusing to uninstall: a component config that cannot be read cannot be shown not to point at this daemon. Repair or move it, then re-run. Nothing was removed.`,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const pointing = componentsPointingHere({ home, env, endpoint, stateDir: dir, readJson });
|
|
501
|
+
const unconfirmed = pointing.filter((p) => !confirmedComponents.includes(p.key));
|
|
502
|
+
if (unconfirmed.length > 0) {
|
|
503
|
+
return {
|
|
504
|
+
action: 'refuse',
|
|
505
|
+
exitCode: 2,
|
|
506
|
+
reason: 'component-still-points-here',
|
|
507
|
+
components: unconfirmed,
|
|
508
|
+
removed: [],
|
|
509
|
+
message: `${unconfirmed.map((p) => p.key).join(' and ')} still point at this daemon. Repoint them, or confirm their removal in the same run. Nothing was removed.`,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
return {
|
|
513
|
+
action: 'uninstall',
|
|
514
|
+
stateDir: dir,
|
|
515
|
+
detach: pointing.map((p) => ({ key: p.key, service: [`ours-${p.key === 'tg' ? 'tg-connector' : 'cowork'}`, 'uninstall-service'] })),
|
|
516
|
+
daemon: planDaemonRemoval({ stateDir: dir, cliStartedIt }),
|
|
517
|
+
state: planStatePurge({ stateDir: dir, purge, assumeYes, exists, typedConfirmation }),
|
|
518
|
+
plugins,
|
|
519
|
+
packages: planGlobalPackages({
|
|
520
|
+
stateDir: dir,
|
|
521
|
+
otherStateDirsWithConfig,
|
|
522
|
+
pluginPackages: plugins.packages,
|
|
523
|
+
// Only the components the operator explicitly confirmed removing — never
|
|
524
|
+
// every component that merely happens to point here.
|
|
525
|
+
detachedComponents: pointing.map((p) => p.key).filter((key) => confirmedComponents.includes(key)),
|
|
526
|
+
}),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// -----------------------------------------------------------------------------
|
|
531
|
+
// §9 — the non-interactive contract
|
|
532
|
+
// -----------------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* What each question answers to under OURS_ASSUME_YES.
|
|
536
|
+
*
|
|
537
|
+
* The `false` entries are the point of the table: assume-yes never turns a
|
|
538
|
+
* component on that was off, never MOVES one that already exists, never removes
|
|
539
|
+
* a harness's plugin, and never deletes state. It suppresses questions; it does
|
|
540
|
+
* not consent on the operator's behalf to anything irreversible or to anything
|
|
541
|
+
* that changes where an existing component is pointing.
|
|
542
|
+
*
|
|
543
|
+
* `plugins: false` is the one that answers a question this uninstaller used to
|
|
544
|
+
* answer the other way. A harness plugin is not this daemon's to give away
|
|
545
|
+
* unasked, and the operator has a way to ask for it by name — OURS_UNINSTALL —
|
|
546
|
+
* which is precisely the interface an unattended run should have to go through.
|
|
547
|
+
*/
|
|
548
|
+
export const NON_INTERACTIVE_ANSWERS = {
|
|
549
|
+
daemon: true,
|
|
550
|
+
mcp: true,
|
|
551
|
+
tg: false,
|
|
552
|
+
cowork: false,
|
|
553
|
+
repointExistingConnector: false,
|
|
554
|
+
plugins: false,
|
|
555
|
+
purge: false,
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* OURS_ASSUME_YES suppresses questions; it NEVER suppresses a refusal. Every
|
|
560
|
+
* refusal in this specification applies unchanged in non-interactive mode and
|
|
561
|
+
* exits 2 without writing anything.
|
|
562
|
+
*/
|
|
563
|
+
export function refusalSurvivesAssumeYes(refusal) {
|
|
564
|
+
return refusal && refusal.action === 'refuse' ? { ...refusal, exitCode: 2 } : refusal;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// -----------------------------------------------------------------------------
|
|
568
|
+
// §9 — the OURS_UNINSTALL_* contract (inventory item 10.9)
|
|
569
|
+
// -----------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* The seven variables. THIS IS A DOCUMENTED PUBLIC INTERFACE, not an internal
|
|
573
|
+
* detail: all seven are in packages/installer/README.md, two of them are in
|
|
574
|
+
* uninstall.sh's header as well, and somebody's unattended uninstall script is
|
|
575
|
+
* written against them. v3 honoured none of them and said nothing, which is the
|
|
576
|
+
* worst of the three available behaviours — a documented variable that quietly
|
|
577
|
+
* does nothing is worse than one that errors, because the operator believes it
|
|
578
|
+
* worked.
|
|
579
|
+
*
|
|
580
|
+
* Each is in exactly one of two categories, and which one is not a matter of
|
|
581
|
+
* taste. A variable is HONOURED when v3 can deliver what it documents. A
|
|
582
|
+
* variable is REFUSED — exit 2, nothing removed, naming the variable and naming
|
|
583
|
+
* the replacement — when what it documents depends on something v3 does not
|
|
584
|
+
* have. Nothing is silently ignored and nothing is silently obeyed.
|
|
585
|
+
*
|
|
586
|
+
* HONOURED
|
|
587
|
+
* OURS_UNINSTALL which harness plugins to remove. Parsed by the same
|
|
588
|
+
* canonHarnesses the nightly picker used, so names,
|
|
589
|
+
* numbers, "all" and "none" all still mean what they
|
|
590
|
+
* meant. Feeds item 9.5's selection.
|
|
591
|
+
* OURS_UNINSTALL_DAEMON whether the daemon goes. See below — this is the one
|
|
592
|
+
* that matters most.
|
|
593
|
+
* OURS_UNINSTALL_TELEGRAM
|
|
594
|
+
* OURS_UNINSTALL_ROOMS at the value `detach`, which is exactly the
|
|
595
|
+
* confirmation §8 step 1 asks a human for and which an
|
|
596
|
+
* unattended run otherwise cannot give, so today the
|
|
597
|
+
* run refuses instead of detaching.
|
|
598
|
+
*
|
|
599
|
+
* REFUSED
|
|
600
|
+
* OURS_UNINSTALL_DATA=yes v3 never deletes state without a human
|
|
601
|
+
* present (§9), and that rule protects private
|
|
602
|
+
* keys that exist nowhere else.
|
|
603
|
+
* OURS_UNINSTALL_PROFILE names an entry in the daemon registry. v3
|
|
604
|
+
* has no registry; the selector is --state-dir.
|
|
605
|
+
* OURS_UNINSTALL_FORGET_PROFILE asks to forget a daemon as metadata while it
|
|
606
|
+
* keeps running. v3 has no metadata to forget.
|
|
607
|
+
* OURS_UNINSTALL_TELEGRAM/_ROOMS at `uninstall` (v3 detaches, it does not
|
|
608
|
+
* remove connector packages) or
|
|
609
|
+
* `reassign:<profile-id>` (again the registry).
|
|
610
|
+
*
|
|
611
|
+
* WHY THE WHOLE CONTRACT ENGAGES OR NONE OF IT DOES. v2 gated on
|
|
612
|
+
* `OURS_UNINSTALL != null || OURS_UNINSTALL_DATA || OURS_UNINSTALL_DAEMON` and
|
|
613
|
+
* then asked nothing at all. That gate is kept exactly: with none of the seven
|
|
614
|
+
* set, `ours-uninstall` behaves precisely as it does today, so the blast radius
|
|
615
|
+
* of this whole change is "an operator who set one of the documented variables".
|
|
616
|
+
*
|
|
617
|
+
* AND THE ESCALATION THIS CLOSES. Before this, `OURS_UNINSTALL="hermes"` — a
|
|
618
|
+
* script that asked for ONE HARNESS PLUGIN to be removed — reached v3, which
|
|
619
|
+
* read none of it, and removed THE DAEMON: its service, its process, and the
|
|
620
|
+
* global packages. Honouring OURS_UNINSTALL_DAEMON is what makes a request to
|
|
621
|
+
* detach a plugin stay a request to detach a plugin.
|
|
622
|
+
*/
|
|
623
|
+
export const UNINSTALL_ENV_VARS = [
|
|
624
|
+
'OURS_UNINSTALL',
|
|
625
|
+
'OURS_UNINSTALL_PROFILE',
|
|
626
|
+
'OURS_UNINSTALL_FORGET_PROFILE',
|
|
627
|
+
'OURS_UNINSTALL_DAEMON',
|
|
628
|
+
'OURS_UNINSTALL_DATA',
|
|
629
|
+
'OURS_UNINSTALL_TELEGRAM',
|
|
630
|
+
'OURS_UNINSTALL_ROOMS',
|
|
631
|
+
];
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Is the contract engaged at all? PRESENCE, not truth — `OURS_UNINSTALL=""` is a
|
|
635
|
+
* deliberate "no harnesses", exactly as v2's `!= null` read it, and is not the
|
|
636
|
+
* same as never having set it.
|
|
637
|
+
*/
|
|
638
|
+
export function uninstallEnvEngaged(env = {}) {
|
|
639
|
+
return UNINSTALL_ENV_VARS.some((name) => env[name] !== undefined && env[name] !== null);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const CONNECTOR_VARS = [
|
|
643
|
+
{ name: 'OURS_UNINSTALL_TELEGRAM', key: 'tg', pkg: '@ours.network/tg-connector' },
|
|
644
|
+
{ name: 'OURS_UNINSTALL_ROOMS', key: 'cowork', pkg: '@ours.network/cowork' },
|
|
645
|
+
];
|
|
646
|
+
|
|
647
|
+
function connectorRefusal(name, value, pkg) {
|
|
648
|
+
if (value === 'uninstall') {
|
|
649
|
+
return `${name}=uninstall asks for the connector's global package to be removed as well; this uninstaller detaches a connector, it does not uninstall one. Use ${name}=detach, then 'npm rm -g ${pkg}' yourself.`;
|
|
650
|
+
}
|
|
651
|
+
if (value.startsWith('reassign:')) {
|
|
652
|
+
return `${name}=${value} asks for the connector to be pointed at another daemon by profile id. This uninstaller has no profile registry, so there is no id it could resolve. Point the connector at the daemon you want first — 'ours-install --state-dir <path>' — and then re-run this uninstall.`;
|
|
653
|
+
}
|
|
654
|
+
return `${name}=${value} is not a value this uninstaller understands. The only supported value is 'detach'.`;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Read the contract, or refuse it. Pure: the caller passes the environment in.
|
|
659
|
+
*
|
|
660
|
+
* A refusal is returned rather than thrown, in the same shape planUninstall uses
|
|
661
|
+
* for its own refusals, so the orchestrator has one thing to print and one exit
|
|
662
|
+
* code to return — and so a test can assert the whole set of refusals at once
|
|
663
|
+
* instead of catching them one at a time.
|
|
664
|
+
*/
|
|
665
|
+
export function parseUninstallEnv(env = {}) {
|
|
666
|
+
const engaged = uninstallEnvEngaged(env);
|
|
667
|
+
const value = (name) => (typeof env[name] === 'string' ? env[name].trim() : '');
|
|
668
|
+
const refusals = [];
|
|
669
|
+
|
|
670
|
+
if (value('OURS_UNINSTALL_DATA') === 'yes') {
|
|
671
|
+
refusals.push({
|
|
672
|
+
variable: 'OURS_UNINSTALL_DATA',
|
|
673
|
+
value: 'yes',
|
|
674
|
+
message: "OURS_UNINSTALL_DATA=yes asks this uninstaller to delete a state directory with nobody present. It will not: that directory holds identity private keys that exist nowhere else and that no peer can give back, and state is never deleted in an unattended run. To delete it, run 'ours-uninstall --state-dir <dir> --purge' from a terminal and type the full path when it asks.",
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
const profile = value('OURS_UNINSTALL_PROFILE');
|
|
678
|
+
if (profile) {
|
|
679
|
+
refusals.push({
|
|
680
|
+
variable: 'OURS_UNINSTALL_PROFILE',
|
|
681
|
+
value: profile,
|
|
682
|
+
message: `OURS_UNINSTALL_PROFILE=${profile} names an entry in the daemon profile registry, which this uninstaller does not have — there is no profile to select. Choose the daemon by its state directory instead: 'ours-uninstall --state-dir <path>'.`,
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
if (value('OURS_UNINSTALL_FORGET_PROFILE') === 'yes') {
|
|
686
|
+
refusals.push({
|
|
687
|
+
variable: 'OURS_UNINSTALL_FORGET_PROFILE',
|
|
688
|
+
value: 'yes',
|
|
689
|
+
message: 'OURS_UNINSTALL_FORGET_PROFILE=yes asks for a daemon to be forgotten as metadata while it keeps running. This uninstaller keeps no registry of daemons, so there is nothing to forget and nothing it could do that would match that request — a daemon it is not pointed at is already left entirely alone.',
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const components = {};
|
|
694
|
+
for (const connector of CONNECTOR_VARS) {
|
|
695
|
+
const raw = value(connector.name);
|
|
696
|
+
if (!raw) continue;
|
|
697
|
+
if (raw === 'detach') { components[connector.key] = 'detach'; continue; }
|
|
698
|
+
refusals.push({
|
|
699
|
+
variable: connector.name,
|
|
700
|
+
value: raw,
|
|
701
|
+
message: connectorRefusal(connector.name, raw, connector.pkg),
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const canon = engaged ? canonHarnesses(env.OURS_UNINSTALL ?? '') : null;
|
|
706
|
+
// A token we cannot map is a request we cannot deliver. The nightly picker
|
|
707
|
+
// reported these and carried on, which under an unattended run means
|
|
708
|
+
// OURS_UNINSTALL="hermez" removes nothing and says so into a log nobody reads.
|
|
709
|
+
// Same rule as the rest of this contract: name it, refuse, remove nothing.
|
|
710
|
+
for (const token of canon?.unknown ?? []) {
|
|
711
|
+
refusals.push({
|
|
712
|
+
variable: 'OURS_UNINSTALL',
|
|
713
|
+
value: token,
|
|
714
|
+
message: `OURS_UNINSTALL names "${token}", which is not a harness this uninstaller knows. Use ${HARNESS_ORDER.join(', ')}, or "all", or "none".`,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const contract = {
|
|
719
|
+
engaged,
|
|
720
|
+
// Not engaged means "decide this the way you always did": null selection, and
|
|
721
|
+
// the daemon removal that IS this command.
|
|
722
|
+
harnesses: canon ? canon.names : null,
|
|
723
|
+
unknownHarnessTokens: canon ? canon.unknown : [],
|
|
724
|
+
daemon: engaged ? value('OURS_UNINSTALL_DAEMON') === 'yes' : true,
|
|
725
|
+
confirmedComponents: Object.keys(components),
|
|
726
|
+
};
|
|
727
|
+
if (refusals.length === 0) return contract;
|
|
728
|
+
return {
|
|
729
|
+
...contract,
|
|
730
|
+
action: 'refuse',
|
|
731
|
+
exitCode: 2,
|
|
732
|
+
reason: 'uninstall-env-unsupported',
|
|
733
|
+
refusals,
|
|
734
|
+
message: `${refusals.map((r) => r.message).join('\n')}\nNothing was removed.`,
|
|
735
|
+
};
|
|
736
|
+
}
|