mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
177 lines
4.6 KiB
JavaScript
177 lines
4.6 KiB
JavaScript
|
|
#!/usr/bin/env node
|
||
|
|
|
||
|
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||
|
|
import path from "node:path";
|
||
|
|
import process from "node:process";
|
||
|
|
|
||
|
|
const repoRoot = process.cwd();
|
||
|
|
const dockerfilePath = path.join(repoRoot, "Dockerfile");
|
||
|
|
const workspacePath = path.join(repoRoot, "pnpm-workspace.yaml");
|
||
|
|
|
||
|
|
function extractDepsStage(dockerfileText) {
|
||
|
|
const lines = dockerfileText.split("\n");
|
||
|
|
const captured = [];
|
||
|
|
let inDeps = false;
|
||
|
|
|
||
|
|
for (const line of lines) {
|
||
|
|
if (!inDeps) {
|
||
|
|
if (/^FROM .* AS deps$/i.test(line.trim())) inDeps = true;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (/^FROM /i.test(line.trim())) break;
|
||
|
|
captured.push(line);
|
||
|
|
}
|
||
|
|
|
||
|
|
return captured.join("\n");
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseWorkspaceRoots(workspaceText) {
|
||
|
|
return workspaceText
|
||
|
|
.split("\n")
|
||
|
|
.map((line) => line.match(/^\s*-\s+(.+)\s*$/)?.[1]?.trim() ?? null)
|
||
|
|
.map((entry) => {
|
||
|
|
if (!entry) return entry;
|
||
|
|
return entry.replace(/^(['"])(.*)\1$/, "$2");
|
||
|
|
})
|
||
|
|
.filter(Boolean)
|
||
|
|
.filter((entry) => !entry.startsWith("!"))
|
||
|
|
.map((entry) => entry.replace(/\*+$/, ""))
|
||
|
|
.filter((entry) => entry.length > 0)
|
||
|
|
.filter((entry) => !entry.includes("examples"))
|
||
|
|
.filter((entry) => !entry.includes("create-paperclip-plugin"));
|
||
|
|
}
|
||
|
|
|
||
|
|
function walkPackageJsonFiles(rootRelative, maxDepth) {
|
||
|
|
const results = [];
|
||
|
|
const rootAbsolute = path.join(repoRoot, rootRelative);
|
||
|
|
|
||
|
|
if (!existsSync(rootAbsolute)) return results;
|
||
|
|
|
||
|
|
function visit(currentAbsolute, depthFromRoot) {
|
||
|
|
const entries = readdirSync(currentAbsolute, { withFileTypes: true });
|
||
|
|
|
||
|
|
for (const entry of entries) {
|
||
|
|
if (entry.name === "node_modules") continue;
|
||
|
|
|
||
|
|
const absolute = path.join(currentAbsolute, entry.name);
|
||
|
|
const relative = path.relative(repoRoot, absolute).split(path.sep).join("/");
|
||
|
|
|
||
|
|
if (entry.isDirectory()) {
|
||
|
|
if (depthFromRoot < maxDepth) visit(absolute, depthFromRoot + 1);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (
|
||
|
|
entry.name === "package.json" &&
|
||
|
|
!relative.includes("/examples/") &&
|
||
|
|
!relative.includes("/create-paperclip-plugin/")
|
||
|
|
) {
|
||
|
|
results.push(relative);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
visit(rootAbsolute, 0);
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
|
||
|
|
function globToRegExp(pattern) {
|
||
|
|
const normalized = pattern.replace("/./", "/");
|
||
|
|
let regex = "";
|
||
|
|
|
||
|
|
for (let index = 0; index < normalized.length; index += 1) {
|
||
|
|
const char = normalized[index];
|
||
|
|
const next = normalized[index + 1];
|
||
|
|
|
||
|
|
if (char === "*" && next === "*") {
|
||
|
|
regex += ".*";
|
||
|
|
index += 1;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (char === "*") {
|
||
|
|
regex += "[^/]*";
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (char === "?") {
|
||
|
|
regex += "[^/]";
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
regex += /[|\\{}()[\]^$+?.]/.test(char) ? `\\${char}` : char;
|
||
|
|
}
|
||
|
|
|
||
|
|
return new RegExp(`^${regex}$`);
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseCopySources(depsStage) {
|
||
|
|
const sources = [];
|
||
|
|
|
||
|
|
for (const rawLine of depsStage.split("\n")) {
|
||
|
|
const line = rawLine.trim();
|
||
|
|
if (!line.startsWith("COPY ")) continue;
|
||
|
|
|
||
|
|
const tokens = line.split(/\s+/);
|
||
|
|
let index = 1;
|
||
|
|
while (tokens[index]?.startsWith("--")) index += 1;
|
||
|
|
|
||
|
|
const args = tokens.slice(index);
|
||
|
|
if (args.length < 2) continue;
|
||
|
|
|
||
|
|
const lineSources = args.slice(0, -1);
|
||
|
|
for (const source of lineSources) {
|
||
|
|
sources.push(source);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return sources;
|
||
|
|
}
|
||
|
|
|
||
|
|
function main() {
|
||
|
|
const depsStage = extractDepsStage(readFileSync(dockerfilePath, "utf8"));
|
||
|
|
if (!depsStage.trim()) {
|
||
|
|
console.error("Could not extract deps stage from Dockerfile (expected 'FROM ... AS deps').");
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
const workspaceRoots = parseWorkspaceRoots(readFileSync(workspacePath, "utf8"));
|
||
|
|
if (workspaceRoots.length === 0) {
|
||
|
|
console.error("Could not derive workspace roots from pnpm-workspace.yaml.");
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
const requiredPackageJsons = [...new Set(
|
||
|
|
workspaceRoots.flatMap((root) => walkPackageJsonFiles(root, 2)),
|
||
|
|
)].sort();
|
||
|
|
|
||
|
|
const copySources = parseCopySources(depsStage);
|
||
|
|
const copyMatchers = copySources.map((source) => ({
|
||
|
|
source,
|
||
|
|
regex: globToRegExp(source),
|
||
|
|
}));
|
||
|
|
|
||
|
|
let missing = 0;
|
||
|
|
for (const pkg of requiredPackageJsons) {
|
||
|
|
const covered = copyMatchers.some(({ regex }) => regex.test(pkg));
|
||
|
|
if (!covered) {
|
||
|
|
console.error(`Dockerfile deps stage missing package manifest coverage for: ${pkg}`);
|
||
|
|
missing = 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (existsSync(path.join(repoRoot, "patches"))) {
|
||
|
|
const patchesCovered = copySources.includes("patches/");
|
||
|
|
if (!patchesCovered) {
|
||
|
|
console.error("Dockerfile deps stage missing: COPY patches/ patches/");
|
||
|
|
missing = 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (missing) {
|
||
|
|
console.error("Dockerfile deps stage is out of sync. Update it to cover the missing files.");
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log("PASS");
|
||
|
|
}
|
||
|
|
|
||
|
|
main();
|