initial commit
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import {createWriteStream} from "node:fs";
|
||||
import {copyFile, mkdir, readFile, readdir, rm, stat} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {fileURLToPath} from "node:url";
|
||||
|
||||
import archiver from "archiver";
|
||||
|
||||
import {buildPacks, verifyPacks} from "./compendium-packs.mjs";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const DIST = path.join(ROOT, "dist");
|
||||
const RELEASE_DIRECTORIES = ["assets", "lang", "packs", "scripts", "styles", "templates"];
|
||||
const RELEASE_FILES = [
|
||||
"module.json",
|
||||
"README.md",
|
||||
"PATCHNOTES.md",
|
||||
"LICENSE",
|
||||
"LICENSE.md",
|
||||
"THIRD_PARTY_NOTICES.md"
|
||||
];
|
||||
|
||||
async function exists(target) {
|
||||
try {
|
||||
await stat(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function requestedTag() {
|
||||
const index = process.argv.indexOf("--tag");
|
||||
if (index === -1) return process.env.GITHUB_REF_NAME;
|
||||
if (!process.argv[index + 1]) throw new Error("--tag requires a value.");
|
||||
return process.argv[index + 1];
|
||||
}
|
||||
|
||||
function validateManifest(manifest, tag) {
|
||||
const expectedTag = `v${manifest.version}`;
|
||||
const expectedArchive = `${manifest.id}.zip`;
|
||||
const expectedDownload = `${manifest.url.replace(/\/$/, "")}/releases/download/${expectedTag}/${expectedArchive}`;
|
||||
|
||||
if (tag && tag !== expectedTag) {
|
||||
throw new Error(`Release tag ${tag} does not match module.json version ${manifest.version}.`);
|
||||
}
|
||||
if (manifest.download !== expectedDownload) {
|
||||
throw new Error(`module.json download must be ${expectedDownload}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateCompendiumAssets(manifest) {
|
||||
const sourceRoot = path.join(ROOT, "packs-src");
|
||||
const failures = [];
|
||||
|
||||
async function inspect(value, location) {
|
||||
if (Array.isArray(value)) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
await inspect(value[index], `${location}[${index}]`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
for (const [key, child] of Object.entries(value)) await inspect(child, `${location}.${key}`);
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "string") return;
|
||||
|
||||
if (/^[A-Za-z]:[\\/]/.test(value) || /^worlds[\\/]/i.test(value)) {
|
||||
failures.push(`${location}: package-external path ${value}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const prefix = `modules/${manifest.id}/`;
|
||||
if (!value.startsWith(prefix)) return;
|
||||
|
||||
const relative = value.slice(prefix.length).split(/[?#]/, 1)[0];
|
||||
const target = path.resolve(ROOT, relative);
|
||||
if (!target.startsWith(`${ROOT}${path.sep}`) || !(await exists(target))) {
|
||||
failures.push(`${location}: missing module asset ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function visit(directory) {
|
||||
for (const entry of await readdir(directory, {withFileTypes: true})) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) await visit(absolute);
|
||||
else if (entry.isFile() && entry.name.endsWith(".json")) {
|
||||
const relative = path.relative(ROOT, absolute).split(path.sep).join("/");
|
||||
await inspect(JSON.parse(await readFile(absolute, "utf8")), relative);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await visit(sourceRoot);
|
||||
if (failures.length) throw new Error(`Invalid compendium asset references:\n${failures.join("\n")}`);
|
||||
}
|
||||
|
||||
async function createZip(archivePath) {
|
||||
const output = createWriteStream(archivePath);
|
||||
const archive = archiver("zip", {zlib: {level: 9}});
|
||||
const completed = new Promise((resolve, reject) => {
|
||||
output.on("close", resolve);
|
||||
output.on("error", reject);
|
||||
archive.on("error", reject);
|
||||
});
|
||||
|
||||
archive.pipe(output);
|
||||
|
||||
for (const filename of RELEASE_FILES) {
|
||||
const source = path.join(ROOT, filename);
|
||||
if (await exists(source)) archive.file(source, {name: filename});
|
||||
}
|
||||
for (const directory of RELEASE_DIRECTORIES) {
|
||||
const source = path.join(ROOT, directory);
|
||||
if (!(await exists(source))) throw new Error(`Required release directory is missing: ${directory}`);
|
||||
archive.directory(source, directory);
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
await completed;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const manifest = JSON.parse(await readFile(path.join(ROOT, "module.json"), "utf8"));
|
||||
validateManifest(manifest, requestedTag());
|
||||
await validateCompendiumAssets(manifest);
|
||||
|
||||
await buildPacks();
|
||||
await verifyPacks();
|
||||
|
||||
await rm(DIST, {force: true, recursive: true, maxRetries: 10});
|
||||
await mkdir(DIST, {recursive: true});
|
||||
|
||||
const archivePath = path.join(DIST, `${manifest.id}.zip`);
|
||||
await createZip(archivePath);
|
||||
await copyFile(path.join(ROOT, "module.json"), path.join(DIST, "module.json"));
|
||||
|
||||
const {size} = await stat(archivePath);
|
||||
console.log(`Created ${path.relative(ROOT, archivePath)} (${size} bytes).`);
|
||||
console.log(`Created ${path.relative(ROOT, path.join(DIST, "module.json"))}.`);
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readdir, readFile, rm, stat, unlink } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
import { compilePack, extractPack } from "@foundryvtt/foundryvtt-cli";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const MANIFEST_PATH = path.join(ROOT, "module.json");
|
||||
const PACK_SOURCE_ROOT = path.join(ROOT, "packs-src");
|
||||
const RUNTIME_FILES = ["LOCK", "LOG", "LOG.old"];
|
||||
|
||||
async function loadPacks() {
|
||||
const manifest = JSON.parse(await readFile(MANIFEST_PATH, "utf8"));
|
||||
const packs = manifest.packs ?? [];
|
||||
|
||||
if (!packs.length)
|
||||
throw new Error("module.json does not declare any compendium packs.");
|
||||
|
||||
return packs.map((pack) => {
|
||||
if (!pack.name || !pack.path)
|
||||
throw new Error(
|
||||
"Every compendium pack requires both name and path.",
|
||||
);
|
||||
|
||||
return {
|
||||
...pack,
|
||||
database: path.resolve(ROOT, pack.path),
|
||||
source: path.join(PACK_SOURCE_ROOT, pack.name),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function extractionOptions() {
|
||||
return {
|
||||
clean: true,
|
||||
expandAdventures: true,
|
||||
folders: true,
|
||||
jsonOptions: { space: 2 },
|
||||
log: true,
|
||||
omitVolatile: true,
|
||||
transformSerialized: (serialized) => `${serialized.trimEnd()}\n`,
|
||||
};
|
||||
}
|
||||
|
||||
async function pathExists(target) {
|
||||
try {
|
||||
await stat(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRuntimeFiles(database) {
|
||||
for (const filename of RUNTIME_FILES) {
|
||||
try {
|
||||
await unlink(path.join(database, filename));
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findJsonFiles(directory) {
|
||||
const files = [];
|
||||
|
||||
async function visit(current) {
|
||||
for (const entry of await readdir(current, { withFileTypes: true })) {
|
||||
const absolute = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) await visit(absolute);
|
||||
else if (entry.isFile() && entry.name.endsWith(".json")) {
|
||||
files.push(
|
||||
path
|
||||
.relative(directory, absolute)
|
||||
.split(path.sep)
|
||||
.join("/"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await visit(directory);
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
async function assertEquivalentSources(
|
||||
expectedDirectory,
|
||||
actualDirectory,
|
||||
packName,
|
||||
) {
|
||||
const expectedFiles = await findJsonFiles(expectedDirectory);
|
||||
const actualFiles = await findJsonFiles(actualDirectory);
|
||||
assert.deepEqual(
|
||||
actualFiles,
|
||||
expectedFiles,
|
||||
`${packName}: round-trip JSON file list differs.`,
|
||||
);
|
||||
|
||||
for (const relative of expectedFiles) {
|
||||
const expected = JSON.parse(
|
||||
await readFile(path.join(expectedDirectory, relative), "utf8"),
|
||||
);
|
||||
const actual = JSON.parse(
|
||||
await readFile(path.join(actualDirectory, relative), "utf8"),
|
||||
);
|
||||
assert.deepEqual(
|
||||
actual,
|
||||
expected,
|
||||
`${packName}: ${relative} changed during the pack round trip.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractPacks() {
|
||||
for (const pack of await loadPacks()) {
|
||||
if (!(await pathExists(pack.database))) {
|
||||
throw new Error(
|
||||
`${pack.name}: generated database does not exist at ${pack.database}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Extracting ${pack.name} to ${path.relative(ROOT, pack.source)}...`,
|
||||
);
|
||||
await extractPack(pack.database, pack.source, extractionOptions());
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildPacks({ destinationRoot } = {}) {
|
||||
for (const pack of await loadPacks()) {
|
||||
if (!(await pathExists(pack.source))) {
|
||||
throw new Error(
|
||||
`${pack.name}: source directory does not exist at ${pack.source}`,
|
||||
);
|
||||
}
|
||||
|
||||
const database = destinationRoot
|
||||
? path.join(destinationRoot, path.relative(ROOT, pack.database))
|
||||
: pack.database;
|
||||
|
||||
console.log(
|
||||
`Building ${pack.name} from ${path.relative(ROOT, pack.source)}...`,
|
||||
);
|
||||
await rm(database, { force: true, recursive: true, maxRetries: 10 });
|
||||
await compilePack(pack.source, database, {
|
||||
log: true,
|
||||
recursive: true,
|
||||
});
|
||||
await removeRuntimeFiles(database);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyPacks() {
|
||||
const temporaryRoot = path.join(
|
||||
os.tmpdir(),
|
||||
`theiks-toolbag-packs-${process.pid}-${Date.now()}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await buildPacks({ destinationRoot: temporaryRoot });
|
||||
|
||||
for (const pack of await loadPacks()) {
|
||||
const database = path.join(
|
||||
temporaryRoot,
|
||||
path.relative(ROOT, pack.database),
|
||||
);
|
||||
const extracted = path.join(temporaryRoot, "extracted", pack.name);
|
||||
await extractPack(database, extracted, extractionOptions());
|
||||
await assertEquivalentSources(pack.source, extracted, pack.name);
|
||||
console.log(
|
||||
`Verified ${pack.name}: JSON survives a complete LevelDB round trip.`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await rm(temporaryRoot, {
|
||||
force: true,
|
||||
recursive: true,
|
||||
maxRetries: 10,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const command = process.argv[2];
|
||||
|
||||
if (command === "extract") return extractPacks();
|
||||
if (command === "build") return buildPacks();
|
||||
if (command === "verify") return verifyPacks();
|
||||
|
||||
throw new Error(
|
||||
"Usage: node tools/compendium-packs.mjs <extract|build|verify>",
|
||||
);
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1]
|
||||
? pathToFileURL(path.resolve(process.argv[1])).href
|
||||
: "";
|
||||
if (invokedPath === import.meta.url) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user