@ours.network/install 1.2.1-nightly.42 → 1.2.1-nightly.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,437 @@
1
+ // Complete removal of Ours from this computer — the pure half.
2
+ //
3
+ // Discovery reads the machine (lib/removal.mjs); everything here decides, from
4
+ // what was read, WHAT is Ours, WHY it is known to be Ours, and in which order it
5
+ // can be removed. Nothing here touches the machine, so every ownership rule is
6
+ // tested directly.
7
+ //
8
+ // Ownership is never inferred from a familiar name alone. Each removable item
9
+ // carries its evidence:
10
+ // - managed installations: a valid installation.json whose root is this
11
+ // directory, and the Compose project recorded there;
12
+ // - containers, volumes, networks: that project's Compose label;
13
+ // - images: an image ID recorded by this installer, or that project's label;
14
+ // - client generations: an owner record naming a removed installation, or —
15
+ // for older generations without one — no remaining Ours installation at all;
16
+ // - harness registrations and global commands: a path inside a generation
17
+ // that is itself being removed;
18
+ // - Fleet services: a unit file that references this client's profile or a
19
+ // removed generation.
20
+ // Anything else that looks related is listed as KEPT with the reason.
21
+
22
+ import { createHash } from 'node:crypto';
23
+ import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
24
+ import { validateContainerEngine } from './container-engine.mjs';
25
+ import { PRODUCTS } from './product.mjs';
26
+
27
+ export const JOURNAL = '.ours-removal.json';
28
+ export const CONFIRMATION = 'remove ours';
29
+ export const CACHE_MOUNT_ID = '/ours-dist-2-npm';
30
+ export const IMAGE_TARGETS = ['runtime', 'gateway', 'maintenance', 'previous-runtime', 'previous-maintenance', 'previous-gateway'];
31
+ export const CLAUDE_PLUGIN = 'ours@ours.network';
32
+ export const CLAUDE_MARKETPLACE = 'ours.network';
33
+ export const CODEX_PLUGIN = 'ours@ours-codex-marketplace';
34
+ export const CODEX_MARKETPLACE = 'ours-codex-marketplace';
35
+ export const FLEET_UNITS = ['ours-fleet.service', 'ours-fleet-web.service', 'ours-fleet-watchdogs.service'];
36
+ const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/;
37
+ const PROJECT = /^ours-[a-z0-9]+$/;
38
+ const GENERATION = /^[0-9a-f]{16}$/;
39
+
40
+ export const GROUPS = {
41
+ services: 'Ours services and containers',
42
+ data: 'Ours data: identities, keys, messages and settings',
43
+ apps: 'Ours connections in your agent apps',
44
+ fleet: 'Persistent agents (Fleet)',
45
+ programs: 'Ours programs and downloads',
46
+ legacy: 'Earlier Ours installations',
47
+ };
48
+
49
+ /** Inside (or equal to) a directory, by normalized lexical path. */
50
+ export const within = (path, directory) => typeof path === 'string' && typeof directory === 'string'
51
+ && (path === directory || path.startsWith(directory.endsWith(sep) ? directory : directory + sep));
52
+
53
+ /**
54
+ * The guard in front of every recursive deletion. Returns null when the path may
55
+ * be deleted, otherwise the reason it may not. A path is removable only when it
56
+ * is absolute and normalized, is not the filesystem root, the home directory or
57
+ * one of its ancestors, has no symbolic link anywhere in it, is a directory (or
58
+ * regular file) owned by this user, and — for private trees — is not readable by
59
+ * anybody else.
60
+ */
61
+ export function unsafeTreeReason(path, { home, stat, realpath, uid, privateTree = true, file = false }) {
62
+ if (typeof path !== 'string' || !isAbsolute(path) || resolve(path) !== path) return 'not an absolute normalized path';
63
+ const parts = path.split(sep).filter(Boolean);
64
+ if (parts.length < 2) return 'too close to the filesystem root';
65
+ if (path === home || within(home, path)) return 'is your home directory or contains it';
66
+ const st = stat(path);
67
+ if (!st) return null; // Already absent: nothing to delete, nothing to guard.
68
+ if (st.type === 'symlink') return 'is a symbolic link';
69
+ if (realpath(path) !== path) return 'is reached through a symbolic link';
70
+ if (file ? st.type !== 'file' : st.type !== 'dir') return file ? 'is not a regular file' : 'is not a directory';
71
+ if (st.uid !== uid) return 'belongs to another user';
72
+ if (privateTree && (st.mode & 0o077)) return 'is not private to you';
73
+ return null;
74
+ }
75
+
76
+ /** A managed record reduced to the identity a retry may rely on. */
77
+ export function journalInstallation(record) {
78
+ return {
79
+ root: record.root, instanceId: record.instanceId, project: record.project, mode: record.mode,
80
+ ...(record.containerEngine ? { containerEngine: record.containerEngine } : {}),
81
+ ...(record.containerBinding ? { containerBinding: record.containerBinding } : {}),
82
+ ...(record.product ? { product: record.product } : {}),
83
+ candidateProjects: record.buildTransition?.candidate?.project ? [record.buildTransition.candidate.project] : [],
84
+ };
85
+ }
86
+
87
+ const IMAGE_ID = /^sha256:[0-9a-f]{64}$/;
88
+
89
+ /**
90
+ * A container this installation created. Containers inherit their image's labels,
91
+ * so the Compose project label alone is no proof: Compose also records the
92
+ * project folder, which must lie inside this installation. The installer's own
93
+ * helper containers carry its fixed names and run its own image.
94
+ */
95
+ export function ownedContainer(value, { root, project }) {
96
+ const labels = value?.Config?.Labels ?? {};
97
+ const name = String(value?.Name ?? '').replace(/^\//, '');
98
+ if (labels['com.docker.compose.project'] === project && within(labels['com.docker.compose.project.working_dir'], root)) return true;
99
+ return (name === `${project}-records` || name.startsWith(`${project}-issue-`)) && String(value?.Config?.Image ?? '').startsWith(`${project}:`);
100
+ }
101
+
102
+ /** The Compose project a managed root was created with; binds a recorded root to its project. */
103
+ export const projectForRoot = root => `ours-${createHash('sha256').update(root).digest('hex').slice(0, 16)}`;
104
+
105
+ /**
106
+ * Where a verified folder is moved immediately before it is deleted. Only this
107
+ * installer creates such a name, after checking the folder's own record, so an
108
+ * interrupted deletion is finished from here and never from an unproven folder.
109
+ */
110
+ export const tombstoneFor = (path, instanceId) => join(dirname(path), `${basename(path)}.ours-removing-${instanceId}`);
111
+
112
+ /** Ours projects other than the ones being removed, from "project<TAB>working_dir" lines. */
113
+ export function otherOursProjects(text, removing, { composeOnly = false } = {}) {
114
+ const others = new Set();
115
+ for (const line of String(text ?? '').split('\n')) {
116
+ const [project = '', workingDir = ''] = line.trim().split('\t');
117
+ if (!/^ours-/.test(project) || removing.has(project)) continue;
118
+ if (composeOnly && !workingDir.trim()) continue;
119
+ others.add(project);
120
+ }
121
+ return [...others];
122
+ }
123
+
124
+ /** Strict journal validation: only identities, never free-form deletion paths. */
125
+ export function validateJournal(value, { home }) {
126
+ const fail = message => { throw new Error(`Unfinished removal record is invalid (${message}); it was left untouched. Remove ${join(home, JOURNAL)} yourself only after checking what remains.`); };
127
+ if (!value || typeof value !== 'object' || Array.isArray(value) || value.schema !== 1) fail('schema');
128
+ const keys = ['schema', 'startedAt', 'installations', 'clientInstanceIds', 'generations', 'fleet', 'legacy', 'installer', 'tombstones'];
129
+ if (Object.keys(value).some(key => !keys.includes(key))) fail('unexpected field');
130
+ if (!Array.isArray(value.installations) || !Array.isArray(value.clientInstanceIds) || !Array.isArray(value.generations)) fail('lists');
131
+ for (const item of value.installations) {
132
+ if (!item || typeof item.root !== 'string' || !isAbsolute(item.root) || resolve(item.root) !== item.root
133
+ || !UUID.test(item.instanceId ?? '') || !PROJECT.test(item.project ?? '') || item.project !== projectForRoot(item.root) || !['docker', 'packages'].includes(item.mode)
134
+ || !Array.isArray(item.candidateProjects ?? []) || (item.candidateProjects ?? []).some(p => !/^ours-build[0-9a-f]{32}$/.test(p))) fail('installation identity');
135
+ if (item.images !== undefined) {
136
+ const projects = [item.project, ...(item.candidateProjects ?? [])];
137
+ if (!item.images || typeof item.images !== 'object' || Array.isArray(item.images)
138
+ || Object.entries(item.images).some(([tag, id]) => !projects.some(p => IMAGE_TARGETS.some(t => tag === `${p}:${t}`)) || !IMAGE_ID.test(id))) fail('image identity');
139
+ }
140
+ if (Object.keys(item).some(key => !['root', 'instanceId', 'project', 'mode', 'containerEngine', 'containerBinding', 'product', 'candidateProjects', 'images'].includes(key))) fail('installation field');
141
+ try { validateContainerEngine(item); } catch { fail('container engine'); }
142
+ if (item.product !== undefined && !PRODUCTS.includes(item.product)) fail('product');
143
+ }
144
+ if (value.clientInstanceIds.some(id => !UUID.test(id))) fail('client identity');
145
+ if (value.generations.some(id => !GENERATION.test(id))) fail('generation identity');
146
+ for (const key of ['fleet', 'legacy', 'installer']) if (typeof value[key] !== 'boolean') fail(key);
147
+ // Folders this removal moved aside, identified by device and inode (kept by a rename).
148
+ if (value.tombstones !== undefined && (!Array.isArray(value.tombstones) || value.tombstones.some(t => !t || Object.keys(t).sort().join(',') !== 'id,instanceId,kind'
149
+ || !['root', 'client'].includes(t.kind) || !UUID.test(t.instanceId ?? '') || !/^\d+:\d+$/.test(t.id ?? '')))) fail('tombstone identity');
150
+ return value;
151
+ }
152
+
153
+ /**
154
+ * Parse one TOML table header line into its key parts, or null when the line is
155
+ * not a well-formed header. Handles [a.b], [[a.b]], quoted and escaped keys.
156
+ */
157
+ function parseTomlHeader(line) {
158
+ let i = 0;
159
+ const skip = () => { while (line[i] === ' ' || line[i] === '\t') i++; };
160
+ skip();
161
+ if (line[i] !== '[') return null;
162
+ const array = line[i + 1] === '[';
163
+ i += array ? 2 : 1;
164
+ const parts = [];
165
+ for (;;) {
166
+ skip();
167
+ if (line[i] === '"') {
168
+ let j = i + 1, raw = '';
169
+ while (j < line.length && line[j] !== '"') { if (line[j] === '\\') { raw += line[j] + (line[j + 1] ?? ''); j += 2; } else raw += line[j++]; }
170
+ if (line[j] !== '"') return null;
171
+ try { parts.push(JSON.parse(`"${raw}"`)); } catch { return null; }
172
+ i = j + 1;
173
+ } else if (line[i] === "'") {
174
+ const j = line.indexOf("'", i + 1);
175
+ if (j < 0) return null;
176
+ parts.push(line.slice(i + 1, j)); i = j + 1;
177
+ } else {
178
+ const m = /^[A-Za-z0-9_-]+/.exec(line.slice(i));
179
+ if (!m) return null;
180
+ parts.push(m[0]); i += m[0].length;
181
+ }
182
+ skip();
183
+ if (line[i] === '.') { i++; continue; }
184
+ break;
185
+ }
186
+ if (array ? line.slice(i, i + 2) !== ']]' : line[i] !== ']') return null;
187
+ i += array ? 2 : 1;
188
+ skip();
189
+ if (i < line.length && line[i] !== '#') return null;
190
+ return { parts, array };
191
+ }
192
+
193
+ /**
194
+ * Remove whole TOML tables whose header key parts satisfy `match`; every other
195
+ * byte is kept. Only real top-level headers count: lines inside multiline
196
+ * strings or multi-line arrays/inline tables are never headers. Returns null
197
+ * when the text cannot be scanned safely, so the caller edits nothing.
198
+ */
199
+ export function removeTomlTables(text, match) {
200
+ const out = [];
201
+ const removed = [];
202
+ let skipping = false, multi = null, depth = 0;
203
+ for (const line of text.split('\n')) {
204
+ if (!multi && depth === 0 && /^\s*\[/.test(line)) {
205
+ const header = parseTomlHeader(line);
206
+ if (!header) return null;
207
+ skipping = match(header.parts);
208
+ if (skipping) removed.push(header.parts.map(p => /^[A-Za-z0-9_-]+$/.test(p) ? p : JSON.stringify(p)).join('.'));
209
+ else out.push(line);
210
+ continue;
211
+ }
212
+ // Follow strings, comments and brackets of value lines across line breaks.
213
+ for (let i = 0; i < line.length; i++) {
214
+ const ch = line[i];
215
+ if (multi) {
216
+ if (multi === '"""' && ch === '\\') { i++; continue; }
217
+ if (line.startsWith(multi, i)) { i += 2; while (line[i + 1] === multi[0]) i++; multi = null; }
218
+ continue;
219
+ }
220
+ if (ch === '#') break;
221
+ if (line.startsWith('"""', i) || line.startsWith("'''", i)) { multi = line.slice(i, i + 3); i += 2; continue; }
222
+ if (ch === '"') { i++; while (i < line.length && line[i] !== '"') { if (line[i] === '\\') i++; i++; } if (i >= line.length) return null; continue; }
223
+ if (ch === "'") { const j = line.indexOf("'", i + 1); if (j < 0) return null; i = j; continue; }
224
+ if (ch === '[' || ch === '{') depth++;
225
+ else if (ch === ']' || ch === '}') { depth--; if (depth < 0) return null; }
226
+ }
227
+ if (!skipping) out.push(line);
228
+ }
229
+ if (multi || depth !== 0) return null;
230
+ return { text: out.join('\n'), removed };
231
+ }
232
+
233
+ /** Header key parts of the Ours tables in a Codex config (and anything nested under them). */
234
+ export const isOursCodexTable = (parts, plugin, marketplace) =>
235
+ (parts[0] === 'plugins' && parts[1] === plugin) || (parts[0] === 'marketplaces' && parts[1] === marketplace)
236
+ || (parts[0] === 'hooks' && parts[1] === 'state' && typeof parts[2] === 'string' && parts[2].startsWith(`${plugin}:`));
237
+
238
+ /** Parse `docker buildx du --verbose` blocks into records. */
239
+ export function parseBuildCache(text) {
240
+ const records = [];
241
+ let current = null;
242
+ for (const line of String(text ?? '').split('\n')) {
243
+ const field = /^([A-Za-z ]+):\s*(.*)$/.exec(line);
244
+ if (!field) continue;
245
+ const [, key, value] = field;
246
+ if (key === 'ID') { current = { id: value.trim() }; records.push(current); }
247
+ else if (current && key === 'Description') current.description = value.trim();
248
+ else if (current && key === 'Size') current.size = value.trim();
249
+ }
250
+ return records;
251
+ }
252
+ /** Exactly the installer's own npm cache mount, identified by its declared id. */
253
+ export const isOursCacheRecord = record => typeof record?.description === 'string'
254
+ && /^cached mount \/root\/\.npm from exec .* with id "\/ours-dist-2-npm"$/.test(record.description)
255
+ && /^[a-z0-9]{20,64}$/.test(record.id ?? '');
256
+
257
+ /**
258
+ * Decide the removal from a discovery. Returns `steps` (ordered, each with its
259
+ * evidence) and `kept` (what was found but is not removed, with the reason).
260
+ */
261
+ export function planRemoval(found, { home }) {
262
+ const steps = [];
263
+ const kept = [...(found.kept ?? [])];
264
+ const add = step => steps.push(step);
265
+ const removedInstances = new Set(found.installations.map(i => i.instanceId));
266
+ const clientBound = Boolean(found.client && removedInstances.has(found.client.instanceId));
267
+ if (found.client && !clientBound) kept.push({ group: 'apps', label: `Saved Ours connection ${join(home, '.ours-client')}`, reason: 'it belongs to an Ours installation that is not being removed' });
268
+ const clientInstances = new Set(clientBound ? [found.client.instanceId] : []);
269
+ const othersRemain = found.otherInstallations?.length > 0;
270
+
271
+ // Generations: owned by a removed installation, or (older ones without an owner
272
+ // record) only when no other Ours installation remains on this computer.
273
+ const generations = [];
274
+ for (const generation of found.generations ?? []) {
275
+ // Owner records: valid (owner IDs), absent (made before owner records) or invalid (damaged: never removed).
276
+ const owners = generation.owners ?? { state: 'absent' };
277
+ const label = `Downloaded Ours programs ${generation.path}`;
278
+ if (owners.state === 'invalid') { kept.push({ group: 'programs', label, residual: true, reason: `their owner record ${generation.path}/owners.json is damaged, so they are left in place; remove the folder yourself if no Ours installation uses it` }); continue; }
279
+ const ownedHere = owners.state === 'valid'
280
+ ? owners.instances.length > 0 && owners.instances.every(id => removedInstances.has(id) || clientInstances.has(id))
281
+ : !othersRemain && (clientBound || !found.client);
282
+ if (ownedHere) generations.push(generation);
283
+ else kept.push({ group: 'programs', label, reason: owners.state === 'valid' ? 'they are also used by another Ours installation' : 'their owner is not recorded and another Ours installation remains' });
284
+ }
285
+ const removedGeneration = path => generations.some(g => within(path, g.path));
286
+ const legacyMarketplaces = join(home, '.ours', 'install', 'marketplaces');
287
+ const ownedMarketplace = path => removedGeneration(path) || (found.legacy?.remove && within(path, legacyMarketplaces));
288
+
289
+ // Fleet first: its agents use the plugins and the client connection.
290
+ if (clientBound && found.fleet) {
291
+ const fleet = found.fleet;
292
+ if (fleet.bin && fleet.installedByOurs) add({ id: 'fleet-down', group: 'fleet', label: 'Stop persistent agents (ours-fleet down)', type: 'fleet-down', bin: fleet.bin });
293
+ for (const unit of fleet.units) {
294
+ if (unit.bound) add({ id: `unit:${unit.name}`, group: 'fleet', label: `Agent service ${unit.name}`, type: 'user-unit', name: unit.name, path: unit.path, dropIn: unit.dropIn ?? null });
295
+ else kept.push({ group: 'fleet', label: `Service ${unit.name}`, reason: 'it does not reference this Ours installation' });
296
+ }
297
+ if (fleet.installedByOurs) {
298
+ if (fleet.configPath && fleet.configExists) add({ id: 'fleet-config', group: 'fleet', label: `Fleet agent configuration ${fleet.configPath}`, type: 'file', path: fleet.configPath, after: ['fleet-down'] });
299
+ if (fleet.stateRoot && fleet.stateExists) add({ id: 'fleet-state', group: 'fleet', label: `Fleet agent state, logs and workspaces ${fleet.stateRoot}`, type: 'tree', path: fleet.stateRoot, privateTree: false, after: ['fleet-down', ...fleet.units.filter(u => u.bound).map(u => `unit:${u.name}`)] });
300
+ } else if (fleet.configExists || fleet.stateExists) kept.push({ group: 'fleet', label: 'Fleet configuration and state', reason: 'Fleet on this computer was not set up by this Ours client' });
301
+ }
302
+
303
+ // Agent app registrations that point into removed downloads.
304
+ const apps = [];
305
+ const claude = found.claude;
306
+ if (claude?.marketplace && !ownedMarketplace(claude.marketplace.path)) kept.push({ group: 'apps', label: `Claude Code marketplace "${CLAUDE_MARKETPLACE}"`, reason: `it points to ${claude.marketplace.path}, which this installer did not create` });
307
+ else if (claude) {
308
+ if (claude.plugin) apps.push({ id: 'claude-plugin', group: 'apps', label: 'Ours plugin in Claude Code', type: 'command', command: ['claude', 'plugin', 'uninstall', CLAUDE_PLUGIN, '--scope', 'user'] });
309
+ if (claude.marketplace) apps.push({ id: 'claude-marketplace', group: 'apps', label: 'Ours plugin source in Claude Code', type: 'command', command: ['claude', 'plugin', 'marketplace', 'remove', CLAUDE_MARKETPLACE], after: ['claude-plugin'] });
310
+ if (claude.settingsEntries?.length) apps.push({ id: 'claude-settings', group: 'apps', label: `Ours entries in ${claude.settingsPath}`, type: 'claude-settings', path: claude.settingsPath, entries: claude.settingsEntries, after: ['claude-plugin', 'claude-marketplace'] });
311
+ if (claude.cache) apps.push({ id: 'claude-cache', group: 'apps', label: `Claude Code's copy of the Ours plugin ${claude.cache}`, type: 'tree', path: claude.cache, privateTree: false, after: ['claude-plugin', 'claude-marketplace'] });
312
+ }
313
+ const codex = found.codex;
314
+ if (codex?.marketplace && !ownedMarketplace(codex.marketplace.path)) kept.push({ group: 'apps', label: `Codex marketplace "${CODEX_MARKETPLACE}"`, reason: `it points to ${codex.marketplace.path}, which this installer did not create` });
315
+ else if (codex) {
316
+ if (codex.plugin) apps.push({ id: 'codex-plugin', group: 'apps', label: 'Ours plugin in Codex', type: 'command', command: ['codex', 'plugin', 'remove', CODEX_PLUGIN] });
317
+ if (codex.marketplace) apps.push({ id: 'codex-marketplace', group: 'apps', label: 'Ours plugin source in Codex', type: 'command', command: ['codex', 'plugin', 'marketplace', 'remove', CODEX_MARKETPLACE], after: ['codex-plugin'] });
318
+ if (codex.configTables?.length) apps.push({ id: 'codex-config', group: 'apps', label: `Ours entries in ${codex.configPath}`, type: 'codex-config', path: codex.configPath, after: ['codex-plugin', 'codex-marketplace'] });
319
+ if (codex.cache) apps.push({ id: 'codex-cache', group: 'apps', label: `Codex's copy of the Ours plugin ${codex.cache}`, type: 'tree', path: codex.cache, privateTree: false, after: ['codex-plugin', 'codex-marketplace'] });
320
+ }
321
+ for (const step of apps) add(step);
322
+ // Ours tool records: only the rows and session folders of the removed instances.
323
+ const tool = found.toolState;
324
+ if (tool) {
325
+ const ids = new Set([...removedInstances, ...clientInstances]);
326
+ const label = `Ours tool records ${tool.root}`;
327
+ if (!tool.valid) kept.push({ group: 'apps', label, residual: true, reason: 'its record file could not be read, so nothing in it is removed automatically' });
328
+ else if (ids.size) {
329
+ const ourSessions = tool.sessions.filter(id => ids.has(id));
330
+ const ourRows = tool.instances.filter(id => ids.has(id));
331
+ const foreign = tool.daemons.length || tool.others.length || tool.sessions.some(id => !ids.has(id)) || tool.instances.some(id => !ids.has(id));
332
+ if (!foreign) add({ id: 'tool-state', group: 'apps', label, type: 'tree', path: tool.root, after: apps.map(s => s.id) });
333
+ else {
334
+ for (const id of ourSessions) add({ id: `tool-sessions:${id}`, group: 'apps', label: `Ours tool session records ${join(tool.root, 'sessions', id)}`, type: 'tree', path: join(tool.root, 'sessions', id), after: apps.map(s => s.id) });
335
+ if (ourRows.length) add({ id: 'tool-config', group: 'apps', label: `Ours entries in ${tool.configPath}`, type: 'tool-config', path: tool.configPath, instances: ourRows, after: apps.map(s => s.id) });
336
+ kept.push({ group: 'apps', label: `Other records in ${tool.root}`, reason: 'they belong to other Ours installations or tools' });
337
+ }
338
+ } else kept.push({ group: 'apps', label, reason: 'it does not record which installation it belongs to, and no Ours installation is being removed' });
339
+ }
340
+
341
+ // Older plugin installers wrote marked blocks and skills; only exact, closed blocks.
342
+ const legacyStops = [];
343
+ if (found.legacy?.remove) {
344
+ for (const service of found.legacy.services ?? []) {
345
+ const step = { id: `legacy-service:${service.dir}`, group: 'legacy', label: service.label, type: 'legacy-service', service };
346
+ legacyStops.push(step.id); add(step);
347
+ }
348
+ }
349
+ for (const block of found.legacy?.blocks ?? []) add({ id: `block:${block.path}`, group: 'legacy', label: `Ours section in ${block.path}`, type: 'managed-block', path: block.path, markers: block.markers });
350
+ for (const dir of found.legacy?.skillDirs ?? []) add({ id: `skills:${dir}`, group: 'legacy', label: `Ours skill ${dir}`, type: 'tree', path: dir, privateTree: false });
351
+
352
+ // Commands published into the npm prefix, before the downloads they point into.
353
+ const commandSteps = [];
354
+ for (const entry of found.npm?.bins ?? []) {
355
+ if (entry.kind === 'generation' && removedGeneration(entry.target)) commandSteps.push({ id: `bin:${entry.path}`, group: 'programs', label: `Command ${entry.name}`, type: 'unlink', path: entry.path, expectTarget: entry.target });
356
+ else if (entry.kind === 'managed-launcher' && found.installations.some(i => i.root === entry.root)) commandSteps.push({ id: `bin:${entry.path}`, group: 'programs', label: `Command ${entry.name} (managed launcher)`, type: 'unlink', path: entry.path, expectLauncher: entry.root });
357
+ else if (entry.kind !== 'package') kept.push({ group: 'programs', label: `Command ${entry.path}`, reason: 'it does not point to Ours programs that are being removed' });
358
+ }
359
+ for (const pkg of found.npm?.packages ?? []) {
360
+ if (pkg.name === '@ours.network/install') continue;
361
+ if (pkg.link && removedGeneration(pkg.target)) commandSteps.push({ id: `npm:${pkg.name}`, group: 'programs', label: `Global package ${pkg.name}`, type: 'npm-uninstall', name: pkg.name, expectTarget: pkg.target });
362
+ else if (!pkg.link && found.legacy?.remove) commandSteps.push({ id: `npm:${pkg.name}`, group: 'legacy', label: `Global package ${pkg.name}`, type: 'npm-uninstall', name: pkg.name, after: legacyStops });
363
+ else kept.push({ group: 'programs', label: `Global package ${pkg.name}`, reason: pkg.link ? 'it points outside the Ours downloads being removed' : 'it is not part of the installations being removed' });
364
+ }
365
+ for (const step of commandSteps) add(step);
366
+ const beforeDownloads = [...apps.map(s => s.id), ...commandSteps.map(s => s.id), ...steps.filter(s => s.group === 'fleet').map(s => s.id)];
367
+
368
+ // Server services, then their engine resources.
369
+ // Engine items are planned when they exist or could not be checked (null/absent inventory).
370
+ const present = value => value === null || value === undefined
371
+ || (Array.isArray(value) ? value.length > 0 : typeof value === 'object' ? Object.keys(value).length > 0 : value > 0);
372
+ for (const item of found.installations) {
373
+ const tag = item.root;
374
+ if (item.record) add({ id: `services:${tag}`, group: 'services', label: `Ours services of ${item.root}`, type: 'server-services', installation: item });
375
+ if (item.mode === 'docker') {
376
+ for (const project of [item.project, ...(item.candidateProjects ?? [])]) {
377
+ const engine = item.engine?.[project] ?? {};
378
+ if (present(engine.containers)) add({ id: `containers:${project}`, group: 'services', label: `Containers of ${project}`, type: 'containers', installation: item, project, ...(Array.isArray(engine.containers) ? { containerIds: engine.containers } : {}), after: [`services:${tag}`] });
379
+ if (present(engine.images)) add({ id: `images:${project}`, group: 'programs', label: `Container images of ${project}`, type: 'images', installation: item, project, ...(engine.images ? { tags: engine.images } : {}), after: [`containers:${project}`] });
380
+ if (present(engine.volumes)) add({ id: `volumes:${project}`, group: 'data', label: `Stored data volumes of ${project}`, type: 'volumes', installation: item, project, after: [`containers:${project}`] });
381
+ if (present(engine.networks)) add({ id: `networks:${project}`, group: 'services', label: `Network of ${project}`, type: 'networks', installation: item, project, after: [`containers:${project}`] });
382
+ }
383
+ }
384
+ }
385
+ const dockerItems = found.installations.filter(i => i.mode === 'docker');
386
+ if (dockerItems.length && present(found.buildCache)) {
387
+ if (found.otherEngineProjects?.length || othersRemain) kept.push({ group: 'programs', label: 'Shared Ours build cache', reason: 'another Ours installation on this engine may still use it' });
388
+ else add({ id: 'build-cache', group: 'programs', label: 'Ours build cache in the container engine', type: 'build-cache', installation: dockerItems[0], after: dockerItems.flatMap(i => [i.project, ...(i.candidateProjects ?? [])].map(p => `images:${p}`)) });
389
+ }
390
+
391
+ // Directories last: downloads after everything that pointed into them, roots after their services.
392
+ for (const generation of generations) add({ id: `generation:${generation.id}`, group: 'programs', label: `Downloaded Ours programs ${generation.path}`, type: 'tree', path: generation.path, after: beforeDownloads });
393
+ if (clientBound) add({ id: 'client', group: 'data', label: `Saved Ours connection and credential ${found.client.root}`, type: 'retire', kind: 'client', path: found.client.root, recordFile: 'profile.json', recordField: 'expectedInstanceId', expectInstance: found.client.instanceId, tombstone: tombstoneFor(found.client.root, found.client.instanceId), after: beforeDownloads });
394
+ for (const tomb of found.clientTombstones ?? []) add({ id: `tombstone:${tomb.path}`, group: 'data', label: `Partly removed Ours connection ${tomb.path}`, type: 'tombstone', path: tomb.path, fileId: tomb.fileId, after: beforeDownloads });
395
+ const clientInstall = join(home, '.ours-client-install');
396
+ if (found.clientInstallEmptyAfter && generations.length) add({ id: 'client-install', group: 'programs', label: `Ours downloads folder ${clientInstall}`, type: 'empty-dir', path: clientInstall, after: generations.map(g => `generation:${g.id}`) });
397
+ for (const item of found.installations) {
398
+ const after = [`services:${item.root}`, ...(item.mode === 'docker' ? [item.project, ...(item.candidateProjects ?? [])].map(p => `containers:${p}`) : [])];
399
+ if (item.record) add({ id: `root:${item.root}`, group: 'data', label: `Ours installation folder ${item.root}`, type: 'retire', kind: 'root', path: item.root, recordFile: 'installation.json', recordField: 'instanceId', expectInstance: item.instanceId, tombstone: tombstoneFor(item.root, item.instanceId), after });
400
+ if (item.tombstone) add({ id: `tombstone:${item.tombstone.path}`, group: 'data', label: `Partly removed Ours installation folder ${item.tombstone.path}`, type: 'tombstone', path: item.tombstone.path, fileId: item.tombstone.fileId, after });
401
+ }
402
+
403
+ if (found.legacy?.remove) {
404
+ for (const dir of found.legacy.dirs ?? []) add({ id: `legacy:${dir}`, group: 'legacy', label: `Earlier Ours data ${dir}`, type: 'tree', path: dir, privateTree: false, after: legacyStops });
405
+ }
406
+ if (found.npm?.packages?.some(p => p.name === '@ours.network/install') && found.removeInstaller) {
407
+ add({ id: 'installer', group: 'programs', label: 'The ours-install command itself', type: 'npm-uninstall', name: '@ours.network/install', last: true });
408
+ }
409
+ // npm leaves the empty @ours.network folder behind once every package in it is gone.
410
+ const removedPackages = new Set(steps.filter(s => s.type === 'npm-uninstall').map(s => s.name));
411
+ if (found.npm?.scope && (found.npm.packages ?? []).every(p => removedPackages.has(p.name))) {
412
+ add({ id: 'npm-scope', group: 'programs', label: `Empty folder ${found.npm.scope}`, type: 'empty-dir', path: found.npm.scope, last: true, after: [...removedPackages].map(name => name === '@ours.network/install' ? 'installer' : `npm:${name}`) });
413
+ }
414
+ return { steps, kept };
415
+ }
416
+
417
+ /** Plain-language summary lines for the confirmation screen, grouped. */
418
+ export function describePlan({ steps, kept }) {
419
+ const lines = [];
420
+ for (const [group, title] of Object.entries(GROUPS)) {
421
+ const items = steps.filter(step => step.group === group);
422
+ if (!items.length) continue;
423
+ lines.push(title + ':');
424
+ for (const item of items) lines.push(` - ${item.label}`);
425
+ }
426
+ const attention = kept.filter(item => item.residual);
427
+ const preserved = kept.filter(item => !item.residual);
428
+ if (attention.length) {
429
+ lines.push('Ours items that cannot be removed automatically (they need your attention):');
430
+ for (const item of attention) lines.push(` - ${item.label} — ${item.reason}`);
431
+ }
432
+ if (preserved.length) {
433
+ lines.push('Kept (not created by this installer, shared, or still in use):');
434
+ for (const item of preserved) lines.push(` - ${item.label} — ${item.reason}`);
435
+ }
436
+ return lines;
437
+ }