#!/usr/bin/env node import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; import { access, chmod, copyFile, cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, utimes, writeFile, } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; const execute = promisify(execFile); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8")); const argument = (name, fallback) => { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : fallback; }; const output = path.resolve( root, argument("--output", "release/" + pkg.name + "-" + pkg.version + ".zip"), ); const checksumOutput = output + ".sha256"; const force = process.argv.includes("--force"); if ( path.extname(output).toLowerCase() !== ".zip" || output === root || output === path.parse(output).root ) throw new Error("Unsafe release target"); const exists = (file) => access(file).then( () => true, () => false, ); if (!force && ((await exists(output)) || (await exists(checksumOutput)))) throw new Error("Release output exists"); const input = path.join(root, "dist"); for (const name of [ "index.html", "manifest.webmanifest", "sw.js", "toolbox-app.json", "favicon.svg", "ocr/worker.min.js", "ocr/core/tesseract-core-lstm.wasm.js", "ocr/core/tesseract-core-simd-lstm.wasm.js", "ocr/core/tesseract-core-relaxedsimd-lstm.wasm.js", "ocr/lang/eng.traineddata.gz", "README.md", "CHANGELOG.md", "CONTRIBUTING.md", "LICENSE", "SECURITY.md", "SOURCE.md", "THIRD_PARTY_NOTICES.md", "LICENSES/README.md", "LICENSES/npm-runtime-licenses.txt", "docs/ACCESSIBILITY.md", "docs/ARCHITECTURE.md", "docs/PRIVACY-SECURITY.md", ]) { const details = await lstat(path.join(input, name)).catch(() => null); if (!details?.isFile() || details.isSymbolicLink()) throw new Error("Missing release file: " + name); } const manifest = JSON.parse( await readFile(path.join(input, "toolbox-app.json"), "utf8"), ); const repository = "https://git.add-ideas.de/lotobo/" + pkg.name; if ( manifest.id !== "de.add-ideas." + pkg.name || manifest.version !== pkg.version || manifest.entry !== "./" || manifest.icon !== "./favicon.svg" || manifest.source?.repository !== repository ) throw new Error("Packaged manifest identity is invalid"); if ( /\b(?:src|href)=["']\//iu.test( await readFile(path.join(input, "index.html"), "utf8"), ) ) throw new Error("Root-absolute asset reference"); async function collect(directory, prefix = "") { const files = []; for (const entry of (await readdir(directory, { withFileTypes: true })).sort( (a, b) => a.name.localeCompare(b.name), )) { const absolute = path.join(directory, entry.name); const relative = prefix ? prefix + "/" + entry.name : entry.name; if (entry.isSymbolicLink()) throw new Error("Symlink in release: " + relative); if (entry.isDirectory()) files.push(...(await collect(absolute, relative))); else if (entry.isFile()) files.push({ absolute, relative }); else throw new Error("Unsupported release entry: " + relative); } return files; } const sourceFiles = await collect(input); for (const file of sourceFiles) if ( file.relative.endsWith(".map") || /(?:^|\/)(?:\.env(?:\.|$)|id_rsa|id_ed25519|.*\.pem$|.*\.key$)/iu.test( file.relative, ) || file.relative.split("/").includes("..") ) throw new Error("Forbidden release entry: " + file.relative); await mkdir(path.dirname(output), { recursive: true }); const stagingRoot = await mkdtemp( path.join(os.tmpdir(), pkg.name + "-release-"), ); const publicationRoot = await mkdtemp( path.join(path.dirname(output), "." + pkg.name + "-publish-"), ); const stagedTree = path.join(stagingRoot, "tree"); const stagedArchive = path.join(stagingRoot, path.basename(output)); try { await cp(input, stagedTree, { recursive: true }); const timestamp = new Date("1980-01-01T00:00:00.000Z"); for (const file of await collect(stagedTree)) { await chmod(file.absolute, 0o644); await utimes(file.absolute, timestamp, timestamp); } await execute( "zip", [ "-X", "-q", "-9", stagedArchive, ...sourceFiles.map((file) => file.relative), ], { cwd: stagedTree, env: { ...process.env, TZ: "UTC" }, maxBuffer: 1024 * 1024, }, ); const archive = await readFile(stagedArchive); const digest = createHash("sha256").update(archive).digest("hex"); const stagedChecksum = stagedArchive + ".sha256"; await writeFile(stagedChecksum, digest + " " + path.basename(output) + "\n"); const publicationArchive = path.join(publicationRoot, path.basename(output)); const publicationChecksum = publicationArchive + ".sha256"; await copyFile(stagedArchive, publicationArchive); await copyFile(stagedChecksum, publicationChecksum); if (force) { await rm(output, { force: true }); await rm(checksumOutput, { force: true }); } await rename(publicationArchive, output); await rename(publicationChecksum, checksumOutput); console.log("Created " + path.relative(root, output) + "\nSHA-256 " + digest); } finally { await rm(stagingRoot, { recursive: true, force: true }); await rm(publicationRoot, { recursive: true, force: true }); }