Files
Termix/scripts/lib/bundled-plugins.cjs
T

279 lines
8.1 KiB
JavaScript

/**
* Reads docker/bundled-plugins.json: which plugins ship in the image and
* where each comes from.
*
* { "id": "x", "source": "tmxplug", "url": "...", "sha256": "..." }
* { "id": "x", "source": "tmxplug", "path": "...", "sha256": "..." }
*
* A tmxplug entry is pinned by sha256 in this file, which is reviewed like
* any other change, so the build does not need the registry signature.
*/
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const ID = /^[a-z0-9][a-z0-9-]*$/;
const SHA256 = /^[0-9a-f]{64}$/;
function parseBundledPlugins(raw) {
const problems = [];
const list = raw && Array.isArray(raw.plugins) ? raw.plugins : null;
if (!list) {
return {
plugins: [],
problems: ['bundled-plugins.json needs a "plugins" array'],
};
}
const seen = new Set();
const plugins = [];
for (const [index, entry] of list.entries()) {
const where = `plugins[${index}]`;
if (!entry || typeof entry.id !== "string" || !ID.test(entry.id)) {
problems.push(`${where} needs a valid id`);
continue;
}
if (seen.has(entry.id)) {
problems.push(`${entry.id} is listed twice`);
continue;
}
seen.add(entry.id);
if (entry.source === "tmxplug") {
const hasUrl =
typeof entry.url === "string" && entry.url.startsWith("https://");
const hasPath = typeof entry.path === "string" && entry.path.length > 0;
if (hasUrl === hasPath) {
problems.push(
`${entry.id} needs exactly one of an https url or a path`,
);
continue;
}
if (typeof entry.sha256 !== "string" || !SHA256.test(entry.sha256)) {
problems.push(`${entry.id} needs a lowercase hex sha256`);
continue;
}
plugins.push({
id: entry.id,
source: "tmxplug",
url: hasUrl ? entry.url : null,
path: hasPath ? entry.path : null,
sha256: entry.sha256,
});
} else {
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
}
}
return { plugins, problems };
}
function loadBundledPlugins(root) {
const configPath = path.join(root, "docker", "bundled-plugins.json");
const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
const { plugins, problems } = parseBundledPlugins(raw);
if (problems.length > 0) {
throw new Error(`docker/bundled-plugins.json:\n ${problems.join("\n ")}`);
}
return plugins;
}
/** Refuses an artifact whose bytes are not the ones the config pinned. */
function checkSha256(buffer, expected, id) {
const actual = crypto.createHash("sha256").update(buffer).digest("hex");
if (actual !== expected) {
throw new Error(
`${id}: sha256 is ${actual}, bundled-plugins.json pins ${expected}`,
);
}
}
/**
* The .tmxplug bytes for an entry: a local path, or a download cached by
* sha256 so rebuilds do not fetch it again.
*/
async function fetchArtifact(entry, root, fetchImpl = fetch) {
if (entry.path) {
const buffer = fs.readFileSync(path.resolve(root, entry.path));
checkSha256(buffer, entry.sha256, entry.id);
return buffer;
}
const cacheDir = path.join(
root,
"node_modules",
".cache",
"termix-bundled-plugins",
);
const cached = path.join(cacheDir, `${entry.sha256}.tmxplug`);
if (fs.existsSync(cached)) {
const buffer = fs.readFileSync(cached);
checkSha256(buffer, entry.sha256, entry.id);
return buffer;
}
const response = await fetchImpl(entry.url);
if (!response.ok) {
throw new Error(`${entry.id}: download failed with ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
checkSha256(buffer, entry.sha256, entry.id);
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cached, buffer);
return buffer;
}
/** Unpacks a checked .tmxplug into outDir and confirms its manifest id. */
async function extractArtifact(buffer, outDir, id) {
const tar = require("tar");
const { Readable } = require("node:stream");
const { pipeline } = require("node:stream/promises");
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });
await pipeline(
Readable.from(buffer),
tar.x({
cwd: outDir,
strict: true,
filter: (_p, entry) =>
entry.type === "File" || entry.type === "Directory",
}),
);
const manifestPath = path.join(outDir, "manifest.json");
if (!fs.existsSync(manifestPath)) {
throw new Error(`${id}: the .tmxplug has no manifest.json`);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (manifest.id !== id) {
throw new Error(`${id}: the .tmxplug is for "${manifest.id}"`);
}
}
/**
* Pins the newest registry version of each wanted plugin. `ids` is the list
* to pin, or null for every plugin in the index.
*/
function pinsFromIndex(index, ids) {
const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
const wanted = ids ?? plugins.map((plugin) => plugin.id);
return [...wanted].sort().map((id) => {
const latest = byId.get(id)?.versions?.[0];
if (!latest) throw new Error(`${id} is not in the registry index`);
return {
id,
source: "tmxplug",
url: latest.url,
sha256: latest.sha256,
};
});
}
const LOCAL_FILES = [
"manifest.json",
"dist",
"locales",
"migrations",
"README.md",
"CHANGELOG.json",
];
/**
* Built plugin repos under dir (each with manifest.json and dist/), by id.
* Used in development to stage local plugin builds instead of the pins.
*/
function findLocalPluginBuilds(dir) {
const builds = new Map();
if (!dir || !fs.existsSync(dir)) return builds;
for (const name of fs.readdirSync(dir)) {
const repo = path.join(dir, name);
const manifestPath = path.join(repo, "manifest.json");
if (!fs.existsSync(manifestPath)) continue;
if (!fs.existsSync(path.join(repo, "dist"))) continue;
try {
const { id } = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (typeof id === "string" && ID.test(id)) builds.set(id, repo);
} catch {
// not a plugin repo
}
}
return builds;
}
const BUILD_INPUTS = [
"src",
"locales",
"migrations",
"manifest.json",
"package.json",
];
function newestMtime(target) {
if (!fs.existsSync(target)) return 0;
const stat = fs.statSync(target);
if (!stat.isDirectory()) return stat.mtimeMs;
let newest = 0;
for (const name of fs.readdirSync(target)) {
newest = Math.max(newest, newestMtime(path.join(target, name)));
}
return newest;
}
/** True when a plugin repo has never been built or its sources changed since. */
function isLocalBuildStale(repo) {
const built = newestMtime(path.join(repo, "dist"));
if (!built) return true;
return BUILD_INPUTS.some(
(entry) => newestMtime(path.join(repo, entry)) > built,
);
}
/** Builds each listed plugin repo whose build is missing or out of date. */
function rebuildLocalPlugins(dir, ids, run) {
const rebuilt = [];
if (!dir || !fs.existsSync(dir)) return rebuilt;
for (const name of fs.readdirSync(dir)) {
const repo = path.join(dir, name);
const manifestPath = path.join(repo, "manifest.json");
if (!fs.existsSync(manifestPath)) continue;
if (!fs.existsSync(path.join(repo, "node_modules"))) continue;
let id;
try {
({ id } = JSON.parse(fs.readFileSync(manifestPath, "utf8")));
} catch {
continue;
}
if (!ids.has(id) || !isLocalBuildStale(repo)) continue;
run(repo, id);
rebuilt.push(id);
}
return rebuilt;
}
/** Copies a local plugin build into the staging folder. */
function copyLocalBuild(repo, destination) {
fs.mkdirSync(destination, { recursive: true });
for (const entry of LOCAL_FILES) {
const from = path.join(repo, entry);
if (fs.existsSync(from)) {
fs.cpSync(from, path.join(destination, entry), { recursive: true });
}
}
}
module.exports = {
findLocalPluginBuilds,
isLocalBuildStale,
rebuildLocalPlugins,
copyLocalBuild,
parseBundledPlugins,
pinsFromIndex,
loadBundledPlugins,
checkSha256,
fetchArtifact,
extractArtifact,
};