@ours.network/install 1.2.1-nightly.41 → 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.
- package/README.md +84 -0
- package/assets/Dockerfile +1 -0
- package/assets/release-lock.json +34 -21
- package/assets/release.json +1 -1
- package/assets/scripts/build/build-common.mjs +9 -1
- package/assets/scripts/maintenance/docker-layout-conversion.mjs +15 -4
- package/assets/scripts/maintenance/recover-notifications.mjs +11 -1
- package/assets/scripts/maintenance/state-operation.mjs +15 -4
- package/assets/scripts/runtime/client-setup.mjs +26 -15
- package/assets/scripts/runtime/daemon-client-prefix.mjs +115 -0
- package/assets/sources.json +1 -1
- package/lib/docker-conversion-runtime.mjs +4 -2
- package/lib/docker-recovery.mjs +6 -1
- package/lib/effects.mjs +136 -8
- package/lib/orchestrate-uninstall.mjs +6 -1
- package/lib/orchestrate.mjs +26 -1
- package/lib/ownership.mjs +63 -0
- package/lib/plan.mjs +14 -4
- package/lib/product.mjs +69 -0
- package/lib/removal-plan.mjs +437 -0
- package/lib/removal.mjs +749 -0
- package/lib/server-onboarding.mjs +22 -4
- package/lib/setup-options.mjs +20 -1
- package/lib/setup.mjs +52 -9
- package/lib/usage.mjs +14 -2
- package/lib/welcome.mjs +219 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|