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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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
- }