feat: publish Regex Tools 0.1.0
This commit is contained in:
24
scripts/build-engines.mjs
Normal file
24
scripts/build-engines.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
const engineDirectory = path.join(root, "public", "engines");
|
||||
const entries = (await readdir(engineDirectory)).sort();
|
||||
|
||||
if (
|
||||
packageJson.version !== "0.1.0" ||
|
||||
entries.length !== 1 ||
|
||||
entries[0] !== "README.md"
|
||||
) {
|
||||
throw new Error(
|
||||
"The ECMAScript release must contain only the documented native-browser engine placeholder.",
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Regex Tools 0.1.0 uses the native browser RegExp engine; no external engine pack needs building.",
|
||||
);
|
||||
12
scripts/checksum-release.mjs
Normal file
12
scripts/checksum-release.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export function sha256(data) {
|
||||
return createHash("sha256").update(data).digest("hex");
|
||||
}
|
||||
|
||||
export function checksumLine(digest, fileName) {
|
||||
if (!/^[a-f0-9]{64}$/u.test(digest) || /[\/\r\n]/u.test(fileName)) {
|
||||
throw new Error("Invalid checksum output");
|
||||
}
|
||||
return `${digest} ${fileName}\n`;
|
||||
}
|
||||
42
scripts/generate-toolbox-manifest.mjs
Normal file
42
scripts/generate-toolbox-manifest.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { format } from "prettier";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourcePath = join(root, "src", "toolbox", "manifest.source.json");
|
||||
const outputPath = join(root, "public", "toolbox-app.json");
|
||||
const packagePath = join(root, "package.json");
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
|
||||
const source = JSON.parse(await readFile(sourcePath, "utf8"));
|
||||
const packageJson = JSON.parse(await readFile(packagePath, "utf8"));
|
||||
|
||||
if (source.version !== packageJson.version) {
|
||||
throw new Error(
|
||||
`Manifest version ${source.version} differs from package version ${packageJson.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
source.source?.repository !== "https://git.add-ideas.de/zemion/regex-tools" ||
|
||||
source.source?.license !== "GPL-3.0-or-later"
|
||||
) {
|
||||
throw new Error("Manifest source identity is incomplete or inconsistent");
|
||||
}
|
||||
|
||||
const serialized = await format(JSON.stringify(source), {
|
||||
filepath: outputPath,
|
||||
});
|
||||
if (checkOnly) {
|
||||
const current = await readFile(outputPath, "utf8").catch(() => "");
|
||||
if (current !== serialized) {
|
||||
throw new Error(
|
||||
`${relative(root, outputPath)} is stale; run npm run manifest:generate`,
|
||||
);
|
||||
}
|
||||
console.log("Toolbox manifest is synchronized");
|
||||
} else {
|
||||
await writeFile(outputPath, serialized);
|
||||
console.log(`Generated ${relative(root, outputPath)}`);
|
||||
}
|
||||
526
scripts/package-release.mjs
Normal file
526
scripts/package-release.mjs
Normal file
@@ -0,0 +1,526 @@
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { zipSync } from "fflate";
|
||||
import { checksumLine, sha256 } from "./checksum-release.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
|
||||
const gplVersion3TextSha256 =
|
||||
"fb981668c18a279e285fc4d83fba1e836cc84dd4daa73c9697d3cfd2d8aca6e0";
|
||||
const requiredRootFiles = [
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"SOURCE.md",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
];
|
||||
|
||||
function compareText(left, right) {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
export function safeArchivePath(fileName) {
|
||||
if (
|
||||
typeof fileName !== "string" ||
|
||||
fileName.length === 0 ||
|
||||
fileName.includes("\0") ||
|
||||
fileName.includes("\\") ||
|
||||
fileName.startsWith("/") ||
|
||||
/^[A-Za-z]:\//u.test(fileName)
|
||||
) {
|
||||
throw new Error(`Invalid release path: ${String(fileName)}`);
|
||||
}
|
||||
const parts = fileName.split("/");
|
||||
if (
|
||||
parts.some(
|
||||
(component) =>
|
||||
component === "" || component === "." || component === "..",
|
||||
) ||
|
||||
path.posix.normalize(fileName) !== fileName
|
||||
) {
|
||||
throw new Error(`Non-canonical release path: ${fileName}`);
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
|
||||
async function collectDirectory(directory, prefix = "") {
|
||||
const result = [];
|
||||
const visit = async (current) => {
|
||||
const children = await readdir(current, { withFileTypes: true });
|
||||
children.sort((left, right) => compareText(left.name, right.name));
|
||||
for (const child of children) {
|
||||
const absolute = path.join(current, child.name);
|
||||
const details = await lstat(absolute);
|
||||
if (details.isSymbolicLink()) {
|
||||
throw new Error(`Release input contains a symlink: ${absolute}`);
|
||||
}
|
||||
if (details.isDirectory()) {
|
||||
await visit(absolute);
|
||||
} else if (details.isFile()) {
|
||||
const relative = path
|
||||
.relative(directory, absolute)
|
||||
.split(path.sep)
|
||||
.join("/");
|
||||
result.push({
|
||||
name: safeArchivePath(
|
||||
prefix ? path.posix.join(prefix, relative) : relative,
|
||||
),
|
||||
data: await readFile(absolute),
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Release input contains a special file: ${absolute}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
await visit(directory);
|
||||
return result;
|
||||
}
|
||||
|
||||
function safePackageDirectory(name, version) {
|
||||
const normalized = `${name.replace(/^@/u, "").replaceAll("/", "__")}-${
|
||||
version
|
||||
}`;
|
||||
if (!/^[A-Za-z0-9._@+-]+$/u.test(normalized)) {
|
||||
throw new Error(`Unsafe package identity: ${name}@${version}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function collectRuntimePackages(packageJson) {
|
||||
const packages = new Map();
|
||||
const visit = async (name, fromDirectory) => {
|
||||
const resolver = createRequire(path.join(fromDirectory, "package.json"));
|
||||
let packagePath;
|
||||
try {
|
||||
packagePath = resolver.resolve(`${name}/package.json`);
|
||||
} catch {
|
||||
const entry = resolver.resolve(name);
|
||||
let directory = path.dirname(entry);
|
||||
while (directory !== path.dirname(directory)) {
|
||||
const candidate = path.join(directory, "package.json");
|
||||
const details = await lstat(candidate).catch(() => null);
|
||||
if (details?.isFile()) {
|
||||
const candidateJson = JSON.parse(await readFile(candidate, "utf8"));
|
||||
if (candidateJson.name === name) {
|
||||
packagePath = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
directory = path.dirname(directory);
|
||||
}
|
||||
}
|
||||
if (!packagePath) {
|
||||
throw new Error(`Could not resolve runtime package ${name}.`);
|
||||
}
|
||||
const details = JSON.parse(await readFile(packagePath, "utf8"));
|
||||
const key = `${details.name}@${details.version}`;
|
||||
if (packages.has(key)) return;
|
||||
const directory = path.dirname(packagePath);
|
||||
packages.set(key, { directory, details });
|
||||
for (const dependency of Object.keys(details.dependencies ?? {}).sort()) {
|
||||
await visit(dependency, directory);
|
||||
}
|
||||
};
|
||||
for (const dependency of Object.keys(packageJson.dependencies ?? {}).sort()) {
|
||||
await visit(dependency, root);
|
||||
}
|
||||
return [...packages.values()].sort((left, right) =>
|
||||
compareText(
|
||||
`${left.details.name}@${left.details.version}`,
|
||||
`${right.details.name}@${right.details.version}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function collectRuntimeLicenceEntries(packageJson) {
|
||||
const packages = await collectRuntimePackages(packageJson);
|
||||
const entries = [];
|
||||
const rows = [
|
||||
"# Runtime npm dependency licences",
|
||||
"",
|
||||
"| Package | Version | Licence | Repository |",
|
||||
"| --- | --- | --- | --- |",
|
||||
];
|
||||
for (const runtimePackage of packages) {
|
||||
const { details, directory } = runtimePackage;
|
||||
const legalFiles = (await readdir(directory, { withFileTypes: true }))
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile() &&
|
||||
/^(?:licen[cs]e|copying|notice)(?:\.|$)/iu.test(entry.name),
|
||||
)
|
||||
.sort((left, right) => compareText(left.name, right.name));
|
||||
if (legalFiles.length === 0) {
|
||||
throw new Error(
|
||||
`Runtime package ${details.name}@${details.version} has no licence file.`,
|
||||
);
|
||||
}
|
||||
const packageDirectory = safePackageDirectory(
|
||||
details.name,
|
||||
details.version,
|
||||
);
|
||||
for (const legalFile of legalFiles) {
|
||||
entries.push({
|
||||
name: safeArchivePath(
|
||||
`LICENSES/npm/${packageDirectory}/${legalFile.name}`,
|
||||
),
|
||||
data: await readFile(path.join(directory, legalFile.name)),
|
||||
});
|
||||
}
|
||||
const repository =
|
||||
typeof details.repository === "string"
|
||||
? details.repository
|
||||
: (details.repository?.url ?? "");
|
||||
rows.push(
|
||||
`| \`${details.name}\` | ${details.version} | ${
|
||||
details.license ?? "See packaged licence"
|
||||
} | ${String(repository).replaceAll("|", "\\|")} |`,
|
||||
);
|
||||
}
|
||||
entries.push({
|
||||
name: "LICENSES/npm/DEPENDENCIES.md",
|
||||
data: Buffer.from(`${rows.join("\n")}\n`),
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function collectBuildGeneratedLicenceEntries() {
|
||||
const resolver = createRequire(path.join(root, "package.json"));
|
||||
const dependencies = [
|
||||
{
|
||||
name: "rolldown",
|
||||
version: "1.1.5",
|
||||
license: "MIT",
|
||||
legalFile: "LICENSE",
|
||||
},
|
||||
{
|
||||
name: "vite",
|
||||
version: "8.1.5",
|
||||
license: "MIT",
|
||||
legalFile: "LICENSE.md",
|
||||
},
|
||||
];
|
||||
const entries = [];
|
||||
for (const dependency of dependencies) {
|
||||
const packagePath = resolver.resolve(`${dependency.name}/package.json`);
|
||||
const details = JSON.parse(await readFile(packagePath, "utf8"));
|
||||
if (
|
||||
details.name !== dependency.name ||
|
||||
details.version !== dependency.version ||
|
||||
details.license !== dependency.license
|
||||
) {
|
||||
throw new Error(
|
||||
`Build dependency identity drifted: expected ${dependency.name}@${dependency.version} (${dependency.license}).`,
|
||||
);
|
||||
}
|
||||
const legalPath = path.join(
|
||||
path.dirname(packagePath),
|
||||
dependency.legalFile,
|
||||
);
|
||||
const legalDetails = await lstat(legalPath);
|
||||
if (legalDetails.isSymbolicLink() || !legalDetails.isFile()) {
|
||||
throw new Error(
|
||||
`Build dependency legal file is not a regular file: ${legalPath}.`,
|
||||
);
|
||||
}
|
||||
entries.push({
|
||||
name: safeArchivePath(
|
||||
`LICENSES/build/${safePackageDirectory(
|
||||
details.name,
|
||||
details.version,
|
||||
)}/${dependency.legalFile}`,
|
||||
),
|
||||
data: await readFile(legalPath),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function scanEntry(entry) {
|
||||
if (entry.name.endsWith(".map")) {
|
||||
throw new Error(`Source map is not approved for release: ${entry.name}`);
|
||||
}
|
||||
const components = entry.name.toLowerCase().split("/");
|
||||
if (
|
||||
components.some((component) =>
|
||||
[
|
||||
".env",
|
||||
".npmrc",
|
||||
".git",
|
||||
"credentials",
|
||||
"id_rsa",
|
||||
"id_ed25519",
|
||||
].includes(component),
|
||||
) ||
|
||||
/\.(?:key|pem|p12|pfx)$/iu.test(entry.name)
|
||||
) {
|
||||
throw new Error(`Credential-like release path: ${entry.name}`);
|
||||
}
|
||||
if (!/\.(?:css|html|js|json|md|svg|txt)$/iu.test(entry.name)) return;
|
||||
const text = entry.data.toString("utf8");
|
||||
if (/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/u.test(text)) {
|
||||
throw new Error(`Private key material detected in ${entry.name}`);
|
||||
}
|
||||
if (/https?:\/\/[^/@\s]+:[^/@\s]+@/iu.test(text)) {
|
||||
throw new Error(`Credential-bearing URL detected in ${entry.name}`);
|
||||
}
|
||||
if (
|
||||
/(?:^|[\s"'`])(?:\/home\/|\/mnt\/|\/Users\/|[A-Za-z]:\\Users\\)/mu.test(
|
||||
text,
|
||||
)
|
||||
) {
|
||||
throw new Error(`Local absolute path detected in ${entry.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function createDeterministicZip(inputEntries) {
|
||||
const entries = inputEntries
|
||||
.map((entry) => ({
|
||||
name: safeArchivePath(entry.name),
|
||||
data: Buffer.from(entry.data),
|
||||
}))
|
||||
.sort((left, right) => compareText(left.name, right.name));
|
||||
const seen = new Set();
|
||||
const zippable = Object.create(null);
|
||||
let totalBytes = 0;
|
||||
for (const entry of entries) {
|
||||
if (seen.has(entry.name)) {
|
||||
throw new Error(`Duplicate release path: ${entry.name}`);
|
||||
}
|
||||
seen.add(entry.name);
|
||||
totalBytes += entry.data.byteLength;
|
||||
if (entry.data.byteLength > 128 * 1024 * 1024) {
|
||||
throw new Error(`Release entry is too large: ${entry.name}`);
|
||||
}
|
||||
if (totalBytes > 512 * 1024 * 1024) {
|
||||
throw new Error("Release exceeds the 512 MiB uncompressed limit.");
|
||||
}
|
||||
scanEntry(entry);
|
||||
zippable[entry.name] = [
|
||||
new Uint8Array(entry.data),
|
||||
{
|
||||
attrs: (0o100644 << 16) >>> 0,
|
||||
level: 9,
|
||||
mtime: zipEpoch,
|
||||
os: 3,
|
||||
},
|
||||
];
|
||||
}
|
||||
return Buffer.from(
|
||||
zipSync(zippable, {
|
||||
attrs: (0o100644 << 16) >>> 0,
|
||||
level: 9,
|
||||
mtime: zipEpoch,
|
||||
os: 3,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function collectReleaseEntries() {
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
if (
|
||||
packageJson.version !== "0.1.0" ||
|
||||
packageJson.license !== "GPL-3.0-or-later" ||
|
||||
packageJson.repository?.url !==
|
||||
"git+https://git.add-ideas.de/zemion/regex-tools.git"
|
||||
) {
|
||||
throw new Error("Package version, licence or source identity drifted.");
|
||||
}
|
||||
const licence = await readFile(path.join(root, "LICENSE"), "utf8");
|
||||
if (
|
||||
!licence.includes("GNU GENERAL PUBLIC LICENSE") ||
|
||||
!licence.includes("Version 3, 29 June 2007") ||
|
||||
!licence.includes("END OF TERMS AND CONDITIONS") ||
|
||||
sha256(Buffer.from(licence)) !== gplVersion3TextSha256
|
||||
) {
|
||||
throw new Error(
|
||||
"Root LICENSE is not the exact complete GPL version 3 text.",
|
||||
);
|
||||
}
|
||||
const entries = await collectDirectory(path.join(root, "dist"));
|
||||
entries.push(
|
||||
...(await collectDirectory(path.join(root, "LICENSES"), "LICENSES")),
|
||||
);
|
||||
entries.push(...(await collectRuntimeLicenceEntries(packageJson)));
|
||||
entries.push(...(await collectBuildGeneratedLicenceEntries()));
|
||||
for (const fileName of requiredRootFiles) {
|
||||
entries.push({
|
||||
name: fileName,
|
||||
data: await readFile(path.join(root, fileName)),
|
||||
});
|
||||
}
|
||||
const names = new Set(entries.map((entry) => entry.name));
|
||||
for (const required of [
|
||||
"index.html",
|
||||
"toolbox-app.json",
|
||||
"favicon.svg",
|
||||
"LICENSE",
|
||||
"CHANGELOG.md",
|
||||
"SOURCE.md",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
"LICENSES/build/rolldown-1.1.5/LICENSE",
|
||||
"LICENSES/build/vite-8.1.5/LICENSE.md",
|
||||
]) {
|
||||
if (!names.has(required)) {
|
||||
throw new Error(`Required release file is missing: ${required}`);
|
||||
}
|
||||
}
|
||||
if (![...names].some((name) => name.startsWith("assets/"))) {
|
||||
throw new Error("Release has no production assets.");
|
||||
}
|
||||
if (![...names].some((name) => name.startsWith("LICENSES/"))) {
|
||||
throw new Error("Release has no third-party licence directory.");
|
||||
}
|
||||
const manifest = JSON.parse(
|
||||
entries
|
||||
.find((entry) => entry.name === "toolbox-app.json")
|
||||
.data.toString("utf8"),
|
||||
);
|
||||
if (
|
||||
manifest.id !== "de.add-ideas.regex-tools" ||
|
||||
manifest.version !== packageJson.version ||
|
||||
manifest.source?.repository !==
|
||||
"https://git.add-ideas.de/zemion/regex-tools" ||
|
||||
manifest.source?.license !== "GPL-3.0-or-later"
|
||||
) {
|
||||
throw new Error("Packaged manifest identity is inconsistent.");
|
||||
}
|
||||
const indexHtml = entries
|
||||
.find((entry) => entry.name === "index.html")
|
||||
.data.toString("utf8");
|
||||
if (/(?:src|href)\s*=\s*["']\/(?!\/)/iu.test(indexHtml)) {
|
||||
throw new Error("index.html contains a root-absolute asset path.");
|
||||
}
|
||||
return { entries, packageJson };
|
||||
}
|
||||
|
||||
export function parseReleaseArguments(argumentsList) {
|
||||
const allowed = new Set(["--force"]);
|
||||
const unknown = argumentsList.filter((argument) => !allowed.has(argument));
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`Unknown release argument: ${unknown.join(", ")}`);
|
||||
}
|
||||
return { force: argumentsList.includes("--force") };
|
||||
}
|
||||
|
||||
async function assertWritableTarget(target, force) {
|
||||
try {
|
||||
const details = await lstat(target);
|
||||
if (details.isSymbolicLink() || !details.isFile()) {
|
||||
throw new Error(`Release target is not a regular file: ${target}`);
|
||||
}
|
||||
if (!force) {
|
||||
throw new Error(
|
||||
`${path.relative(root, target)} exists; pass --force to replace this exact version.`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && error.code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function publishReleasePair({
|
||||
archive,
|
||||
archivePath,
|
||||
checksum,
|
||||
checksumPath,
|
||||
archiveExists,
|
||||
checksumExists,
|
||||
}) {
|
||||
const releaseDirectory = path.dirname(archivePath);
|
||||
const stage = await mkdtemp(
|
||||
path.join(releaseDirectory, ".regex-tools-release-"),
|
||||
);
|
||||
const stagedArchive = path.join(stage, path.basename(archivePath));
|
||||
const stagedChecksum = path.join(stage, path.basename(checksumPath));
|
||||
const previousArchive = path.join(stage, "previous-archive");
|
||||
const previousChecksum = path.join(stage, "previous-checksum");
|
||||
let archiveBackedUp = false;
|
||||
let checksumBackedUp = false;
|
||||
let archivePublished = false;
|
||||
let checksumPublished = false;
|
||||
try {
|
||||
await writeFile(stagedArchive, archive, { flag: "wx", mode: 0o644 });
|
||||
await writeFile(stagedChecksum, checksum, {
|
||||
encoding: "utf8",
|
||||
flag: "wx",
|
||||
mode: 0o644,
|
||||
});
|
||||
if (archiveExists) {
|
||||
await rename(archivePath, previousArchive);
|
||||
archiveBackedUp = true;
|
||||
}
|
||||
if (checksumExists) {
|
||||
await rename(checksumPath, previousChecksum);
|
||||
checksumBackedUp = true;
|
||||
}
|
||||
await rename(stagedArchive, archivePath);
|
||||
archivePublished = true;
|
||||
await rename(stagedChecksum, checksumPath);
|
||||
checksumPublished = true;
|
||||
} catch (error) {
|
||||
if (checksumPublished) await rm(checksumPath, { force: true });
|
||||
if (archivePublished) await rm(archivePath, { force: true });
|
||||
if (checksumBackedUp) await rename(previousChecksum, checksumPath);
|
||||
if (archiveBackedUp) await rename(previousArchive, archivePath);
|
||||
throw error;
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseReleaseArguments(process.argv.slice(2));
|
||||
const { entries, packageJson } = await collectReleaseEntries();
|
||||
const archive = createDeterministicZip(entries);
|
||||
const fileName = `regex-tools-${packageJson.version}.zip`;
|
||||
const digest = sha256(archive);
|
||||
const releaseDirectory = path.join(root, "release");
|
||||
await mkdir(releaseDirectory, { recursive: true });
|
||||
const releaseDirectoryDetails = await lstat(releaseDirectory);
|
||||
if (
|
||||
releaseDirectoryDetails.isSymbolicLink() ||
|
||||
!releaseDirectoryDetails.isDirectory()
|
||||
) {
|
||||
throw new Error("release/ must be a real directory.");
|
||||
}
|
||||
const archivePath = path.join(releaseDirectory, fileName);
|
||||
const checksumPath = `${archivePath}.sha256`;
|
||||
const archiveExists = await assertWritableTarget(archivePath, options.force);
|
||||
const checksumExists = await assertWritableTarget(
|
||||
checksumPath,
|
||||
options.force,
|
||||
);
|
||||
await publishReleasePair({
|
||||
archive,
|
||||
archivePath,
|
||||
checksum: checksumLine(digest, fileName),
|
||||
checksumPath,
|
||||
archiveExists,
|
||||
checksumExists,
|
||||
});
|
||||
console.log(
|
||||
`Created release/${fileName} (${archive.byteLength} bytes, SHA-256 ${digest})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
await main();
|
||||
}
|
||||
51
scripts/package-release.test.ts
Normal file
51
scripts/package-release.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDeterministicZip,
|
||||
parseReleaseArguments,
|
||||
safeArchivePath,
|
||||
} from "./package-release.mjs";
|
||||
import { sha256 } from "./checksum-release.mjs";
|
||||
|
||||
describe("release packaging", () => {
|
||||
it("creates a deterministic archive independent of input order", () => {
|
||||
const entries = [
|
||||
{ name: "index.html", data: Buffer.from("<p>ok</p>") },
|
||||
{ name: "assets/app.js", data: Buffer.from("export {};") },
|
||||
];
|
||||
expect(sha256(createDeterministicZip(entries))).toBe(
|
||||
sha256(createDeterministicZip([...entries].reverse())),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["../secret", "/absolute", "C:/absolute", "a\\b", "a/./b", ""])(
|
||||
"rejects unsafe path %j",
|
||||
(candidate) => {
|
||||
expect(() => safeArchivePath(candidate)).toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects duplicate paths, source maps and credential-like entries", () => {
|
||||
expect(() =>
|
||||
createDeterministicZip([
|
||||
{ name: "index.html", data: Buffer.from("one") },
|
||||
{ name: "index.html", data: Buffer.from("two") },
|
||||
]),
|
||||
).toThrow("Duplicate release path");
|
||||
expect(() =>
|
||||
createDeterministicZip([
|
||||
{ name: "assets/app.js.map", data: Buffer.from("{}") },
|
||||
]),
|
||||
).toThrow("Source map");
|
||||
expect(() =>
|
||||
createDeterministicZip([{ name: ".env", data: Buffer.from("TOKEN=x") }]),
|
||||
).toThrow("Credential-like");
|
||||
});
|
||||
|
||||
it("requires an explicit force flag for exact-version replacement", () => {
|
||||
expect(parseReleaseArguments([])).toEqual({ force: false });
|
||||
expect(parseReleaseArguments(["--force"])).toEqual({ force: true });
|
||||
expect(() => parseReleaseArguments(["--unknown"])).toThrow(
|
||||
"Unknown release argument",
|
||||
);
|
||||
});
|
||||
});
|
||||
79
scripts/serve-test.mjs
Normal file
79
scripts/serve-test.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createServer } from "node:http";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"dist",
|
||||
);
|
||||
const nestedPrefix = "/deep/nested/regex/";
|
||||
const mediaTypes = new Map([
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".json", "application/json; charset=utf-8"],
|
||||
[".svg", "image/svg+xml"],
|
||||
[".wasm", "application/wasm"],
|
||||
]);
|
||||
const testCatalogue = {
|
||||
schemaVersion: 1,
|
||||
id: "de.add-ideas.regex-tools.browser-test",
|
||||
name: "Regex Tools browser-test Toolbox",
|
||||
home: "./",
|
||||
theme: { mode: "system", brand: "add·ideas" },
|
||||
apps: [{ manifest: "./toolbox-app.json", enabled: true }],
|
||||
};
|
||||
|
||||
function safeFile(requestPath) {
|
||||
const decoded = decodeURIComponent(requestPath);
|
||||
const relative = decoded.startsWith(nestedPrefix)
|
||||
? decoded.slice(nestedPrefix.length)
|
||||
: decoded.replace(/^\/+/u, "");
|
||||
const normalized = path.posix.normalize(relative || "index.html");
|
||||
if (
|
||||
normalized === ".." ||
|
||||
normalized.startsWith("../") ||
|
||||
path.isAbsolute(normalized)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return path.join(root, normalized);
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||
if (url.pathname === "/toolbox.catalog.json") {
|
||||
response.writeHead(200, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-cache",
|
||||
});
|
||||
response.end(JSON.stringify(testCatalogue));
|
||||
return;
|
||||
}
|
||||
let file = safeFile(url.pathname);
|
||||
if (!file) {
|
||||
response.writeHead(400).end("Bad request");
|
||||
return;
|
||||
}
|
||||
const details = await stat(file).catch(() => null);
|
||||
if (details?.isDirectory()) file = path.join(file, "index.html");
|
||||
const content = await readFile(file);
|
||||
response.writeHead(200, {
|
||||
"Content-Type":
|
||||
mediaTypes.get(path.extname(file)) ?? "application/octet-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
});
|
||||
response.end(content);
|
||||
} catch {
|
||||
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
response.end("Not found");
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(4173, "127.0.0.1", () => {
|
||||
console.log("Test static server listening on http://127.0.0.1:4173");
|
||||
});
|
||||
34
scripts/verify-engine-assets.mjs
Normal file
34
scripts/verify-engine-assets.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import { lstat, readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const engineDirectory = path.join(root, "dist", "engines");
|
||||
const details = await lstat(engineDirectory);
|
||||
if (details.isSymbolicLink() || !details.isDirectory()) {
|
||||
throw new Error("dist/engines must be a real directory.");
|
||||
}
|
||||
|
||||
const entries = (await readdir(engineDirectory, { withFileTypes: true })).sort(
|
||||
(left, right) =>
|
||||
left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
|
||||
);
|
||||
if (
|
||||
entries.length !== 1 ||
|
||||
!entries[0]?.isFile() ||
|
||||
entries[0].name !== "README.md"
|
||||
) {
|
||||
throw new Error(
|
||||
"Regex Tools 0.1.0 must not ship an undeclared external engine pack.",
|
||||
);
|
||||
}
|
||||
|
||||
const notice = await readFile(path.join(engineDirectory, "README.md"), "utf8");
|
||||
if (
|
||||
!notice.includes("native `RegExp`") ||
|
||||
!notice.includes("no external engine")
|
||||
) {
|
||||
throw new Error("The engine asset notice does not describe this release.");
|
||||
}
|
||||
|
||||
console.log("Verified ECMAScript-only engine assets (no WebAssembly pack).");
|
||||
Reference in New Issue
Block a user