Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
553 lines
22 KiB
JavaScript
553 lines
22 KiB
JavaScript
/*
|
|
* Licensed to the Apache Software Foundation (ASF) under one
|
|
* or more contributor license agreements. See the NOTICE file
|
|
* distributed with this work for additional information
|
|
* regarding copyright ownership. The ASF licenses this file
|
|
* to you under the Apache License, Version 2.0 (the
|
|
* "License"); you may not use this file except in compliance
|
|
* with the License. You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing,
|
|
* software distributed under the License is distributed on an
|
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
* KIND, either express or implied. See the License for the
|
|
* specific language governing permissions and limitations
|
|
* under the License.
|
|
*/
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { existsSync } from 'node:fs';
|
|
import { access, mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { basename, join, resolve } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import {
|
|
feedServed,
|
|
startDesktopUpdateFeed,
|
|
verifyDesktopUpdateArtifacts,
|
|
} from './desktop-update-contract.mjs';
|
|
import {
|
|
evaluateInRenderer,
|
|
findRendererTarget,
|
|
isolatedUserEnv,
|
|
waitForDevToolsPort,
|
|
waitForUsableRenderer,
|
|
runCommand,
|
|
stopChild,
|
|
} from './verify-packaged-app.mjs';
|
|
import {
|
|
completeInstalledApplicationUninstall,
|
|
installerVersion,
|
|
readUninstallDisplayVersions,
|
|
terminateInstalledProcesses,
|
|
waitForInstalledProcessAppearance,
|
|
waitForInstalledProcessesToExit,
|
|
} from './verify-windows-installer-lifecycle.mjs';
|
|
import {
|
|
assertWindowsProductVersion,
|
|
powerShellLiteral,
|
|
verifyPackagedWindowsApp,
|
|
} from './verify-windows-x64.mjs';
|
|
import { compareProductReleaseVersions } from './release-version.mjs';
|
|
|
|
const uninstallExecutableName = 'Uninstall Maka.exe';
|
|
const executableName = 'Maka.exe';
|
|
|
|
function delay(milliseconds) {
|
|
return new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
|
|
}
|
|
|
|
function step(label) {
|
|
console.log(`[verify-windows-autoupdate] ${label}`);
|
|
}
|
|
|
|
export async function waitForInstalledProductVersion(
|
|
executablePath,
|
|
{
|
|
run = runCommand,
|
|
timeoutMs = 60_000,
|
|
probeTimeoutMs = 10_000,
|
|
pollIntervalMs = 1_000,
|
|
sleep = delay,
|
|
} = {},
|
|
) {
|
|
const script = `(Get-Item ${powerShellLiteral(executablePath)}).VersionInfo.ProductVersion`;
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastProbeError;
|
|
for (;;) {
|
|
try {
|
|
const { stdout } = await run(
|
|
'powershell',
|
|
['-NoProfile', '-NonInteractive', '-Command', script],
|
|
{ timeoutMs: Math.max(1, Math.min(probeTimeoutMs, deadline - Date.now())) },
|
|
);
|
|
return stdout;
|
|
} catch (error) {
|
|
lastProbeError = error;
|
|
}
|
|
if (Date.now() >= deadline) {
|
|
throw new Error(
|
|
`Could not read the installed ProductVersion within ${timeoutMs}ms: ${lastProbeError.message}`,
|
|
{ cause: lastProbeError },
|
|
);
|
|
}
|
|
await sleep(pollIntervalMs);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* End-to-end Windows automatic-update verification.
|
|
*
|
|
* Installs the candidate build, points its packaged updater at a loopback feed
|
|
* serving a version-bumped installer, and asserts the complete in-app path:
|
|
* check → background download (feed request log) → `downloaded` status →
|
|
* `installUpdate` handoff → old process exit → NSIS upgrade → automatic
|
|
* relaunch as the new version → full packaged smoke of the upgraded install →
|
|
* silent uninstall. Every stage has its own deadline and named failure; the
|
|
* overall shape fails closed rather than reporting a partial success.
|
|
*/
|
|
export async function verifyWindowsAutoupdate(
|
|
candidateInputPath,
|
|
nextDirectoryInput,
|
|
{
|
|
platform = process.platform,
|
|
makeTemporaryDirectory = () => mkdtemp(join(tmpdir(), 'maka-autoupdate-')),
|
|
run = runCommand,
|
|
} = {},
|
|
) {
|
|
if (platform !== 'win32') {
|
|
throw new Error('Windows auto-update verification requires Windows.');
|
|
}
|
|
if (!candidateInputPath || !nextDirectoryInput) {
|
|
throw new Error(
|
|
'Usage: npm run verify:windows-autoupdate -- <candidate-exe> <next-release-directory>',
|
|
);
|
|
}
|
|
|
|
const candidateInstaller = resolve(candidateInputPath);
|
|
const nextDirectory = resolve(nextDirectoryInput);
|
|
const candidateVersion = installerVersion(candidateInstaller);
|
|
await access(candidateInstaller);
|
|
const latestYml = await readFile(join(nextDirectory, 'latest.yml'), 'utf8');
|
|
const nextVersion = /^version:\s*(.+)\s*$/m.exec(latestYml)?.[1]?.trim();
|
|
if (!nextVersion) {
|
|
throw new Error(`latest.yml in ${nextDirectory} does not advertise a version.`);
|
|
}
|
|
if (compareProductReleaseVersions(nextVersion, candidateVersion) <= 0) {
|
|
throw new Error(
|
|
`The served version ${nextVersion} must be newer than the candidate ${candidateVersion}.`,
|
|
);
|
|
}
|
|
const nextInstallerName = `Maka-${nextVersion}-win-x64.exe`;
|
|
installerVersion(join(nextDirectory, nextInstallerName));
|
|
await verifyDesktopUpdateArtifacts({
|
|
directory: nextDirectory,
|
|
metadataName: 'latest.yml',
|
|
version: nextVersion,
|
|
artifactName: nextInstallerName,
|
|
});
|
|
|
|
const temporaryDirectory = await makeTemporaryDirectory();
|
|
const installDirectory = join(temporaryDirectory, 'installed');
|
|
const uninstaller = join(installDirectory, uninstallExecutableName);
|
|
const installedExecutable = join(installDirectory, executableName);
|
|
let installationStarted = false;
|
|
let uninstallCompleted = false;
|
|
let feed;
|
|
let child;
|
|
let primaryError;
|
|
|
|
try {
|
|
step('starting the loopback update feed');
|
|
// The candidate's own blockmap is served too, exactly as a GitHub release
|
|
// hosts the previous version's assets: the updater requests it to attempt
|
|
// a differential download. If the file is missing next to the candidate
|
|
// installer, the mapped-but-absent 404 makes the updater fall back to the
|
|
// full download — both are valid production shapes.
|
|
feed = await startDesktopUpdateFeed(
|
|
new Map([
|
|
['latest.yml', join(nextDirectory, 'latest.yml')],
|
|
[nextInstallerName, join(nextDirectory, nextInstallerName)],
|
|
[`${nextInstallerName}.blockmap`, join(nextDirectory, `${nextInstallerName}.blockmap`)],
|
|
[`${basename(candidateInstaller)}.blockmap`, `${candidateInstaller}.blockmap`],
|
|
]),
|
|
);
|
|
|
|
step(`installing candidate ${candidateVersion} into ${installDirectory}`);
|
|
installationStarted = true;
|
|
await run(candidateInstaller, ['/S', `/D=${installDirectory}`], { timeoutMs: 120_000 });
|
|
await access(uninstaller);
|
|
|
|
step('launching the installed candidate against the loopback feed');
|
|
const home = join(temporaryDirectory, 'home');
|
|
const userData = join(temporaryDirectory, 'user-data');
|
|
const userEnv = isolatedUserEnv(home);
|
|
await mkdir(home, { recursive: true });
|
|
await mkdir(userData, { recursive: true });
|
|
await mkdir(userEnv.APPDATA, { recursive: true });
|
|
await mkdir(userEnv.LOCALAPPDATA, { recursive: true });
|
|
const childEnv = {
|
|
...process.env,
|
|
MAKA_SKIP_SHELL_ENV: '1',
|
|
...userEnv,
|
|
MAKA_UPDATE_TEST_FEED: feed.url,
|
|
};
|
|
// The mocks short-circuit before the real updater; leaking them from the
|
|
// caller's environment would turn this into a fixture test.
|
|
delete childEnv.MAKA_UPDATE_MOCK_VERSION;
|
|
delete childEnv.MAKA_UPDATE_MOCK_STATE;
|
|
child = spawn(
|
|
installedExecutable,
|
|
['--remote-debugging-port=0', `--user-data-dir=${userData}`, '--enable-logging=stderr'],
|
|
{ cwd: temporaryDirectory, env: childEnv, stdio: ['ignore', 'ignore', 'pipe'] },
|
|
);
|
|
let stderr = '';
|
|
child.stderr.setEncoding('utf8');
|
|
child.stderr.on('data', (chunk) => {
|
|
stderr = `${stderr}${chunk}`.slice(-16_384);
|
|
});
|
|
|
|
const cdpPort = await waitForDevToolsPort(child);
|
|
// On a CDP attach failure the app's own stderr tail is the only evidence
|
|
// of what the packaged process was doing (renderer crash, GPU fallback,
|
|
// profile lock); the bare "fetch failed" without it has already burned
|
|
// full CI cycles on this exact stage.
|
|
const target = await findRendererTarget(cdpPort, child).catch((error) => {
|
|
throw new Error(
|
|
`${error.message}${stderr.trim() ? `\napp stderr tail:\n${stderr.trim()}` : ''}`,
|
|
{ cause: error },
|
|
);
|
|
});
|
|
await waitForUsableRenderer(target.webSocketDebuggerUrl, child, {
|
|
description: 'Candidate renderer',
|
|
}).catch((error) => {
|
|
throw new Error(`${error.message}${stderr.trim() ? `\n${stderr.trim()}` : ''}`, {
|
|
cause: error,
|
|
});
|
|
});
|
|
|
|
step('driving an update check through the renderer bridge');
|
|
await evaluateInRenderer(target.webSocketDebuggerUrl, 'window.maka.app.checkForUpdates()', {
|
|
awaitPromise: true,
|
|
timeoutMs: 30_000,
|
|
});
|
|
|
|
step('waiting for the update to reach the downloaded state');
|
|
const downloadDeadline = Date.now() + 180_000;
|
|
let status;
|
|
for (;;) {
|
|
status = await evaluateInRenderer(
|
|
target.webSocketDebuggerUrl,
|
|
'window.maka.app.updateStatus()',
|
|
{
|
|
awaitPromise: true,
|
|
},
|
|
);
|
|
if (status?.state === 'error') {
|
|
throw new Error(
|
|
`Update ${status.operation ?? 'flow'} failed: ${status.message ?? '<no message>'}`,
|
|
);
|
|
}
|
|
if (status?.state === 'downloaded') break;
|
|
if (child.exitCode !== null) {
|
|
throw new Error(`Candidate exited while downloading the update.\n${stderr.trim()}`);
|
|
}
|
|
if (Date.now() >= downloadDeadline) {
|
|
throw new Error(`Update never reached 'downloaded': ${JSON.stringify(status)}`);
|
|
}
|
|
await delay(500);
|
|
}
|
|
if (status.currentVersion !== candidateVersion || status.latestVersion !== nextVersion) {
|
|
throw new Error(
|
|
`Downloaded update advertises ${status.currentVersion} -> ${status.latestVersion}, ` +
|
|
`expected ${candidateVersion} -> ${nextVersion}.`,
|
|
);
|
|
}
|
|
|
|
step('asserting the download really came from the loopback feed');
|
|
// Exact root-level paths, matching the server's own allowlist shape.
|
|
if (!feedServed(feed, 'latest.yml')) {
|
|
throw new Error(
|
|
`The app never fetched latest.yml from the loopback feed: ${JSON.stringify(feed.requests)}`,
|
|
);
|
|
}
|
|
if (!feedServed(feed, nextInstallerName)) {
|
|
throw new Error(
|
|
`The app never downloaded the installer from the loopback feed: ${JSON.stringify(feed.requests)}`,
|
|
);
|
|
}
|
|
// The differential probe is a deterministic part of the flow: the updater
|
|
// asks for the running version's blockmap before deciding between a
|
|
// differential and a full download. Both outcomes are valid production
|
|
// shapes; the probe itself must have happened. The mode is reported from
|
|
// the installer transfer responses themselves — a served previous blockmap
|
|
// alone proves nothing about which download the updater then performed.
|
|
const oldBlockmapProbe = feed.requests.find(
|
|
(request) => request.path === `/${basename(candidateInstaller)}.blockmap`,
|
|
);
|
|
if (!oldBlockmapProbe) {
|
|
throw new Error(
|
|
`The updater never probed the previous blockmap for a differential download: ${JSON.stringify(feed.requests)}`,
|
|
);
|
|
}
|
|
const installerResponses = feed.requests
|
|
.filter((request) => request.method === 'GET' && request.path === `/${nextInstallerName}`)
|
|
.map((request) => request.status);
|
|
step(
|
|
`installer transfer observed: previous blockmap ${oldBlockmapProbe.status}, ` +
|
|
`installer responses [${installerResponses.join(', ')}] ` +
|
|
`(${installerResponses.includes(206) ? 'ranged/differential transfer' : 'single full download'})`,
|
|
);
|
|
if (feed.unexpectedCount() > 0) {
|
|
throw new Error(`The app requested unexpected feed paths: ${JSON.stringify(feed.requests)}`);
|
|
}
|
|
|
|
step('handing off to the installer via installUpdate');
|
|
let installResult;
|
|
try {
|
|
installResult = await evaluateInRenderer(
|
|
target.webSocketDebuggerUrl,
|
|
'window.maka.app.installUpdate({ allowInterruptActiveTasks: true })',
|
|
{ awaitPromise: true, timeoutMs: 30_000 },
|
|
);
|
|
} catch (error) {
|
|
// The evaluation races the app quitting for the installer handoff; a
|
|
// dropped socket here is the expected shape of success. A structured
|
|
// failure is not.
|
|
installResult = { racedAppExit: true, message: error.message };
|
|
}
|
|
if (installResult && installResult.ok === false) {
|
|
throw new Error(`installUpdate refused: ${JSON.stringify(installResult)}`);
|
|
}
|
|
|
|
step('waiting for the candidate process to exit for the installer');
|
|
await new Promise((resolvePromise, reject) => {
|
|
const timeout = setTimeout(
|
|
() => reject(new Error('Candidate did not exit for the installer within 60s.')),
|
|
60_000,
|
|
);
|
|
if (child.exitCode !== null) {
|
|
clearTimeout(timeout);
|
|
resolvePromise();
|
|
return;
|
|
}
|
|
child.once('exit', () => {
|
|
clearTimeout(timeout);
|
|
resolvePromise();
|
|
});
|
|
});
|
|
|
|
step('waiting for the upgraded app to relaunch automatically');
|
|
const relaunched = await waitForInstalledProcessAppearance(installDirectory, executableName, {
|
|
timeoutMs: 120_000,
|
|
});
|
|
const productVersion = await waitForInstalledProductVersion(installedExecutable, { run });
|
|
try {
|
|
assertWindowsProductVersion(productVersion, nextVersion);
|
|
} catch (error) {
|
|
// Nondeterministic CI state observed once: a Maka.exe process is
|
|
// running from the install directory while the on-disk executable is
|
|
// still the previous version. The only code path in the NSIS template
|
|
// that launches the app is StartApp at full Section success, which
|
|
// contradicts old bytes on disk — so on mismatch, capture the raw
|
|
// state needed to attribute the launch and the transaction stage:
|
|
// each process's command line (StartApp passes `--updated`), the
|
|
// pre-upgrade backup directory (exists ⇒ the upgrade quit between
|
|
// customInit and customInstall), the uninstall registration, and the
|
|
// executable timestamps (rewritten ⇒ extraction ran).
|
|
const evidence = [`relaunched processes: ${JSON.stringify(relaunched)}`];
|
|
try {
|
|
const commandLines = await run(
|
|
'powershell',
|
|
[
|
|
'-NoProfile',
|
|
'-NonInteractive',
|
|
'-Command',
|
|
`Get-CimInstance Win32_Process -Filter "Name='${executableName}'" | Select-Object ProcessId,ExecutablePath,CommandLine,CreationDate | ConvertTo-Json -Compress`,
|
|
],
|
|
// Bounded: this probe runs on a machine already in a wedged state,
|
|
// and an evidence collector that hangs destroys the evidence — the
|
|
// catch below keeps a timeout from aborting the rest of the capture.
|
|
{ timeoutMs: 30_000 },
|
|
);
|
|
evidence.push(`process command lines: ${commandLines.stdout.trim() || '<none>'}`);
|
|
} catch (processError) {
|
|
evidence.push(`process command lines unavailable: ${processError.message}`);
|
|
}
|
|
const backupDirectory = `${installDirectory}.pre-upgrade-backup`;
|
|
evidence.push(
|
|
`backup directory ${backupDirectory}: ${existsSync(backupDirectory) ? 'present' : 'absent'}`,
|
|
);
|
|
try {
|
|
const registrations = await readUninstallDisplayVersions({ run });
|
|
evidence.push(`uninstall registrations: ${JSON.stringify(registrations)}`);
|
|
} catch (registryError) {
|
|
evidence.push(`uninstall registrations unavailable: ${registryError.message}`);
|
|
}
|
|
for (const name of [executableName, uninstallExecutableName]) {
|
|
const filePath = join(installDirectory, name);
|
|
try {
|
|
const fileStat = await stat(filePath);
|
|
evidence.push(`${name}: mtime ${fileStat.mtime.toISOString()}, ${fileStat.size} bytes`);
|
|
} catch (statError) {
|
|
evidence.push(`${name}: ${statError.code ?? statError.message}`);
|
|
}
|
|
}
|
|
throw new Error(`${error.message}\n${evidence.join('\n')}`, { cause: error });
|
|
}
|
|
|
|
step('stopping the relaunched instance');
|
|
// isForceRunAfter relaunches without our CDP/user-data arguments, so the
|
|
// instance is observed (it exists, and the image on disk is the new
|
|
// version) and then stopped before it can touch further state; the
|
|
// environment it inherited still points at the isolated home. The
|
|
// tolerant kill-then-prove policy lives in terminateInstalledProcesses,
|
|
// shared with the cleanup path below.
|
|
await terminateInstalledProcesses(installDirectory, { run });
|
|
|
|
step('running the full packaged smoke against the upgraded install');
|
|
// The sandbox probe writes its manifest into workingDirectory before the
|
|
// renderer smoke would have created any subdirectories, so the directory
|
|
// must exist first.
|
|
const smokeDirectory = join(temporaryDirectory, 'smoke');
|
|
await mkdir(smokeDirectory, { recursive: true });
|
|
const upgradedStatusExpression = 'window.maka.app.updateStatus()';
|
|
await verifyPackagedWindowsApp(installDirectory, {
|
|
workingDirectory: smokeDirectory,
|
|
expectedVersion: nextVersion,
|
|
smokeRenderer: async (executable, { workingDirectory }) => {
|
|
const smokeHome = join(workingDirectory, 'home');
|
|
const smokeUserData = join(workingDirectory, 'user-data');
|
|
const smokeEnv = isolatedUserEnv(smokeHome);
|
|
await mkdir(smokeHome, { recursive: true });
|
|
await mkdir(smokeUserData, { recursive: true });
|
|
await mkdir(smokeEnv.APPDATA, { recursive: true });
|
|
await mkdir(smokeEnv.LOCALAPPDATA, { recursive: true });
|
|
const smokeChild = spawn(
|
|
executable,
|
|
[
|
|
'--remote-debugging-port=0',
|
|
`--user-data-dir=${smokeUserData}`,
|
|
'--enable-logging=stderr',
|
|
],
|
|
{
|
|
cwd: workingDirectory,
|
|
env: { ...process.env, MAKA_SKIP_SHELL_ENV: '1', ...smokeEnv },
|
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
},
|
|
);
|
|
smokeChild.stderr.setEncoding('utf8');
|
|
// A persistent collector, like every sibling smoke: without one the
|
|
// piped stream pauses once waitForDevToolsPort removes its listener,
|
|
// Chromium's stderr logging can fill the pipe and block the child,
|
|
// and the failure evidence this capture exists for is lost.
|
|
let smokeStderr = '';
|
|
smokeChild.stderr.on('data', (chunk) => {
|
|
smokeStderr = `${smokeStderr}${chunk}`.slice(-16_384);
|
|
});
|
|
try {
|
|
const smokePort = await waitForDevToolsPort(smokeChild);
|
|
const smokeTarget = await findRendererTarget(smokePort, smokeChild);
|
|
await waitForUsableRenderer(smokeTarget.webSocketDebuggerUrl, smokeChild, {
|
|
description: 'Upgraded renderer',
|
|
}).catch((error) => {
|
|
throw new Error(
|
|
`${error.message}${smokeStderr.trim() ? `\n${smokeStderr.trim()}` : ''}`,
|
|
{ cause: error },
|
|
);
|
|
});
|
|
const upgradedStatus = await evaluateInRenderer(
|
|
smokeTarget.webSocketDebuggerUrl,
|
|
upgradedStatusExpression,
|
|
{ awaitPromise: true },
|
|
);
|
|
if (upgradedStatus?.currentVersion !== nextVersion) {
|
|
throw new Error(
|
|
`Upgraded app reports version ${upgradedStatus?.currentVersion}, expected ${nextVersion}.`,
|
|
);
|
|
}
|
|
} finally {
|
|
await stopChild(smokeChild);
|
|
}
|
|
},
|
|
});
|
|
await waitForInstalledProcessesToExit(installDirectory);
|
|
|
|
step('uninstalling the upgraded application');
|
|
// Files gone is not uninstall over: the detached uninstaller deletes the
|
|
// uninstall registry keys as its last action, tens of seconds later on a
|
|
// busy runner. Declaring success on files alone let the next verify step
|
|
// install inside that window and lose its fresh registration to this
|
|
// step's stale uninstaller (see waitForUninstallRegistrationToClear).
|
|
await completeInstalledApplicationUninstall(installDirectory, uninstaller, { run });
|
|
uninstallCompleted = true;
|
|
step(`verified automatic update ${candidateVersion} -> ${nextVersion}`);
|
|
return { candidateVersion, nextVersion, installDirectory };
|
|
} catch (error) {
|
|
primaryError = error;
|
|
throw error;
|
|
} finally {
|
|
const cleanupErrors = [];
|
|
if (child) {
|
|
try {
|
|
await stopChild(child);
|
|
} catch (error) {
|
|
cleanupErrors.push(error);
|
|
}
|
|
}
|
|
if (feed) {
|
|
try {
|
|
await feed.close();
|
|
} catch (error) {
|
|
cleanupErrors.push(error);
|
|
}
|
|
}
|
|
if (installationStarted && !uninstallCompleted) {
|
|
let exited = false;
|
|
try {
|
|
// Same stop-and-prove policy as the main path: a stale-PID exit 128
|
|
// from cleanup's own taskkill (run 32378497920) must not skip the
|
|
// authoritative exit wait and the uninstall/registration barrier.
|
|
await terminateInstalledProcesses(installDirectory, { run });
|
|
exited = true;
|
|
} catch (error) {
|
|
cleanupErrors.push(error);
|
|
}
|
|
if (exited) {
|
|
try {
|
|
await completeInstalledApplicationUninstall(installDirectory, uninstaller, { run });
|
|
} catch (error) {
|
|
cleanupErrors.push(error);
|
|
}
|
|
}
|
|
}
|
|
try {
|
|
await rm(temporaryDirectory, {
|
|
recursive: true,
|
|
force: true,
|
|
maxRetries: 20,
|
|
retryDelay: 250,
|
|
});
|
|
} catch (error) {
|
|
cleanupErrors.push(error);
|
|
}
|
|
if (cleanupErrors.length > 0) {
|
|
const cleanupFailure = new AggregateError(
|
|
cleanupErrors,
|
|
'Auto-update verifier cleanup failed.',
|
|
);
|
|
if (!primaryError) throw cleanupFailure;
|
|
if (primaryError instanceof Error && primaryError.cause === undefined) {
|
|
primaryError.cause = cleanupFailure;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
const result = await verifyWindowsAutoupdate(process.argv[2], process.argv[3]);
|
|
console.log(`Verified automatic update ${result.candidateVersion} -> ${result.nextVersion}`);
|
|
}
|