#!/usr/bin/env node import { execFile } from "node:child_process"; import { lstat, mkdir, mkdtemp, readdir, rm, stat } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { promisify } from "node:util"; type Options = { dryRun: boolean; outputDirectory: string; sourceDirectory: string; temporaryDirectory: string; }; const execFileAsync = promisify(execFile); function usage(): void { console.log(`Usage: pnpm extract-archives [options] Recursively extracts every ZIP from catalog into the output directory. Category and program paths are preserved; each archive is extracted into a directory named after it. Existing destination directories and files are skipped. --dry-run list operations without extracting files --source read ZIP archives from a directory other than catalog --temp-dir use a specific directory for temporary files --help show this help message`); } function parseArguments(args: string[]): Options { const positional: string[] = []; let dryRun = false; let sourceDirectory = path.resolve("catalog"); let temporaryDirectory = "/tmp/codex/extract-archives"; for (let index = 0; index < args.length; index += 1) { const argument = args[index]; if (argument === "--help" || argument === "-h") { usage(); process.exit(0); } else if (argument === "--dry-run") { dryRun = true; } else if (argument === "--source") { const value = args[index + 1]; if (value === undefined) { throw new Error("--source requires a path"); } sourceDirectory = path.resolve(value); index += 1; } else if (argument === "--temp-dir") { const value = args[index + 1]; if (value === undefined) { throw new Error("--temp-dir requires a path"); } temporaryDirectory = path.resolve(value); index += 1; } else if (argument.startsWith("-")) { throw new Error(`Unknown option: ${argument}`); } else { positional.push(argument); } } if (positional.length !== 1) { throw new Error("Expected one output directory path"); } return { dryRun, outputDirectory: path.resolve(positional[0]), sourceDirectory, temporaryDirectory, }; } async function run(command: string, args: string[]): Promise { const { stdout } = await execFileAsync(command, args, { encoding: "utf8", maxBuffer: 32 * 1024 * 1024, }); return stdout; } async function collectArchives(directory: string): Promise { const archives: string[] = []; async function visit(currentDirectory: string): Promise { const entries = await readdir(currentDirectory, { withFileTypes: true }); entries.sort((left, right) => left.name.localeCompare(right.name, "en")); for (const entry of entries) { const entryPath = path.join(currentDirectory, entry.name); if (entry.isDirectory()) { await visit(entryPath); } else if (entry.isFile() && path.extname(entry.name).toLowerCase() === ".zip") { archives.push(entryPath); } } } await visit(directory); return archives; } function destinationFor(archive: string, sourceDirectory: string, outputDirectory: string): string { const basename = path.basename(archive); const stem = basename.slice(0, -path.extname(basename).length); if (stem.length === 0) { throw new Error(`Archive has no usable name: ${archive}`); } const relativeArchive = path.relative(sourceDirectory, archive); if ( relativeArchive === ".." || relativeArchive.startsWith(`..${path.sep}`) || path.isAbsolute(relativeArchive) ) { throw new Error(`Archive is outside the source directory: ${archive}`); } let relativeParent = path.dirname(relativeArchive); if (path.basename(relativeParent) === "files") { relativeParent = path.dirname(relativeParent); } return path.join(outputDirectory, relativeParent, stem); } async function pathExists(target: string): Promise { try { await lstat(target); return true; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return false; } throw error; } } function validateArchivePaths(archive: string, listing: string): void { for (const originalEntry of listing.split(/\r?\n/)) { if (originalEntry.length === 0) { continue; } const entry = originalEntry.replaceAll("\\", "/"); const parts = entry.split("/"); if ( entry.startsWith("/") || /^[a-z]:($|\/)/i.test(entry) || parts.includes("..") ) { throw new Error(`${archive}: unsafe path in ZIP: ${originalEntry}`); } } } async function validateExtractedTree(directory: string): Promise { for (const entry of await readdir(directory, { withFileTypes: true })) { const entryPath = path.join(directory, entry.name); const entryStat = await lstat(entryPath); if (entryStat.isSymbolicLink()) { throw new Error(`ZIP contains a symbolic link: ${entryPath}`); } if (entryStat.isDirectory()) { await validateExtractedTree(entryPath); } else if (!entryStat.isFile()) { throw new Error(`ZIP contains an unsupported special file: ${entryPath}`); } } } async function extractArchive(archive: string, destination: string, temporaryRoot: string): Promise { await run("7z", ["t", archive, "-bso0", "-bsp0"]); validateArchivePaths(archive, await run("zipinfo", ["-1", archive])); const temporaryDirectory = await mkdtemp(path.join(temporaryRoot, "archive-")); let destinationCreated = false; try { await run("7z", ["x", archive, `-o${temporaryDirectory}`, "-y", "-bso0", "-bsp0"]); await validateExtractedTree(temporaryDirectory); await mkdir(path.dirname(destination), { recursive: true }); await mkdir(destination); destinationCreated = true; await run("cp", ["-a", "--", `${temporaryDirectory}${path.sep}.`, destination]); } catch (error) { if (destinationCreated) { await rm(destination, { recursive: true, force: true }); } throw error; } finally { await rm(temporaryDirectory, { recursive: true, force: true }); } } async function main(): Promise { const options = parseArguments(process.argv.slice(2)); const rootStat = await stat(options.sourceDirectory); if (!rootStat.isDirectory()) { throw new Error(`Expected a source directory: ${options.sourceDirectory}`); } const relativeOutput = path.relative(options.sourceDirectory, options.outputDirectory); if ( relativeOutput === "" || (!relativeOutput.startsWith(`..${path.sep}`) && relativeOutput !== ".." && !path.isAbsolute(relativeOutput)) ) { throw new Error("Output directory must not be inside the source directory"); } const archives = await collectArchives(options.sourceDirectory); if (archives.length === 0) { console.log(`No ZIP archives found in ${options.sourceDirectory}`); return; } if (!options.dryRun) { await mkdir(options.outputDirectory, { recursive: true }); await mkdir(options.temporaryDirectory, { recursive: true }); } let extracted = 0; let failed = 0; let skipped = 0; for (const archive of archives) { try { const destination = destinationFor( archive, options.sourceDirectory, options.outputDirectory, ); if (await pathExists(destination)) { skipped += 1; console.log(`Skipped: ${archive} (destination exists: ${destination})`); } else if (options.dryRun) { console.log(`Would extract: ${archive} -> ${destination}`); } else { await extractArchive(archive, destination, options.temporaryDirectory); extracted += 1; console.log(`Extracted: ${archive} -> ${destination}`); } } catch (error) { failed += 1; const message = error instanceof Error ? error.message : String(error); console.error(`Failed: ${archive}: ${message}`); } } console.log( `Done. Archives: ${archives.length}; extracted: ${extracted}; skipped: ${skipped}; failed: ${failed}`, ); if (failed > 0) { process.exitCode = 1; } } try { await main(); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`Error: ${message}`); process.exitCode = 1; }