362 lines
13 KiB
TypeScript
362 lines
13 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import {
|
|
existsSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, join, relative, resolve, sep } from "node:path";
|
|
import {
|
|
activeExecutablePath,
|
|
commandPath,
|
|
installRoot,
|
|
machineTransactionRoot,
|
|
windowsUninstallFencePath,
|
|
} from "./aidlc-install-paths.ts";
|
|
import {
|
|
executePlan,
|
|
writeOperation,
|
|
} from "./aidlc-transaction.ts";
|
|
|
|
export type WindowsUninstallJournal = {
|
|
schemaVersion: 1;
|
|
operation: "windows-uninstall-continuation";
|
|
status: "pending" | "recovering";
|
|
parentPid: number;
|
|
shimPid: number | null;
|
|
installRoot: string;
|
|
commandPath: string;
|
|
pointerPath: string;
|
|
cleanupPath: string;
|
|
fencePath: string;
|
|
purge: boolean;
|
|
preserved: string[];
|
|
};
|
|
|
|
function quoted(value: string): string {
|
|
return value.replaceAll("'", "''");
|
|
}
|
|
|
|
function cleanupScript(journal: WindowsUninstallJournal): string {
|
|
return [
|
|
"param([string]$JournalPath)",
|
|
"$ErrorActionPreference = 'Stop'",
|
|
"$journal = Get-Content -Raw -LiteralPath $JournalPath | ConvertFrom-Json",
|
|
"if ($journal.schemaVersion -ne 1 -or $journal.operation -ne 'windows-uninstall-continuation' -or $journal.status -notin @('pending', 'recovering')) { exit 4 }",
|
|
`$expectedRoot = [IO.Path]::GetFullPath('${quoted(journal.installRoot)}')`,
|
|
`$expectedCommand = [IO.Path]::GetFullPath('${quoted(journal.commandPath)}')`,
|
|
`$expectedPointer = [IO.Path]::GetFullPath('${quoted(journal.pointerPath)}')`,
|
|
`$expectedCleanup = [IO.Path]::GetFullPath('${quoted(journal.cleanupPath)}')`,
|
|
`$expectedFence = [IO.Path]::GetFullPath('${quoted(journal.fencePath)}')`,
|
|
"$root = [IO.Path]::GetFullPath([string]$journal.installRoot)",
|
|
"$command = [IO.Path]::GetFullPath([string]$journal.commandPath)",
|
|
"$pointer = [IO.Path]::GetFullPath([string]$journal.pointerPath)",
|
|
"$cleanup = [IO.Path]::GetFullPath([string]$journal.cleanupPath)",
|
|
"$fence = [IO.Path]::GetFullPath([string]$journal.fencePath)",
|
|
"if ($root -ne $expectedRoot -or $command -ne $expectedCommand -or $pointer -ne $expectedPointer -or $cleanup -ne $expectedCleanup -or $fence -ne $expectedFence -or $cleanup -ne [IO.Path]::GetFullPath($PSCommandPath)) { exit 4 }",
|
|
"if (-not (Test-Path -LiteralPath $fence -PathType Leaf)) { exit 4 }",
|
|
"$fenceRecord = Get-Content -Raw -LiteralPath $fence | ConvertFrom-Json",
|
|
"if ($fenceRecord.schemaVersion -ne 1 -or $fenceRecord.operation -ne 'windows-uninstall-continuation' -or [IO.Path]::GetFullPath([string]$fenceRecord.journalPath) -ne [IO.Path]::GetFullPath($JournalPath)) { exit 4 }",
|
|
"function Wait-ForExit([int]$TargetPid) {",
|
|
" if ($TargetPid -le 0) { return }",
|
|
" for ($i = 0; $i -lt 600; $i++) {",
|
|
" if (-not (Get-Process -Id $TargetPid -ErrorAction SilentlyContinue)) { return }",
|
|
" Start-Sleep -Milliseconds 100",
|
|
" }",
|
|
" throw \"process $TargetPid did not exit before uninstall cleanup timed out\"",
|
|
"}",
|
|
"Wait-ForExit ([int]$journal.parentPid)",
|
|
"if ($null -ne $journal.shimPid) { Wait-ForExit ([int]$journal.shimPid) }",
|
|
"Start-Sleep -Milliseconds 100",
|
|
"$prefix = $root.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar",
|
|
"$keep = @($journal.preserved | ForEach-Object { [IO.Path]::GetFullPath([string]$_) })",
|
|
"if ($keep | Where-Object { -not $_.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) }) { exit 4 }",
|
|
"$saved = @{}",
|
|
"foreach ($path in $keep) {",
|
|
" if (Test-Path -LiteralPath $path -PathType Leaf) { $saved[$path] = [IO.File]::ReadAllBytes($path) }",
|
|
"}",
|
|
"if (Test-Path -LiteralPath $command) { Remove-Item -LiteralPath $command -Force -ErrorAction Stop }",
|
|
"if (Test-Path -LiteralPath $pointer) { Remove-Item -LiteralPath $pointer -Force -ErrorAction Stop }",
|
|
"$fenceInsideRoot = $fence.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)",
|
|
"if (Test-Path -LiteralPath $root) {",
|
|
" if ($fenceInsideRoot) {",
|
|
" Get-ChildItem -Force -LiteralPath $root | Where-Object { [IO.Path]::GetFullPath($_.FullName) -ne $fence } | Remove-Item -Recurse -Force -ErrorAction Stop",
|
|
" } else {",
|
|
" Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction Stop",
|
|
" }",
|
|
"}",
|
|
"foreach ($entry in $saved.GetEnumerator()) {",
|
|
" [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($entry.Key)) | Out-Null",
|
|
" [IO.File]::WriteAllBytes($entry.Key, $entry.Value)",
|
|
"}",
|
|
"$journal.status = 'completed'",
|
|
"$journal | Add-Member -NotePropertyName completedAt -NotePropertyValue ([DateTime]::UtcNow.ToString('o')) -Force",
|
|
"$journal | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $JournalPath",
|
|
"Remove-Item -LiteralPath $fence -Force -ErrorAction Stop",
|
|
"if ($fenceInsideRoot -and $saved.Count -eq 0) { Remove-Item -LiteralPath $root -Force -ErrorAction SilentlyContinue }",
|
|
"Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue",
|
|
"Remove-Item -LiteralPath $JournalPath -Force -ErrorAction SilentlyContinue",
|
|
"",
|
|
].join("\r\n");
|
|
}
|
|
|
|
function fenceReferencesJournal(fencePath: string, journalPath: string): boolean {
|
|
try {
|
|
const value = JSON.parse(readFileSync(fencePath, "utf-8")) as {
|
|
schemaVersion?: unknown;
|
|
operation?: unknown;
|
|
journalPath?: unknown;
|
|
};
|
|
return value.schemaVersion === 1 &&
|
|
value.operation === "windows-uninstall-continuation" &&
|
|
typeof value.journalPath === "string" &&
|
|
resolve(value.journalPath) === resolve(journalPath);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function readJournal(path: string): WindowsUninstallJournal | null {
|
|
try {
|
|
const match = /^aidlc-uninstall-([0-9a-f-]+)\.json$/.exec(basename(path));
|
|
if (!match) return null;
|
|
const value = JSON.parse(readFileSync(path, "utf-8")) as Partial<WindowsUninstallJournal>;
|
|
const cleanupPath = join(tmpdir(), `aidlc-uninstall-${match[1]}.ps1`);
|
|
if (
|
|
value.schemaVersion !== 1 ||
|
|
value.operation !== "windows-uninstall-continuation" ||
|
|
(value.status !== "pending" && value.status !== "recovering") ||
|
|
!Number.isSafeInteger(value.parentPid) ||
|
|
(value.shimPid !== null && !Number.isSafeInteger(value.shimPid)) ||
|
|
typeof value.installRoot !== "string" ||
|
|
typeof value.commandPath !== "string" ||
|
|
typeof value.pointerPath !== "string" ||
|
|
typeof value.cleanupPath !== "string" ||
|
|
typeof value.fencePath !== "string" ||
|
|
typeof value.purge !== "boolean" ||
|
|
!Array.isArray(value.preserved) ||
|
|
value.preserved.some((entry) => typeof entry !== "string") ||
|
|
resolve(value.installRoot) !== resolve(installRoot()) ||
|
|
resolve(value.commandPath) !== resolve(commandPath()) ||
|
|
resolve(value.pointerPath) !== resolve(activeExecutablePath()) ||
|
|
resolve(value.cleanupPath) !== resolve(cleanupPath) ||
|
|
resolve(value.fencePath) !== resolve(windowsUninstallFencePath()) ||
|
|
!fenceReferencesJournal(value.fencePath, path) ||
|
|
!existsSync(cleanupPath)
|
|
) {
|
|
return null;
|
|
}
|
|
const prefix = `${resolve(value.installRoot)}${sep}`;
|
|
if (
|
|
value.preserved.some((entry) =>
|
|
!resolve(entry).startsWith(prefix)
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
return value as WindowsUninstallJournal;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function pendingWindowsUninstallJournals(): Array<{
|
|
path: string;
|
|
journal: WindowsUninstallJournal;
|
|
}> {
|
|
return scanWindowsUninstallJournals().pending;
|
|
}
|
|
|
|
export function scanWindowsUninstallJournals(): {
|
|
pending: Array<{ path: string; journal: WindowsUninstallJournal }>;
|
|
invalid: string[];
|
|
} {
|
|
const pending: Array<{ path: string; journal: WindowsUninstallJournal }> = [];
|
|
const invalid: string[] = [];
|
|
try {
|
|
for (
|
|
const entry of readdirSync(tmpdir())
|
|
.filter((name) => /^aidlc-uninstall-[0-9a-f-]+\.json$/.test(name))
|
|
.sort()
|
|
) {
|
|
const path = join(tmpdir(), entry);
|
|
let belongsToCurrentInstall = false;
|
|
try {
|
|
const raw = JSON.parse(readFileSync(path, "utf-8")) as {
|
|
installRoot?: unknown;
|
|
};
|
|
if (typeof raw.installRoot !== "string") {
|
|
invalid.push(path);
|
|
continue;
|
|
}
|
|
belongsToCurrentInstall = typeof raw.installRoot === "string" &&
|
|
resolve(raw.installRoot) === resolve(installRoot());
|
|
} catch {
|
|
invalid.push(path);
|
|
continue;
|
|
}
|
|
if (!belongsToCurrentInstall) continue;
|
|
const journal = readJournal(path);
|
|
if (journal) pending.push({ path, journal });
|
|
else invalid.push(path);
|
|
}
|
|
} catch {
|
|
// An unreadable temp directory has no actionable per-install evidence.
|
|
}
|
|
const fencePath = windowsUninstallFencePath();
|
|
if (
|
|
existsSync(fencePath) &&
|
|
!pending.some(({ path }) => fenceReferencesJournal(fencePath, path))
|
|
) {
|
|
invalid.push(fencePath);
|
|
}
|
|
return { pending, invalid };
|
|
}
|
|
|
|
function launch(path: string, journal: WindowsUninstallJournal): void {
|
|
const shimPid = Number(process.env.AIDLC_SHIM_PID);
|
|
const recovering: WindowsUninstallJournal = {
|
|
...journal,
|
|
status: "recovering",
|
|
parentPid: process.pid,
|
|
shimPid: Number.isSafeInteger(shimPid) && shimPid > 0 ? shimPid : null,
|
|
};
|
|
writeFileSync(path, `${JSON.stringify(recovering, null, 2)}\n`, { mode: 0o600 });
|
|
try {
|
|
const processArgument = (value: string): string =>
|
|
`'"${quoted(value)}"'`;
|
|
const broker = [
|
|
"$ErrorActionPreference = 'Stop'",
|
|
[
|
|
"Start-Process -FilePath 'powershell.exe' -ArgumentList @(",
|
|
[
|
|
"'-NoProfile'",
|
|
"'-NonInteractive'",
|
|
"'-ExecutionPolicy'",
|
|
"'Bypass'",
|
|
"'-File'",
|
|
processArgument(recovering.cleanupPath),
|
|
processArgument(path),
|
|
].join(","),
|
|
") -WindowStyle Hidden",
|
|
].join(""),
|
|
].join("; ");
|
|
const launched = Bun.spawnSync(
|
|
[
|
|
"powershell.exe",
|
|
"-NoProfile",
|
|
"-NonInteractive",
|
|
"-ExecutionPolicy",
|
|
"Bypass",
|
|
"-Command",
|
|
broker,
|
|
],
|
|
{
|
|
stdin: "ignore",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
windowsHide: true,
|
|
},
|
|
);
|
|
if (launched.exitCode !== 0) {
|
|
const stderr = Buffer.from(launched.stderr).toString("utf-8").trim();
|
|
throw new Error(`Windows uninstall cleanup launch failed${stderr ? `: ${stderr}` : ""}`);
|
|
}
|
|
} catch (error) {
|
|
writeFileSync(
|
|
path,
|
|
`${JSON.stringify({ ...recovering, status: "pending" }, null, 2)}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function scheduleWindowsUninstall(
|
|
purge: boolean,
|
|
preserved: readonly string[],
|
|
): void {
|
|
const fencePath = windowsUninstallFencePath();
|
|
if (existsSync(fencePath)) {
|
|
throw new Error(
|
|
`pending Windows uninstall fence requires recovery: ${fencePath}`,
|
|
);
|
|
}
|
|
const id = randomUUID();
|
|
const journalPath = join(tmpdir(), `aidlc-uninstall-${id}.json`);
|
|
const cleanupPath = join(tmpdir(), `aidlc-uninstall-${id}.ps1`);
|
|
const journal: WindowsUninstallJournal = {
|
|
schemaVersion: 1,
|
|
operation: "windows-uninstall-continuation",
|
|
status: "pending",
|
|
parentPid: process.pid,
|
|
shimPid: null,
|
|
installRoot: resolve(installRoot()),
|
|
commandPath: resolve(commandPath()),
|
|
pointerPath: resolve(activeExecutablePath()),
|
|
cleanupPath: resolve(cleanupPath),
|
|
fencePath: resolve(fencePath),
|
|
purge,
|
|
preserved: purge ? [] : preserved.map((path) => resolve(path)),
|
|
};
|
|
try {
|
|
writeFileSync(cleanupPath, cleanupScript(journal), { flag: "wx", mode: 0o600 });
|
|
writeFileSync(journalPath, `${JSON.stringify(journal, null, 2)}\n`, {
|
|
flag: "wx",
|
|
mode: 0o600,
|
|
});
|
|
executePlan({
|
|
schemaVersion: 1,
|
|
root: machineTransactionRoot(),
|
|
operations: [writeOperation(
|
|
relative(machineTransactionRoot(), journal.fencePath),
|
|
`${JSON.stringify({
|
|
schemaVersion: 1,
|
|
operation: journal.operation,
|
|
journalPath,
|
|
}, null, 2)}\n`,
|
|
"absent",
|
|
0o600,
|
|
)],
|
|
}, {
|
|
allowPendingWindowsUninstall: true,
|
|
});
|
|
launch(journalPath, journal);
|
|
} catch (error) {
|
|
if (!existsSync(journal.fencePath)) {
|
|
rmSync(journalPath, { force: true });
|
|
rmSync(cleanupPath, { force: true });
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function recoverWindowsUninstallContinuations(requestedPurge?: boolean): number {
|
|
const scan = scanWindowsUninstallJournals();
|
|
if (scan.invalid.length > 0) {
|
|
throw new Error(
|
|
`invalid Windows uninstall journal(s): ${scan.invalid.join(", ")}`,
|
|
);
|
|
}
|
|
const mismatched = requestedPurge === undefined
|
|
? []
|
|
: scan.pending.filter(({ journal }) => journal.purge !== requestedPurge);
|
|
if (mismatched.length > 0) {
|
|
const pendingMode = mismatched[0].journal.purge ? "--purge" : "non-purge";
|
|
const requestedMode = requestedPurge ? "--purge" : "non-purge";
|
|
throw new Error(
|
|
`pending Windows ${pendingMode} uninstall cannot be resumed as ${requestedMode}; ` +
|
|
"finish or recover the pending uninstall before changing purge mode",
|
|
);
|
|
}
|
|
if (process.platform !== "win32") return 0;
|
|
let recovered = 0;
|
|
for (const { path, journal } of scan.pending) {
|
|
launch(path, journal);
|
|
recovered++;
|
|
}
|
|
return recovered;
|
|
}
|