#!/usr/bin/env node import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { execFileSync } from "node:child_process"; function fail(message) { console.error(message); process.exit(1); } function argument(name) { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : undefined; } const root = fs.realpathSync(argument("--repository") ?? process.cwd()); const outputArgument = argument("--output"); const reportArgument = argument("--report"); const allowlistPath = path.resolve(root, argument("--allowlist") ?? "public-source.allowlist"); if (!outputArgument) fail("Usage: node scripts/export-public-source.mjs --output [--report ]"); const output = path.resolve(outputArgument); const reportPath = reportArgument ? path.resolve(reportArgument) : undefined; if (output === root || output.startsWith(`${root}${path.sep}`)) fail("The public export must be outside the canonical repository."); if (fs.existsSync(output)) fail(`Output already exists: ${output}`); if (!fs.existsSync(path.join(root, "LICENSE"))) fail("Public export blocked: confirm the license proposal and add LICENSE first."); const status = execFileSync("git", ["-C", root, "status", "--porcelain=v1", "--untracked-files=all"], { encoding: "utf8" }); if (status.trim()) fail("Public export blocked: the canonical repository must be clean and committed."); const allowlist = fs.readFileSync(allowlistPath, "utf8") .split(/\r?\n/u) .map((line) => line.trim()) .filter((line) => line && !line.startsWith("#")); function selected(relativePath) { return allowlist.some((rule) => rule.endsWith("/**") ? relativePath.startsWith(rule.slice(0, -2)) : relativePath === rule); } const deniedPrefixes = [".agents/", ".claude/", ".codex/", "artifacts/", "design/", "planning/", "prompts/"]; const deniedRootFiles = new Set(["AGENTS.md", "AUDIT.md", "CURRENT_STATE.md", "DECISIONS.md", "MASTER_PROMPT.txt", "PACKAGE_MANIFEST.md", "PACKAGE_REPORT.md", "PLANS.md", "ROADMAP.md", "START_HERE.md"]); function denied(relativePath) { return deniedRootFiles.has(relativePath) || path.posix.basename(relativePath) === "AGENTS.md" || deniedPrefixes.some((prefix) => relativePath.startsWith(prefix)); } const replacements = [ [/https?:\/\/pulse\.itworx\.tech/giu, "https://pulse.example.com"], [/\bgitea\.itworx\.tech\b/giu, "git.example.com"], [/\b192\.168\.10\.150\b/gu, "192.0.2.10"], [/\bTower\.local\b/gu, "unraid.example.test"], [/\/mnt\/user\/appdata\/itworx-pulse/gu, "/srv/pulse"], [/C:\\Users\\Jens\\/gu, "C:\\Users\\example\\"] ]; function renderedBytes(sourceBytes) { if (sourceBytes.includes(0)) return sourceBytes; let value = sourceBytes.toString("utf8"); for (const [expression, replacement] of replacements) value = value.replace(expression, replacement); return Buffer.from(value, "utf8"); } const privatePatterns = [ { id: "private-domain", expression: /\b(?:pulse|gitea)\.itworx\.tech\b/iu }, { id: "private-hostname", expression: /\bTower\.local\b/iu }, { id: "private-lan-address", expression: /\b192\.168\.10\.150\b/u }, { id: "operator-appdata-path", expression: /\/mnt\/user\/appdata\/itworx-pulse/iu }, { id: "personal-windows-path", expression: /[A-Z]:\\Users\\Jens\\/iu }, { id: "private-repository-owner", expression: /(?:git@[^\s:]+:|https?:\/\/[^\s/]+\/)Jens\/ITWorx-Pulse/iu } ]; const tracked = execFileSync("git", ["-C", root, "ls-files", "-z"], { encoding: "utf8" }) .split("\0").filter(Boolean).map((entry) => entry.replaceAll("\\", "/")); const files = tracked.filter((relativePath) => selected(relativePath) && !denied(relativePath)) .sort((left, right) => left.localeCompare(right, "en")); if (files.length === 0) fail("The allowlist selected no tracked files."); const findings = []; const rendered = new Map(); for (const relativePath of files) { const source = path.join(root, ...relativePath.split("/")); const stat = fs.lstatSync(source); if (!stat.isFile() || stat.isSymbolicLink()) { findings.push({ rule: "non-regular-file", path: relativePath }); continue; } if (stat.size > 5 * 1024 * 1024) { findings.push({ rule: "oversized-file", path: relativePath, bytes: stat.size }); continue; } const bytes = renderedBytes(fs.readFileSync(source)); rendered.set(relativePath, bytes); if (!bytes.includes(0)) { const text = bytes.toString("utf8"); for (const rule of privatePatterns) if (rule.expression.test(text)) findings.push({ rule: rule.id, path: relativePath }); } } for (const required of ["README.md", "SECURITY.md", "CONTRIBUTING.md", "LICENSE", "go.mod", "package.json", "deploy/compose.yaml", ".gitea/workflows/public-validation.yml"]) { if (!files.includes(required)) findings.push({ rule: "missing-required-file", path: required }); } const sourceRevision = execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); const report = { schemaVersion: 1, sourceRevision, selectedFiles: files.length, findings }; if (reportPath) { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); } if (findings.length > 0) fail(`Public export blocked by ${findings.length} finding(s). See ${reportPath ?? "the scan output"}.`); fs.mkdirSync(output, { recursive: false }); const manifest = []; for (const relativePath of files) { const bytes = rendered.get(relativePath); const destination = path.join(output, ...relativePath.split("/")); fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.writeFileSync(destination, bytes); manifest.push({ path: relativePath, bytes: bytes.length, sha256: crypto.createHash("sha256").update(bytes).digest("hex") }); } fs.writeFileSync(path.join(output, "PUBLIC_SOURCE_EXPORT.md"), `# Curated public source export\n\nGenerated from private canonical revision \`${sourceRevision}\`.\n\nThis parentless candidate excludes private operational history, evidence, planning, prompts, and machine-local agent configuration.\n`, "utf8"); fs.writeFileSync(path.join(output, "PUBLIC_SOURCE_MANIFEST.json"), `${JSON.stringify({ schemaVersion: 1, sourceRevision, files: manifest }, null, 2)}\n`, "utf8"); console.log(`Exported ${files.length} reviewed files to ${output}`);