@ours.network/install 1.2.1-nightly.42 → 1.2.1-nightly.44
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 +94 -3
- package/assets/Dockerfile +1 -0
- package/assets/release-lock.json +120 -106
- package/assets/release.json +5 -5
- package/assets/scripts/build/build-common.mjs +9 -1
- package/assets/scripts/maintenance/docker-layout-conversion.mjs +15 -4
- 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 +7 -7
- package/lib/boot-readiness.mjs +67 -14
- 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
package/lib/removal.mjs
ADDED
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
// Complete removal of Ours from this computer — discovery, confirmation,
|
|
2
|
+
// execution and verification. Decisions live in lib/removal-plan.mjs.
|
|
3
|
+
//
|
|
4
|
+
// ours-install remove [--state-dir PATH] [--dry-run]
|
|
5
|
+
//
|
|
6
|
+
// Interactive only: permanent deletion of identities and their private keys
|
|
7
|
+
// needs a person who has read the list and typed the confirmation. A retry
|
|
8
|
+
// record is written before the first change and kept until a fresh scan finds
|
|
9
|
+
// nothing left, so an interrupted or partly failed removal can be finished by
|
|
10
|
+
// running the same command again.
|
|
11
|
+
|
|
12
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
13
|
+
import { modify, applyEdits, parse as parseJsonc } from 'jsonc-parser';
|
|
14
|
+
import { validateInstallation, unitNameForStateDir } from './plan.mjs';
|
|
15
|
+
import { validateContainerEngine, runContainer } from './container-engine.mjs';
|
|
16
|
+
import { classifyStateDir } from './detect.mjs';
|
|
17
|
+
import { stripManagedBlock, YAML_BLOCK, MD_BLOCK } from './uninstall.mjs';
|
|
18
|
+
import { readOwners } from './ownership.mjs';
|
|
19
|
+
import {
|
|
20
|
+
JOURNAL, CONFIRMATION, IMAGE_TARGETS, CLAUDE_PLUGIN, CLAUDE_MARKETPLACE, CODEX_PLUGIN, CODEX_MARKETPLACE, FLEET_UNITS,
|
|
21
|
+
within, unsafeTreeReason, journalInstallation, validateJournal, removeTomlTables, parseBuildCache, isOursCacheRecord, ownedContainer, isOursCodexTable, projectForRoot, tombstoneFor, otherOursProjects,
|
|
22
|
+
planRemoval, describePlan,
|
|
23
|
+
} from './removal-plan.mjs';
|
|
24
|
+
import { ok, info, warn, heading, progress } from './ui.mjs';
|
|
25
|
+
|
|
26
|
+
export const EXIT_OK = 0;
|
|
27
|
+
export const EXIT_INCOMPLETE = 1;
|
|
28
|
+
export const EXIT_REFUSED = 2;
|
|
29
|
+
export const EXIT_CANCELLED = 130;
|
|
30
|
+
const GENERATION = /^[0-9a-f]{16}$/;
|
|
31
|
+
const MANAGED_CLI_MARKER = '// ours-managed-cli-v1 ';
|
|
32
|
+
|
|
33
|
+
export const REMOVE_USAGE = `ours-install remove — remove Ours completely from this computer.
|
|
34
|
+
|
|
35
|
+
ours-install remove [--state-dir PATH] [--dry-run]
|
|
36
|
+
|
|
37
|
+
Finds everything Ours installed or created for you — its services and
|
|
38
|
+
containers, stored identities, keys and messages, the Ours plugins in Claude
|
|
39
|
+
Code and Codex, persistent agents set up by Ours, downloaded programs and
|
|
40
|
+
earlier Ours installations — shows the complete list, and removes it after you
|
|
41
|
+
type the confirmation. Your own agent-app settings, skills and other plugins are
|
|
42
|
+
kept, and anything shared or not created by Ours is listed and left in place.
|
|
43
|
+
|
|
44
|
+
--state-dir remove only this Ours installation (and the connection of this
|
|
45
|
+
computer's agent apps to it). Without it, everything Ours is removed.
|
|
46
|
+
--dry-run show the list and change nothing.
|
|
47
|
+
|
|
48
|
+
Removal needs a terminal: identities and their private keys exist nowhere else,
|
|
49
|
+
so they are never deleted without a person confirming. If removal is interrupted
|
|
50
|
+
or something cannot be removed, run the same command again to finish.`;
|
|
51
|
+
|
|
52
|
+
function parseRemoveArgs(argv, home) {
|
|
53
|
+
const options = { stateDirs: [], dryRun: false, help: false };
|
|
54
|
+
for (let i = 0; i < argv.length; i++) {
|
|
55
|
+
const arg = argv[i];
|
|
56
|
+
if (arg === '--dry-run') options.dryRun = true;
|
|
57
|
+
else if (arg === '--help' || arg === '-h') options.help = true;
|
|
58
|
+
else if (arg === '--state-dir' || arg.startsWith('--state-dir=')) {
|
|
59
|
+
const value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : argv[++i];
|
|
60
|
+
if (!value) throw new Error('--state-dir requires a folder');
|
|
61
|
+
options.stateDirs.push(value === '~' ? home : value.startsWith('~/') ? join(home, value.slice(2)) : resolve(value));
|
|
62
|
+
} else throw new Error(`Unknown option for remove: ${arg}`);
|
|
63
|
+
}
|
|
64
|
+
return options;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function json(text) { try { return JSON.parse(text); } catch { return undefined; } }
|
|
68
|
+
|
|
69
|
+
/** Read-only inventory of everything that may be Ours. */
|
|
70
|
+
export async function discoverFootprint(effects, { stateDirs = [], journal = null } = {}) {
|
|
71
|
+
const home = effects.home;
|
|
72
|
+
const found = { installations: [], otherInstallations: [], kept: [], generations: [], npm: null, legacy: null };
|
|
73
|
+
// A residual is something of Ours that is left because it can no longer be proven; it keeps the removal unfinished.
|
|
74
|
+
const keep = (group, label, reason, residual = false) => found.kept.push({ group, label, reason, ...(residual ? { residual: true } : {}) });
|
|
75
|
+
// A folder at a tombstone name counts only when this removal recorded moving it there.
|
|
76
|
+
const tombstone = (path, kind, instanceId) => {
|
|
77
|
+
const at = tombstoneFor(path, instanceId), st = effects.stat(at);
|
|
78
|
+
if (!st) return null;
|
|
79
|
+
if (journal?.tombstones?.some(t => t.kind === kind && t.instanceId === instanceId && t.id === st.id)) return { path: at, fileId: st.id };
|
|
80
|
+
keep('data', `Folder ${at}`, 'it is named like a folder Ours moves aside while removing it, but this removal did not put it there, so it is left in place; check it and remove it yourself if it is not yours', true);
|
|
81
|
+
return null;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Managed installations.
|
|
85
|
+
const homeEntries = effects.list(home) ?? [];
|
|
86
|
+
const candidates = new Set([join(home, '.ours-install'), ...homeEntries.filter(name => name.startsWith('.ours')).map(name => join(home, name))]);
|
|
87
|
+
for (const dir of stateDirs) candidates.add(dir);
|
|
88
|
+
for (const item of journal?.installations ?? []) candidates.add(item.root);
|
|
89
|
+
const selected = root => !stateDirs.length || stateDirs.includes(root) || journal?.installations?.some(i => i.root === root);
|
|
90
|
+
for (const root of candidates) {
|
|
91
|
+
const text = effects.readText(join(root, 'installation.json'));
|
|
92
|
+
const retained = journal?.installations?.find(i => i.root === root);
|
|
93
|
+
if (text === null) {
|
|
94
|
+
// Partly removed earlier: finish engine items from the retry record. A folder
|
|
95
|
+
// still there without its record is no longer provably the installation.
|
|
96
|
+
if (retained) {
|
|
97
|
+
found.installations.push({ ...retained, record: null, tombstone: tombstone(root, 'root', retained.instanceId) });
|
|
98
|
+
if (effects.stat(root)) keep('data', `Folder ${root}`, 'it no longer holds the Ours installation record it had when the removal started, so it is left in place; check it and remove it yourself if it is not yours', true);
|
|
99
|
+
}
|
|
100
|
+
else if (stateDirs.includes(root)) keep('data', root, 'no Ours installation record was found there');
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
let record;
|
|
104
|
+
try { record = validateInstallation(json(text), root); if (record.containerBinding || record.containerEngine) validateContainerEngine(record); }
|
|
105
|
+
catch { keep('data', `Folder ${root}`, 'its Ours installation record is damaged, so nothing in it is removed automatically; repair or remove the folder yourself, then run the removal again', true); continue; }
|
|
106
|
+
if (retained && retained.instanceId !== record.instanceId) { keep('data', `Folder ${root}`, 'it now holds a different Ours installation than the unfinished removal recorded'); continue; }
|
|
107
|
+
const item = { ...journalInstallation(record), ...(retained?.candidateProjects?.length ? { candidateProjects: [...new Set([...retained.candidateProjects, ...journalInstallation(record).candidateProjects])] } : {}), ...(retained?.images ? { images: retained.images } : {}), record, tombstone: tombstone(root, 'root', record.instanceId) };
|
|
108
|
+
item.ownedImages = json(effects.readText(join(root, 'owned-images.json')) ?? '')?.images ?? null;
|
|
109
|
+
// An update interrupted before it recorded its transition still recorded the
|
|
110
|
+
// images it built: their candidate projects are part of this installation.
|
|
111
|
+
const recordedCandidates = Object.keys(item.ownedImages ?? {}).map(tag => tag.split(':')[0]).filter(project => /^ours-build[0-9a-f]{32}$/.test(project));
|
|
112
|
+
item.candidateProjects = [...new Set([...(item.candidateProjects ?? []), ...recordedCandidates])];
|
|
113
|
+
(selected(root) ? found.installations : found.otherInstallations).push(item);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The saved client connection of this computer's agent apps.
|
|
117
|
+
const clientRoot = join(home, '.ours-client');
|
|
118
|
+
if (effects.stat(clientRoot)) {
|
|
119
|
+
const profile = json(effects.readText(join(clientRoot, 'profile.json')) ?? '');
|
|
120
|
+
if (profile && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(profile.expectedInstanceId ?? '')) {
|
|
121
|
+
found.client = { root: clientRoot, instanceId: profile.expectedInstanceId, integrations: profile.installer?.integrations ?? [], path: join(clientRoot, 'profile.json') };
|
|
122
|
+
} else keep('apps', `Saved Ours connection ${clientRoot}`, 'its connection file is missing or cannot be read, so it is not provably this installation\'s and is left in place; remove it yourself if no Ours installation uses it', true);
|
|
123
|
+
}
|
|
124
|
+
// Recorded client tombstones are found from the retry record itself, so a retired
|
|
125
|
+
// connection stays discoverable after any number of failed retries.
|
|
126
|
+
found.clientTombstones = [...new Set([...(journal?.clientInstanceIds ?? []), ...(journal?.tombstones ?? []).filter(t => t.kind === 'client').map(t => t.instanceId),
|
|
127
|
+
...(found.client?.instanceId ? [found.client.instanceId] : [])])]
|
|
128
|
+
.map(id => tombstone(clientRoot, 'client', id)).filter(Boolean);
|
|
129
|
+
|
|
130
|
+
// Downloaded client programs.
|
|
131
|
+
const installRoot = join(home, '.ours-client-install');
|
|
132
|
+
const generationNames = effects.list(installRoot) ?? [];
|
|
133
|
+
for (const name of generationNames) {
|
|
134
|
+
const path = join(installRoot, name);
|
|
135
|
+
if (!GENERATION.test(name) || effects.stat(path)?.type !== 'dir') { keep('programs', path, 'it was not created by this installer'); continue; }
|
|
136
|
+
found.generations.push({ id: name, path, owners: readOwners(effects, path), fleet: Boolean(effects.stat(join(path, 'node_modules/@ours.network/fleet'))) });
|
|
137
|
+
}
|
|
138
|
+
found.clientInstallEmptyAfter = generationNames.length > 0 && generationNames.every(name => GENERATION.test(name));
|
|
139
|
+
|
|
140
|
+
// npm global commands and packages.
|
|
141
|
+
try {
|
|
142
|
+
const prefix = (await effects.run('npm', ['prefix', '--global'])).stdout.trim();
|
|
143
|
+
const root = (await effects.run('npm', ['root', '--global'])).stdout.trim();
|
|
144
|
+
const bins = [];
|
|
145
|
+
for (const name of (effects.list(join(prefix, 'bin')) ?? []).filter(name => name === 'ours' || name.startsWith('ours-'))) {
|
|
146
|
+
const path = join(prefix, 'bin', name), st = effects.stat(path);
|
|
147
|
+
if (!st) continue;
|
|
148
|
+
if (st.type === 'symlink') {
|
|
149
|
+
const target = effects.realpath(path);
|
|
150
|
+
if (target && within(target, installRoot)) bins.push({ name, path, target, kind: 'generation' });
|
|
151
|
+
else if (target && within(target, join(root, '@ours.network'))) bins.push({ name, path, target, kind: 'package' });
|
|
152
|
+
else bins.push({ name, path, target, kind: 'other' });
|
|
153
|
+
} else if (st.type === 'file') {
|
|
154
|
+
const line = (effects.readText(path) ?? '').split('\n')[1] ?? '';
|
|
155
|
+
const binding = line.startsWith(MANAGED_CLI_MARKER) ? json(line.slice(MANAGED_CLI_MARKER.length)) : null;
|
|
156
|
+
bins.push(binding?.targetRoot ? { name, path, kind: 'managed-launcher', root: binding.targetRoot } : { name, path, kind: 'other' });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const packages = [];
|
|
160
|
+
for (const name of effects.list(join(root, '@ours.network')) ?? []) {
|
|
161
|
+
const path = join(root, '@ours.network', name), st = effects.stat(path);
|
|
162
|
+
if (!st) continue;
|
|
163
|
+
packages.push({ name: `@ours.network/${name}`, path, link: st.type === 'symlink', target: st.type === 'symlink' ? effects.realpath(path) : path });
|
|
164
|
+
}
|
|
165
|
+
found.npm = { prefix, root, bins, packages, scope: effects.stat(join(root, '@ours.network'))?.type === 'dir' ? join(root, '@ours.network') : null };
|
|
166
|
+
} catch { keep('programs', 'Global npm commands', 'npm is not available to check them', true); }
|
|
167
|
+
|
|
168
|
+
// Claude Code and Codex registrations.
|
|
169
|
+
const claudeSettingsPath = join(home, '.claude', 'settings.json');
|
|
170
|
+
const settingsText = effects.readText(claudeSettingsPath);
|
|
171
|
+
const settings = settingsText === null ? null : parseJsonc(settingsText, [], { allowTrailingComma: true });
|
|
172
|
+
const settingsEntries = [];
|
|
173
|
+
if (settings?.enabledPlugins && Object.hasOwn(settings.enabledPlugins, CLAUDE_PLUGIN)) settingsEntries.push(['enabledPlugins', CLAUDE_PLUGIN]);
|
|
174
|
+
if (settings?.extraKnownMarketplaces && Object.hasOwn(settings.extraKnownMarketplaces, CLAUDE_MARKETPLACE)) settingsEntries.push(['extraKnownMarketplaces', CLAUDE_MARKETPLACE]);
|
|
175
|
+
const settingsMarketplacePath = settings?.extraKnownMarketplaces?.[CLAUDE_MARKETPLACE]?.source?.path ?? null;
|
|
176
|
+
let claude = null;
|
|
177
|
+
try {
|
|
178
|
+
const plugins = json((await effects.run('claude', ['plugin', 'list', '--json'], { timeout: 30_000 })).stdout) ?? [];
|
|
179
|
+
const markets = json((await effects.run('claude', ['plugin', 'marketplace', 'list', '--json'], { timeout: 30_000 })).stdout) ?? [];
|
|
180
|
+
const market = markets.find(m => m?.name === CLAUDE_MARKETPLACE);
|
|
181
|
+
const plugin = plugins.find(p => p?.id === CLAUDE_PLUGIN);
|
|
182
|
+
// Claude Code keeps its copy of the plugin after uninstalling it.
|
|
183
|
+
const cache = effects.stat(join(home, '.claude', 'plugins', 'cache', 'ours-network'))?.type === 'dir' ? join(home, '.claude', 'plugins', 'cache', 'ours-network') : null;
|
|
184
|
+
if (plugin || market || settingsEntries.length || cache) {
|
|
185
|
+
claude = { plugin: Boolean(plugin), marketplace: market || settingsMarketplacePath ? { path: market?.path ?? market?.installLocation ?? settingsMarketplacePath } : null, settingsPath: claudeSettingsPath, settingsEntries, cache };
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
if (settingsEntries.length || effects.stat(join(home, '.claude/plugins/cache/ours-network')))
|
|
189
|
+
keep('apps', 'Ours plugin in Claude Code', 'the claude command is not available to remove it; inside Claude Code run: /plugin uninstall ours@ours.network, then /plugin marketplace remove ours.network', true);
|
|
190
|
+
}
|
|
191
|
+
found.claude = claude;
|
|
192
|
+
|
|
193
|
+
const codexDir = effects.env?.CODEX_HOME || join(home, '.codex');
|
|
194
|
+
const codexConfigPath = join(codexDir, 'config.toml');
|
|
195
|
+
const codexText = effects.readText(codexConfigPath);
|
|
196
|
+
const scanned = codexText === null ? { removed: [] } : removeTomlTables(codexText, parts => isOursCodexTable(parts, CODEX_PLUGIN, CODEX_MARKETPLACE));
|
|
197
|
+
if (scanned === null) keep('apps', `Ours entries in ${codexConfigPath}`, 'the file could not be read safely, so it is not edited; remove the [plugins."ours@ours-codex-marketplace"] and [marketplaces.ours-codex-marketplace] sections yourself', true);
|
|
198
|
+
const configTables = scanned?.removed ?? [];
|
|
199
|
+
let codex = null;
|
|
200
|
+
try {
|
|
201
|
+
const listed = json((await effects.run('codex', ['plugin', 'marketplace', 'list', '--json'], { timeout: 30_000 })).stdout);
|
|
202
|
+
const market = listed?.marketplaces?.find(m => m?.name === CODEX_MARKETPLACE);
|
|
203
|
+
const plugin = configTables.some(label => label.startsWith(`plugins.${JSON.stringify(CODEX_PLUGIN)}`));
|
|
204
|
+
const cache = effects.stat(join(codexDir, 'plugins', 'cache', CODEX_MARKETPLACE))?.type === 'dir' ? join(codexDir, 'plugins', 'cache', CODEX_MARKETPLACE) : null;
|
|
205
|
+
if (market || plugin || configTables.length || cache) codex = { plugin, marketplace: market ? { path: market.marketplaceSource?.source ?? market.root } : null, configPath: codexConfigPath, configTables, cache };
|
|
206
|
+
} catch {
|
|
207
|
+
if (configTables.length) keep('apps', 'Ours plugin in Codex', 'the codex command is not available to remove it; run: codex plugin remove ours@ours-codex-marketplace', true);
|
|
208
|
+
// A partly finished removal can leave only the cached plugin; without the codex
|
|
209
|
+
// command its source cannot be checked, so it is reported, not deleted.
|
|
210
|
+
else if (effects.stat(join(codexDir, 'plugins', 'cache', CODEX_MARKETPLACE))?.type === 'dir') keep('apps', `Codex's copy of the Ours plugin ${join(codexDir, 'plugins', 'cache', CODEX_MARKETPLACE)}`, 'the codex command is not available to confirm where it came from; make codex available and run the removal again, or delete that folder yourself', true);
|
|
211
|
+
}
|
|
212
|
+
found.codex = codex;
|
|
213
|
+
|
|
214
|
+
// Fleet set up by this client.
|
|
215
|
+
const fleetHome = effects.env?.OURS_FLEET_HOME || home;
|
|
216
|
+
const unitsDir = join(home, '.config', 'systemd', 'user');
|
|
217
|
+
const clientProfile = join(clientRoot, 'profile.json');
|
|
218
|
+
const units = [];
|
|
219
|
+
for (const name of effects.list(unitsDir) ?? []) {
|
|
220
|
+
if (!(FLEET_UNITS.includes(name) || /^ours-fleet-agent@[^/]*\.service$/.test(name))) continue;
|
|
221
|
+
const path = join(unitsDir, name), st = effects.stat(path);
|
|
222
|
+
if (st?.type !== 'file') continue;
|
|
223
|
+
const text = effects.readText(path) ?? '';
|
|
224
|
+
units.push({ name, path, bound: text.includes(`OURS_CONFIG=${clientProfile}`) || within(text.match(/ExecStart=\S*\s+"?([^"\s]+)/)?.[1] ?? '', installRoot),
|
|
225
|
+
dropIn: effects.stat(`${path}.d`)?.type === 'dir' ? `${path}.d` : null });
|
|
226
|
+
}
|
|
227
|
+
const fleetGeneration = found.generations.find(g => g.fleet);
|
|
228
|
+
const installedByOurs = Boolean(found.client?.integrations?.includes('fleet') && fleetGeneration);
|
|
229
|
+
const configPath = join(fleetHome, 'fleet.yaml'), stateRoot = join(fleetHome, '.ours-fleet');
|
|
230
|
+
if (installedByOurs || units.length || effects.stat(configPath) || effects.stat(stateRoot)) {
|
|
231
|
+
found.fleet = { installedByOurs, bin: fleetGeneration ? join(fleetGeneration.path, 'node_modules/.bin/ours-fleet') : null, configPath, configExists: Boolean(effects.stat(configPath)), stateRoot, stateExists: Boolean(effects.stat(stateRoot)), units };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Earlier installations: the shared-daemon layout and the marked plugin blocks.
|
|
235
|
+
if (!stateDirs.length) {
|
|
236
|
+
const legacy = { dirs: [], services: [], blocks: [], skillDirs: [] };
|
|
237
|
+
const daemonDir = join(home, '.ours');
|
|
238
|
+
const io = { exists: path => Boolean(effects.stat(path)), readJson: path => json(effects.readText(path) ?? '') ?? null };
|
|
239
|
+
if (effects.stat(daemonDir)?.type === 'dir' && !effects.stat(join(daemonDir, 'installation.json')) && (classifyStateDir(daemonDir, io).isDaemon || effects.stat(join(daemonDir, 'install')))) {
|
|
240
|
+
legacy.dirs.push(daemonDir);
|
|
241
|
+
if (effects.stat(join(daemonDir, 'config.json'))) {
|
|
242
|
+
const config = io.readJson(join(daemonDir, 'config.json'));
|
|
243
|
+
const unit = unitNameForStateDir(daemonDir);
|
|
244
|
+
legacy.services.push({ kind: 'daemon', label: 'Earlier Ours daemon and its boot service', dir: daemonDir,
|
|
245
|
+
port: Number.isInteger(config?.port) ? config.port : 3050, cliStartedIt: Boolean(effects.stat(join(daemonDir, 'ours-cli-daemon.json'))),
|
|
246
|
+
unitPath: unit.ok ? join(home, '.config', 'systemd', 'user', unit.unit) : null });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const [name, service, unitName] of [['.ours-telegram', 'ours-tg-connector', 'ours-telegram.service'], ['.ours-cowork', 'ours-cowork', 'ours-cowork.service']]) {
|
|
250
|
+
const dir = join(home, name);
|
|
251
|
+
if (effects.stat(dir)?.type !== 'dir') continue;
|
|
252
|
+
legacy.dirs.push(dir);
|
|
253
|
+
// Only a boot service that serves this earlier folder; verified gone afterwards.
|
|
254
|
+
const unitPath = join(home, '.config', 'systemd', 'user', unitName);
|
|
255
|
+
if (effects.stat(unitPath)?.type === 'file' && (effects.readText(unitPath) ?? '').includes(dir)) {
|
|
256
|
+
legacy.services.push({ kind: 'connector', label: `Earlier ${service} boot service`, command: [service, 'uninstall-service'], unitPath, dir });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const hermesDir = effects.env?.HERMES_DIR || join(home, '.hermes');
|
|
260
|
+
const skillsDir = effects.env?.SKILLS_DIR || join(home, '.agents', 'skills');
|
|
261
|
+
for (const [path, markers] of [[join(hermesDir, 'config.yaml'), YAML_BLOCK], [codexConfigPath, YAML_BLOCK], [join(codexDir, 'AGENTS.md'), MD_BLOCK]]) {
|
|
262
|
+
const text = effects.readText(path);
|
|
263
|
+
if (text === null || !text.includes(markers.start)) continue;
|
|
264
|
+
if (stripManagedBlock(text, markers).action === 'strip') legacy.blocks.push({ path, markers });
|
|
265
|
+
else keep('legacy', `Ours section in ${path}`, 'it has no closing marker; remove it by hand so nothing you wrote after it is lost', true);
|
|
266
|
+
}
|
|
267
|
+
for (const dir of [join(skillsDir, 'ours'), join(skillsDir, 'writing-agent-bios'), join(hermesDir, 'skills', 'communication', 'ours'), join(hermesDir, 'skills', 'communication', 'writing-agent-bios')]) {
|
|
268
|
+
if (effects.stat(dir)?.type === 'dir') legacy.skillDirs.push(dir);
|
|
269
|
+
}
|
|
270
|
+
legacy.remove = !found.otherInstallations.length;
|
|
271
|
+
// The Ours tools in Claude Code and Codex keep per-instance records here.
|
|
272
|
+
const toolRoot = join(home, '.ours-mcp');
|
|
273
|
+
if (effects.stat(toolRoot)?.type === 'dir') {
|
|
274
|
+
const configText = effects.readText(join(toolRoot, 'config.json'));
|
|
275
|
+
const config = configText === null ? null : json(configText);
|
|
276
|
+
const valid = configText === null || (config && config.version === 1 && typeof config.daemons === 'object' && !Array.isArray(config.daemons)
|
|
277
|
+
&& (config.instances === undefined || (typeof config.instances === 'object' && !Array.isArray(config.instances))));
|
|
278
|
+
found.toolState = { root: toolRoot, configPath: join(toolRoot, 'config.json'), valid: Boolean(valid),
|
|
279
|
+
daemons: Object.keys(config?.daemons ?? {}), instances: Object.keys(config?.instances ?? {}),
|
|
280
|
+
sessions: effects.list(join(toolRoot, 'sessions')) ?? [], others: (effects.list(toolRoot) ?? []).filter(name => !['config.json', 'sessions'].includes(name)) };
|
|
281
|
+
}
|
|
282
|
+
// Session guards are shared by every Ours tool session of this user, of any
|
|
283
|
+
// installation, and are temporary: reported, never removed.
|
|
284
|
+
if (effects.stat(`/tmp/ours-${effects.uid}`)?.type === 'dir') keep('apps', `Temporary Ours session guards /tmp/ours-${effects.uid}`, 'they are shared by every Ours tool session of your user and are cleared when the computer restarts');
|
|
285
|
+
if (legacy.dirs.length || legacy.blocks.length || legacy.skillDirs.length) found.legacy = legacy;
|
|
286
|
+
found.removeInstaller = !found.otherInstallations.length;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Other Ours projects on the same engine keep the shared build cache.
|
|
290
|
+
if (found.installations.some(i => i.mode === 'docker')) {
|
|
291
|
+
const removing = new Set(found.installations.flatMap(i => [i.project, ...(i.candidateProjects ?? [])]));
|
|
292
|
+
const binding = found.installations.find(i => i.mode === 'docker');
|
|
293
|
+
const others = new Set();
|
|
294
|
+
// Containers inherit their image's project label, so only Compose-created ones
|
|
295
|
+
// (with a project folder) show another installation; volume labels are not inherited.
|
|
296
|
+
const label = '{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.project.working_dir"}}';
|
|
297
|
+
for (const [kind, composeOnly] of [[['ps', '-a'], true], [['volume', 'ls'], false]]) {
|
|
298
|
+
try {
|
|
299
|
+
const listed = await runContainer(effects, binding, [...kind, '--format', label]);
|
|
300
|
+
for (const project of otherOursProjects(listed.stdout, removing, { composeOnly })) others.add(project);
|
|
301
|
+
} catch { others.add('unknown'); }
|
|
302
|
+
}
|
|
303
|
+
found.otherEngineProjects = [...others];
|
|
304
|
+
for (const item of found.installations.filter(i => i.mode === 'docker')) item.engine = await engineInventory(effects, item, keep);
|
|
305
|
+
try {
|
|
306
|
+
const listed = await runContainer(effects, binding, ['buildx', 'du', '--verbose', '--filter', 'type=exec.cachemount']);
|
|
307
|
+
found.buildCache = parseBuildCache(listed.stdout).filter(isOursCacheRecord).map(record => record.id);
|
|
308
|
+
} catch { found.buildCache = null; }
|
|
309
|
+
}
|
|
310
|
+
return found;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* What of one installation still exists in its container engine, so the plan
|
|
315
|
+
* lists only real items and a finished removal rescans as empty. A failed query
|
|
316
|
+
* leaves that kind unknown (null), which is planned and checked again.
|
|
317
|
+
*/
|
|
318
|
+
async function engineInventory(effects, item, keep) {
|
|
319
|
+
const inventory = {};
|
|
320
|
+
const inspect = async args => { try { return json((await runContainer(effects, item, args)).stdout) ?? null; } catch { return null; } };
|
|
321
|
+
const names = async args => { try { return (await runContainer(effects, item, args)).stdout.split(/\s+/).filter(Boolean); } catch { return null; } };
|
|
322
|
+
for (const project of [item.project, ...(item.candidateProjects ?? [])]) {
|
|
323
|
+
const label = `label=com.docker.compose.project=${project}`;
|
|
324
|
+
// Labelled containers plus the installer's named helpers; ownership decided per container.
|
|
325
|
+
const listed = await names(['ps', '-aq', '--no-trunc', '--filter', label]);
|
|
326
|
+
const helpers = (await names(['ps', '-aq', '--no-trunc', '--filter', `name=${project}-`])) ?? [];
|
|
327
|
+
let containers = null;
|
|
328
|
+
const imageIds = new Set();
|
|
329
|
+
if (listed !== null) {
|
|
330
|
+
const ids = [...new Set([...listed, ...helpers])];
|
|
331
|
+
const values = ids.length ? await inspect(['inspect', ...ids]) : [];
|
|
332
|
+
if (values !== null) {
|
|
333
|
+
containers = [];
|
|
334
|
+
for (const value of values) {
|
|
335
|
+
const name = String(value?.Name ?? '').replace(/^\//, '');
|
|
336
|
+
if (ownedContainer(value, { root: item.root, project })) {
|
|
337
|
+
containers.push(value.Id);
|
|
338
|
+
const image = value.Config?.Labels?.['com.docker.compose.image'] ?? value.Image;
|
|
339
|
+
if (/^sha256:[0-9a-f]{64}$/.test(image ?? '')) imageIds.add(image);
|
|
340
|
+
} else if (value?.Config?.Labels?.['com.docker.compose.project'] === project) {
|
|
341
|
+
keep('services', `Container ${name}`, 'it uses an Ours image but was not created by this installer, so it is left in place');
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// An image is proven by the ID this installer recorded for that name, or by
|
|
347
|
+
// being the image an owned container was created from. Names are no proof.
|
|
348
|
+
const images = {};
|
|
349
|
+
let imagesKnown = true;
|
|
350
|
+
for (const target of IMAGE_TARGETS) {
|
|
351
|
+
const tag = `${project}:${target}`;
|
|
352
|
+
let found;
|
|
353
|
+
try { found = await runContainer(effects, item, ['image', 'inspect', tag], { allowCodes: [1] }); } catch { imagesKnown = false; continue; }
|
|
354
|
+
if (found.code === 1) continue;
|
|
355
|
+
const id = (json(found.stdout) ?? [])[0]?.Id;
|
|
356
|
+
const recorded = item.ownedImages?.[tag] ?? item.images?.[tag];
|
|
357
|
+
if (id && (recorded ? recorded === id : imageIds.has(id))) images[tag] = id;
|
|
358
|
+
// An unfinished update's project name comes from this installation's own record,
|
|
359
|
+
// so such an image is Ours even when its build was not recorded: report it.
|
|
360
|
+
else if (project !== item.project) keep('programs', `Container image ${tag}`, `it was built by an unfinished Ours update whose image was not recorded, so it is left in place; remove it with: docker image rm ${tag}`, true);
|
|
361
|
+
else keep('programs', `Container image ${tag}`, 'it has an Ours name but is not provably the image this installer built, so it is left in place');
|
|
362
|
+
}
|
|
363
|
+
inventory[project] = {
|
|
364
|
+
containers,
|
|
365
|
+
images: imagesKnown ? images : null,
|
|
366
|
+
volumes: (await names(['volume', 'ls', '-q', '--filter', label]))?.length ?? null,
|
|
367
|
+
networks: (await names(['network', 'ls', '-q', '--filter', label]))?.length ?? null,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
return inventory;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const verifyTarget = (effects, path, expected) => effects.stat(path)?.type === 'symlink' && effects.realpath(path) === expected;
|
|
374
|
+
|
|
375
|
+
async function executeStep(step, effects, context) {
|
|
376
|
+
const { home } = effects;
|
|
377
|
+
switch (step.type) {
|
|
378
|
+
case 'command': {
|
|
379
|
+
try { await effects.run(step.command[0], step.command.slice(1), { timeout: 120_000 }); }
|
|
380
|
+
catch (error) { if (!step.tolerate) throw error; }
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
case 'legacy-service': {
|
|
384
|
+
// Run the earlier tools' own stop commands, then verify: an error from a
|
|
385
|
+
// command is only a failure when the service is in fact still there.
|
|
386
|
+
const errors = [];
|
|
387
|
+
// One short line per failed command; a missing program is said plainly.
|
|
388
|
+
const attempt = async command => {
|
|
389
|
+
try { await effects.run(command[0], command.slice(1), { timeout: 120_000 }); }
|
|
390
|
+
catch (error) {
|
|
391
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
392
|
+
errors.push(/ENOENT/.test(text) || error?.code === 'ENOENT' ? `the ${command[0]} command is not installed` : `${command.slice(0, 2).join(' ')} failed: ${text.split('\n').find(line => line.trim()) ?? 'no reason given'}`.slice(0, 200));
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
const unitHelp = path => `run: systemctl --user disable --now ${basename(path)}, then delete ${path}`;
|
|
396
|
+
if (step.service.kind === 'daemon') {
|
|
397
|
+
const { dir, port, unitPath, cliStartedIt } = step.service;
|
|
398
|
+
if (unitPath && effects.stat(unitPath)) await attempt(['ours-daemon', 'uninstall-service', '--yes', '--state-dir', dir, '--config', join(dir, 'config.json')]);
|
|
399
|
+
if (cliStartedIt) await attempt(['ours-daemon', 'stop', '--state-dir', dir, '--config', join(dir, 'config.json')]);
|
|
400
|
+
// Stopped only when the port refuses connections, or answers as a different
|
|
401
|
+
// Ours daemon folder. Timeouts, errors and odd replies are not proof.
|
|
402
|
+
let state = 'unknown';
|
|
403
|
+
for (let i = 0; i < 10 && state !== 'stopped'; i++) {
|
|
404
|
+
const port_ = await effects.portState(port);
|
|
405
|
+
if (port_ === 'refused') state = 'stopped';
|
|
406
|
+
else if (port_ === 'open') {
|
|
407
|
+
const probe = await effects.probe(port);
|
|
408
|
+
state = probe?.ok && typeof probe.stateDir === 'string' && resolve(probe.stateDir) !== dir ? 'stopped' : probe?.ok ? 'running' : 'unknown';
|
|
409
|
+
} else state = 'unknown';
|
|
410
|
+
if (state !== 'stopped') await effects.sleep?.(1000);
|
|
411
|
+
}
|
|
412
|
+
const reason = () => errors.length ? ` (${[...new Set(errors)].join('; ')})` : '';
|
|
413
|
+
if (state === 'running') throw new Error(`the earlier daemon for ${dir} is still running on port ${port}${reason()}; stop that process, then run the removal again`);
|
|
414
|
+
if (state !== 'stopped') throw new Error(`it could not be confirmed that the earlier daemon for ${dir} has stopped: port ${port} still accepts connections or does not answer clearly${reason()}; stop whatever uses that port, then run the removal again`);
|
|
415
|
+
if (unitPath && effects.stat(unitPath)) throw new Error(`its boot service ${unitPath} is still installed${reason()}; ${unitHelp(unitPath)}`);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (effects.stat(step.service.unitPath)) await attempt(step.service.command);
|
|
419
|
+
if (effects.stat(step.service.unitPath)) throw new Error(`its boot service ${step.service.unitPath} is still installed${errors.length ? ` (${[...new Set(errors)].join('; ')})` : ''}; ${unitHelp(step.service.unitPath)}`);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
case 'fleet-down': {
|
|
423
|
+
await effects.run(step.bin, ['down'], { env: { OURS_CONFIG: join(home, '.ours-client', 'profile.json') }, timeout: 180_000, allowCodes: [1] });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
case 'user-unit': {
|
|
427
|
+
await effects.run('systemctl', ['--user', 'disable', '--now', step.name], { allowCodes: [1, 5] });
|
|
428
|
+
const reason = unsafeTreeReason(step.path, { home, stat: effects.stat, realpath: effects.realpath, uid: effects.uid, privateTree: false, file: true });
|
|
429
|
+
if (reason) throw new Error(`${step.path} ${reason}`);
|
|
430
|
+
effects.removeFile(step.path);
|
|
431
|
+
if (step.dropIn && !unsafeTreeReason(step.dropIn, { home, stat: effects.stat, realpath: effects.realpath, uid: effects.uid, privateTree: false })) effects.removeDir(step.dropIn);
|
|
432
|
+
context.reloadUnits = true;
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
case 'claude-settings': {
|
|
436
|
+
const text = effects.readText(step.path);
|
|
437
|
+
if (text === null) return;
|
|
438
|
+
let updated = text;
|
|
439
|
+
const edit = path => { updated = applyEdits(updated, modify(updated, path, undefined, { formattingOptions: { insertSpaces: true, tabSize: 2 } })); };
|
|
440
|
+
for (const path of step.entries) {
|
|
441
|
+
const current = parseJsonc(updated, [], { allowTrailingComma: true });
|
|
442
|
+
if (current?.[path[0]] && Object.hasOwn(current[path[0]], path[1])) edit(path);
|
|
443
|
+
}
|
|
444
|
+
// A section that held only the Ours entry is not left behind empty.
|
|
445
|
+
for (const key of new Set(step.entries.map(path => path[0]))) {
|
|
446
|
+
const value = parseJsonc(updated, [], { allowTrailingComma: true })?.[key];
|
|
447
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) edit([key]);
|
|
448
|
+
}
|
|
449
|
+
if (updated !== text) effects.writeText(step.path, updated);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
case 'codex-config': {
|
|
453
|
+
const text = effects.readText(step.path);
|
|
454
|
+
if (text === null) return;
|
|
455
|
+
const result = removeTomlTables(text, parts => isOursCodexTable(parts, CODEX_PLUGIN, CODEX_MARKETPLACE));
|
|
456
|
+
if (result === null) throw new Error('the file changed and can no longer be read safely; it was not edited');
|
|
457
|
+
if (result.removed.length) effects.writeText(step.path, result.text);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
case 'tool-config': {
|
|
461
|
+
const text = effects.readText(step.path);
|
|
462
|
+
if (text === null) return;
|
|
463
|
+
const value = JSON.parse(text);
|
|
464
|
+
if (value?.version !== 1 || !value.instances || typeof value.instances !== 'object') throw new Error('it changed format since it was checked');
|
|
465
|
+
const instances = Object.fromEntries(Object.entries(value.instances).filter(([id]) => !step.instances.includes(id)));
|
|
466
|
+
if (Object.keys(instances).length === Object.keys(value.instances).length) return;
|
|
467
|
+
effects.writeText(step.path, JSON.stringify({ ...value, instances }, null, 2) + '\n');
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
case 'managed-block': {
|
|
471
|
+
const text = effects.readText(step.path);
|
|
472
|
+
if (text === null) return;
|
|
473
|
+
const stripped = stripManagedBlock(text, step.markers);
|
|
474
|
+
if (stripped.action === 'absent') return;
|
|
475
|
+
if (stripped.action !== 'strip') throw new Error(stripped.reason);
|
|
476
|
+
effects.writeText(step.path, stripped.text);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
case 'unlink': {
|
|
480
|
+
const st = effects.stat(step.path);
|
|
481
|
+
if (!st) return;
|
|
482
|
+
if (step.expectTarget && !verifyTarget(effects, step.path, step.expectTarget)) throw new Error('the command changed since it was checked');
|
|
483
|
+
if (step.expectLauncher) {
|
|
484
|
+
const line = (effects.readText(step.path) ?? '').split('\n')[1] ?? '';
|
|
485
|
+
if (!line.startsWith(MANAGED_CLI_MARKER) || json(line.slice(MANAGED_CLI_MARKER.length))?.targetRoot !== step.expectLauncher) throw new Error('the command changed since it was checked');
|
|
486
|
+
}
|
|
487
|
+
effects.removeFile(step.path);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
case 'npm-uninstall': {
|
|
491
|
+
if (step.expectTarget) {
|
|
492
|
+
const path = join(context.npmRoot ?? '', step.name);
|
|
493
|
+
if (effects.stat(path) && effects.realpath(path) !== step.expectTarget) throw new Error('the package changed since it was checked');
|
|
494
|
+
}
|
|
495
|
+
await effects.run('npm', ['uninstall', '--global', step.name], { timeout: 180_000 });
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
case 'server-services': {
|
|
499
|
+
if (!step.installation.record) return; // Already removed earlier; containers are still cleaned by label below.
|
|
500
|
+
await effects.serverStopForRemoval(step.installation.record);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
case 'containers': {
|
|
504
|
+
let ids = step.containerIds;
|
|
505
|
+
if (!ids) {
|
|
506
|
+
const label = `label=com.docker.compose.project=${step.project}`;
|
|
507
|
+
const labelled = (await runContainer(effects, step.installation, ['ps', '-aq', '--no-trunc', '--filter', label])).stdout.split(/\s+/).filter(Boolean);
|
|
508
|
+
const helpers = (await runContainer(effects, step.installation, ['ps', '-aq', '--no-trunc', '--filter', `name=${step.project}-`])).stdout.split(/\s+/).filter(Boolean);
|
|
509
|
+
ids = [...new Set([...labelled, ...helpers])];
|
|
510
|
+
}
|
|
511
|
+
if (!ids.length) return;
|
|
512
|
+
const present = (await runContainer(effects, step.installation, ['ps', '-aq', '--no-trunc'])).stdout.split(/\s+/).filter(Boolean);
|
|
513
|
+
const live = ids.filter(id => present.includes(id));
|
|
514
|
+
if (!live.length) return;
|
|
515
|
+
const owned = [];
|
|
516
|
+
for (const value of JSON.parse((await runContainer(effects, step.installation, ['inspect', ...live])).stdout)) {
|
|
517
|
+
if (ownedContainer(value, { root: step.installation.root, project: step.project })) owned.push(value.Id);
|
|
518
|
+
else if (step.containerIds) throw new Error('a container changed ownership while being removed');
|
|
519
|
+
}
|
|
520
|
+
if (!owned.length) return;
|
|
521
|
+
await runContainer(effects, step.installation, ['stop', '--time', '30', ...owned], { allowCodes: [1] });
|
|
522
|
+
await runContainer(effects, step.installation, ['rm', '--force', '--volumes=false', ...owned]);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
case 'images': {
|
|
526
|
+
const problems = [];
|
|
527
|
+
// Only names whose image ID was proven during the check; unknown inventories
|
|
528
|
+
// fall back to the IDs this installer recorded.
|
|
529
|
+
const expected = step.tags ?? Object.fromEntries(IMAGE_TARGETS.map(target => `${step.project}:${target}`)
|
|
530
|
+
.map(tag => [tag, step.installation.ownedImages?.[tag] ?? step.installation.images?.[tag]]).filter(([, id]) => id));
|
|
531
|
+
for (const [tag, id] of Object.entries(expected)) {
|
|
532
|
+
const found = await runContainer(effects, step.installation, ['image', 'inspect', tag], { allowCodes: [1] });
|
|
533
|
+
if (found.code === 1) continue;
|
|
534
|
+
const [image] = JSON.parse(found.stdout);
|
|
535
|
+
if (image.Id !== id) { problems.push(`${tag} changed since it was checked and was left in place`); continue; }
|
|
536
|
+
// Remove only this name. An image ID still named or used elsewhere stays.
|
|
537
|
+
try { await runContainer(effects, step.installation, ['image', 'rm', tag]); }
|
|
538
|
+
catch {
|
|
539
|
+
let users = '';
|
|
540
|
+
try { users = (await runContainer(effects, step.installation, ['ps', '-a', '--filter', `ancestor=${id}`, '--format', '{{.Names}}'])).stdout.split(/\s+/).filter(Boolean).join(', '); } catch { /* named generically below */ }
|
|
541
|
+
problems.push(users ? `${tag} is still used by container ${users}, which Ours did not create; remove that container if you no longer need it` : `${tag} is still in use elsewhere`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (problems.length) throw new Error(problems.join('; '));
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
case 'volumes':
|
|
548
|
+
case 'networks': {
|
|
549
|
+
const kind = step.type === 'volumes' ? 'volume' : 'network';
|
|
550
|
+
const names = (await runContainer(effects, step.installation, [kind, 'ls', '-q', '--filter', `label=com.docker.compose.project=${step.project}`])).stdout.split(/\s+/).filter(Boolean);
|
|
551
|
+
for (const name of names) {
|
|
552
|
+
const [value] = JSON.parse((await runContainer(effects, step.installation, [kind, 'inspect', name])).stdout);
|
|
553
|
+
if (value?.Labels?.['com.docker.compose.project'] !== step.project) throw new Error(`${name} changed ownership while being removed`);
|
|
554
|
+
await runContainer(effects, step.installation, [kind, 'rm', name]);
|
|
555
|
+
}
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
case 'build-cache': {
|
|
559
|
+
if ((step.installation.containerEngine ?? 'docker') !== 'docker') throw new Error('Podman keeps no separate Ours build cache record to select; nothing was pruned');
|
|
560
|
+
const listed = await runContainer(effects, step.installation, ['buildx', 'du', '--verbose', '--filter', 'type=exec.cachemount']);
|
|
561
|
+
for (const record of parseBuildCache(listed.stdout).filter(isOursCacheRecord)) {
|
|
562
|
+
await runContainer(effects, step.installation, ['buildx', 'prune', '--force', '--filter', `id=${record.id}`]);
|
|
563
|
+
}
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
case 'tree':
|
|
567
|
+
case 'file': {
|
|
568
|
+
const reason = unsafeTreeReason(step.path, { home, stat: effects.stat, realpath: effects.realpath, uid: effects.uid, privateTree: step.privateTree !== false, file: step.type === 'file' });
|
|
569
|
+
if (reason) throw new Error(`${step.path} ${reason}`);
|
|
570
|
+
if (!effects.stat(step.path)) return;
|
|
571
|
+
if (step.type === 'file') effects.removeFile(step.path); else effects.removeDir(step.path);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
case 'retire': {
|
|
575
|
+
// Verify the folder's own record, move it aside in one rename, then delete.
|
|
576
|
+
if (!effects.stat(step.path)) return;
|
|
577
|
+
const guard = { home, stat: effects.stat, realpath: effects.realpath, uid: effects.uid };
|
|
578
|
+
const reason = unsafeTreeReason(step.path, guard);
|
|
579
|
+
if (reason) throw new Error(`${step.path} ${reason}`);
|
|
580
|
+
const record = json(effects.readText(join(step.path, step.recordFile)) ?? '');
|
|
581
|
+
if (!record || record[step.recordField] !== step.expectInstance) throw new Error('it no longer holds the Ours record it had when checked, so it was left in place');
|
|
582
|
+
const occupied = effects.stat(step.tombstone);
|
|
583
|
+
if (occupied) {
|
|
584
|
+
// Only a folder this removal itself moved there earlier may be cleared.
|
|
585
|
+
const recorded = context.journal?.tombstones?.some(t => t.kind === step.kind && t.instanceId === step.expectInstance && t.id === occupied.id);
|
|
586
|
+
if (!recorded) throw new Error(`${step.tombstone} already exists and was not put there by this removal; both folders were left in place`);
|
|
587
|
+
const leftover = unsafeTreeReason(step.tombstone, guard);
|
|
588
|
+
if (leftover) throw new Error(`${step.tombstone} ${leftover}`);
|
|
589
|
+
effects.removeDir(step.tombstone);
|
|
590
|
+
}
|
|
591
|
+
// Record the folder's identity before moving it, so a retry can prove the tombstone.
|
|
592
|
+
const own = effects.stat(step.path);
|
|
593
|
+
if (!own?.id) throw new Error('its identity could not be read, so it was left in place');
|
|
594
|
+
if (context.journal) {
|
|
595
|
+
context.journal.tombstones = [...(context.journal.tombstones ?? []).filter(t => !(t.kind === step.kind && t.instanceId === step.expectInstance)), { kind: step.kind, instanceId: step.expectInstance, id: own.id }];
|
|
596
|
+
context.saveJournal?.();
|
|
597
|
+
}
|
|
598
|
+
effects.renamePath(step.path, step.tombstone);
|
|
599
|
+
effects.removeDir(step.tombstone);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
case 'tombstone': {
|
|
603
|
+
const st = effects.stat(step.path);
|
|
604
|
+
if (!st) return;
|
|
605
|
+
if (st.id !== step.fileId) throw new Error('it changed since it was checked; it was left in place');
|
|
606
|
+
const reason = unsafeTreeReason(step.path, { home, stat: effects.stat, realpath: effects.realpath, uid: effects.uid });
|
|
607
|
+
if (reason) throw new Error(`${step.path} ${reason}`);
|
|
608
|
+
effects.removeDir(step.path);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
case 'empty-dir': {
|
|
612
|
+
if ((effects.list(step.path) ?? []).length) throw new Error('it still contains other files');
|
|
613
|
+
if (effects.stat(step.path)) effects.removeEmptyDir(step.path);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
default: throw new Error(`unknown step ${step.type}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Run the plan in order; a step waits for the steps it depends on. */
|
|
621
|
+
export async function executeRemoval(plan, effects, { journal, saveJournal } = {}) {
|
|
622
|
+
const results = new Map();
|
|
623
|
+
const context = { npmRoot: null, journal, saveJournal };
|
|
624
|
+
try { context.npmRoot = (await effects.run('npm', ['root', '--global'])).stdout.trim(); } catch { /* checked per step */ }
|
|
625
|
+
const ordered = [...plan.steps.filter(s => !s.last), ...plan.steps.filter(s => s.last)];
|
|
626
|
+
let index = 0;
|
|
627
|
+
for (const step of ordered) {
|
|
628
|
+
index += 1;
|
|
629
|
+
const blocked = (step.after ?? []).filter(id => results.has(id) && results.get(id).state === 'failed');
|
|
630
|
+
effects.out(progress(index - 1, ordered.length, step.label, ''));
|
|
631
|
+
if (blocked.length) {
|
|
632
|
+
results.set(step.id, { state: 'failed', reason: 'skipped because an earlier step it depends on did not finish' });
|
|
633
|
+
effects.out(warn(`Not removed yet: ${step.label} (it depends on a step that did not finish)`));
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (step.last && [...results.values()].some(r => r.state === 'failed')) {
|
|
637
|
+
results.set(step.id, { state: 'skipped', reason: 'kept so the removal can be retried' });
|
|
638
|
+
effects.out(info(`${step.label}: kept so you can run the removal again`));
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
await executeStep(step, effects, context);
|
|
643
|
+
results.set(step.id, { state: 'done' });
|
|
644
|
+
effects.out(ok(`Removed: ${step.label}`));
|
|
645
|
+
} catch (error) {
|
|
646
|
+
results.set(step.id, { state: 'failed', reason: error instanceof Error ? error.message : String(error) });
|
|
647
|
+
effects.out(warn(`Could not remove: ${step.label} — ${error instanceof Error ? error.message : String(error)}`));
|
|
648
|
+
}
|
|
649
|
+
if (context.reloadUnits && !ordered.slice(index).some(s => s.type === 'user-unit')) {
|
|
650
|
+
try { await effects.run('systemctl', ['--user', 'daemon-reload']); } catch { /* reported by verification */ }
|
|
651
|
+
context.reloadUnits = false;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return results;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** The whole command. */
|
|
658
|
+
export async function runRemoval(argv, effects) {
|
|
659
|
+
let options;
|
|
660
|
+
try { options = parseRemoveArgs(argv, effects.home); }
|
|
661
|
+
catch (error) { effects.out(warn(`ours-install remove: ${error.message}`)); return EXIT_REFUSED; }
|
|
662
|
+
if (options.help) { effects.out(REMOVE_USAGE); return EXIT_OK; }
|
|
663
|
+
const journalPath = join(effects.home, JOURNAL);
|
|
664
|
+
let journal = null;
|
|
665
|
+
const journalText = effects.readText(journalPath);
|
|
666
|
+
if (journalText !== null) {
|
|
667
|
+
try {
|
|
668
|
+
const st = effects.stat(journalPath);
|
|
669
|
+
if (st?.type !== 'file' || st.uid !== effects.uid || (st.mode & 0o077)) throw new Error('The unfinished removal record is not a private file you own; it was left untouched');
|
|
670
|
+
journal = validateJournal(json(journalText), { home: effects.home });
|
|
671
|
+
} catch (error) { effects.out(warn(error.message)); return EXIT_REFUSED; }
|
|
672
|
+
}
|
|
673
|
+
effects.out(heading('Remove Ours from this computer'));
|
|
674
|
+
if (journal) effects.out(info('An earlier removal did not finish. Its list is used again, together with a fresh check of this computer.'));
|
|
675
|
+
effects.out(info('Checking what Ours installed here…'));
|
|
676
|
+
const found = await discoverFootprint(effects, { stateDirs: options.stateDirs, journal });
|
|
677
|
+
const plan = planRemoval(found, { home: effects.home });
|
|
678
|
+
// Only this command itself left: Ours is already removed; say so instead of asking.
|
|
679
|
+
const onlyInstaller = plan.steps.every(step => step.id === 'installer' || step.id === 'npm-scope') && !journal;
|
|
680
|
+
const residualsFound = plan.kept.filter(item => item.residual);
|
|
681
|
+
if ((!plan.steps.length || onlyInstaller) && residualsFound.length) {
|
|
682
|
+
effects.out(warn('Some Ours items are left and cannot be removed automatically:'));
|
|
683
|
+
for (const item of residualsFound) effects.out(warn(` - ${item.label} — ${item.reason}`));
|
|
684
|
+
effects.out(info(journal ? `Fix the reason shown, then run ours-install remove again. The list of what is left is kept in ${journalPath}.` : 'Fix the reason shown, then run ours-install remove again.'));
|
|
685
|
+
return EXIT_INCOMPLETE;
|
|
686
|
+
}
|
|
687
|
+
if (!plan.steps.length || onlyInstaller) {
|
|
688
|
+
for (const line of describePlan({ steps: [], kept: plan.kept })) effects.out(info(line));
|
|
689
|
+
effects.out(ok(journal ? 'Nothing from Ours is left to remove. The removal is complete.' : 'Nothing from Ours was found on this computer. Nothing was changed.'));
|
|
690
|
+
if (onlyInstaller && plan.steps.some(step => step.id === 'installer')) effects.out(info('Only the ours-install command itself is installed. To remove it too, run: npm uninstall --global @ours.network/install'));
|
|
691
|
+
if (journal && !options.dryRun) effects.removeFile(journalPath);
|
|
692
|
+
return EXIT_OK;
|
|
693
|
+
}
|
|
694
|
+
effects.out(info('This will remove:'));
|
|
695
|
+
for (const line of describePlan(plan)) effects.out(info(line));
|
|
696
|
+
if (plan.steps.some(step => step.group === 'data')) effects.out(warn('This is permanent. Your Ours identities and their private keys, messages, rooms, agents and Ours settings will be deleted. They are not stored anywhere else and nobody can give them back. If you may need them, stop here and make a backup first: ours-install server backup server <label> --state-dir <folder>.'));
|
|
697
|
+
effects.out(info('Your own Claude Code and Codex settings, skills and other plugins are kept. Docker, Node.js, npm, Claude Code and Codex themselves are not removed.'));
|
|
698
|
+
if (options.dryRun) { effects.out(info('Preview only: nothing was removed.')); return EXIT_OK; }
|
|
699
|
+
if (!effects.interactive || effects.env?.OURS_ASSUME_YES) {
|
|
700
|
+
effects.out(warn('Removal needs a terminal so a person can confirm it. Nothing was removed. Run ours-install remove in a terminal.'));
|
|
701
|
+
return EXIT_REFUSED;
|
|
702
|
+
}
|
|
703
|
+
const typed = await effects.askLine(`To permanently remove everything listed above, type: ${CONFIRMATION}\n`, '');
|
|
704
|
+
if (String(typed ?? '').trim().toLowerCase() !== CONFIRMATION) {
|
|
705
|
+
effects.out(info('Cancelled. Nothing was removed.'));
|
|
706
|
+
return EXIT_CANCELLED;
|
|
707
|
+
}
|
|
708
|
+
// Retry record before the first change: only identities, never paths to delete.
|
|
709
|
+
const record = {
|
|
710
|
+
schema: 1, startedAt: journal?.startedAt ?? new Date(effects.now?.() ?? Date.now()).toISOString(),
|
|
711
|
+
installations: found.installations.filter(item => item.project === projectForRoot(item.root)).map(({ record: _record, ownedImages: _owned, engine, images: retained, tombstone: _tombstone, ...item }) => {
|
|
712
|
+
const images = { ...(retained ?? {}), ...Object.assign({}, ...Object.values(engine ?? {}).map(e => e.images ?? {})) };
|
|
713
|
+
return { ...item, ...(Object.keys(images).length ? { images } : {}) };
|
|
714
|
+
}).concat((journal?.installations ?? []).filter(retained => !found.installations.some(item => item.root === retained.root))),
|
|
715
|
+
// Retained identities are never dropped when the record is rewritten.
|
|
716
|
+
clientInstanceIds: [...new Set([...(journal?.clientInstanceIds ?? []), ...(found.client?.instanceId ? [found.client.instanceId] : [])])],
|
|
717
|
+
generations: [...new Set([...(journal?.generations ?? []), ...plan.steps.filter(s => s.id.startsWith('generation:')).map(s => s.id.slice('generation:'.length))])],
|
|
718
|
+
fleet: Boolean(journal?.fleet) || plan.steps.some(s => s.group === 'fleet'), legacy: Boolean(journal?.legacy) || plan.steps.some(s => s.group === 'legacy'),
|
|
719
|
+
installer: Boolean(journal?.installer) || plan.steps.some(s => s.id === 'installer'),
|
|
720
|
+
tombstones: journal?.tombstones ?? [],
|
|
721
|
+
};
|
|
722
|
+
effects.writeJson(journalPath, JSON.stringify(record, null, 2) + '\n');
|
|
723
|
+
let release = () => {};
|
|
724
|
+
try { release = await effects.holdInstallationLocks(found.installations.map(i => i.root)); }
|
|
725
|
+
catch (error) { effects.out(warn(`${error.message}. Nothing was removed.`)); return EXIT_REFUSED; }
|
|
726
|
+
let results;
|
|
727
|
+
const saveJournal = () => effects.writeJson(journalPath, JSON.stringify(record, null, 2) + '\n');
|
|
728
|
+
try { results = await executeRemoval(plan, effects, { journal: record, saveJournal }); }
|
|
729
|
+
finally { release(); }
|
|
730
|
+
// Fresh check: only what is actually gone counts as removed.
|
|
731
|
+
const after = planRemoval(await discoverFootprint(effects, { stateDirs: options.stateDirs, journal: record }), { home: effects.home });
|
|
732
|
+
const remaining = after.steps.filter(step => !(step.id === 'installer' && results.get('installer')?.state === 'done'));
|
|
733
|
+
const failures = [...results.entries()].filter(([, r]) => r.state === 'failed');
|
|
734
|
+
const residuals = after.kept.filter(item => item.residual);
|
|
735
|
+
effects.out(heading('Removal summary'));
|
|
736
|
+
if (!remaining.length && !failures.length && !residuals.length) {
|
|
737
|
+
effects.removeFile(journalPath);
|
|
738
|
+
effects.out(ok('Ours has been removed from this computer.'));
|
|
739
|
+
for (const item of after.kept) effects.out(info(`Kept: ${item.label} — ${item.reason}`));
|
|
740
|
+
effects.out(info('Restart Claude Code and Codex so they stop offering the Ours tools in sessions that were already open.'));
|
|
741
|
+
return EXIT_OK;
|
|
742
|
+
}
|
|
743
|
+
effects.out(warn('Some Ours items could not be removed yet:'));
|
|
744
|
+
for (const step of remaining) effects.out(warn(` - ${step.label}${results.get(step.id)?.reason ? ` — ${results.get(step.id).reason}` : ''}`));
|
|
745
|
+
for (const [id, r] of failures) if (!remaining.some(step => step.id === id)) effects.out(warn(` - ${plan.steps.find(s => s.id === id)?.label ?? id} — ${r.reason}`));
|
|
746
|
+
for (const item of residuals) effects.out(warn(` - ${item.label} — ${item.reason}`));
|
|
747
|
+
effects.out(info(`Fix the reason shown, then run ours-install remove again; it continues from where it stopped. The list of what is left is kept in ${journalPath}.`));
|
|
748
|
+
return EXIT_INCOMPLETE;
|
|
749
|
+
}
|