Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions build-scripts/check-links.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#!/usr/bin/env node

const fs = require("fs");
const path = require("path");

const repoRoot = path.resolve(__dirname, "..");
const blueprintsDir = path.join(repoRoot, "blueprints");

const MAX_CONCURRENCY = 10;
const TIMEOUT_MS = 15000;
const USER_AGENT = "Mozilla/5.0 (compatible; DokployTemplatesLinkChecker/1.0)";

const results = { ok: [], broken: [], skipped: [] };
let completed = 0;
let total = 0;

async function checkUrl(url, label, blueprintId) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);

try {
const res = await fetch(url, {
method: "HEAD",
signal: controller.signal,
redirect: "follow",
headers: { "User-Agent": USER_AGENT },
});

if (res.ok || res.status === 403 || res.status === 429) {
if (res.status === 429) {
results.skipped.push({ blueprintId, label, url, status: 429, reason: "Rate limited" });
} else if (res.status === 403) {
results.ok.push({ blueprintId, label, url, status: 403, note: "Forbidden (may still be valid)" });
} else {
results.ok.push({ blueprintId, label, url, status: res.status });
}
} else {
// If HEAD fails, try GET as fallback
try {
const getRes = await fetch(url, {
method: "GET",
signal: AbortSignal.timeout(TIMEOUT_MS),
redirect: "follow",
headers: { "User-Agent": USER_AGENT },
});
if (getRes.ok || getRes.status === 403) {
results.ok.push({ blueprintId, label, url, status: getRes.status, note: `HEAD returned ${res.status}, GET ok` });
} else {
results.broken.push({ blueprintId, label, url, status: getRes.status });
}
} catch {
results.broken.push({ blueprintId, label, url, status: res.status, reason: "HEAD & GET both failed" });
}
}
} catch (err) {
if (err.name === "AbortError") {
results.broken.push({ blueprintId, label, url, status: null, reason: "Timeout" });
} else if (err.code === "ENOTFOUND" || err.code === "EAI_AGAIN") {
results.broken.push({ blueprintId, label, url, status: null, reason: "DNS resolution failed" });
} else if (err.code === "ECONNREFUSED") {
results.broken.push({ blueprintId, label, url, status: null, reason: "Connection refused" });
} else if (err.code === "ECONNRESET") {
results.broken.push({ blueprintId, label, url, status: null, reason: "Connection reset" });
} else if (err.code === "ENETUNREACH") {
results.broken.push({ blueprintId, label, url, status: null, reason: "Network unreachable" });
} else if (err.code === "ERR_INVALID_URL") {
results.broken.push({ blueprintId, label, url, status: null, reason: "Invalid URL" });
} else {
results.broken.push({ blueprintId, label, url, status: null, reason: err.message || String(err) });
}
} finally {
clearTimeout(timer);
}
}

async function checkLinks(entries) {
const queue = [];
for (const [blueprintId, links] of entries) {
for (const [label, url] of Object.entries(links)) {
if (!url || typeof url !== "string" || url.trim() === "") {
results.skipped.push({ blueprintId, label, url, reason: "Empty URL" });
continue;
}
const trimmed = url.trim();
if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
results.skipped.push({ blueprintId, label, url: trimmed, reason: "Non-HTTP URL" });
continue;
}
total++;
queue.push({ url: trimmed, label, blueprintId });
}
}

console.log(`\nChecking ${total} links across ${entries.length} templates...\n`);

const runNext = async () => {
while (queue.length > 0) {
const item = queue.shift();
await checkUrl(item.url, item.label, item.blueprintId);
completed++;
if (completed % 50 === 0 || completed === total) {
const pct = ((completed / total) * 100).toFixed(1);
console.log(` Progress: ${completed}/${total} (${pct}%) — ${results.broken.length} broken so far`);
}
}
};

const workers = Array.from({ length: Math.min(MAX_CONCURRENCY, total || 1) }, () => runNext());
await Promise.all(workers);
}

function printReport() {
console.log("\n" + "=".repeat(70));
console.log("LINK CHECK REPORT");
console.log("=".repeat(70));
console.log(` Total checked : ${total}`);
console.log(` OK : ${results.ok.length}`);
console.log(` Broken : ${results.broken.length}`);
console.log(` Skipped : ${results.skipped.length}`);

if (results.broken.length > 0) {
console.log("\n" + "-".repeat(70));
console.log("BROKEN LINKS:");
console.log("-".repeat(70));
for (const b of results.broken) {
const status = b.status ? `HTTP ${b.status}` : "N/A";
const reason = b.reason ? ` — ${b.reason}` : "";
console.log(` [${b.blueprintId}] ${b.label}: ${b.url}`);
console.log(` Status: ${status}${reason}`);
}
}

if (results.skipped.length > 0) {
console.log("\n" + "-".repeat(70));
console.log("SKIPPED:");
console.log("-".repeat(70));
for (const s of results.skipped) {
console.log(` [${s.blueprintId}] ${s.label}: ${s.url || "(empty)"} — ${s.reason}`);
}
}
}

async function main() {
const dirs = fs
.readdirSync(blueprintsDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.sort((a, b) => a.localeCompare(b));

const entries = [];

for (const dir of dirs) {
const metaFile = path.join(blueprintsDir, dir, "meta.json");
if (!fs.existsSync(metaFile)) continue;

try {
const entry = JSON.parse(fs.readFileSync(metaFile, "utf8"));
if (entry.links && typeof entry.links === "object") {
entries.push([entry.id || dir, entry.links]);
}
} catch {
// skip invalid JSON
}
}

await checkLinks(entries);
printReport();

process.exit(results.broken.length > 0 ? 1 : 0);
}

main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});
Loading