mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
665 lines
21 KiB
TypeScript
665 lines
21 KiB
TypeScript
import {
|
|
parseClientConfiguration,
|
|
serializeClientConfiguration,
|
|
clientServerPath,
|
|
legacyClientServerPath,
|
|
withClientServers,
|
|
type ClientConfigurationDocument,
|
|
} from "./ClientConfigurationDocument.js";
|
|
import { copyFile, lstat, realpath, rm } from "node:fs/promises";
|
|
import { constants as fsConstants } from "node:fs";
|
|
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
|
import { join } from "node:path";
|
|
|
|
import writeFileAtomic from "write-file-atomic";
|
|
import { z } from "zod";
|
|
|
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
|
import {
|
|
isManagedSkillManifest,
|
|
MANAGED_SKILL_FILES,
|
|
readSkillFile,
|
|
skillDestinations,
|
|
} from "./SetupSkill.js";
|
|
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
|
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
|
import { readRegularFileText } from "./RegularFileRead.js";
|
|
import {
|
|
manualRegistrationRemediation,
|
|
supportedClients,
|
|
type SetupClient,
|
|
} from "./SupportedClients.js";
|
|
export { isUninstallFailure } from "./UninstallFailure.js";
|
|
|
|
interface ManagedPathStats {
|
|
readonly uid?: number;
|
|
isFile?(): boolean;
|
|
isDirectory?(): boolean;
|
|
isSymbolicLink(): boolean;
|
|
}
|
|
|
|
/** Injectable filesystem operations used to prove uninstall failure recovery. */
|
|
export interface UninstallFileSystem {
|
|
readText(path: string): Promise<string>;
|
|
readRegularText?(path: string): Promise<string>;
|
|
/** Preserve the first backup; reject an existing destination with EEXIST. */
|
|
copy(source: string, destination: string): Promise<void>;
|
|
writeText(path: string, contents: string): Promise<void>;
|
|
stat(path: string): Promise<ManagedPathStats>;
|
|
realpath?(path: string): Promise<string>;
|
|
remove(path: string, recursive?: boolean): Promise<void>;
|
|
}
|
|
|
|
const systemFileSystem: UninstallFileSystem = {
|
|
readText: (path) => readRegularFileText(path),
|
|
readRegularText: readSkillFile,
|
|
copy: (source, destination) =>
|
|
copyFile(source, destination, fsConstants.COPYFILE_EXCL),
|
|
writeText: (path, contents) =>
|
|
writeFileAtomic(path, contents, { encoding: "utf8" }),
|
|
stat: (path) => lstat(path),
|
|
realpath: (path) => realpath(path),
|
|
remove: (path, recursive = true) => rm(path, { recursive }),
|
|
};
|
|
|
|
/** One explicitly classified uninstall action. */
|
|
export interface UninstallItem {
|
|
readonly name: string;
|
|
readonly status: "removed" | "retained" | "skipped" | "failed";
|
|
readonly detail: string;
|
|
}
|
|
|
|
/** Structured, idempotent result of removing REA-owned state. */
|
|
export interface UninstallResult {
|
|
readonly status: "complete" | "failed";
|
|
readonly items: readonly UninstallItem[];
|
|
}
|
|
|
|
/** Filesystem boundary used by the contained uninstall workflow. */
|
|
export interface UninstallHost {
|
|
clients(): Promise<readonly SetupClient[]>;
|
|
/** Report a configuration that cannot be read safely, before any removal. */
|
|
inspectClient(client: SetupClient): Promise<UninstallItem | undefined>;
|
|
removeClient(client: SetupClient): Promise<UninstallItem>;
|
|
removeSkill(): Promise<UninstallItem>;
|
|
purgeData(): Promise<readonly UninstallItem[]>;
|
|
}
|
|
|
|
/**
|
|
* Remove only REA-owned registrations and managed files, optionally including
|
|
* local state. A client configuration that cannot be read safely stops the
|
|
* operation before anything is removed; one that fails during removal stops
|
|
* the remaining removals.
|
|
*/
|
|
export const runUninstall = async (
|
|
purgeData: boolean,
|
|
host: UninstallHost = systemUninstallHost(),
|
|
): Promise<UninstallResult> => {
|
|
const clients = await host.clients();
|
|
const blocked: UninstallItem[] = [];
|
|
for (const client of clients) {
|
|
const blocking = await host.inspectClient(client);
|
|
if (blocking !== undefined) blocked.push(blocking);
|
|
}
|
|
const items: UninstallItem[] =
|
|
blocked.length > 0
|
|
? [
|
|
...blocked,
|
|
item(
|
|
"uninstall",
|
|
"skipped",
|
|
"No REA registration, skill, or data was removed. Repair each failed configuration, then rerun uninstall.",
|
|
),
|
|
]
|
|
: await removeAll(clients, purgeData, host);
|
|
items.push({
|
|
name: "analysis_engine",
|
|
status: "retained",
|
|
detail: "Hopper is not owned by REA uninstall.",
|
|
});
|
|
const reported = collapseUninstallItems(items);
|
|
return {
|
|
status: reported.some(({ status }) => status === "failed")
|
|
? "failed"
|
|
: "complete",
|
|
items: reported,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Each client is reread when it is edited, so a concurrent change is never
|
|
* overwritten. A client that fails then still stops the remaining removals.
|
|
*/
|
|
const removeAll = async (
|
|
clients: readonly SetupClient[],
|
|
purgeData: boolean,
|
|
host: UninstallHost,
|
|
): Promise<UninstallItem[]> => {
|
|
const items: UninstallItem[] = [];
|
|
for (const client of clients) {
|
|
const removed = await host.removeClient(client);
|
|
items.push(removed);
|
|
if (removed.status === "failed")
|
|
return [
|
|
...items,
|
|
item(
|
|
"uninstall",
|
|
"skipped",
|
|
"Uninstall stopped at this failure; the items above report what changed. Repair the failed configuration, then rerun uninstall.",
|
|
),
|
|
];
|
|
}
|
|
items.push(await host.removeSkill());
|
|
if (purgeData) items.push(...(await host.purgeData()));
|
|
return items;
|
|
};
|
|
|
|
/** Create uninstall effects contained to detected client configs and REA-owned paths. */
|
|
export const systemUninstallHost = (
|
|
selectedHome: string | undefined = undefined,
|
|
fileSystem: UninstallFileSystem = systemFileSystem,
|
|
environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
|
platform: NodeJS.Platform = process.platform,
|
|
): UninstallHost => {
|
|
const home =
|
|
selectedHome ?? homeDirectoryFromEnvironment(environment, platform);
|
|
return {
|
|
clients: () =>
|
|
Promise.resolve(
|
|
supportedClients(home, platform, environment).flatMap((client) =>
|
|
client.configPaths === undefined
|
|
? [client]
|
|
: client.configPaths.map((configPath) => ({
|
|
...client,
|
|
configPath,
|
|
})),
|
|
),
|
|
),
|
|
inspectClient: async (client) => {
|
|
const read = await readClientConfiguration(client, fileSystem);
|
|
return read.kind === "item" && read.item.status === "failed"
|
|
? read.item
|
|
: undefined;
|
|
},
|
|
removeClient: (client) => removeClient(client, fileSystem),
|
|
removeSkill: () =>
|
|
removeManagedSkills(home, fileSystem, environment, platform),
|
|
purgeData: async () => [
|
|
await removeManagedPath(join(home, ".rea/cache"), "cache", fileSystem),
|
|
await removeManagedPath(join(home, ".rea/state"), "state", fileSystem),
|
|
],
|
|
};
|
|
};
|
|
|
|
type ClientConfigurationRead =
|
|
| { readonly kind: "item"; readonly item: UninstallItem }
|
|
| {
|
|
readonly kind: "configuration";
|
|
readonly transactionPath: string;
|
|
readonly original: string;
|
|
readonly parsed: ClientConfigurationDocument;
|
|
};
|
|
|
|
const readClientConfiguration = async (
|
|
client: SetupClient,
|
|
fileSystem: UninstallFileSystem,
|
|
): Promise<ClientConfigurationRead> => {
|
|
if (client.configPathError !== undefined)
|
|
return itemRead(item(client.name, "failed", client.configPathError));
|
|
if (client.format === "unsupported")
|
|
return itemRead(
|
|
item(
|
|
client.name,
|
|
"skipped",
|
|
manualRegistrationRemediation(client.name) ??
|
|
"This client has no documented local MCP configuration boundary.",
|
|
),
|
|
);
|
|
const resolved = await resolveUninstallConfigPath(client, fileSystem);
|
|
if (typeof resolved !== "string") return itemRead(resolved);
|
|
const transactionPath = resolved;
|
|
let original: string;
|
|
try {
|
|
original = await fileSystem.readText(transactionPath);
|
|
} catch (cause: unknown) {
|
|
return itemRead(
|
|
isMissing(cause)
|
|
? item(client.name, "skipped", "Configuration does not exist.")
|
|
: item(
|
|
client.name,
|
|
"failed",
|
|
"Configuration could not be read. Check file permissions, then rerun uninstall.",
|
|
),
|
|
);
|
|
}
|
|
try {
|
|
const parsed = parseClientConfiguration(original, client.format);
|
|
return { kind: "configuration", transactionPath, original, parsed };
|
|
} catch (cause: unknown) {
|
|
void cause;
|
|
return itemRead(
|
|
item(
|
|
client.name,
|
|
"failed",
|
|
`Configuration is not valid ${client.format === "toml" || client.format === "grok" ? "TOML" : client.format === "opencode" ? "JSONC" : "JSON"} and was not changed. Repair it, then rerun uninstall.`,
|
|
),
|
|
);
|
|
}
|
|
};
|
|
|
|
const itemRead = (result: UninstallItem): ClientConfigurationRead => ({
|
|
kind: "item",
|
|
item: result,
|
|
});
|
|
|
|
const removeClient = async (
|
|
client: SetupClient,
|
|
fileSystem: UninstallFileSystem,
|
|
): Promise<UninstallItem> => {
|
|
const read = await readClientConfiguration(client, fileSystem);
|
|
if (read.kind === "item") return read.item;
|
|
const { transactionPath, original, parsed } = read;
|
|
const name = PRODUCT_IDENTITY.mcpServerKey;
|
|
// Beside a native OpenCode V2 table, OpenCode also loads a V1 `mcp.rea`.
|
|
const legacyPath = legacyClientServerPath(parsed, name);
|
|
const hasNative = Object.hasOwn(parsed.servers, name);
|
|
if (!hasNative && legacyPath === undefined)
|
|
return item(client.name, "skipped", "REA registration is absent.");
|
|
const removeNative =
|
|
hasNative && isOwnedRegistration(parsed.servers[name], client);
|
|
const removeLegacy =
|
|
legacyPath !== undefined &&
|
|
isOwnedRegistration(parsed.legacyServers[name], client);
|
|
if (!removeNative && !removeLegacy)
|
|
return item(
|
|
client.name,
|
|
"retained",
|
|
"The rea-named registration is not REA-owned.",
|
|
);
|
|
const remaining = { ...parsed.servers };
|
|
if (removeNative) delete remaining[name];
|
|
const document = withClientServers(
|
|
parsed,
|
|
remaining,
|
|
removeLegacy ? name : undefined,
|
|
);
|
|
const editedPaths = [
|
|
...(removeNative ? [clientServerPath(parsed, name)] : []),
|
|
...(removeLegacy ? [legacyPath] : []),
|
|
];
|
|
const backupPath = `${client.configPath}.rea.backup`;
|
|
try {
|
|
await fileSystem.copy(transactionPath, backupPath);
|
|
} catch (cause: unknown) {
|
|
if (!(cause instanceof Error && "code" in cause && cause.code === "EEXIST"))
|
|
return item(
|
|
client.name,
|
|
"failed",
|
|
"Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall.",
|
|
);
|
|
}
|
|
try {
|
|
await fileSystem.writeText(
|
|
transactionPath,
|
|
serializeClientConfiguration(
|
|
document,
|
|
client.format,
|
|
original,
|
|
editedPaths,
|
|
),
|
|
);
|
|
const readback = parseClientConfiguration(
|
|
await fileSystem.readText(transactionPath),
|
|
client.format,
|
|
);
|
|
if (
|
|
(removeNative && Object.hasOwn(readback.servers, name)) ||
|
|
(removeLegacy && Object.hasOwn(readback.legacyServers, name))
|
|
)
|
|
throw new Error("registration readback mismatch");
|
|
return item(
|
|
client.name,
|
|
"removed",
|
|
`Removed registration from ${client.configPath}.`,
|
|
);
|
|
} catch (cause: unknown) {
|
|
// Update failure attempts a restore before reporting; cause is preserved below.
|
|
void cause;
|
|
try {
|
|
await fileSystem.writeText(transactionPath, original);
|
|
} catch (restoreCause: unknown) {
|
|
void restoreCause;
|
|
return item(
|
|
client.name,
|
|
"failed",
|
|
"Configuration could not be updated or restored. Restore its `.rea.backup` manually, then rerun uninstall.",
|
|
);
|
|
}
|
|
return item(
|
|
client.name,
|
|
"failed",
|
|
"Configuration could not be updated. The original was restored and its `.rea.backup` was retained. Repair the configuration or restore the backup, then rerun uninstall.",
|
|
);
|
|
}
|
|
};
|
|
|
|
const resolveUninstallConfigPath = async (
|
|
client: SetupClient,
|
|
fileSystem: UninstallFileSystem,
|
|
): Promise<string | UninstallItem> => {
|
|
const path = await resolveClientConfigTransactionPath(
|
|
client.configPath,
|
|
fileSystem.realpath === undefined
|
|
? { lstat: fileSystem.stat }
|
|
: { lstat: fileSystem.stat, realpath: fileSystem.realpath },
|
|
);
|
|
return (
|
|
path ??
|
|
item(
|
|
client.name,
|
|
"failed",
|
|
"Configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun uninstall.",
|
|
)
|
|
);
|
|
};
|
|
|
|
/** Ownership rule for persistent MCP registrations created by REA setup. */
|
|
const isOwnedRegistration = (value: unknown, client: SetupClient): boolean => {
|
|
if (client.format === "opencode") {
|
|
const parsed = z
|
|
.object({ type: z.literal("local"), command: z.array(z.string()).min(1) })
|
|
.passthrough()
|
|
.safeParse(value);
|
|
if (!parsed.success) return false;
|
|
const command = parsed.data.command;
|
|
return isOwnedClientRegistrationCommand(command);
|
|
}
|
|
const parsed = registrationSchema.safeParse(value);
|
|
if (!parsed.success) return false;
|
|
const { command, args } = parsed.data;
|
|
return isOwnedClientRegistrationCommand([command, ...args]);
|
|
};
|
|
|
|
const removeManagedPath = async (
|
|
path: string,
|
|
name: string,
|
|
fileSystem: UninstallFileSystem,
|
|
): Promise<UninstallItem> => {
|
|
try {
|
|
const stats = await fileSystem.stat(path);
|
|
if (stats.isSymbolicLink())
|
|
return item(
|
|
name,
|
|
"retained",
|
|
"REA did not remove this item because its managed path is a symbolic link. Verify the link target before removing it manually.",
|
|
);
|
|
await fileSystem.remove(path);
|
|
return item(name, "removed", `Removed ${path}.`);
|
|
} catch (cause: unknown) {
|
|
return isMissing(cause)
|
|
? item(name, "skipped", `${path} does not exist.`)
|
|
: item(
|
|
name,
|
|
"failed",
|
|
"This item could not be removed. Check file permissions, then rerun uninstall.",
|
|
);
|
|
}
|
|
};
|
|
|
|
const removeManagedSkills = async (
|
|
home: string,
|
|
fileSystem: UninstallFileSystem,
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
platform: NodeJS.Platform,
|
|
): Promise<UninstallItem> => {
|
|
const results = await Promise.all(
|
|
skillDestinations(home, undefined, environment, platform).map(
|
|
({ client, path }) =>
|
|
removeManagedSkill(
|
|
path,
|
|
client === "claude_code" ? "Claude Code skill" : "skill",
|
|
fileSystem,
|
|
),
|
|
),
|
|
);
|
|
const failed = results.find(({ status }) => status === "failed");
|
|
if (failed !== undefined)
|
|
return item(
|
|
"skill",
|
|
"failed",
|
|
results
|
|
.map(({ name, status, detail }) => `${name}: ${status} (${detail})`)
|
|
.join(" "),
|
|
);
|
|
const status = results.some(
|
|
({ status: resultStatus }) => resultStatus === "removed",
|
|
)
|
|
? "removed"
|
|
: results.some(({ status: resultStatus }) => resultStatus === "retained")
|
|
? "retained"
|
|
: "skipped";
|
|
return item(
|
|
"skill",
|
|
status,
|
|
results
|
|
.map(
|
|
({ name, status: resultStatus, detail }) =>
|
|
`${name}: ${resultStatus} (${detail})`,
|
|
)
|
|
.join(" "),
|
|
);
|
|
};
|
|
|
|
const removeManagedSkill = async (
|
|
path: string,
|
|
name: string,
|
|
fileSystem: UninstallFileSystem,
|
|
): Promise<UninstallItem> => {
|
|
let root: ManagedPathStats;
|
|
try {
|
|
root = await fileSystem.stat(path);
|
|
} catch (cause: unknown) {
|
|
return isMissing(cause)
|
|
? item(name, "skipped", "Skill directory does not exist.")
|
|
: item(name, "failed", "Skill directory could not be inspected.");
|
|
}
|
|
if (root.isSymbolicLink())
|
|
return item(name, "retained", "Skill directory is a symbolic link.");
|
|
if (root.isDirectory?.() !== true)
|
|
return item(name, "retained", "Skill path is not a directory.");
|
|
|
|
const manifestPath = join(path, "SKILL.md");
|
|
try {
|
|
const manifest = await fileSystem.stat(manifestPath);
|
|
if (manifest.isSymbolicLink())
|
|
return item(name, "retained", "Skill manifest is a symbolic link.");
|
|
if (manifest.isFile?.() !== true)
|
|
return item(name, "retained", "Skill manifest is not a regular file.");
|
|
} catch (cause: unknown) {
|
|
return isMissing(cause)
|
|
? item(name, "retained", "Skill ownership could not be established.")
|
|
: item(name, "failed", "Skill manifest could not be inspected.");
|
|
}
|
|
let content: string;
|
|
try {
|
|
content = await (fileSystem.readRegularText?.(manifestPath) ??
|
|
fileSystem.readText(manifestPath));
|
|
} catch (cause: unknown) {
|
|
return isMissing(cause)
|
|
? item(name, "retained", "Skill ownership could not be established.")
|
|
: item(name, "failed", "Skill manifest could not be read.");
|
|
}
|
|
if (!isManagedSkillManifest(content))
|
|
return item(name, "retained", "Skill manifest is not REA-owned.");
|
|
|
|
const manifestName = "SKILL.md";
|
|
const otherFiles = MANAGED_SKILL_FILES.filter(
|
|
(relativePath) => relativePath !== manifestName,
|
|
);
|
|
const results: UninstallItem[] = [];
|
|
for (const relativePath of otherFiles)
|
|
results.push(
|
|
await removeManagedSkillFile(path, relativePath, name, fileSystem),
|
|
);
|
|
if (
|
|
results.some(({ status }) => status === "failed" || status === "retained")
|
|
)
|
|
results.push(
|
|
item(
|
|
name,
|
|
"retained",
|
|
"REA ownership manifest was retained because a managed file could not be safely removed.",
|
|
),
|
|
);
|
|
else
|
|
results.push(
|
|
await removeManagedSkillFile(path, manifestName, name, fileSystem),
|
|
);
|
|
const failed = results.find(({ status }) => status === "failed");
|
|
if (failed !== undefined)
|
|
return item(name, "failed", results.map(({ detail }) => detail).join(" "));
|
|
const removed = results.some(({ status }) => status === "removed");
|
|
return item(
|
|
name,
|
|
removed ? "removed" : "skipped",
|
|
"REA-managed skill files were removed; other files were preserved.",
|
|
);
|
|
};
|
|
|
|
const removeManagedSkillFile = async (
|
|
root: string,
|
|
relativePath: string,
|
|
name: string,
|
|
fileSystem: UninstallFileSystem,
|
|
): Promise<UninstallItem> => {
|
|
const segments = relativePath.split(/[\\/]/u);
|
|
let parent = root;
|
|
for (const segment of segments.slice(0, -1)) {
|
|
parent = join(parent, segment);
|
|
try {
|
|
const stats = await fileSystem.stat(parent);
|
|
if (stats.isSymbolicLink() || stats.isDirectory?.() !== true)
|
|
return item(
|
|
name,
|
|
"retained",
|
|
`Managed path parent was retained: ${parent}.`,
|
|
);
|
|
} catch (cause: unknown) {
|
|
return isMissing(cause)
|
|
? item(
|
|
name,
|
|
"skipped",
|
|
`${join(parent, segments.at(-1) ?? "")} does not exist.`,
|
|
)
|
|
: item(
|
|
name,
|
|
"failed",
|
|
`Managed path parent could not be inspected: ${parent}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const target = join(root, relativePath);
|
|
let stats: ManagedPathStats;
|
|
try {
|
|
stats = await fileSystem.stat(target);
|
|
} catch (cause: unknown) {
|
|
return isMissing(cause)
|
|
? item(name, "skipped", `${target} does not exist.`)
|
|
: item(name, "failed", `Managed file could not be inspected: ${target}.`);
|
|
}
|
|
if (stats.isSymbolicLink())
|
|
return item(
|
|
name,
|
|
"retained",
|
|
`Managed file is a symbolic link: ${target}.`,
|
|
);
|
|
if (stats.isFile?.() !== true)
|
|
return item(
|
|
name,
|
|
"retained",
|
|
`Managed path is not a regular file: ${target}.`,
|
|
);
|
|
try {
|
|
await fileSystem.remove(target, false);
|
|
return item(name, "removed", `Removed ${target}.`);
|
|
} catch (cause: unknown) {
|
|
return isMissing(cause)
|
|
? item(name, "skipped", `${target} does not exist.`)
|
|
: item(name, "failed", `Managed file could not be removed: ${target}.`);
|
|
}
|
|
};
|
|
|
|
const item = (
|
|
name: string,
|
|
status: UninstallItem["status"],
|
|
detail: string,
|
|
): UninstallItem => ({ name, status, detail });
|
|
|
|
/**
|
|
* Layered clients are removed file by file, but callers look up one status by
|
|
* client name. A missing earlier file must not hide a later removal.
|
|
*/
|
|
const collapseUninstallItems = (
|
|
items: readonly UninstallItem[],
|
|
): UninstallItem[] => {
|
|
const collapsed: UninstallItem[] = [];
|
|
for (const next of items) {
|
|
const index = collapsed.findIndex(({ name }) => name === next.name);
|
|
const current = collapsed[index];
|
|
if (current === undefined) {
|
|
collapsed.push(next);
|
|
continue;
|
|
}
|
|
collapsed[index] = mergeUninstallItems(current, next);
|
|
}
|
|
return collapsed;
|
|
};
|
|
|
|
const mergeUninstallItems = (
|
|
current: UninstallItem,
|
|
next: UninstallItem,
|
|
): UninstallItem => {
|
|
const status = preferredUninstallStatus(current.status, next.status);
|
|
const details = [current, next]
|
|
.filter(
|
|
(entry) =>
|
|
entry.status === status ||
|
|
(status === "removed" && entry.status === "retained"),
|
|
)
|
|
.map(({ detail }) => detail);
|
|
return item(current.name, status, [...new Set(details)].join(" "));
|
|
};
|
|
|
|
const preferredUninstallStatus = (
|
|
current: UninstallItem["status"],
|
|
next: UninstallItem["status"],
|
|
): UninstallItem["status"] =>
|
|
uninstallStatusPriority(next) > uninstallStatusPriority(current)
|
|
? next
|
|
: current;
|
|
|
|
const uninstallStatusPriority = (status: UninstallItem["status"]): number => {
|
|
switch (status) {
|
|
case "failed":
|
|
return 3;
|
|
case "removed":
|
|
return 2;
|
|
case "retained":
|
|
return 1;
|
|
case "skipped":
|
|
return 0;
|
|
default: {
|
|
const exhaustive: never = status;
|
|
throw new TypeError(
|
|
`Unhandled uninstall item status: ${String(exhaustive)}`,
|
|
);
|
|
}
|
|
}
|
|
};
|
|
const registrationSchema = z
|
|
.object({ command: z.string(), args: z.array(z.string()) })
|
|
.passthrough();
|
|
const isMissing = (cause: unknown): boolean =>
|
|
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|