mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
d8748e3a45
- .env backup and removal are now one atomic action: a failed backup throws into executePlan's catch and the deletion never runs (the bash original's set -e gave the same guarantee; the port had lost it) - containers are re-listed by install label at removal time instead of removed from scan-time ids — the live host can spawn containers during the confirm phase - uninstall telemetry no longer creates data/install-id (persistId:false on emit), so --dry-run truly changes nothing and the already-clean exit can fire - runtime-tail failure notes are printed before the Done line instead of being discarded - uninstall.sh translates the old short flags (-n/-y) instead of silently dropping them (-n used to fall through to a real interactive uninstall) - nanoclaw.sh gates the TS uninstaller on node (tsx's interpreter), not pnpm, which the direct-exec path never uses - detectExistingInstall also checks the system-level systemd unit - a delete-onecli-agent spawn failure now notes the manual command instead of claiming the agent was already gone - setupLog.userInput is skipped when logs/ is absent so the uninstall doesn't recreate it Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
194 lines
6.2 KiB
TypeScript
194 lines
6.2 KiB
TypeScript
/**
|
|
* Removal-plan executor. Each action runs in its own try/catch: a failure
|
|
* becomes a summary note and execution continues (re-running the
|
|
* uninstaller is idempotent — the next scan only finds what's left).
|
|
*
|
|
* Must stay safe to run after logs/ and node_modules/ are gone: only static
|
|
* imports, no dynamic import(), no setup-log writes. Output goes through
|
|
* the injected `log` callback.
|
|
*/
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import type { RunCommand } from './onecli-agents.js';
|
|
import type { RemovalAction } from './plan.js';
|
|
|
|
export interface ExecDeps {
|
|
runCommand: RunCommand;
|
|
log: (line: string) => void;
|
|
/** True when running as root — required to remove a system-level unit. */
|
|
isRoot: boolean;
|
|
}
|
|
|
|
export function executePlan(
|
|
actions: RemovalAction[],
|
|
deps: ExecDeps,
|
|
): { notes: string[] } {
|
|
const notes: string[] = [];
|
|
for (const action of actions) {
|
|
try {
|
|
runAction(action, deps, notes);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
notes.push(
|
|
`${action.kind}: failed (${msg}) — re-run the uninstaller to retry.`,
|
|
);
|
|
}
|
|
}
|
|
return { notes };
|
|
}
|
|
|
|
/**
|
|
* Copy .env aside before deletion. Never clobbers an existing backup —
|
|
* falls back to a timestamped name on collision. Returns the backup path.
|
|
*/
|
|
export function backupEnv(envPath: string): string {
|
|
const dir = path.dirname(envPath);
|
|
let backup = path.join(dir, '.env.bak');
|
|
if (fs.existsSync(backup)) {
|
|
const stamp = new Date()
|
|
.toISOString()
|
|
.replace(/[-:]/g, '')
|
|
.replace('T', '-')
|
|
.slice(0, 15);
|
|
backup = path.join(dir, `.env.bak.${stamp}`);
|
|
}
|
|
fs.copyFileSync(envPath, backup);
|
|
return backup;
|
|
}
|
|
|
|
function runAction(action: RemovalAction, deps: ExecDeps, notes: string[]): void {
|
|
const { runCommand, log } = deps;
|
|
switch (action.kind) {
|
|
case 'unload-service':
|
|
switch (action.flavor) {
|
|
case 'launchd':
|
|
runCommand('launchctl', ['unload', action.unitPath]);
|
|
fs.rmSync(action.unitPath, { force: true });
|
|
log('✓ background service removed');
|
|
break;
|
|
case 'systemd-user':
|
|
runCommand('systemctl', [
|
|
'--user',
|
|
'disable',
|
|
'--now',
|
|
`${action.unitName}.service`,
|
|
]);
|
|
fs.rmSync(action.unitPath, { force: true });
|
|
runCommand('systemctl', ['--user', 'daemon-reload']);
|
|
log('✓ background service removed');
|
|
break;
|
|
case 'systemd-system':
|
|
if (!deps.isRoot) {
|
|
log('! system service needs root — left in place');
|
|
notes.push(
|
|
`System service ${action.unitPath} — re-run with sudo to remove.`,
|
|
);
|
|
break;
|
|
}
|
|
runCommand('systemctl', ['disable', '--now', `${action.unitName}.service`]);
|
|
fs.rmSync(action.unitPath, { force: true });
|
|
runCommand('systemctl', ['daemon-reload']);
|
|
log('✓ system service removed');
|
|
break;
|
|
}
|
|
break;
|
|
case 'kill-pid': {
|
|
let pid = NaN;
|
|
try {
|
|
pid = Number(fs.readFileSync(action.pidFile, 'utf-8').trim());
|
|
} catch {
|
|
// pidfile already gone
|
|
}
|
|
if (Number.isInteger(pid) && pid > 0) {
|
|
try {
|
|
process.kill(pid);
|
|
log('✓ stopped host process');
|
|
} catch {
|
|
// not running
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case 'pkill-host':
|
|
// Exit 1 = no matching process — not a failure.
|
|
runCommand('pkill', ['-f', action.pattern]);
|
|
break;
|
|
case 'rm-containers': {
|
|
// Re-list at removal time: the host was alive during the confirm
|
|
// phase and may have spawned containers the scan never saw.
|
|
const ps = runCommand(action.runtime, [
|
|
'ps',
|
|
'-aq',
|
|
'--filter',
|
|
`label=${action.labelFilter}`,
|
|
]);
|
|
if (ps.status !== 0) {
|
|
notes.push(
|
|
`Containers: '${action.runtime}' unavailable — remove later with: ` +
|
|
`${action.runtime} ps -aq --filter label=${action.labelFilter} | xargs -r ${action.runtime} rm -f`,
|
|
);
|
|
break;
|
|
}
|
|
const ids = ps.stdout
|
|
.split('\n')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
if (ids.length === 0) break;
|
|
runCommand(action.runtime, ['rm', '-f', ...ids]);
|
|
log(`✓ removed ${ids.length} container(s)`);
|
|
break;
|
|
}
|
|
case 'rmi': {
|
|
const res = runCommand(action.runtime, ['rmi', action.image]);
|
|
if (res.status === 0) {
|
|
log('✓ removed container image');
|
|
} else {
|
|
log('! could not remove image (in use?)');
|
|
notes.push(
|
|
`Image ${action.image}: not removed — retry with: ${action.runtime} rmi ${action.image}`,
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case 'rm-ncl-symlink':
|
|
fs.rmSync(action.linkPath, { force: true });
|
|
log('✓ removed ncl command');
|
|
break;
|
|
case 'delete-onecli-agent': {
|
|
const res = runCommand('onecli', [
|
|
'agents',
|
|
'delete',
|
|
'--id',
|
|
action.agent.uuid,
|
|
]);
|
|
if (res.status === 0) {
|
|
log(`✓ deleted OneCLI agent ${action.agent.name} (${action.agent.identifier})`);
|
|
} else if (res.status === null) {
|
|
// spawn failure (binary gone since the scan), not a missing agent
|
|
log(`! couldn't run onecli for ${action.agent.identifier}`);
|
|
notes.push(
|
|
`OneCLI agent ${action.agent.name} (${action.agent.identifier}): couldn't run onecli — ` +
|
|
`delete manually with: onecli agents delete --id ${action.agent.uuid}`,
|
|
);
|
|
} else {
|
|
log(`! OneCLI agent ${action.agent.identifier} already gone`);
|
|
}
|
|
break;
|
|
}
|
|
case 'backup-env': {
|
|
// Backup and removal are one action so a failed backup (which throws
|
|
// into executePlan's catch) can never be followed by the deletion.
|
|
const backup = backupEnv(action.envPath);
|
|
fs.rmSync(action.envPath, { force: true });
|
|
log(`✓ removed .env (backup at ${backup})`);
|
|
break;
|
|
}
|
|
case 'delete-path':
|
|
case 'delete-runtime-path':
|
|
fs.rmSync(action.item.path, { recursive: true, force: true });
|
|
log(`✓ removed ${action.item.what}`);
|
|
break;
|
|
}
|
|
}
|