fix(tools): recover interrupted managed installs
Persist atomic promotion state beside each managed tool, restore usable backups after interrupted renames, and retain precise recovery failures. Verify installed executable hashes from the staged manifest record and make cleanup diagnostics unable to strand repair single-flight state.
This commit is contained in:
@@ -27,10 +27,33 @@ function writeExistingInstallation(installPath: string, contents = 'old executab
|
|||||||
fs.writeFileSync(path.join(installPath, 'bin', 'streamlink.exe'), contents);
|
fs.writeFileSync(path.join(installPath, 'bin', 'streamlink.exe'), contents);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeVerifiedInstallation(installPath: string, contents = 'old executable'): void {
|
||||||
|
writeExistingInstallation(installPath, contents);
|
||||||
|
const toolManifest = manifest();
|
||||||
|
fs.writeFileSync(path.join(installPath, '.tool-manifest.json'), JSON.stringify({
|
||||||
|
id: toolManifest.id,
|
||||||
|
version: toolManifest.version,
|
||||||
|
sourceUrl: toolManifest.sourceUrl,
|
||||||
|
archiveName: toolManifest.archiveName,
|
||||||
|
sha256: toolManifest.sha256,
|
||||||
|
executableHashes: { 'streamlink.exe': sha256(contents) }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeInterruptedPromotionJournal(installPath: string, backupPath: string, stagingPath: string, phase: string): void {
|
||||||
|
fs.writeFileSync(`${installPath}.transaction.json`, JSON.stringify({
|
||||||
|
installationDirectory: installPath,
|
||||||
|
backupDirectory: backupPath,
|
||||||
|
stagingDirectory: stagingPath,
|
||||||
|
phase
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function createInstaller(options: {
|
function createInstaller(options: {
|
||||||
archiveContents?: string;
|
archiveContents?: string;
|
||||||
extract?: (archivePath: string, destinationPath: string) => Promise<void>;
|
extract?: (archivePath: string, destinationPath: string) => Promise<void>;
|
||||||
rename?: (sourcePath: string, destinationPath: string) => void;
|
rename?: (sourcePath: string, destinationPath: string) => void;
|
||||||
|
remove?: (targetPath: string, options: { recursive?: boolean; force?: boolean }) => void;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const installPath = path.join(directory, 'installed');
|
const installPath = path.join(directory, 'installed');
|
||||||
const tempPath = path.join(directory, 'temporary');
|
const tempPath = path.join(directory, 'temporary');
|
||||||
@@ -50,7 +73,8 @@ function createInstaller(options: {
|
|||||||
temporaryDirectory: tempPath,
|
temporaryDirectory: tempPath,
|
||||||
download,
|
download,
|
||||||
extract,
|
extract,
|
||||||
rename: options.rename
|
rename: options.rename,
|
||||||
|
remove: options.remove
|
||||||
}),
|
}),
|
||||||
download
|
download
|
||||||
};
|
};
|
||||||
@@ -127,7 +151,7 @@ describe('managed tool installer', () => {
|
|||||||
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
|
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports promotion failure when the previous installation cannot be restored', async () => {
|
it('reports recovery failure when the previous installation cannot be restored', async () => {
|
||||||
const installPath = path.join(directory, 'installed');
|
const installPath = path.join(directory, 'installed');
|
||||||
const { installer } = createInstaller({
|
const { installer } = createInstaller({
|
||||||
rename: (sourcePath, destinationPath) => {
|
rename: (sourcePath, destinationPath) => {
|
||||||
@@ -145,7 +169,7 @@ describe('managed tool installer', () => {
|
|||||||
const result = await installer.repair(manifest());
|
const result = await installer.repair(manifest());
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error).toBe('promotion-failed');
|
expect(result.error).toBe('recovery-restore-failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports a verified manifest version only after a staged install is promoted', async () => {
|
it('reports a verified manifest version only after a staged install is promoted', async () => {
|
||||||
@@ -208,4 +232,92 @@ describe('managed tool installer', () => {
|
|||||||
expect(secondResult.success).toBe(true);
|
expect(secondResult.success).toBe(true);
|
||||||
expect(download).toHaveBeenCalledTimes(1);
|
expect(download).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('restores a valid previous tool at the active path after interruption between backup and promotion', () => {
|
||||||
|
const { installer, installPath } = createInstaller();
|
||||||
|
const backupPath = `${installPath}.backup-interrupted`;
|
||||||
|
const stagingPath = `${installPath}.stage-interrupted`;
|
||||||
|
writeVerifiedInstallation(backupPath, 'old executable');
|
||||||
|
writeVerifiedInstallation(stagingPath, 'new executable');
|
||||||
|
writeInterruptedPromotionJournal(installPath, backupPath, stagingPath, 'backup-created');
|
||||||
|
|
||||||
|
expect(installer.status(manifest())).toMatchObject({ state: 'verified', verified: true });
|
||||||
|
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
|
||||||
|
expect(fs.existsSync(backupPath)).toBe(false);
|
||||||
|
expect(fs.existsSync(stagingPath)).toBe(false);
|
||||||
|
expect(fs.existsSync(`${installPath}.transaction.json`)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a valid promoted tool after interruption between promotion and journal cleanup', () => {
|
||||||
|
const { installer, installPath } = createInstaller();
|
||||||
|
const backupPath = `${installPath}.backup-interrupted`;
|
||||||
|
const stagingPath = `${installPath}.stage-interrupted`;
|
||||||
|
writeVerifiedInstallation(installPath, 'new executable');
|
||||||
|
writeVerifiedInstallation(backupPath, 'old executable');
|
||||||
|
writeInterruptedPromotionJournal(installPath, backupPath, stagingPath, 'backup-created');
|
||||||
|
|
||||||
|
expect(installer.status(manifest())).toMatchObject({ state: 'verified', verified: true });
|
||||||
|
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('new executable');
|
||||||
|
expect(fs.existsSync(backupPath)).toBe(false);
|
||||||
|
expect(fs.existsSync(`${installPath}.transaction.json`)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a precise recovery failure while preserving the usable backup path', async () => {
|
||||||
|
const installPath = path.join(directory, 'installed');
|
||||||
|
const backupPath = `${installPath}.backup-interrupted`;
|
||||||
|
const stagingPath = `${installPath}.stage-interrupted`;
|
||||||
|
const { installer } = createInstaller({
|
||||||
|
rename: (sourcePath, destinationPath) => {
|
||||||
|
if (sourcePath === backupPath && destinationPath === installPath) {
|
||||||
|
throw new Error('restore denied');
|
||||||
|
}
|
||||||
|
fs.renameSync(sourcePath, destinationPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
writeVerifiedInstallation(backupPath, 'old executable');
|
||||||
|
writeVerifiedInstallation(stagingPath, 'new executable');
|
||||||
|
writeInterruptedPromotionJournal(installPath, backupPath, stagingPath, 'backup-created');
|
||||||
|
|
||||||
|
const result = await installer.repair(manifest());
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('recovery-restore-failed');
|
||||||
|
expect(result.detail).toContain('restore denied');
|
||||||
|
expect(fs.readFileSync(path.join(backupPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('old executable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leave a repair in flight when archive cleanup fails', async () => {
|
||||||
|
let archiveCleanupFailed = false;
|
||||||
|
const { installer, download } = createInstaller({
|
||||||
|
remove: (targetPath, options) => {
|
||||||
|
if (!archiveCleanupFailed && String(targetPath).includes(manifest().archiveName)) {
|
||||||
|
archiveCleanupFailed = true;
|
||||||
|
throw new Error('archive cleanup denied');
|
||||||
|
}
|
||||||
|
fs.rmSync(targetPath, options);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await installer.repair(manifest());
|
||||||
|
const second = await installer.repair(manifest());
|
||||||
|
|
||||||
|
expect(first.success).toBe(true);
|
||||||
|
expect(first.diagnostics).toContain('archive cleanup denied');
|
||||||
|
expect(second.success).toBe(true);
|
||||||
|
expect(download).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a changed managed executable corrupt and repairs it from a verified archive', async () => {
|
||||||
|
const { installer, installPath } = createInstaller();
|
||||||
|
const installed = await installer.repair(manifest());
|
||||||
|
expect(installed.success).toBe(true);
|
||||||
|
fs.writeFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'changed executable');
|
||||||
|
|
||||||
|
expect(installer.status(manifest())).toMatchObject({ state: 'corrupt', verified: false });
|
||||||
|
|
||||||
|
const repaired = await installer.repair(manifest());
|
||||||
|
expect(repaired.success).toBe(true);
|
||||||
|
expect(repaired.status).toMatchObject({ state: 'verified', verified: true });
|
||||||
|
expect(fs.readFileSync(path.join(installPath, 'bin', 'streamlink.exe'), 'utf8')).toBe('new executable');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ export interface ManagedToolStatus {
|
|||||||
export interface ManagedToolInstallResult {
|
export interface ManagedToolInstallResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
status: ManagedToolStatus;
|
status: ManagedToolStatus;
|
||||||
error?: 'download-failed' | 'archive-hash-mismatch' | 'extract-failed' | 'required-executable-missing' | 'promotion-failed';
|
error?: 'download-failed' | 'archive-hash-mismatch' | 'extract-failed' | 'required-executable-missing' | 'promotion-failed' | 'recovery-journal-invalid' | 'recovery-restore-failed';
|
||||||
|
detail?: string;
|
||||||
|
diagnostics: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ManagedToolRecord {
|
interface ManagedToolRecord {
|
||||||
@@ -34,6 +36,27 @@ interface ManagedToolRecord {
|
|||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
archiveName: string;
|
archiveName: string;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
|
executableHashes?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstallationJournal {
|
||||||
|
installationDirectory: string;
|
||||||
|
backupDirectory: string;
|
||||||
|
stagingDirectory: string;
|
||||||
|
phase: 'staged' | 'backup-created' | 'promoted';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstallationInspection {
|
||||||
|
state: ManagedToolStatus['state'];
|
||||||
|
version: string;
|
||||||
|
layoutUsable: boolean;
|
||||||
|
strictValid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecoveryResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: NonNullable<ManagedToolInstallResult['error']>;
|
||||||
|
detail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ManagedToolInstallerOptions {
|
export interface ManagedToolInstallerOptions {
|
||||||
@@ -42,9 +65,13 @@ export interface ManagedToolInstallerOptions {
|
|||||||
download(sourceUrl: string, archivePath: string): Promise<void>;
|
download(sourceUrl: string, archivePath: string): Promise<void>;
|
||||||
extract(archivePath: string, destinationPath: string): Promise<void>;
|
extract(archivePath: string, destinationPath: string): Promise<void>;
|
||||||
rename?(sourcePath: string, destinationPath: string): void;
|
rename?(sourcePath: string, destinationPath: string): void;
|
||||||
|
remove?(targetPath: string, options: { recursive?: boolean; force?: boolean }): void;
|
||||||
|
diagnostic?(message: string, details: Record<string, string>): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RECORD_FILE_NAME = '.tool-manifest.json';
|
const RECORD_FILE_NAME = '.tool-manifest.json';
|
||||||
|
const JOURNAL_SUFFIX = '.transaction.json';
|
||||||
|
const INVALID_JOURNAL = Symbol('invalid-journal');
|
||||||
|
|
||||||
function findFileRecursive(rootDir: string, fileName: string): string | null {
|
function findFileRecursive(rootDir: string, fileName: string): string | null {
|
||||||
try {
|
try {
|
||||||
@@ -79,6 +106,27 @@ function isRecord(value: unknown): value is ManagedToolRecord {
|
|||||||
&& typeof record.sha256 === 'string';
|
&& typeof record.sha256 === 'string';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isJournal(value: unknown, installationDirectory: string): value is InstallationJournal {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
const journal = value as Record<string, unknown>;
|
||||||
|
if (typeof journal.installationDirectory !== 'string'
|
||||||
|
|| typeof journal.backupDirectory !== 'string'
|
||||||
|
|| typeof journal.stagingDirectory !== 'string'
|
||||||
|
|| (journal.phase !== 'staged' && journal.phase !== 'backup-created' && journal.phase !== 'promoted')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const expectedDirectory = path.resolve(installationDirectory);
|
||||||
|
return path.resolve(journal.installationDirectory) === expectedDirectory
|
||||||
|
&& isInstallationSibling(expectedDirectory, journal.backupDirectory, '.backup-')
|
||||||
|
&& isInstallationSibling(expectedDirectory, journal.stagingDirectory, '.stage-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInstallationSibling(installationDirectory: string, candidate: string, suffix: string): boolean {
|
||||||
|
const resolvedCandidate = path.resolve(candidate);
|
||||||
|
return path.dirname(resolvedCandidate) === path.dirname(installationDirectory)
|
||||||
|
&& path.basename(resolvedCandidate).startsWith(`${path.basename(installationDirectory)}${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
function matchesManifest(record: ManagedToolRecord, manifest: ExternalToolManifest): boolean {
|
function matchesManifest(record: ManagedToolRecord, manifest: ExternalToolManifest): boolean {
|
||||||
return record.id === manifest.id
|
return record.id === manifest.id
|
||||||
&& record.version === manifest.version
|
&& record.version === manifest.version
|
||||||
@@ -87,54 +135,33 @@ function matchesManifest(record: ManagedToolRecord, manifest: ExternalToolManife
|
|||||||
&& record.sha256 === manifest.sha256;
|
&& record.sha256 === manifest.sha256;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasExecutableHashes(record: ManagedToolRecord, executables: string[]): record is ManagedToolRecord & { executableHashes: Record<string, string> } {
|
||||||
|
if (!record.executableHashes || typeof record.executableHashes !== 'object') return false;
|
||||||
|
return executables.every((executable) => typeof record.executableHashes?.[executable] === 'string'
|
||||||
|
&& /^[a-f0-9]{64}$/i.test(record.executableHashes[executable]));
|
||||||
|
}
|
||||||
|
|
||||||
export class ManagedToolInstaller {
|
export class ManagedToolInstaller {
|
||||||
private readonly inFlight = new Map<ManagedToolId, Promise<ManagedToolInstallResult>>();
|
private readonly inFlight = new Map<ManagedToolId, Promise<ManagedToolInstallResult>>();
|
||||||
|
|
||||||
constructor(private readonly options: ManagedToolInstallerOptions) {}
|
constructor(private readonly options: ManagedToolInstallerOptions) {}
|
||||||
|
|
||||||
status(manifest: ExternalToolManifest, includeInstallState = true): ManagedToolStatus {
|
status(manifest: ExternalToolManifest, includeInstallState = true): ManagedToolStatus {
|
||||||
const base: Omit<ManagedToolStatus, 'state' | 'verified'> = {
|
|
||||||
id: manifest.id,
|
|
||||||
version: manifest.version,
|
|
||||||
sourceUrl: manifest.sourceUrl,
|
|
||||||
archiveName: manifest.archiveName
|
|
||||||
};
|
|
||||||
|
|
||||||
if (includeInstallState && this.inFlight.has(manifest.id)) {
|
if (includeInstallState && this.inFlight.has(manifest.id)) {
|
||||||
return { ...base, state: 'installing', verified: false };
|
return this.createStatus(manifest, 'installing', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(this.options.installationDirectory)) {
|
const recovery = this.recover(manifest, []);
|
||||||
return { ...base, state: 'missing', verified: false };
|
if (!recovery.success) {
|
||||||
|
return this.createStatus(manifest, 'corrupt', false);
|
||||||
}
|
}
|
||||||
|
return this.statusFromInspection(manifest);
|
||||||
let record: ManagedToolRecord | null = null;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(fs.readFileSync(path.join(this.options.installationDirectory, RECORD_FILE_NAME), 'utf8'));
|
|
||||||
if (!isRecord(parsed)) {
|
|
||||||
return { ...base, state: 'corrupt', verified: false };
|
|
||||||
}
|
|
||||||
record = parsed;
|
|
||||||
} catch {
|
|
||||||
const hasAnyExecutable = manifest.executables.some((name) => findFileRecursive(this.options.installationDirectory, name));
|
|
||||||
return { ...base, state: hasAnyExecutable ? 'unverified' : 'corrupt', verified: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const recordBase = { ...base, version: record.version };
|
|
||||||
if (!matchesManifest(record, manifest)) {
|
|
||||||
return { ...recordBase, state: 'unverified', verified: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const allExecutablesPresent = manifest.executables.every((name) => findFileRecursive(this.options.installationDirectory, name));
|
|
||||||
return allExecutablesPresent
|
|
||||||
? { ...recordBase, state: 'verified', verified: true }
|
|
||||||
: { ...recordBase, state: 'corrupt', verified: false };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
install(manifest: ExternalToolManifest): Promise<ManagedToolInstallResult> {
|
install(manifest: ExternalToolManifest): Promise<ManagedToolInstallResult> {
|
||||||
const currentStatus = this.status(manifest);
|
const currentStatus = this.status(manifest);
|
||||||
if (currentStatus.verified) {
|
if (currentStatus.verified) {
|
||||||
return Promise.resolve({ success: true, status: currentStatus });
|
return Promise.resolve({ success: true, status: currentStatus, diagnostics: [] });
|
||||||
}
|
}
|
||||||
return this.start(manifest);
|
return this.start(manifest);
|
||||||
}
|
}
|
||||||
@@ -147,7 +174,14 @@ export class ManagedToolInstaller {
|
|||||||
if (this.inFlight.has(manifest.id)) {
|
if (this.inFlight.has(manifest.id)) {
|
||||||
return this.status(manifest);
|
return this.status(manifest);
|
||||||
}
|
}
|
||||||
fs.rmSync(this.options.installationDirectory, { recursive: true, force: true });
|
const diagnostics: string[] = [];
|
||||||
|
const journal = this.readJournal();
|
||||||
|
if (isJournal(journal, this.options.installationDirectory)) {
|
||||||
|
this.cleanup(journal.backupDirectory, true, diagnostics);
|
||||||
|
this.cleanup(journal.stagingDirectory, true, diagnostics);
|
||||||
|
}
|
||||||
|
this.cleanup(this.options.installationDirectory, true, diagnostics);
|
||||||
|
this.cleanup(this.journalPath(), false, diagnostics);
|
||||||
return this.status(manifest);
|
return this.status(manifest);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,13 +189,16 @@ export class ManagedToolInstaller {
|
|||||||
const existing = this.inFlight.get(manifest.id);
|
const existing = this.inFlight.get(manifest.id);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
const operation = this.installOnce(manifest);
|
let operation!: Promise<ManagedToolInstallResult>;
|
||||||
this.inFlight.set(manifest.id, operation);
|
operation = Promise.resolve()
|
||||||
void operation.then(() => {
|
.then(() => this.installOnce(manifest))
|
||||||
|
.catch((error: unknown) => this.failure(manifest, 'download-failed', this.errorText(error), []))
|
||||||
|
.finally(() => {
|
||||||
if (this.inFlight.get(manifest.id) === operation) {
|
if (this.inFlight.get(manifest.id) === operation) {
|
||||||
this.inFlight.delete(manifest.id);
|
this.inFlight.delete(manifest.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
this.inFlight.set(manifest.id, operation);
|
||||||
return operation;
|
return operation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,78 +206,245 @@ export class ManagedToolInstaller {
|
|||||||
const uniqueSuffix = crypto.randomUUID();
|
const uniqueSuffix = crypto.randomUUID();
|
||||||
const archivePath = path.join(this.options.temporaryDirectory, `${manifest.archiveName}.${uniqueSuffix}`);
|
const archivePath = path.join(this.options.temporaryDirectory, `${manifest.archiveName}.${uniqueSuffix}`);
|
||||||
const stagingDirectory = `${this.options.installationDirectory}.stage-${uniqueSuffix}`;
|
const stagingDirectory = `${this.options.installationDirectory}.stage-${uniqueSuffix}`;
|
||||||
let error: ManagedToolInstallResult['error'] | undefined;
|
const diagnostics: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const recovery = this.recover(manifest, diagnostics);
|
||||||
|
if (!recovery.success) {
|
||||||
|
return this.failure(manifest, recovery.error!, recovery.detail, diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
fs.mkdirSync(this.options.temporaryDirectory, { recursive: true });
|
fs.mkdirSync(this.options.temporaryDirectory, { recursive: true });
|
||||||
await this.options.download(manifest.sourceUrl, archivePath);
|
await this.options.download(manifest.sourceUrl, archivePath);
|
||||||
if (sha256File(archivePath).toLowerCase() !== manifest.sha256.toLowerCase()) {
|
if (sha256File(archivePath).toLowerCase() !== manifest.sha256.toLowerCase()) {
|
||||||
error = 'archive-hash-mismatch';
|
return this.failure(manifest, 'archive-hash-mismatch', undefined, diagnostics);
|
||||||
return { success: false, error, status: this.status(manifest, false) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.options.extract(archivePath, stagingDirectory);
|
await this.options.extract(archivePath, stagingDirectory);
|
||||||
} catch {
|
} catch (error) {
|
||||||
error = 'extract-failed';
|
return this.failure(manifest, 'extract-failed', this.errorText(error), diagnostics);
|
||||||
return { success: false, error, status: this.status(manifest, false) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!manifest.executables.every((name) => findFileRecursive(stagingDirectory, name))) {
|
const executablePaths = this.executablePaths(stagingDirectory, manifest.executables);
|
||||||
error = 'required-executable-missing';
|
if (!executablePaths) {
|
||||||
return { success: false, error, status: this.status(manifest, false) };
|
return this.failure(manifest, 'required-executable-missing', undefined, diagnostics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const executableHashes = Object.fromEntries(executablePaths.map(([name, executablePath]) => [name, sha256File(executablePath)]));
|
||||||
const record: ManagedToolRecord = {
|
const record: ManagedToolRecord = {
|
||||||
id: manifest.id,
|
id: manifest.id,
|
||||||
version: manifest.version,
|
version: manifest.version,
|
||||||
sourceUrl: manifest.sourceUrl,
|
sourceUrl: manifest.sourceUrl,
|
||||||
archiveName: manifest.archiveName,
|
archiveName: manifest.archiveName,
|
||||||
sha256: manifest.sha256
|
sha256: manifest.sha256,
|
||||||
|
executableHashes
|
||||||
};
|
};
|
||||||
fs.writeFileSync(path.join(stagingDirectory, RECORD_FILE_NAME), JSON.stringify(record));
|
fs.writeFileSync(path.join(stagingDirectory, RECORD_FILE_NAME), JSON.stringify(record));
|
||||||
|
|
||||||
try {
|
const promotion = this.promote(manifest, stagingDirectory, diagnostics);
|
||||||
this.promote(stagingDirectory);
|
if (!promotion.success) {
|
||||||
} catch {
|
return this.failure(manifest, promotion.error!, promotion.detail, diagnostics);
|
||||||
error = 'promotion-failed';
|
|
||||||
return { success: false, error, status: this.status(manifest, false) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, status: this.status(manifest, false) };
|
return { success: true, status: this.statusFromInspection(manifest), diagnostics };
|
||||||
} catch {
|
} catch (error) {
|
||||||
error = 'download-failed';
|
return this.failure(manifest, 'download-failed', this.errorText(error), diagnostics);
|
||||||
return { success: false, error, status: this.status(manifest, false) };
|
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(archivePath, { force: true });
|
this.cleanup(archivePath, false, diagnostics);
|
||||||
fs.rmSync(stagingDirectory, { recursive: true, force: true });
|
this.cleanup(stagingDirectory, true, diagnostics);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private promote(stagingDirectory: string): void {
|
private promote(manifest: ExternalToolManifest, stagingDirectory: string, diagnostics: string[]): RecoveryResult {
|
||||||
const backupDirectory = `${this.options.installationDirectory}.backup-${crypto.randomUUID()}`;
|
const backupDirectory = `${this.options.installationDirectory}.backup-${crypto.randomUUID()}`;
|
||||||
const rename = this.options.rename ?? fs.renameSync;
|
const journal: InstallationJournal = {
|
||||||
let previousMoved = false;
|
installationDirectory: this.options.installationDirectory,
|
||||||
|
backupDirectory,
|
||||||
|
stagingDirectory,
|
||||||
|
phase: 'staged'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.writeJournal(journal, diagnostics);
|
||||||
|
if (fs.existsSync(this.options.installationDirectory)) {
|
||||||
|
this.rename(this.options.installationDirectory, backupDirectory);
|
||||||
|
journal.phase = 'backup-created';
|
||||||
|
this.writeJournal(journal, diagnostics);
|
||||||
|
}
|
||||||
|
this.rename(stagingDirectory, this.options.installationDirectory);
|
||||||
|
journal.phase = 'promoted';
|
||||||
|
this.writeJournal(journal, diagnostics);
|
||||||
|
this.cleanup(backupDirectory, true, diagnostics);
|
||||||
|
this.cleanup(this.journalPath(), false, diagnostics);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
const recovery = this.recover(manifest, diagnostics);
|
||||||
|
return recovery.success
|
||||||
|
? { success: false, error: 'promotion-failed', detail: this.errorText(error) }
|
||||||
|
: recovery;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private recover(manifest: ExternalToolManifest, diagnostics: string[]): RecoveryResult {
|
||||||
|
const journal = this.readJournal();
|
||||||
|
if (journal === undefined) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
if (!isJournal(journal, this.options.installationDirectory)) {
|
||||||
|
return { success: false, error: 'recovery-journal-invalid', detail: 'Ungültiges Installationsjournal.' };
|
||||||
|
}
|
||||||
|
return this.recoverJournal(manifest, journal, diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
private recoverJournal(manifest: ExternalToolManifest, journal: InstallationJournal, diagnostics: string[]): RecoveryResult {
|
||||||
|
const active = this.inspectInstallation(this.options.installationDirectory, manifest);
|
||||||
|
if (active.strictValid || (journal.phase === 'staged' && active.layoutUsable)) {
|
||||||
|
this.cleanup(journal.backupDirectory, true, diagnostics);
|
||||||
|
this.cleanup(journal.stagingDirectory, true, diagnostics);
|
||||||
|
this.cleanup(this.journalPath(), false, diagnostics);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const backup = this.inspectInstallation(journal.backupDirectory, manifest);
|
||||||
|
if (!backup.layoutUsable) {
|
||||||
|
return { success: false, error: 'recovery-restore-failed', detail: 'Kein nutzbares Tool-Backup für die Wiederherstellung vorhanden.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let displacedDirectory: string | undefined;
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(this.options.installationDirectory)) {
|
if (fs.existsSync(this.options.installationDirectory)) {
|
||||||
rename(this.options.installationDirectory, backupDirectory);
|
displacedDirectory = `${this.options.installationDirectory}.recovery-${crypto.randomUUID()}`;
|
||||||
previousMoved = true;
|
this.rename(this.options.installationDirectory, displacedDirectory);
|
||||||
}
|
|
||||||
rename(stagingDirectory, this.options.installationDirectory);
|
|
||||||
if (previousMoved) {
|
|
||||||
try {
|
|
||||||
fs.rmSync(backupDirectory, { recursive: true, force: true });
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
|
this.rename(journal.backupDirectory, this.options.installationDirectory);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (previousMoved && fs.existsSync(backupDirectory) && !fs.existsSync(this.options.installationDirectory)) {
|
return { success: false, error: 'recovery-restore-failed', detail: this.errorText(error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cleanup(journal.stagingDirectory, true, diagnostics);
|
||||||
|
if (displacedDirectory) {
|
||||||
|
this.cleanup(displacedDirectory, true, diagnostics);
|
||||||
|
}
|
||||||
|
this.cleanup(this.journalPath(), false, diagnostics);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private statusFromInspection(manifest: ExternalToolManifest): ManagedToolStatus {
|
||||||
|
const inspection = this.inspectInstallation(this.options.installationDirectory, manifest);
|
||||||
|
return this.createStatus(manifest, inspection.state, inspection.strictValid, inspection.version);
|
||||||
|
}
|
||||||
|
|
||||||
|
private inspectInstallation(directory: string, manifest: ExternalToolManifest): InstallationInspection {
|
||||||
|
if (!fs.existsSync(directory)) {
|
||||||
|
return { state: 'missing', version: manifest.version, layoutUsable: false, strictValid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const executablePaths = this.executablePaths(directory, manifest.executables);
|
||||||
|
if (!executablePaths) {
|
||||||
|
return { state: 'corrupt', version: manifest.version, layoutUsable: false, strictValid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = this.readRecord(directory);
|
||||||
|
if (!record || !isRecord(record)) {
|
||||||
|
return { state: 'unverified', version: manifest.version, layoutUsable: true, strictValid: false };
|
||||||
|
}
|
||||||
|
if (!matchesManifest(record, manifest)) {
|
||||||
|
return { state: 'unverified', version: record.version, layoutUsable: true, strictValid: false };
|
||||||
|
}
|
||||||
|
if (!hasExecutableHashes(record, manifest.executables)) {
|
||||||
|
return { state: 'unverified', version: record.version, layoutUsable: true, strictValid: false };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
rename(backupDirectory, this.options.installationDirectory);
|
const hashesMatch = executablePaths.every(([name, executablePath]) => sha256File(executablePath).toLowerCase() === record.executableHashes[name].toLowerCase());
|
||||||
} catch {}
|
return hashesMatch
|
||||||
|
? { state: 'verified', version: record.version, layoutUsable: true, strictValid: true }
|
||||||
|
: { state: 'corrupt', version: record.version, layoutUsable: true, strictValid: false };
|
||||||
|
} catch {
|
||||||
|
return { state: 'corrupt', version: record.version, layoutUsable: true, strictValid: false };
|
||||||
}
|
}
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private executablePaths(directory: string, executables: string[]): Array<[string, string]> | null {
|
||||||
|
const paths = executables.map((name) => [name, findFileRecursive(directory, name)] as const);
|
||||||
|
if (paths.some(([, executablePath]) => !executablePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return paths as Array<[string, string]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readRecord(directory: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(path.join(directory, RECORD_FILE_NAME), 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readJournal(): unknown | typeof INVALID_JOURNAL | undefined {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(this.journalPath())) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return JSON.parse(fs.readFileSync(this.journalPath(), 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return INVALID_JOURNAL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeJournal(journal: InstallationJournal, diagnostics: string[]): void {
|
||||||
|
const journalPath = this.journalPath();
|
||||||
|
const temporaryPath = `${journalPath}.${crypto.randomUUID()}.tmp`;
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(temporaryPath, JSON.stringify(journal));
|
||||||
|
this.rename(temporaryPath, journalPath);
|
||||||
|
} finally {
|
||||||
|
this.cleanup(temporaryPath, false, diagnostics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private journalPath(): string {
|
||||||
|
return `${this.options.installationDirectory}${JOURNAL_SUFFIX}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rename(sourcePath: string, destinationPath: string): void {
|
||||||
|
(this.options.rename ?? fs.renameSync)(sourcePath, destinationPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup(targetPath: string, recursive: boolean, diagnostics: string[]): void {
|
||||||
|
try {
|
||||||
|
(this.options.remove ?? fs.rmSync)(targetPath, { recursive, force: true });
|
||||||
|
} catch (error) {
|
||||||
|
const errorText = this.errorText(error);
|
||||||
|
diagnostics.push(errorText);
|
||||||
|
this.options.diagnostic?.('managed-tool-cleanup-failed', { path: targetPath, error: errorText });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createStatus(manifest: ExternalToolManifest, state: ManagedToolStatus['state'], verified: boolean, version = manifest.version): ManagedToolStatus {
|
||||||
|
return {
|
||||||
|
id: manifest.id,
|
||||||
|
version,
|
||||||
|
sourceUrl: manifest.sourceUrl,
|
||||||
|
archiveName: manifest.archiveName,
|
||||||
|
state,
|
||||||
|
verified
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private failure(manifest: ExternalToolManifest, error: NonNullable<ManagedToolInstallResult['error']>, detail: string | undefined, diagnostics: string[]): ManagedToolInstallResult {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error,
|
||||||
|
detail,
|
||||||
|
status: this.statusFromInspection(manifest),
|
||||||
|
diagnostics
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private errorText(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -397,7 +397,8 @@ function getManagedToolInstaller(toolId: 'streamlink' | 'ffmpeg'): ManagedToolIn
|
|||||||
if (!await extractZip(archivePath, destinationPath)) {
|
if (!await extractZip(archivePath, destinationPath)) {
|
||||||
throw new Error('tool archive extraction failed');
|
throw new Error('tool archive extraction failed');
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
diagnostic: (message, details) => _appendDebugLog(message, details)
|
||||||
});
|
});
|
||||||
managedToolInstallers.set(toolId, installer);
|
managedToolInstallers.set(toolId, installer);
|
||||||
return installer;
|
return installer;
|
||||||
|
|||||||
Reference in New Issue
Block a user