Initial curated projectbrain source from 8a7b1e0651dbdab97d744cbd2e653ab3970ac6c5
This commit is contained in:
commit
5aa3e8d685
700 files changed
+132801
No files matched your search
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { resolve, relative, extname, dirname } from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const config = JSON.parse(await readFile(resolve(root, "config/architecture-budgets.json"), "utf8"));
|
||||
const normalized = (path) => relative(root, path).replaceAll("\\", "/");
|
||||
const sourceRoots = [resolve(root, "src"), resolve(root, "backend/src")];
|
||||
|
||||
const walk = async (directory) => {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const nested = await Promise.all(entries.map((entry) => entry.isDirectory() ? walk(resolve(directory, entry.name)) : [resolve(directory, entry.name)]));
|
||||
return nested.flat();
|
||||
};
|
||||
|
||||
const failures = [];
|
||||
const sources = (await Promise.all(sourceRoots.map(walk))).flat().filter((path) => [".ts", ".tsx"].includes(extname(path)));
|
||||
const sourceSet = new Set(sources);
|
||||
const contentByPath = new Map();
|
||||
for (const path of sources) {
|
||||
const name = normalized(path);
|
||||
const content = await readFile(path, "utf8");
|
||||
contentByPath.set(path, content);
|
||||
const lines = content.split(/\r?\n/).length;
|
||||
const exception = config.temporaryFileExceptions[name];
|
||||
const maximum = exception?.maxLines ?? config.maximumNewSourceLines;
|
||||
if (lines > maximum) failures.push(`${name}: ${lines} lines exceeds ${maximum}${exception ? " temporary ceiling" : " general ceiling"}`);
|
||||
}
|
||||
|
||||
const resolveImport = (from, specifier) => {
|
||||
if (!specifier.startsWith(".") && !specifier.startsWith("@/")) return undefined;
|
||||
const base = specifier.startsWith("@/") ? resolve(root, "src", specifier.slice(2)) : resolve(dirname(from), specifier);
|
||||
return [base, `${base}.ts`, `${base}.tsx`, resolve(base, "index.ts"), resolve(base, "index.tsx")].find((candidate) => sourceSet.has(candidate));
|
||||
};
|
||||
const graph = new Map(sources.map((path) => {
|
||||
const imports = [...contentByPath.get(path).matchAll(/(?:from\s+|import\s*\(\s*|import\s+)["']([^"']+)["']/g)]
|
||||
.map((match) => resolveImport(path, match[1]))
|
||||
.filter(Boolean);
|
||||
return [path, [...new Set(imports)]];
|
||||
}));
|
||||
const visiting = new Set();
|
||||
const visited = new Set();
|
||||
const stack = [];
|
||||
const cycles = new Set();
|
||||
const visit = (path) => {
|
||||
if (visiting.has(path)) {
|
||||
const start = stack.indexOf(path);
|
||||
cycles.add([...stack.slice(start), path].map(normalized).join(" -> "));
|
||||
return;
|
||||
}
|
||||
if (visited.has(path)) return;
|
||||
visiting.add(path); stack.push(path);
|
||||
for (const dependency of graph.get(path) ?? []) visit(dependency);
|
||||
stack.pop(); visiting.delete(path); visited.add(path);
|
||||
};
|
||||
for (const path of sources) visit(path);
|
||||
for (const cycle of cycles) failures.push(`circular import: ${cycle}`);
|
||||
|
||||
const hashes = new Map();
|
||||
for (const [path, content] of contentByPath) {
|
||||
if (content.split(/\r?\n/).length < 20) continue;
|
||||
const hash = createHash("sha256").update(content.replaceAll(/\s+/g, " ").trim()).digest("hex");
|
||||
const duplicate = hashes.get(hash);
|
||||
if (duplicate) failures.push(`exact duplicate modules: ${normalized(duplicate)} and ${normalized(path)}`);
|
||||
else hashes.set(hash, path);
|
||||
}
|
||||
|
||||
const forbiddenRoots = [resolve(root, "src/shared/components/ui"), resolve(root, "src/shared/components/layout/AppLayout.tsx")];
|
||||
for (const path of forbiddenRoots) {
|
||||
try { await stat(path); failures.push(`${normalized(path)}: deprecated duplicate UI boundary still exists`); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
}
|
||||
|
||||
const distAssets = resolve(root, "dist/assets");
|
||||
try {
|
||||
const assets = (await readdir(distAssets)).filter((name) => name.endsWith(".js"));
|
||||
for (const asset of assets) {
|
||||
const bytes = (await stat(resolve(distAssets, asset))).size;
|
||||
if (/^index-/.test(asset) && bytes > config.bundle.maxEntryBytes) failures.push(`${asset}: ${bytes} bytes exceeds entry budget ${config.bundle.maxEntryBytes}`);
|
||||
if (!/^vendor-|^index-|^rolldown-runtime-/.test(asset)) {
|
||||
const exception = Object.entries(config.bundle.routeExceptions ?? {}).find(([prefix]) => asset.startsWith(prefix))?.[1];
|
||||
const maximum = exception?.maxBytes ?? config.bundle.maxRouteBytes;
|
||||
if (bytes > maximum) failures.push(`${asset}: ${bytes} bytes exceeds route budget ${maximum}${exception ? " temporary ceiling" : ""}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
failures.push("dist/assets is missing; run pnpm build before the architecture audit");
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("Architecture budget failed:\n- " + failures.join("\n- "));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Architecture budget passed for ${sources.length} source files; ${Object.keys(config.temporaryFileExceptions).length} frozen exceptions, no import cycles/exact duplicate modules and production bundle ceilings verified.`);
|
||||
@@ -0,0 +1,56 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
const read = (path) => fs.existsSync(path) ? fs.readFileSync(path, "utf8") : "";
|
||||
const failures = [];
|
||||
|
||||
const expectIncludes = (file, needle, message) => {
|
||||
const text = read(file);
|
||||
if (!text.includes(needle)) failures.push(`${file}: ${message}`);
|
||||
};
|
||||
|
||||
const expectExists = (file, message) => {
|
||||
if (!fs.existsSync(file)) failures.push(`${file}: ${message}`);
|
||||
};
|
||||
|
||||
expectIncludes("src/shared/lib/model.ts", "BrowserBridgeSession", "BrowserBridgeSession type must exist.");
|
||||
expectIncludes("src/shared/lib/model.ts", "BrowserBridgePayload", "BrowserBridgePayload type must exist.");
|
||||
expectIncludes("src/shared/lib/model.ts", "BrowserBridgeStatus", "BrowserBridgeStatus type must exist.");
|
||||
expectIncludes("src/shared/lib/model.ts", "BrowserBridgeTarget", "BrowserBridgeTarget type must exist.");
|
||||
expectIncludes("src/shared/lib/model.ts", "BrowserBridgeTransferMode", "BrowserBridgeTransferMode type must exist.");
|
||||
expectIncludes("src/shared/lib/browserBridge.ts", "createBrowserBridgePayload", "Browser bridge payload helper must exist.");
|
||||
expectIncludes("src/shared/lib/browserBridge.ts", "formatBrowserBridgePrompt", "Browser bridge prompt formatter must exist.");
|
||||
expectIncludes("src/shared/lib/browserBridge.ts", "parseBrowserBridgeResponse", "Browser bridge response parser must exist.");
|
||||
expectIncludes("src/shared/lib/browserBridge.ts", "No API cost", "No API cost copy must be present.");
|
||||
expectIncludes("src/shared/lib/browserBridge.ts", "Uses your browser session", "Browser session control copy must be present.");
|
||||
expectIncludes("src/shared/contexts/ProjectContext.tsx", "createBrowserBridgeSession", "ProjectContext must create bridge sessions.");
|
||||
expectIncludes("src/shared/contexts/ProjectContext.tsx", "pasteBrowserBridgeResponse", "ProjectContext must paste bridge responses.");
|
||||
expectIncludes("src/shared/contexts/ProjectContext.tsx", "createExecutionFeedbackFromBridge", "ProjectContext must create feedback from bridge.");
|
||||
expectIncludes("src/app/routes.tsx", "/projects/:projectId/browser-bridge", "Browser Bridge route must be registered.");
|
||||
expectExists("src/features/browser-bridge/pages/BrowserBridge.tsx", "Browser Bridge page must exist.");
|
||||
expectIncludes("src/features/browser-bridge/pages/BrowserBridge.tsx", "No session pack is ready yet", "Browser Bridge must explain the empty session-pack state.");
|
||||
expectIncludes("src/features/browser-bridge/pages/BrowserBridge.tsx", "createExecutionFeedbackFromBridge(project.id, selectedSession.id, responseText)", "Browser Bridge must support one-step response save and feedback creation.");
|
||||
expectIncludes("src/features/browser-bridge/pages/BrowserBridge.tsx", "Clipboard is unavailable", "Browser Bridge must handle unavailable clipboard APIs.");
|
||||
expectIncludes("src/features/browser-bridge/pages/BrowserBridge.tsx", "readBridgePayload", "Browser Bridge must validate stored payload JSON before instruction export.");
|
||||
expectIncludes("src/features/browser-bridge/pages/BrowserBridge.tsx", "Feedback draft already exists", "Browser Bridge must route completed sessions back to feedback review.");
|
||||
expectIncludes("src/features/session-packs/pages/SessionPacks.tsx", "Prepare browser bridge", "Session Packs must expose bridge preparation.");
|
||||
expectIncludes("src/features/execution-feedback/pages/ExecutionFeedback.tsx", "Import from browser bridge", "Execution Feedback must import bridge responses.");
|
||||
expectIncludes("src/features/build-loop/pages/BuildLoopDashboard.tsx", "Run manually in ChatGPT", "Build Loop must expose manual browser execution.");
|
||||
expectIncludes("src/features/execution/pages/ExecutionCenter.tsx", "No API cost browser bridge", "Execution Center must expose bridge path.");
|
||||
expectIncludes("src/features/handoffs/pages/CodexHandoffs.tsx", "Run via Browser Bridge", "Handoffs must expose browser bridge handoff path.");
|
||||
expectIncludes("src/features/history/pages/ProjectHistory.tsx", "browserBridge", "History must include browser bridge entries.");
|
||||
expectIncludes("src/features/audit/pages/ProjectAuditCenter.tsx", "Browser Bridge control", "Audit Center must include browser bridge checks.");
|
||||
expectExists("tools/projectbrain-browser-bridge-extension/manifest.json", "Optional extension manifest must exist.");
|
||||
expectIncludes("tools/projectbrain-browser-bridge-extension/content-script.js", "ProjectBrain Bridge", "Extension helper must render visible bridge UI.");
|
||||
expectIncludes("tools/projectbrain-browser-bridge-extension/README.md", "does not read cookies", "Extension README must document safety boundary.");
|
||||
|
||||
const pkg = read("package.json");
|
||||
expectIncludes("package.json", "audit:browser-bridge", "package.json must expose audit:browser-bridge.");
|
||||
expectIncludes("package.json", "audit:session-pack-export && pnpm run audit:browser-bridge", "audit:all must include browser bridge after session pack export.");
|
||||
|
||||
if (failures.length) {
|
||||
console.error("Browser Bridge readiness failed:");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Browser Bridge readiness passed.");
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
const requiredFiles = [
|
||||
"src/shared/lib/buildLoop.ts",
|
||||
"src/features/build-loop/pages/BuildLoopDashboard.tsx",
|
||||
"src/app/routes.tsx",
|
||||
"src/shared/lib/navigation.tsx",
|
||||
"src/shared/components/ProjectFlowRail.tsx",
|
||||
"src/features/projects/pages/Workspace.tsx",
|
||||
"src/features/projects/pages/Projects.tsx",
|
||||
"src/features/execution/pages/ExecutionCenter.tsx",
|
||||
"src/features/repo-builder/pages/RepoBuilder.tsx",
|
||||
"src/features/handoffs/pages/CodexHandoffs.tsx",
|
||||
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
|
||||
"src/features/audit/pages/ProjectAuditCenter.tsx",
|
||||
"src/shared/lib/projectOperatingStep.ts",
|
||||
"src/shared/lib/projectSearch.ts",
|
||||
"src/shared/lib/routeRegistry.ts",
|
||||
"package.json",
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
|
||||
}
|
||||
|
||||
const read = (file) => readFileSync(file, "utf8");
|
||||
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
|
||||
|
||||
const helper = text["src/shared/lib/buildLoop.ts"] ?? "";
|
||||
const page = text["src/features/build-loop/pages/BuildLoopDashboard.tsx"] ?? "";
|
||||
const routes = text["src/app/routes.tsx"] ?? "";
|
||||
const navigation = text["src/shared/lib/navigation.tsx"] ?? "";
|
||||
const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? "";
|
||||
const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? "";
|
||||
const projects = text["src/features/projects/pages/Projects.tsx"] ?? "";
|
||||
const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? "";
|
||||
const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? "";
|
||||
const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? "";
|
||||
const feedback = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? "";
|
||||
const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? "";
|
||||
const operatingStep = text["src/shared/lib/projectOperatingStep.ts"] ?? "";
|
||||
const projectSearch = text["src/shared/lib/projectSearch.ts"] ?? "";
|
||||
const routeRegistry = text["src/shared/lib/routeRegistry.ts"] ?? "";
|
||||
const pkg = text["package.json"] ?? "";
|
||||
|
||||
const mustContain = [
|
||||
[helper, "getBuildLoopStatus", "buildLoop helper must expose getBuildLoopStatus."],
|
||||
[helper, "getCurrentBuildLoopPhase", "buildLoop helper must derive current phase."],
|
||||
[helper, "getRecommendedBuildLoopAction", "buildLoop helper must recommend next action."],
|
||||
[helper, "getBuildLoopRisks", "buildLoop helper must derive risks."],
|
||||
[helper, "getBuildLoopValidationSummary", "buildLoop helper must summarize validation."],
|
||||
[helper, "getBuildLoopSkillCoverage", "buildLoop helper must summarize skill coverage."],
|
||||
[helper, "getBuildLoopProgress", "buildLoop helper must calculate progress."],
|
||||
[helper, "formatBuildLoopMarkdown", "buildLoop helper must export markdown."],
|
||||
[helper, "repositoryIntakes", "buildLoop helper must use repository intakes/gaps."],
|
||||
[helper, "codexHandoffs", "buildLoop helper must use Codex handoffs."],
|
||||
[helper, "handoffReviews", "buildLoop helper must use handoff reviews."],
|
||||
[helper, "codexSessionPacks", "buildLoop helper must use session packs."],
|
||||
[helper, "Create Codex session pack", "buildLoop helper must recommend creating session packs."],
|
||||
[helper, "Run session and add feedback", "buildLoop helper must recommend feedback after session packs."],
|
||||
[helper, "latestSessionPack", "buildLoop helper must expose latest session pack."],
|
||||
[helper, "codexExecutionFeedback", "buildLoop helper must use execution feedback."],
|
||||
[helper, "getActiveProjectSkills", "buildLoop helper must use Codex skills."],
|
||||
[routes, "/projects/:projectId/build-loop", "Build Loop route missing."],
|
||||
[routes, "BuildLoopDashboard", "Build Loop page must be imported in routes."],
|
||||
[navigation, "getPrimaryPathNavigationItems", "Primary navigation must expose the workspace that owns the Build Loop entry."],
|
||||
[routeRegistry, 'key: "workspace"', "Canonical route registry must expose the workspace that owns the Build Loop entry."],
|
||||
[flowRail, 'id: "build-loop"', "Project Flow Rail must include Build Loop."],
|
||||
[navigation, '`${base}/build-loop`', "Technical navigation must expose Build Loop."],
|
||||
[workspace, "Technische details", "Workspace must progressively disclose technical execution."],
|
||||
[page, "getBuildLoopStatus", "Technical Build Loop must use the shared helper."],
|
||||
[projects, "resolveProjectListOperatingState", "Projects must combine daily guidance with live Auto state."],
|
||||
[projects, "getProjectOperatingStep", "Projects cards must derive the central next safe action."],
|
||||
[projects, "operatingState.label", "Projects must render the central operating-state label."],
|
||||
[projects, "operatingState.description", "Projects must render the central operating-state explanation."],
|
||||
[execution, "View build loop", "Execution Center must link to Build Loop."],
|
||||
[execution, "getBuildLoopStatus", "Execution Center must use Build Loop recommended action."],
|
||||
[repoBuilder, "Naar de buildloop", "Repo Builder must link back to the Build Loop from its task-first header."],
|
||||
[handoffs, "View in Build Loop", "Codex Handoffs must link to Build Loop."],
|
||||
[feedback, "Linked session pack", "Execution Feedback must link session packs."],
|
||||
[feedback, "Return to Build Loop", "Execution Feedback must link back to Build Loop."],
|
||||
[audit, "Build Loop cockpit", "Audit Center must show Build Loop coverage."],
|
||||
[audit, "Codex session pack readiness", "Audit Center must show session pack coverage."],
|
||||
[projectSearch, 'type: "buildLoop"', "Search index must include Build Loop result."],
|
||||
[page, "Technische uitvoeringsstatus", "Build Loop page must show status summary."],
|
||||
[page, "Interne fasen", "Build Loop page must show timeline."],
|
||||
[page, "Geslaagde controles", "Build Loop page must show safety and validation."],
|
||||
[page, "Repositorytoestand", "Build Loop page must show repo health."],
|
||||
[page, "uitvoeringsprofielen", "Build Loop page must show skill coverage."],
|
||||
[helper, "latestSessionPack", "Build Loop data must retain session pack readiness."],
|
||||
[helper, "codexExecutionFeedback", "Build Loop data must retain execution feedback."],
|
||||
[page, "Recente technische gebeurtenissen", "Build Loop page must show recent events."],
|
||||
[helper, "formatBuildLoopMarkdown", "Build Loop must retain its export formatter."],
|
||||
[pkg, "audit:build-loop", "package.json must expose audit:build-loop."],
|
||||
[pkg, "check-build-loop-readiness.mjs", "package.json must wire Build Loop readiness script."],
|
||||
];
|
||||
|
||||
for (const [haystack, needle, message] of mustContain) {
|
||||
if (!haystack.includes(needle)) failures.push(message);
|
||||
}
|
||||
|
||||
const scripts = JSON.parse(pkg || "{}").scripts ?? {};
|
||||
const experienceAudit = scripts["audit:experience"] ?? "";
|
||||
const allAudit = scripts["audit:all"] ?? "";
|
||||
if (!experienceAudit.includes("audit:build-loop") || !allAudit.includes("audit:experience")) {
|
||||
failures.push("audit:all must include the experience gate with Build Loop readiness.");
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("ProjectBrain build loop readiness: FAIL");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("ProjectBrain build loop readiness: PASS");
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
const requiredFiles = [
|
||||
"src/app/routes.tsx",
|
||||
"src/shared/lib/navigation.tsx",
|
||||
"src/shared/components/ProjectFlowRail.tsx",
|
||||
"src/shared/lib/projectStartFlow.ts",
|
||||
"src/features/projects/pages/Projects.tsx",
|
||||
"src/features/projects/pages/Workspace.tsx",
|
||||
"src/features/build-loop/pages/BuildLoopDashboard.tsx",
|
||||
"src/features/repo-builder/pages/RepoBuilder.tsx",
|
||||
"src/features/handoffs/pages/CodexHandoffs.tsx",
|
||||
"src/features/session-packs/pages/SessionPacks.tsx",
|
||||
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
|
||||
"src/shared/lib/safeData.ts",
|
||||
"src/shared/lib/codexSessionPackExport.ts",
|
||||
"src/shared/lib/downloads.ts",
|
||||
"package.json",
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
|
||||
}
|
||||
|
||||
const read = (file) => readFileSync(file, "utf8");
|
||||
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
|
||||
|
||||
const routes = text["src/app/routes.tsx"] ?? "";
|
||||
const navigation = text["src/shared/lib/navigation.tsx"] ?? "";
|
||||
const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? "";
|
||||
const startFlow = text["src/shared/lib/projectStartFlow.ts"] ?? "";
|
||||
const projects = text["src/features/projects/pages/Projects.tsx"] ?? "";
|
||||
const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? "";
|
||||
const buildLoop = text["src/features/build-loop/pages/BuildLoopDashboard.tsx"] ?? "";
|
||||
const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? "";
|
||||
const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? "";
|
||||
const sessionPacks = text["src/features/session-packs/pages/SessionPacks.tsx"] ?? "";
|
||||
const feedback = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? "";
|
||||
const safeData = text["src/shared/lib/safeData.ts"] ?? "";
|
||||
const exportHelper = text["src/shared/lib/codexSessionPackExport.ts"] ?? "";
|
||||
const downloads = text["src/shared/lib/downloads.ts"] ?? "";
|
||||
const pkg = text["package.json"] ?? "";
|
||||
|
||||
const mustContain = [
|
||||
[routes, "/start", "Unified Start route must be registered."],
|
||||
[routes, "/projects/new", "Projects/new compatibility route must remain registered."],
|
||||
[routes, "/projects/:projectId/workspace", "Workspace route missing."],
|
||||
[routes, "/projects/:projectId/source-library", "Source Library route missing."],
|
||||
[routes, "/projects/:projectId/skills", "Skill Library route missing."],
|
||||
[routes, "/projects/:projectId/repo-builder", "Repo Builder route missing."],
|
||||
[routes, "/projects/:projectId/handoffs", "Codex Handoffs route missing."],
|
||||
[routes, "/projects/:projectId/session-packs", "Session Packs route missing."],
|
||||
[routes, "/projects/:projectId/execution-feedback", "Execution Feedback route missing."],
|
||||
[routes, "/projects/:projectId/build-loop", "Build Loop route missing."],
|
||||
[routes, "/projects/:projectId/execution", "Execution Center route missing."],
|
||||
[routes, "/projects/:projectId/history", "History route missing."],
|
||||
[routes, "/projects/:projectId/audit", "Audit Center route missing."],
|
||||
[routes, "/projects/:projectId/prompts", "Prompt Builder route missing."],
|
||||
[navigation, "Build Loop", "Navigation must expose Build Loop."],
|
||||
[navigation, "Repo Builder", "Navigation must expose Repo Builder."],
|
||||
[navigation, "Codex Handoffs", "Navigation must expose Codex Handoffs."],
|
||||
[navigation, "Session Packs", "Navigation must expose Session Packs."],
|
||||
[navigation, "Execution Feedback", "Navigation must expose Execution Feedback."],
|
||||
[flowRail, 'id: "source-library"', "Flow Rail must include Source Library."],
|
||||
[flowRail, 'id: "skills"', "Flow Rail must include Skills."],
|
||||
[flowRail, 'id: "repo-builder"', "Flow Rail must include Repo Builder."],
|
||||
[flowRail, 'id: "handoffs"', "Flow Rail must include Handoffs."],
|
||||
[flowRail, 'id: "session-packs"', "Flow Rail must include Session Packs."],
|
||||
[flowRail, 'id: "execution-feedback"', "Flow Rail must include Execution Feedback."],
|
||||
[flowRail, 'id: "build-loop"', "Flow Rail must include Build Loop."],
|
||||
[startFlow, "/start?path=no-idea", "Start flow must support no-idea path."],
|
||||
[startFlow, "/start?path=rough-idea", "Start flow must support rough-idea path."],
|
||||
[startFlow, "/start?path=source-material", "Start flow must support source-material path."],
|
||||
[startFlow, "/start?path=existing-repo", "Start flow must support existing-repo path."],
|
||||
[startFlow, "/projects/new?source=unified-start", "Direct project setup must pass through unified project setup route."],
|
||||
[projects, "/start", "Projects page must link into unified Start."],
|
||||
[projects, "getProjectNextAction", "Projects page must use shared next-action helper."],
|
||||
[workspace, "Build Loop", "Workspace must expose Build Loop."],
|
||||
[workspace, "Session Packs", "Workspace must expose Session Packs."],
|
||||
[buildLoop, "Open linked module", "Build Loop page must expose linked module navigation."],
|
||||
[buildLoop, "Open Session Packs", "Build Loop page must link to Session Packs."],
|
||||
[repoBuilder, "Imported repository evidence is not live filesystem truth", "Repo Builder must label imported evidence boundary."],
|
||||
[handoffs, "ProjectBrain-derived impact simulation, not a live git diff", "Codex Handoffs must label simulated review boundary."],
|
||||
[handoffs, "controlled input for a Codex build session", "Codex Handoffs must explain session pack boundary."],
|
||||
[sessionPacks, "Download instructions/scripts ZIP", "Session Packs must expose clear instructions/scripts ZIP export."],
|
||||
[sessionPacks, "Export session pack files", "Session Packs must keep individual-file export fallback."],
|
||||
[sessionPacks, "ZIP export contains only", "Session Packs must explain ZIP contents."],
|
||||
[feedback, "not live git truth or live filesystem truth", "Execution Feedback must label feedback truth boundary."],
|
||||
[safeData, "repositoryIntakes: asArray(project.repositoryIntakes)", "safeData must normalize repository intakes."],
|
||||
[safeData, "codexHandoffs: asArray(project.codexHandoffs)", "safeData must normalize Codex handoffs."],
|
||||
[safeData, "handoffReviews: asArray(project.handoffReviews)", "safeData must normalize handoff reviews."],
|
||||
[safeData, "codexExecutionFeedback: asArray(project.codexExecutionFeedback)", "safeData must normalize execution feedback."],
|
||||
[safeData, "codexSessionPacks: asArray(project.codexSessionPacks)", "safeData must normalize session packs."],
|
||||
[safeData, "repoStateSnapshots: asArray(project.repoStateSnapshots)", "safeData must normalize repo state snapshots."],
|
||||
[safeData, "repoDeliveryPackages: asArray(project.repoDeliveryPackages)", "safeData must normalize delivery packages."],
|
||||
[safeData, "repoWorkPlans: asArray(project.repoWorkPlans)", "safeData must normalize work plans."],
|
||||
[safeData, "codexSkills: asArray(project.codexSkills)", "safeData must normalize project skill selections."],
|
||||
[safeData, "hiddenHistoryEntryIds: asArray(project.hiddenHistoryEntryIds)", "safeData must normalize hidden history ids."],
|
||||
[exportHelper, "zipExport: true", "Session pack manifest must state ZIP export availability."],
|
||||
[exportHelper, "downloadCodexSessionPackZip", "Session pack helper must expose ZIP download."],
|
||||
[exportHelper, "bundle-manifest.json", "Session pack bundle must include manifest."],
|
||||
[downloads, "URL.revokeObjectURL", "Download helper must revoke Blob URLs."],
|
||||
[pkg, "audit:release-consolidation", "package.json must expose release consolidation audit."],
|
||||
[pkg, "check-build-loop-release-consolidation.mjs", "package.json must wire release consolidation audit."],
|
||||
];
|
||||
|
||||
for (const [haystack, needle, message] of mustContain) {
|
||||
if (!haystack.includes(needle)) failures.push(message);
|
||||
}
|
||||
|
||||
if (sessionPacks.includes("Download Bundle / ZIP")) {
|
||||
failures.push("Session Packs must not imply that a real ZIP is produced.");
|
||||
}
|
||||
|
||||
const auditAll = JSON.parse(pkg || "{}")?.scripts?.["audit:all"] ?? "";
|
||||
if (!auditAll.includes("audit:release-consolidation")) {
|
||||
failures.push("audit:all must include the release consolidation audit.");
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("ProjectBrain release consolidation readiness: FAIL");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("ProjectBrain release consolidation readiness: PASS");
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
const requiredFiles = [
|
||||
"src/shared/lib/buildLoopWalkthrough.ts",
|
||||
"src/shared/lib/buildLoop.ts",
|
||||
"src/features/build-loop/pages/BuildLoopDashboard.tsx",
|
||||
"src/features/projects/pages/Projects.tsx",
|
||||
"src/features/projects/pages/Workspace.tsx",
|
||||
"src/features/repo-builder/pages/RepoBuilder.tsx",
|
||||
"src/features/handoffs/pages/CodexHandoffs.tsx",
|
||||
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
|
||||
"src/features/skills/pages/SkillLibrary.tsx",
|
||||
"src/features/source-library/pages/SourceLibrary.tsx",
|
||||
"src/features/operations/pages/ReviewQueue.tsx",
|
||||
"src/features/execution/pages/ExecutionCenter.tsx",
|
||||
"src/features/history/pages/ProjectHistory.tsx",
|
||||
"src/features/audit/pages/ProjectAuditCenter.tsx",
|
||||
"package.json",
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
|
||||
}
|
||||
|
||||
const read = (file) => readFileSync(file, "utf8");
|
||||
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
|
||||
|
||||
const helper = text["src/shared/lib/buildLoopWalkthrough.ts"] ?? "";
|
||||
const buildLoop = text["src/features/build-loop/pages/BuildLoopDashboard.tsx"] ?? "";
|
||||
const projects = text["src/features/projects/pages/Projects.tsx"] ?? "";
|
||||
const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? "";
|
||||
const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? "";
|
||||
const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? "";
|
||||
const feedback = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? "";
|
||||
const skills = text["src/features/skills/pages/SkillLibrary.tsx"] ?? "";
|
||||
const sourceLibrary = text["src/features/source-library/pages/SourceLibrary.tsx"] ?? "";
|
||||
const reviewQueue = text["src/features/operations/pages/ReviewQueue.tsx"] ?? "";
|
||||
const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? "";
|
||||
const history = text["src/features/history/pages/ProjectHistory.tsx"] ?? "";
|
||||
const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? "";
|
||||
const pkg = text["package.json"] ?? "";
|
||||
|
||||
const requiredSteps = [
|
||||
"start-project",
|
||||
"add-sources-or-repo-intake",
|
||||
"review-context",
|
||||
"configure-skills",
|
||||
"shape-repo",
|
||||
"create-delivery-package",
|
||||
"create-codex-handoff",
|
||||
"review-impact",
|
||||
"create-session-pack",
|
||||
"execute-in-codex",
|
||||
"add-execution-feedback",
|
||||
"continue-next-loop",
|
||||
];
|
||||
|
||||
const mustContain = [
|
||||
[helper, "getFirstTimeBuildLoopSteps", "Walkthrough helper must expose getFirstTimeBuildLoopSteps."],
|
||||
[helper, "getBuildLoopEmptyState", "Walkthrough helper must expose getBuildLoopEmptyState."],
|
||||
[helper, "getBuildLoopModuleExplanation", "Walkthrough helper must expose module explanations."],
|
||||
[helper, "getBuildLoopStepStatus", "Walkthrough helper must expose step status."],
|
||||
[helper, "getNextBeginnerAction", "Walkthrough helper must expose next beginner action."],
|
||||
[helper, "formatBuildLoopWalkthroughMarkdown", "Walkthrough helper must export markdown."],
|
||||
[buildLoop, "Guided walkthrough", "Build Loop page must show guided walkthrough."],
|
||||
[buildLoop, "Copy walkthrough", "Build Loop page must support walkthrough export."],
|
||||
[buildLoop, "getFirstTimeBuildLoopSteps", "Build Loop page must use the walkthrough helper."],
|
||||
[buildLoop, "Use this when:", "Build Loop page must explain the active module."],
|
||||
[workspace, "getNextBeginnerAction", "Workspace must use beginner next action."],
|
||||
[projects, "Existing project memories", "Projects page must put existing project memories first."],
|
||||
[projects, "Start another project through one flow", "Projects page must still route to unified Start for new projects."],
|
||||
[projects, "Continue build loop", "Project cards must use harmonized build-loop CTA."],
|
||||
[repoBuilder, "Repository intake", "Repo Builder must show repository intake subflow."],
|
||||
[repoBuilder, "Create delivery package", "Repo Builder must show delivery-package step."],
|
||||
[repoBuilder, "Continue build loop", "Repo Builder must link back to the loop."],
|
||||
[handoffs, "Create handoff", "Handoffs must show create/review/run/feedback mini-flow."],
|
||||
[handoffs, "Create session pack", "Handoffs must include create-session-pack action."],
|
||||
[handoffs, "Run in Codex", "Handoffs must include run-in-Codex stage."],
|
||||
[handoffs, "Stage:", "Handoff cards must show stage."],
|
||||
[feedback, "user-provided execution evidence", "Execution Feedback must clarify evidence boundary."],
|
||||
[feedback, "Linked session pack", "Execution Feedback must link session packs."],
|
||||
[feedback, "Use this feedback template", "Execution Feedback must use session pack feedback templates."],
|
||||
[feedback, "Create follow-up handoff", "Execution Feedback must keep follow-up handoff CTA."],
|
||||
[skills, "No active skills yet", "Skill Library must explain empty active skills state."],
|
||||
[sourceLibrary, "Use this when:", "Source Library must have module explanation."],
|
||||
[reviewQueue, "Use this when:", "Review Queue must have module explanation."],
|
||||
[execution, "Use this when:", "Execution Center must have module explanation."],
|
||||
[history, "Use this when:", "History must have module explanation."],
|
||||
[audit, "Use this when:", "Audit Center must have module explanation."],
|
||||
[audit, "Codex session pack readiness", "Audit Center must show session pack readiness."],
|
||||
[pkg, "audit:build-loop-walkthrough", "package.json must expose audit:build-loop-walkthrough."],
|
||||
[pkg, "check-build-loop-walkthrough-readiness.mjs", "package.json must wire the walkthrough readiness script."],
|
||||
];
|
||||
|
||||
for (const step of requiredSteps) {
|
||||
if (!helper.includes(step)) failures.push(`Walkthrough helper missing step: ${step}`);
|
||||
}
|
||||
|
||||
for (const [haystack, needle, message] of mustContain) {
|
||||
if (!haystack.includes(needle)) failures.push(message);
|
||||
}
|
||||
|
||||
if (!pkg.includes("audit:build-loop-walkthrough") || !pkg.includes("audit:all")) {
|
||||
failures.push("audit:all must include the build-loop walkthrough readiness check.");
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("ProjectBrain build loop walkthrough readiness: FAIL");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("ProjectBrain build loop walkthrough readiness: PASS");
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
const requiredFiles = [
|
||||
"src/shared/lib/model.ts",
|
||||
"src/shared/lib/codexHandoff.ts",
|
||||
"src/shared/lib/codexSessionPack.ts",
|
||||
"src/shared/lib/safeData.ts",
|
||||
"src/shared/api/realClient.ts",
|
||||
"src/shared/hooks/useProjectData.ts",
|
||||
"src/shared/contexts/ProjectContext.tsx",
|
||||
"src/app/routes.tsx",
|
||||
"src/shared/lib/navigation.tsx",
|
||||
"src/shared/components/ProjectFlowRail.tsx",
|
||||
"src/features/handoffs/pages/CodexHandoffs.tsx",
|
||||
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
|
||||
"src/features/repo-builder/pages/RepoBuilder.tsx",
|
||||
"src/features/execution/pages/ExecutionCenter.tsx",
|
||||
"src/features/prompts/pages/PromptGenerator.tsx",
|
||||
"src/features/history/pages/ProjectHistory.tsx",
|
||||
"src/features/audit/pages/ProjectAuditCenter.tsx",
|
||||
"src/features/projects/pages/Workspace.tsx",
|
||||
"package.json",
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
|
||||
}
|
||||
|
||||
const read = (file) => readFileSync(file, "utf8");
|
||||
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
|
||||
|
||||
const model = text["src/shared/lib/model.ts"] ?? "";
|
||||
const helper = text["src/shared/lib/codexHandoff.ts"] ?? "";
|
||||
const sessionPackHelper = text["src/shared/lib/codexSessionPack.ts"] ?? "";
|
||||
const safeData = text["src/shared/lib/safeData.ts"] ?? "";
|
||||
const realClient = text["src/shared/api/realClient.ts"] ?? "";
|
||||
const hooks = text["src/shared/hooks/useProjectData.ts"] ?? "";
|
||||
const context = text["src/shared/contexts/ProjectContext.tsx"] ?? "";
|
||||
const routes = text["src/app/routes.tsx"] ?? "";
|
||||
const navigation = text["src/shared/lib/navigation.tsx"] ?? "";
|
||||
const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? "";
|
||||
const handoffPage = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? "";
|
||||
const feedbackPage = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? "";
|
||||
const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? "";
|
||||
const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? "";
|
||||
const promptBuilder = text["src/features/prompts/pages/PromptGenerator.tsx"] ?? "";
|
||||
const history = text["src/features/history/pages/ProjectHistory.tsx"] ?? "";
|
||||
const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? "";
|
||||
const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? "";
|
||||
const pkg = text["package.json"] ?? "";
|
||||
|
||||
const mustContain = [
|
||||
[model, "export interface CodexHandoff", "CodexHandoff model missing."],
|
||||
[model, "export interface CodexHandoffValidationItem", "CodexHandoffValidationItem model missing."],
|
||||
[model, "export interface CodexHandoffRisk", "CodexHandoffRisk model missing."],
|
||||
[model, "export interface CodexHandoffScope", "CodexHandoffScope model missing."],
|
||||
[model, "export interface CodexHandoffQualityScore", "CodexHandoffQualityScore model missing."],
|
||||
[model, "codexHandoffs?: CodexHandoff[]", "Project model must persist Codex handoffs."],
|
||||
[model, "codexSessionPacks?: CodexSessionPack[]", "Project model must persist Codex session packs."],
|
||||
[safeData, "codexHandoffs: asArray(project.codexHandoffs)", "Runtime normalization must preserve Codex handoffs."],
|
||||
[realClient, "createHandoffBundle", "Real client must expose backend handoff bundle creation."],
|
||||
[realClient, "createCodexTaskFromHandoff", "Real client must expose backend Codex task creation from handoff bundles."],
|
||||
[hooks, "useProjectHandoffBundlesData", "Async hooks must expose backend handoff bundles."],
|
||||
[hooks, "useProjectCodexTasksData", "Async hooks must expose backend Codex tasks."],
|
||||
[helper, "generateCodexHandoffFromDeliveryPackage", "Handoff helper must generate from delivery packages."],
|
||||
[helper, "generateCodexHandoffFromRepoGap", "Handoff helper must generate from repository gaps."],
|
||||
[helper, "generateCodexHandoffFromWorkPlan", "Handoff helper must generate from work plans."],
|
||||
[helper, "generateCodexHandoffFromRepoState", "Handoff helper must generate from repo state snapshots."],
|
||||
[helper, "buildAllowedFiles", "Handoff helper must build allowed files."],
|
||||
[helper, "buildForbiddenChanges", "Handoff helper must build forbidden changes."],
|
||||
[helper, "buildValidationMatrix", "Handoff helper must build validation matrix."],
|
||||
[helper, "buildRollbackPlan", "Handoff helper must build rollback plan."],
|
||||
[helper, "buildDoneCriteria", "Handoff helper must build done criteria."],
|
||||
[helper, "formatCodexHandoffMarkdown", "Handoff helper must format markdown."],
|
||||
[helper, "scoreCodexHandoffQuality", "Handoff helper must score quality."],
|
||||
[helper, "getHandoffWarnings", "Handoff helper must produce warnings."],
|
||||
[helper, "promptInstructions", "Skill prompt instructions must be included."],
|
||||
[helper, "validationCommands", "Skill validation commands must be included."],
|
||||
[helper, "qualityChecklist", "Skill quality checklist must be included."],
|
||||
[helper, "riskNotes", "Skill risk notes must be included."],
|
||||
[helper, "This is derived from imported repository evidence, not live filesystem truth", "Repo-gap handoffs must label imported evidence boundary."],
|
||||
[sessionPackHelper, "generateCodexSessionPack", "Session pack helper must generate packs from handoffs."],
|
||||
[routes, "/projects/:projectId/handoffs", "Codex Handoffs route missing."],
|
||||
[routes, "CodexHandoffs", "Codex Handoffs page must be imported in routes."],
|
||||
[navigation, "Codex Handoffs", "Navigation must expose Codex Handoffs."],
|
||||
[flowRail, 'id: "handoffs"', "Project Flow Rail must include Handoffs."],
|
||||
[handoffPage, "Codex Handoffs", "Handoff page must exist."],
|
||||
[handoffPage, "Live backend handoff path", "Handoff page must expose the live backend handoff bridge."],
|
||||
[handoffPage, "Create live handoff", "Handoff page must support creating backend-persisted handoff bundles."],
|
||||
[handoffPage, "Create Codex task", "Handoff page must support creating backend-persisted Codex tasks from handoff bundles."],
|
||||
[handoffPage, "useProjectHandoffBundlesData", "Handoff page must read backend handoff bundles in real mode."],
|
||||
[handoffPage, "quality", "Handoff page must show quality score."],
|
||||
[handoffPage, "Allowed files", "Handoff page must show allowed files."],
|
||||
[handoffPage, "Forbidden changes", "Handoff page must show forbidden changes."],
|
||||
[handoffPage, "Validation matrix", "Handoff page must show validation matrix."],
|
||||
[handoffPage, "Rollback", "Handoff page must show rollback."],
|
||||
[handoffPage, "Generated prompt", "Handoff page must show generated prompt."],
|
||||
[handoffPage, "Copy next Codex prompt", "Handoff page must support copying prompts."],
|
||||
[handoffPage, "Generate impact review", "Handoff page must support impact review generation."],
|
||||
[handoffPage, "ProjectBrain-derived impact simulation, not a live git diff", "Handoff page must distinguish simulated review from live git diff."],
|
||||
[handoffPage, "Add execution feedback", "Handoff page must link to execution feedback intake."],
|
||||
[handoffPage, "Create session pack", "Handoff page must create session packs."],
|
||||
[handoffPage, "Open session pack", "Handoff page must open session packs."],
|
||||
[handoffPage, "Copy session pack", "Handoff page must copy session packs."],
|
||||
[handoffPage, "Download session pack", "Handoff page must download session packs."],
|
||||
[handoffPage, "Download ZIP", "Handoff page must download session pack ZIP."],
|
||||
[handoffPage, "Export session pack files", "Handoff page must export session pack files."],
|
||||
[handoffPage, "controlled input for a Codex build session", "Handoff page must explain session pack purpose."],
|
||||
[handoffPage, "View in Build Loop", "Handoff page must link to Build Loop."],
|
||||
[feedbackPage, "Execution Feedback", "Execution feedback page must exist for post-handoff evidence intake."],
|
||||
[repoBuilder, "Create Codex handoff", "Repo Builder must generate handoffs."],
|
||||
[repoBuilder, "Create handoff from work plan", "Repo Builder must generate handoffs from work plans."],
|
||||
[repoBuilder, "Create handoff from repo state", "Repo Builder must generate handoffs from repo state."],
|
||||
[execution, "Codex Handoff Quality Engine", "Execution Center must show handoff engine."],
|
||||
[execution, "Copy next Codex prompt", "Execution Center must copy next prompt."],
|
||||
[execution, "Open Codex Handoff", "Execution Center must link to Handoffs."],
|
||||
[promptBuilder, "Codex Handoff source", "Prompt Builder must expose handoff source."],
|
||||
[promptBuilder, "Use handoff promptText", "Prompt Builder must use handoff promptText."],
|
||||
[context, "generateCodexHandoff", "ProjectContext must expose generateCodexHandoff."],
|
||||
[context, "generateCodexSessionPack", "ProjectContext must expose generateCodexSessionPack."],
|
||||
[context, "archiveCodexHandoff", "ProjectContext must expose archiveCodexHandoff."],
|
||||
[context, "codexHandoffItems", "Project search must index Codex handoffs."],
|
||||
[history, 'kind: "handoff"', "Project History must include handoff records."],
|
||||
[audit, "Codex handoff quality", "Audit Center must show handoff quality."],
|
||||
[workspace, "codexHandoffCount", "Workspace must count Codex handoffs."],
|
||||
[workspace, "Codex Handoffs", "Workspace must expose Codex Handoffs."],
|
||||
[pkg, "audit:handoffs", "package.json must expose audit:handoffs."],
|
||||
[pkg, "check-codex-handoff-readiness.mjs", "package.json must wire handoff readiness script."],
|
||||
];
|
||||
|
||||
for (const [haystack, needle, message] of mustContain) {
|
||||
if (!haystack.includes(needle)) failures.push(message);
|
||||
}
|
||||
|
||||
if (!pkg.includes("audit:handoffs") || !pkg.includes("audit:all")) {
|
||||
failures.push("audit:all must include the Codex handoff readiness check.");
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("ProjectBrain Codex handoff readiness: FAIL");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("ProjectBrain Codex handoff readiness: PASS");
|
||||
@@ -0,0 +1,252 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const root = process.cwd();
|
||||
const migrationsDir = path.join(root, "backend", "prisma", "migrations");
|
||||
const schemaPath = path.join(root, "backend", "prisma", "schema.prisma");
|
||||
const dockerComposePath = path.join(root, "docker-compose.yml");
|
||||
const backendDockerfilePath = path.join(root, "backend", "Dockerfile");
|
||||
const rootPackagePath = path.join(root, "package.json");
|
||||
const workspacePath = path.join(root, "pnpm-workspace.yaml");
|
||||
const backendEntrypointPath = path.join(root, "backend", "scripts", "docker-entrypoint.sh");
|
||||
const systemReadinessRoutePath = path.join(root, "backend", "src", "routes", "systemReadiness.ts");
|
||||
const appPath = path.join(root, "backend", "src", "app.ts");
|
||||
const initiativeRoutesPath = path.join(root, "backend", "src", "routes", "initiatives.ts");
|
||||
const initiativeRepoPath = path.join(root, "backend", "src", "repositories", "initiativesRepository.ts");
|
||||
const initiativeSmokePath = path.join(root, "backend", "scripts", "smoke-initiatives.mjs");
|
||||
|
||||
const exists = (filePath) => fs.existsSync(filePath);
|
||||
const read = (filePath) => fs.readFileSync(filePath, "utf8");
|
||||
const fail = [];
|
||||
const warn = [];
|
||||
const requiredPnpmBuiltDeps = ["@prisma/client", "@prisma/engines", "@swc/core", "esbuild", "prisma"];
|
||||
|
||||
const parseWorkspaceAllowBuilds = (workspaceYaml) => {
|
||||
const allowed = new Set();
|
||||
const allowBuildsIndex = workspaceYaml.indexOf("allowBuilds:");
|
||||
if (allowBuildsIndex === -1) return allowed;
|
||||
const lines = workspaceYaml.slice(allowBuildsIndex).split("\n").slice(1);
|
||||
for (const line of lines) {
|
||||
if (/^[^\s#]/.test(line)) break;
|
||||
const match = line.match(/^\s*[\'"]?([^\'":]+)[\'"]?\s*:\s*(true|false)\s*(?:#.*)?$/);
|
||||
if (match && match[2] === "true") allowed.add(match[1].trim());
|
||||
}
|
||||
return allowed;
|
||||
};
|
||||
|
||||
if (!exists(schemaPath)) fail.push("Missing backend/prisma/schema.prisma.");
|
||||
if (!exists(migrationsDir)) fail.push("Missing backend/prisma/migrations directory.");
|
||||
|
||||
const migrationFolders = exists(migrationsDir)
|
||||
? fs.readdirSync(migrationsDir).filter((entry) => fs.statSync(path.join(migrationsDir, entry)).isDirectory()).sort()
|
||||
: [];
|
||||
|
||||
const migrationSql = migrationFolders.map((folder) => ({
|
||||
folder,
|
||||
path: path.join(migrationsDir, folder, "migration.sql"),
|
||||
})).filter((item) => exists(item.path));
|
||||
|
||||
if (migrationSql.length === 0) {
|
||||
fail.push("No Prisma migration.sql files were found.");
|
||||
}
|
||||
|
||||
const legacyDuplicateInitMigration = "20260606200000_init_project_workspace";
|
||||
if (migrationFolders.includes(legacyDuplicateInitMigration)) {
|
||||
fail.push(`${legacyDuplicateInitMigration} must not exist. It is an obsolete duplicate init migration that recreates ProjectStatus and breaks fresh Docker database startup.`);
|
||||
}
|
||||
|
||||
const initMigrations = migrationSql.filter((item) => item.folder.includes("init_project_workspace"));
|
||||
if (initMigrations.length !== 1) {
|
||||
fail.push(`Expected exactly one init_project_workspace migration, found ${initMigrations.length}: ${initMigrations.map((item) => item.folder).join(", ") || "none"}.`);
|
||||
}
|
||||
|
||||
const initIndex = migrationSql.findIndex((item) => item.folder.includes("init_project_workspace"));
|
||||
if (initIndex < 0) {
|
||||
fail.push("No init_project_workspace migration found.");
|
||||
} else if (initIndex !== 0) {
|
||||
fail.push(`Init migration must be first, but it is at position ${initIndex + 1}: ${migrationSql[initIndex].folder}.`);
|
||||
}
|
||||
|
||||
const projectTableIndex = migrationSql.findIndex((item) => read(item.path).includes('CREATE TABLE "Project"'));
|
||||
const captureTableIndex = migrationSql.findIndex((item) => read(item.path).includes('CREATE TABLE "CaptureItem"'));
|
||||
if (projectTableIndex < 0) fail.push('No migration creates "Project".');
|
||||
if (captureTableIndex >= 0 && projectTableIndex >= 0 && projectTableIndex > captureTableIndex) {
|
||||
fail.push('Migration order is invalid: "CaptureItem" appears before "Project".');
|
||||
}
|
||||
|
||||
const schema = exists(schemaPath) ? read(schemaPath) : "";
|
||||
|
||||
const duplicateFields = [];
|
||||
const modelBlocks = [...schema.matchAll(/model\s+(\w+)\s+\{([\s\S]*?)\n\}/g)];
|
||||
for (const [, modelName, body] of modelBlocks) {
|
||||
const seen = new Set();
|
||||
for (const rawLine of body.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("@@") || line.startsWith("//")) continue;
|
||||
const fieldName = line.split(/\s+/)[0];
|
||||
if (!fieldName || fieldName.startsWith("@")) continue;
|
||||
if (seen.has(fieldName)) duplicateFields.push(`${modelName}.${fieldName}`);
|
||||
seen.add(fieldName);
|
||||
}
|
||||
}
|
||||
if (duplicateFields.length > 0) fail.push(`Prisma schema contains duplicate field definitions: ${duplicateFields.join(", ")}.`);
|
||||
if (!schema.includes("linux-musl-openssl-3.0.x")) fail.push("Prisma generator must include linux-musl-openssl-3.0.x binaryTarget for the Docker Alpine runtime.");
|
||||
|
||||
for (const model of ["Project", "CaptureItem", "AcceptedTruthItem", "CurrentStateReport", "DocumentationItem", "BlueprintGraph", "BlueprintNode", "BlueprintNodeRun", "BrainstormSession", "Initiative"]) {
|
||||
if (!schema.includes(`model ${model} `)) fail.push(`Schema is missing model ${model}.`);
|
||||
}
|
||||
|
||||
const migrationText = migrationSql.map((item) => read(item.path)).join("\n");
|
||||
for (const table of ["Project", "CaptureItem", "AcceptedTruthItem", "CurrentStateReport", "DocumentationItem", "BlueprintGraph", "BlueprintNode", "BlueprintNodeRun", "BrainstormSession", "Initiative"]) {
|
||||
if (!migrationText.includes(`"${table}"`)) fail.push(`Migrations do not reference table ${table}.`);
|
||||
}
|
||||
|
||||
|
||||
if (exists(rootPackagePath)) {
|
||||
const rootPackage = JSON.parse(read(rootPackagePath));
|
||||
if (rootPackage.packageManager !== "pnpm@11.5.2") {
|
||||
fail.push("Root package.json must pin packageManager to pnpm@11.5.2 for reproducible Docker/Corepack builds.");
|
||||
}
|
||||
if (rootPackage.pnpm) {
|
||||
fail.push("Root package.json must not contain a legacy pnpm field; pnpm 11 ignores it and emits Docker build warnings. Keep approvals in pnpm-workspace.yaml allowBuilds.");
|
||||
}
|
||||
} else {
|
||||
fail.push("Missing root package.json.");
|
||||
}
|
||||
|
||||
if (exists(workspacePath)) {
|
||||
const workspaceAllowedBuilds = parseWorkspaceAllowBuilds(read(workspacePath));
|
||||
for (const dependency of requiredPnpmBuiltDeps) {
|
||||
if (!workspaceAllowedBuilds.has(dependency)) {
|
||||
fail.push(`pnpm-workspace.yaml allowBuilds must set ${dependency}: true for Docker installs with pnpm 11.`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fail.push("Missing pnpm-workspace.yaml.");
|
||||
}
|
||||
|
||||
if (exists(dockerComposePath)) {
|
||||
const compose = read(dockerComposePath);
|
||||
if (!compose.includes("projectbrain-postgres")) fail.push("docker-compose.yml does not define projectbrain-postgres.");
|
||||
if (!compose.includes("PROJECTBRAIN_DATA_SOURCE")) fail.push("docker-compose.yml does not configure PROJECTBRAIN_DATA_SOURCE.");
|
||||
if (!compose.includes("VITE_PROJECTBRAIN_CLIENT_MODE")) warn.push("docker-compose.yml does not pass VITE_PROJECTBRAIN_CLIENT_MODE to frontend build args.");
|
||||
if (!compose.includes("PROJECTBRAIN_SEED_ON_START")) warn.push("docker-compose.yml does not expose PROJECTBRAIN_SEED_ON_START.");
|
||||
if (!compose.includes("/api/system/readiness")) fail.push("docker-compose.yml does not healthcheck /api/system/readiness.");
|
||||
if (!compose.includes("condition: service_healthy")) fail.push("docker-compose.yml does not wait for healthy backend before frontend startup.");
|
||||
} else {
|
||||
fail.push("Missing docker-compose.yml.");
|
||||
}
|
||||
|
||||
if (exists(backendDockerfilePath)) {
|
||||
const dockerfile = read(backendDockerfilePath);
|
||||
if (!dockerfile.includes("prisma:generate")) fail.push("Backend Dockerfile does not run prisma:generate during build.");
|
||||
if (!dockerfile.includes("docker-entrypoint.sh")) fail.push("Backend Dockerfile does not use the database-aware docker entrypoint.");
|
||||
if (!dockerfile.includes("/api/system/readiness")) fail.push("Backend Dockerfile does not expose a system readiness healthcheck.");
|
||||
if (!dockerfile.includes("apk add --no-cache") || !dockerfile.includes("openssl")) fail.push("Backend Dockerfile does not install OpenSSL; Prisma migrations fail at runtime on Alpine without it.");
|
||||
if (!dockerfile.includes("libc6-compat")) fail.push("Backend Dockerfile does not install libc6-compat for native engine compatibility on Alpine.");
|
||||
} else {
|
||||
fail.push("Missing backend/Dockerfile.");
|
||||
}
|
||||
|
||||
if (exists(backendEntrypointPath)) {
|
||||
const entrypoint = read(backendEntrypointPath);
|
||||
if (!entrypoint.includes("prisma migrate deploy")) fail.push("Backend entrypoint does not run prisma migrate deploy.");
|
||||
if (!entrypoint.includes("PROJECTBRAIN_SEED_ON_START")) fail.push("Backend entrypoint does not support PROJECTBRAIN_SEED_ON_START.");
|
||||
if (!entrypoint.includes("20260606200000_init_project_workspace")) fail.push("Backend entrypoint must guard against the obsolete duplicate init migration when older overlay deployments left it on disk or in _prisma_migrations.");
|
||||
if (!entrypoint.includes("prisma migrate resolve --rolled-back 20260606200000_init_project_workspace")) fail.push("Backend entrypoint must resolve failed obsolete duplicate init migration records as rolled back before migrate deploy.");
|
||||
} else {
|
||||
fail.push("Missing backend/scripts/docker-entrypoint.sh.");
|
||||
}
|
||||
|
||||
if (!exists(systemReadinessRoutePath)) {
|
||||
fail.push("Missing backend/src/routes/systemReadiness.ts live readiness route.");
|
||||
} else {
|
||||
const route = read(systemReadinessRoutePath);
|
||||
if (!route.includes('/api/system/readiness')) fail.push("System readiness route does not expose /api/system/readiness.");
|
||||
if (!route.includes('prisma.$queryRaw`SELECT 1`')) fail.push("System readiness route does not run a live Prisma SELECT 1 probe.");
|
||||
}
|
||||
|
||||
if (exists(appPath)) {
|
||||
const app = read(appPath);
|
||||
if (!app.includes("registerSystemReadinessRoutes")) fail.push("Fastify app does not register system readiness routes.");
|
||||
if (!app.includes("registerBrainstormRoutes")) fail.push("Fastify app does not register Brainstorm CPU routes.");
|
||||
if (!app.includes("registerInitiativeRoutes")) fail.push("Fastify app does not register Initiative routes.");
|
||||
} else {
|
||||
fail.push("Missing backend/src/app.ts.");
|
||||
}
|
||||
|
||||
if (!exists(initiativeRoutesPath)) {
|
||||
fail.push("Missing backend/src/routes/initiatives.ts.");
|
||||
} else {
|
||||
const routes = read(initiativeRoutesPath);
|
||||
for (const signal of [
|
||||
"/api/projects/:projectId/initiatives",
|
||||
"/api/projects/:projectId/initiatives/:initiativeId",
|
||||
"/api/projects/:projectId/initiatives/:initiativeId/feature-candidates",
|
||||
]) {
|
||||
if (!routes.includes(signal)) fail.push(`Initiative routes are missing ${signal}.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!exists(initiativeRepoPath)) {
|
||||
fail.push("Missing backend/src/repositories/initiativesRepository.ts.");
|
||||
} else {
|
||||
const repo = read(initiativeRepoPath);
|
||||
for (const symbol of ["createInitiativeForProject", "updateInitiativeForProject", "getInitiativeForProject", "createFeatureCandidatesForInitiative", "syncInitiativeReviewAction"]) {
|
||||
if (!repo.includes(symbol)) fail.push(`Initiative repository is missing ${symbol}.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!exists(initiativeSmokePath)) fail.push("Missing backend/scripts/smoke-initiatives.mjs.");
|
||||
else {
|
||||
const smoke = read(initiativeSmokePath);
|
||||
if (!smoke.includes("/review-queue/${firstCandidate.reviewItemId}/accept")) fail.push("Initiative smoke does not validate review accept materialization.");
|
||||
if (!smoke.includes("initiativeSync")) fail.push("Initiative smoke does not assert initiativeSync output.");
|
||||
}
|
||||
|
||||
const result = {
|
||||
status: fail.length === 0 ? "PASS" : "FAIL",
|
||||
migrationRoots: [path.relative(root, migrationsDir).replaceAll("\\", "/")],
|
||||
migrations: migrationSql.map((item) => item.folder),
|
||||
initMigration: initIndex >= 0 ? migrationSql[initIndex]?.folder : null,
|
||||
checks: {
|
||||
projectTableBeforeCapture: projectTableIndex >= 0 && (captureTableIndex < 0 || projectTableIndex < captureTableIndex),
|
||||
backendEntrypoint: exists(backendEntrypointPath),
|
||||
dockerCompose: exists(dockerComposePath),
|
||||
schema: exists(schemaPath),
|
||||
systemReadinessRoute: exists(systemReadinessRoutePath),
|
||||
legacyDuplicateInitMigrationAbsent: !migrationFolders.includes("20260606200000_init_project_workspace"),
|
||||
exactlyOneInitMigration: initMigrations.length === 1,
|
||||
initiativeRoutes: exists(initiativeRoutesPath),
|
||||
initiativeSmoke: exists(initiativeSmokePath),
|
||||
duplicateSchemaFields: duplicateFields.length === 0,
|
||||
prismaAlpineBinaryTarget: schema.includes("linux-musl-openssl-3.0.x"),
|
||||
backendDockerfileInstallsOpenSSL: exists(backendDockerfilePath) && read(backendDockerfilePath).includes("openssl"),
|
||||
pnpmDockerBuildScriptsApproved: exists(workspacePath) && (() => {
|
||||
const allowedBuilds = parseWorkspaceAllowBuilds(read(workspacePath));
|
||||
return requiredPnpmBuiltDeps.every((dependency) => allowedBuilds.has(dependency));
|
||||
})(),
|
||||
},
|
||||
warnings: warn,
|
||||
failures: fail,
|
||||
};
|
||||
|
||||
if (process.argv.includes("--json")) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(`ProjectBrain database readiness: ${result.status}`);
|
||||
console.log(`Migration roots checked: ${result.migrationRoots.join(", ")}`);
|
||||
console.log(`Migrations: ${migrationSql.length}`);
|
||||
console.log(`Init migration: ${result.initMigration ?? "missing"}`);
|
||||
if (warn.length > 0) {
|
||||
console.log("Warnings:");
|
||||
for (const item of warn) console.log(`- ${item}`);
|
||||
}
|
||||
if (fail.length > 0) {
|
||||
console.log("Failures:");
|
||||
for (const item of fail) console.log(`- ${item}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (fail.length > 0) process.exit(1);
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import { dirname, resolve, relative } from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const docsRoot = resolve(root, "docs");
|
||||
const walk = async (directory) => (await Promise.all((await readdir(directory, { withFileTypes: true })).map((entry) => entry.isDirectory() ? walk(resolve(directory, entry.name)) : [resolve(directory, entry.name)]))).flat();
|
||||
const files = (await walk(docsRoot)).filter((path) => path.endsWith(".md") && !path.includes(`${resolve(docsRoot, "archive")}\\`) && !path.includes(`${resolve(docsRoot, "archive")}/`));
|
||||
const failures = [];
|
||||
for (const file of files) {
|
||||
const source = await readFile(file, "utf8");
|
||||
const links = [...source.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)].map((match) => match[1].trim().replace(/^<|>$/g, ""));
|
||||
for (const link of links) {
|
||||
if (!link || /^(https?:|mailto:|#)/i.test(link)) continue;
|
||||
const targetPart = decodeURIComponent(link.split("#")[0]);
|
||||
if (!targetPart) continue;
|
||||
const target = resolve(dirname(file), targetPart);
|
||||
try { await stat(target); } catch { failures.push(`${relative(root, file)} -> ${link}`); }
|
||||
}
|
||||
}
|
||||
if (failures.length) { console.error(`Broken active documentation links:\n- ${failures.join("\n- ")}`); process.exit(1); }
|
||||
console.log(`Documentation link audit passed for ${files.length} active Markdown files.`);
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = process.cwd();
|
||||
const failures = [];
|
||||
const warnings = [];
|
||||
|
||||
const read = (relativePath) => fs.readFileSync(path.join(root, relativePath), "utf8");
|
||||
const exists = (relativePath) => fs.existsSync(path.join(root, relativePath));
|
||||
|
||||
const requiredBuiltDependencies = ["@prisma/client", "@prisma/engines", "@swc/core", "esbuild", "prisma"];
|
||||
|
||||
const parseWorkspaceAllowBuilds = (workspaceYaml) => {
|
||||
const allowed = new Set();
|
||||
const allowBuildsIndex = workspaceYaml.indexOf("allowBuilds:");
|
||||
if (allowBuildsIndex === -1) return allowed;
|
||||
const afterAllowBuilds = workspaceYaml.slice(allowBuildsIndex).split("\n").slice(1);
|
||||
for (const line of afterAllowBuilds) {
|
||||
if (/^[^\s#]/.test(line)) break;
|
||||
const match = line.match(/^\s*[\'"]?([^\'":]+)[\'"]?\s*:\s*(true|false)\s*(?:#.*)?$/);
|
||||
if (match && match[2] === "true") allowed.add(match[1].trim());
|
||||
}
|
||||
return allowed;
|
||||
};
|
||||
|
||||
if (!exists("package.json")) {
|
||||
failures.push("Missing package.json.");
|
||||
} else {
|
||||
const rootPackage = JSON.parse(read("package.json"));
|
||||
if (rootPackage.packageManager !== "pnpm@11.5.2") {
|
||||
failures.push("package.json must pin packageManager to pnpm@11.5.2 for reproducible Docker/Corepack installs.");
|
||||
}
|
||||
if (rootPackage.pnpm) {
|
||||
failures.push("package.json must not contain a legacy pnpm field; pnpm 11 ignores it and emits noisy warnings during Docker builds. Keep build approvals in pnpm-workspace.yaml allowBuilds.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!exists("pnpm-workspace.yaml")) {
|
||||
failures.push("Missing pnpm-workspace.yaml; pnpm 11 Docker builds need workspace-level allowBuilds.");
|
||||
} else {
|
||||
const workspaceYaml = read("pnpm-workspace.yaml");
|
||||
const workspaceAllowedBuilds = parseWorkspaceAllowBuilds(workspaceYaml);
|
||||
if (!workspaceYaml.includes("fetchWarnTimeout: 120000")) {
|
||||
warnings.push("pnpm-workspace.yaml should set fetchWarnTimeout: 120000 for quiet Docker registry fetch logs.");
|
||||
}
|
||||
for (const dependency of requiredBuiltDependencies) {
|
||||
if (!workspaceAllowedBuilds.has(dependency)) {
|
||||
failures.push(`pnpm-workspace.yaml allowBuilds must set ${dependency}: true for non-interactive pnpm 11 Docker installs.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const dockerfilePath of ["backend/Dockerfile", "Dockerfile.frontend"]) {
|
||||
if (!exists(dockerfilePath)) {
|
||||
failures.push(`Missing ${dockerfilePath}.`);
|
||||
continue;
|
||||
}
|
||||
const dockerfile = read(dockerfilePath);
|
||||
if (!dockerfile.includes("corepack enable")) warnings.push(`${dockerfilePath} does not explicitly enable Corepack.`);
|
||||
if (!dockerfile.includes("ENV CI=true")) warnings.push(`${dockerfilePath} should set CI=true to suppress interactive package-manager notices during Docker builds.`);
|
||||
if (!dockerfile.includes("COREPACK_ENABLE_DOWNLOAD_PROMPT=0")) warnings.push(`${dockerfilePath} should suppress Corepack download prompts for clean non-interactive Docker logs.`);
|
||||
if (!dockerfile.includes("npm_config_fetch_warn_timeout=60000")) warnings.push(`${dockerfilePath} should raise npm_config_fetch_warn_timeout to avoid noisy slow-registry warnings in Docker logs.`);
|
||||
if (!dockerfile.includes("pnpm install --frozen-lockfile")) failures.push(`${dockerfilePath} does not run pnpm install with a frozen lockfile.`);
|
||||
if (!dockerfile.includes('"[W" "ARN] Request took"')) failures.push(`${dockerfilePath} must filter pnpm slow-registry timing warnings from successful Docker install logs.`);
|
||||
}
|
||||
|
||||
if (exists(".dockerignore")) {
|
||||
const dockerignore = read(".dockerignore");
|
||||
if (!dockerignore.includes("backend/prisma/migrations/20260606200000_init_project_workspace")) {
|
||||
failures.push(".dockerignore must exclude the obsolete duplicate init migration so update overlays cannot copy it into Docker images.");
|
||||
}
|
||||
} else {
|
||||
failures.push("Missing .dockerignore.");
|
||||
}
|
||||
|
||||
if (exists("backend/scripts/docker-entrypoint.sh")) {
|
||||
const entrypoint = read("backend/scripts/docker-entrypoint.sh");
|
||||
if (!entrypoint.includes("20260606200000_init_project_workspace")) {
|
||||
failures.push("backend/scripts/docker-entrypoint.sh must guard against the obsolete duplicate init migration at runtime.");
|
||||
}
|
||||
if (!entrypoint.includes("prisma migrate resolve --rolled-back 20260606200000_init_project_workspace")) {
|
||||
failures.push("backend/scripts/docker-entrypoint.sh must recover failed obsolete duplicate init migration records with prisma migrate resolve --rolled-back.");
|
||||
}
|
||||
}
|
||||
|
||||
if (exists("backend/prisma/migrations/20260606200000_init_project_workspace")) {
|
||||
failures.push("backend/prisma/migrations/20260606200000_init_project_workspace must be deleted from source; it duplicates the init migration and breaks fresh database startup.");
|
||||
}
|
||||
|
||||
if (exists("backend/Dockerfile")) {
|
||||
const backendDockerfile = read("backend/Dockerfile");
|
||||
if (!backendDockerfile.includes("pnpm run prisma:generate")) failures.push("backend/Dockerfile must generate Prisma client after install and copy.");
|
||||
if (!backendDockerfile.includes("scripts/docker-entrypoint.sh")) failures.push("backend/Dockerfile must use the database-aware entrypoint.");
|
||||
if (!backendDockerfile.includes("apk add --no-cache") || !backendDockerfile.includes("openssl")) failures.push("backend/Dockerfile must install openssl in the Alpine image so Prisma schema engines can run during migrate deploy.");
|
||||
if (!backendDockerfile.includes("libc6-compat")) warnings.push("backend/Dockerfile should install libc6-compat for native engine compatibility on Alpine.");
|
||||
if (!backendDockerfile.includes("PRISMA_SKIP_POSTINSTALL_GENERATE=true")) warnings.push("backend/Dockerfile should skip Prisma postinstall generate and run the explicit prisma:generate step after the schema is copied.");
|
||||
}
|
||||
|
||||
if (exists("backend/prisma/schema.prisma")) {
|
||||
const prismaSchema = read("backend/prisma/schema.prisma");
|
||||
if (!prismaSchema.includes("linux-musl-openssl-3.0.x")) failures.push("Prisma generator must include linux-musl-openssl-3.0.x binaryTarget for the Node Alpine Docker runtime.");
|
||||
}
|
||||
|
||||
if (exists("backend/src/services/blueprintEngine.ts")) {
|
||||
const backendBlueprintEngine = read("backend/src/services/blueprintEngine.ts");
|
||||
if (!backendBlueprintEngine.includes("const archetypes: Archetype[]")) {
|
||||
failures.push("backend/src/services/blueprintEngine.ts must type archetypes as Archetype[] so cluster includes checks accept every BlueprintClusterIdDto during backend builds.");
|
||||
}
|
||||
if (backendBlueprintEngine.includes("type Archetype = (typeof archetypes)[number]")) {
|
||||
failures.push("backend/src/services/blueprintEngine.ts must not infer Archetype from the const archetype array; that narrows clusters per object and breaks Docker backend tsc.");
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
status: failures.length === 0 ? "PASS" : "FAIL",
|
||||
migrationRoots: ["backend/prisma/migrations"],
|
||||
requiredBuiltDependencies,
|
||||
workspaceAllowBuilds: exists("pnpm-workspace.yaml") ? Array.from(parseWorkspaceAllowBuilds(read("pnpm-workspace.yaml"))).sort() : [],
|
||||
opensslRuntime: exists("backend/Dockerfile") ? read("backend/Dockerfile").includes("openssl") : false,
|
||||
prismaMuslTarget: exists("backend/prisma/schema.prisma") ? read("backend/prisma/schema.prisma").includes("linux-musl-openssl-3.0.x") : false,
|
||||
obsoleteDuplicateInitMigrationExcluded: exists(".dockerignore") ? read(".dockerignore").includes("backend/prisma/migrations/20260606200000_init_project_workspace") : false,
|
||||
obsoleteDuplicateInitMigrationAbsent: !exists("backend/prisma/migrations/20260606200000_init_project_workspace"),
|
||||
failures,
|
||||
warnings,
|
||||
};
|
||||
|
||||
if (process.argv.includes("--json")) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(`ProjectBrain Docker build readiness: ${result.status}`);
|
||||
console.log(`Migration roots checked: ${result.migrationRoots.join(", ")}`);
|
||||
console.log(`Approved build dependencies required: ${requiredBuiltDependencies.join(", ")}`);
|
||||
console.log(`Workspace allowBuilds: ${exists("pnpm-workspace.yaml") ? Array.from(parseWorkspaceAllowBuilds(read("pnpm-workspace.yaml"))).sort().join(", ") : "missing"}`);
|
||||
console.log(`Backend OpenSSL runtime: ${exists("backend/Dockerfile") && read("backend/Dockerfile").includes("openssl") ? "yes" : "no"}`);
|
||||
console.log(`Prisma Alpine binary target: ${exists("backend/prisma/schema.prisma") && read("backend/prisma/schema.prisma").includes("linux-musl-openssl-3.0.x") ? "yes" : "no"}`);
|
||||
console.log(`Obsolete duplicate init migration excluded: ${exists(".dockerignore") && read(".dockerignore").includes("backend/prisma/migrations/20260606200000_init_project_workspace") ? "yes" : "no"}`);
|
||||
if (warnings.length > 0) {
|
||||
console.log("Warnings:");
|
||||
for (const warning of warnings) console.log(`- ${warning}`);
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
console.log("Failures:");
|
||||
for (const failure of failures) console.log(`- ${failure}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) process.exit(1);
|
||||
@@ -0,0 +1,63 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
const requiredFiles = [
|
||||
"src/features/execution/pages/ExecutionCenter.tsx",
|
||||
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
|
||||
"src/app/routes.tsx",
|
||||
"src/shared/lib/navigation.tsx",
|
||||
"src/shared/components/ProjectFlowRail.tsx",
|
||||
"src/features/projects/pages/Workspace.tsx",
|
||||
"package.json",
|
||||
];
|
||||
|
||||
const fail = (message) => {
|
||||
console.error(`ProjectBrain execution center readiness: FAIL - ${message}`);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file)) fail(`missing ${file}`);
|
||||
}
|
||||
|
||||
const read = (file) => readFileSync(file, "utf8");
|
||||
const page = read("src/features/execution/pages/ExecutionCenter.tsx");
|
||||
const routes = read("src/app/routes.tsx");
|
||||
const nav = read("src/shared/lib/navigation.tsx");
|
||||
const rail = read("src/shared/components/ProjectFlowRail.tsx");
|
||||
const workspace = read("src/features/projects/pages/Workspace.tsx");
|
||||
const pkg = read("package.json");
|
||||
|
||||
if (!routes.includes('/projects/:projectId/execution')) fail("execution route is not registered");
|
||||
if (!routes.includes('ExecutionCenter')) fail("ExecutionCenter page is not imported in routes");
|
||||
if (!nav.includes('Execution Center')) fail("Execution Center is missing from navigation");
|
||||
if (!nav.includes('/execution')) fail("execution navigation path is missing");
|
||||
if (!rail.includes('id: "execution"')) fail("project flow rail lacks execution step");
|
||||
if (!workspace.includes('Open Execution') || !workspace.includes('Execution Center')) fail("workspace lacks execution shortcuts");
|
||||
if (!page.includes('RuntimeExecutionQueue')) fail("execution page lacks runtime execution queue");
|
||||
if (!page.includes('RuntimeHandoffBuilder') || !page.includes('RuntimeContextPromptPilot')) fail("execution page lacks handoff/prompt bridge");
|
||||
if (!page.includes('generateFirstImplementationTask')) fail("execution page cannot create the first implementation task");
|
||||
if (!page.includes('downloadText') || !page.includes('navigator.clipboard')) fail("execution page lacks copy/download brief support");
|
||||
if (!page.includes('Review-gated execution inputs')) fail("execution page does not expose review-gated inputs");
|
||||
if (!page.includes('acceptedStarterReviews')) fail("execution page does not distinguish accepted starter reviews");
|
||||
if (!page.includes('Active Codex Skills')) fail("execution page does not show active Codex skills");
|
||||
if (!page.includes('formatSkillsForCodexHandoff')) fail("execution brief does not include skill blocks");
|
||||
if (!page.includes('QA/QC skill is active')) fail("execution readiness does not check QA/QC skill coverage");
|
||||
if (!page.includes('Codex Handoff Quality Engine')) fail("execution page does not show Codex handoff quality engine");
|
||||
if (!page.includes('Codex Session Pack')) fail("execution page does not show Codex session pack.");
|
||||
if (!page.includes('Run in Codex')) fail("execution page must expose Run in Codex action.");
|
||||
if (!page.includes('Download ZIP')) fail("execution page does not expose session pack ZIP action.");
|
||||
if (!page.includes('/session-packs')) fail("execution page does not link to Session Packs.");
|
||||
if (!page.includes('Copy next Codex prompt')) fail("execution page cannot copy next Codex prompt");
|
||||
if (!page.includes('Open Codex Handoff')) fail("execution page does not link to Codex handoffs");
|
||||
if (!page.includes('Review impact')) fail("execution page does not expose handoff impact review");
|
||||
if (!page.includes('Recommended to approve the impact review before executing this prompt')) fail("execution page must warn before unapproved prompt execution");
|
||||
if (!page.includes('Codex Execution Feedback')) fail("execution page must show execution feedback status");
|
||||
if (!page.includes('View build loop')) fail("execution page must link to Build Loop");
|
||||
if (!routes.includes('/projects/:projectId/execution-feedback')) fail("execution feedback route is not registered");
|
||||
if (!routes.includes('/projects/:projectId/build-loop')) fail("build loop route is not registered");
|
||||
if (!pkg.includes('audit:execution-feedback')) fail("package scripts lack audit:execution-feedback");
|
||||
if (!pkg.includes('audit:build-loop')) fail("package scripts lack audit:build-loop");
|
||||
if (!pkg.includes('audit:execution-center')) fail("package scripts lack audit:execution-center");
|
||||
if (!pkg.includes('audit:history-center') || !pkg.includes('audit:source-library') || !pkg.includes('audit:guided-start') || !pkg.includes('audit:skills') || !pkg.includes('audit:repository-intake') || !pkg.includes('audit:handoffs') || !pkg.includes('audit:handoff-review') || !pkg.includes('audit:execution-feedback') || !pkg.includes('audit:build-loop')) fail("audit chain lost previous ProjectBrain gates");
|
||||
|
||||
console.log("ProjectBrain execution center readiness: PASS");
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
const requiredFiles = [
|
||||
"src/shared/lib/model.ts",
|
||||
"src/shared/lib/codexExecutionFeedback.ts",
|
||||
"src/shared/lib/safeData.ts",
|
||||
"src/shared/api/realClient.ts",
|
||||
"src/shared/hooks/useProjectData.ts",
|
||||
"src/shared/contexts/ProjectContext.tsx",
|
||||
"src/app/routes.tsx",
|
||||
"src/shared/lib/navigation.tsx",
|
||||
"src/shared/components/ProjectFlowRail.tsx",
|
||||
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
|
||||
"src/features/handoffs/pages/CodexHandoffs.tsx",
|
||||
"src/features/execution/pages/ExecutionCenter.tsx",
|
||||
"src/features/repo-builder/pages/RepoBuilder.tsx",
|
||||
"src/features/history/pages/ProjectHistory.tsx",
|
||||
"src/features/audit/pages/ProjectAuditCenter.tsx",
|
||||
"src/features/projects/pages/Workspace.tsx",
|
||||
"src/shared/lib/projectStartFlow.ts",
|
||||
"package.json",
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
|
||||
}
|
||||
|
||||
const read = (file) => readFileSync(file, "utf8");
|
||||
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
|
||||
|
||||
const model = text["src/shared/lib/model.ts"] ?? "";
|
||||
const helper = text["src/shared/lib/codexExecutionFeedback.ts"] ?? "";
|
||||
const safeData = text["src/shared/lib/safeData.ts"] ?? "";
|
||||
const realClient = text["src/shared/api/realClient.ts"] ?? "";
|
||||
const hooks = text["src/shared/hooks/useProjectData.ts"] ?? "";
|
||||
const context = text["src/shared/contexts/ProjectContext.tsx"] ?? "";
|
||||
const routes = text["src/app/routes.tsx"] ?? "";
|
||||
const navigation = text["src/shared/lib/navigation.tsx"] ?? "";
|
||||
const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? "";
|
||||
const page = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? "";
|
||||
const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? "";
|
||||
const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? "";
|
||||
const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? "";
|
||||
const history = text["src/features/history/pages/ProjectHistory.tsx"] ?? "";
|
||||
const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? "";
|
||||
const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? "";
|
||||
const startFlow = text["src/shared/lib/projectStartFlow.ts"] ?? "";
|
||||
const pkg = text["package.json"] ?? "";
|
||||
|
||||
const mustContain = [
|
||||
[model, "export interface CodexExecutionFeedback", "CodexExecutionFeedback type missing."],
|
||||
[model, "export interface CodexExecutionValidationResult", "CodexExecutionValidationResult type missing."],
|
||||
[model, "export interface CodexExecutionChangedFile", "CodexExecutionChangedFile type missing."],
|
||||
[model, "export type CodexExecutionOutcome", "CodexExecutionOutcome type missing."],
|
||||
[model, "export interface CodexExecutionFollowUp", "CodexExecutionFollowUp type missing."],
|
||||
[model, "export type CodexExecutionEvidenceStatus", "CodexExecutionEvidenceStatus type missing."],
|
||||
[model, "codexExecutionFeedback?: CodexExecutionFeedback[]", "Project model must persist execution feedback."],
|
||||
[safeData, "codexExecutionFeedback: asArray(project.codexExecutionFeedback)", "Runtime normalization must preserve execution feedback."],
|
||||
[helper, "parseCodexExecutionReport", "Feedback parser helper missing."],
|
||||
[helper, "extractChangedFilesFromReport", "Changed-file parser missing."],
|
||||
[helper, "extractValidationResultsFromReport", "Validation parser missing."],
|
||||
[helper, "compareFeedbackToHandoff", "Feedback-to-handoff comparison missing."],
|
||||
[helper, "deriveExecutionOutcome", "Outcome derivation missing."],
|
||||
[helper, "deriveResolvedGaps", "Resolved gap derivation missing."],
|
||||
[helper, "deriveNewGapCandidates", "New gap candidate derivation missing."],
|
||||
[helper, "deriveExecutionFollowUps", "Follow-up derivation missing."],
|
||||
[helper, "formatExecutionFeedbackMarkdown", "Feedback markdown formatter missing."],
|
||||
[helper, "scoreFeedbackCompleteness", "Feedback completeness scoring missing."],
|
||||
[helper, "not live git truth", "Helper must state feedback is not live git truth."],
|
||||
[realClient, "importCodexOutput", "Real client must expose backend Codex output import."],
|
||||
[hooks, "useProjectCodexTasksData", "Async hooks must expose backend Codex tasks for feedback import."],
|
||||
[hooks, "useProjectReviewQueueData", "Async hooks must expose Review Queue reload after feedback import."],
|
||||
[context, "saveCodexExecutionFeedback", "ProjectContext must save feedback."],
|
||||
[context, "updateCodexExecutionFeedbackStatus", "ProjectContext must update evidence status."],
|
||||
[context, "archiveCodexExecutionFeedback", "ProjectContext must archive feedback."],
|
||||
[context, "createFollowUpHandoffFromFeedback", "ProjectContext must create follow-up handoff from feedback."],
|
||||
[context, "executionFeedbackItems", "Project search must index execution feedback."],
|
||||
[routes, "/projects/:projectId/execution-feedback", "Execution Feedback route missing."],
|
||||
[navigation, "Execution Feedback", "Navigation must expose Execution Feedback."],
|
||||
[flowRail, 'id: "execution-feedback"', "Project Flow Rail must include feedback step."],
|
||||
[page, "Intake Codex report", "Execution Feedback page must show intake form."],
|
||||
[page, "Live backend Codex output import", "Execution Feedback page must expose the live backend Codex output import bridge."],
|
||||
[page, "Import to backend Review Queue", "Execution Feedback page must import backend Codex output into Review Queue."],
|
||||
[page, "useProjectCodexTasksData", "Execution Feedback page must read live backend Codex tasks."],
|
||||
[page, "realClient.runtimeV7.importCodexOutput", "Execution Feedback page must call the backend Codex output import endpoint."],
|
||||
[page, "Linked handoff", "Execution Feedback page must link handoffs."],
|
||||
[page, "Linked session pack", "Execution Feedback page must link session packs."],
|
||||
[page, "Session pack feedback boundary", "Execution Feedback page must show session pack feedback boundary."],
|
||||
[page, "Use this feedback template", "Execution Feedback page must insert session pack feedback template."],
|
||||
[page, "Download template", "Execution Feedback page must download session pack feedback template."],
|
||||
[page, "Paste Codex report generated from this session pack", "Execution Feedback page must label reports from session packs."],
|
||||
[page, "Parsed preview", "Execution Feedback page must show parsed preview."],
|
||||
[page, "Claimed changed files", "Execution Feedback page must show changed files."],
|
||||
[page, "Validation results", "Execution Feedback page must show validation results."],
|
||||
[page, "Accept evidence", "Execution Feedback page must accept evidence."],
|
||||
[page, "Create follow-up handoff", "Execution Feedback page must create follow-up handoffs."],
|
||||
[page, "not live git truth", "Execution Feedback page must show evidence boundary."],
|
||||
[page, "Return to Build Loop", "Execution Feedback page must link back to Build Loop."],
|
||||
[handoffs, "Add execution feedback", "Codex Handoffs page must link feedback intake."],
|
||||
[handoffs, "Create session pack", "Codex Handoffs page must create session packs."],
|
||||
[handoffs, "Feedback status", "Codex Handoffs page must show feedback status."],
|
||||
[execution, "Codex Execution Feedback", "Execution Center must show execution feedback."],
|
||||
[execution, "Codex Session Pack", "Execution Center must show session pack status."],
|
||||
[repoBuilder, "feedback records", "Repo Builder must show feedback status."],
|
||||
[history, 'kind: "executionFeedback"', "Project History must include feedback records."],
|
||||
[history, 'kind: "sessionPack"', "Project History must include session pack records."],
|
||||
[audit, "Execution feedback intake", "Audit Center must show feedback coverage."],
|
||||
[audit, "Codex session pack readiness", "Audit Center must show session pack coverage."],
|
||||
[workspace, "Execution Feedback", "Workspace must expose Execution Feedback."],
|
||||
[startFlow, "add-execution-feedback", "Next action must add execution feedback."],
|
||||
[startFlow, "review-execution-feedback", "Next action must review execution feedback."],
|
||||
[startFlow, "create-feedback-follow-up", "Next action must create feedback follow-up."],
|
||||
[pkg, "audit:execution-feedback", "package.json must expose audit:execution-feedback."],
|
||||
[pkg, "check-execution-feedback-readiness.mjs", "package.json must wire feedback readiness script."],
|
||||
];
|
||||
|
||||
for (const [haystack, needle, message] of mustContain) {
|
||||
if (!haystack.includes(needle)) failures.push(message);
|
||||
}
|
||||
|
||||
if (!pkg.includes("audit:execution-feedback") || !pkg.includes("audit:all")) {
|
||||
failures.push("audit:all must include the execution feedback readiness check.");
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("ProjectBrain execution feedback readiness: FAIL");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("ProjectBrain execution feedback readiness: PASS");
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
|
||||
const checks = [];
|
||||
const fail = (message) => checks.push({ ok: false, message });
|
||||
const pass = (message) => checks.push({ ok: true, message });
|
||||
const read = (path) => readFileSync(path, 'utf8');
|
||||
|
||||
const boundaryPath = 'src/shared/components/RuntimeErrorBoundary.tsx';
|
||||
if (existsSync(boundaryPath)) {
|
||||
const boundary = read(boundaryPath);
|
||||
boundary.includes('componentDidCatch') ? pass('RuntimeErrorBoundary catches render crashes') : fail('RuntimeErrorBoundary must implement componentDidCatch');
|
||||
boundary.includes('Frontend runtime guard') ? pass('RuntimeErrorBoundary renders user-facing fallback') : fail('RuntimeErrorBoundary needs visible fallback copy');
|
||||
boundary.includes('Copy diagnostics') && boundary.includes('navigator.clipboard.writeText')
|
||||
? pass('RuntimeErrorBoundary can copy diagnostics')
|
||||
: fail('RuntimeErrorBoundary must support one-click diagnostic copy');
|
||||
} else {
|
||||
fail('RuntimeErrorBoundary component is missing');
|
||||
}
|
||||
|
||||
const app = read('src/App.tsx');
|
||||
app.includes('<RuntimeErrorBoundary>') ? pass('App wraps routes in RuntimeErrorBoundary') : fail('App must wrap routes in RuntimeErrorBoundary');
|
||||
const routes = read('src/app/routes.tsx');
|
||||
|
||||
const projectBrainClient = read('src/shared/api/projectBrainClient.ts');
|
||||
projectBrainClient.includes('const useRealClient = false')
|
||||
? pass('Synchronous projectBrainClient cannot call async realClient')
|
||||
: fail('projectBrainClient must not route sync selectors to async realClient');
|
||||
projectBrainClient.includes('Real HTTP calls are async')
|
||||
? pass('Sync/real client boundary is documented')
|
||||
: fail('Sync/real client boundary must be documented');
|
||||
|
||||
const projectContext = read('src/shared/contexts/ProjectContext.tsx');
|
||||
projectContext.includes('toProjectArray(getProjects())')
|
||||
? pass('ProjectContext normalizes project arrays')
|
||||
: fail('ProjectContext must normalize getProjects result before .map');
|
||||
projectContext.includes('toFeatureGroups(project)')
|
||||
? pass('ProjectContext guards featureGroups access')
|
||||
: fail('ProjectContext must guard featureGroups access');
|
||||
projectContext.includes('realClient.projects.listProjects()') && projectContext.includes('reloadProjectData')
|
||||
? pass('ProjectContext can hydrate projects from the real backend')
|
||||
: fail('ProjectContext must expose a real backend project hydration path');
|
||||
projectContext.includes('realClient.projects.createProject') && projectContext.includes('Project could not be saved to the backend')
|
||||
? pass('ProjectContext can create projects through the real backend')
|
||||
: fail('ProjectContext must create projects through the real backend in real mode');
|
||||
projectContext.includes('realClient.workspace.updateActiveContextPack') && projectContext.includes('Active context pack could not be saved to the backend')
|
||||
? pass('ProjectContext can persist active context pack changes')
|
||||
: fail('ProjectContext must persist active context pack changes in real mode');
|
||||
projectContext.includes('realClient.workspace.getWorkspaceState(projectId)') && projectContext.includes('realClient.projectDNA.getProjectDNA(projectId)')
|
||||
? pass('ProjectContext hydrates workspace and DNA with project data')
|
||||
: fail('ProjectContext must hydrate workspace and DNA in real project mode');
|
||||
projectContext.includes('projectDataMode: "local" | "real"') && projectContext.includes('projectDataLoading') && projectContext.includes('projectDataError')
|
||||
? pass('ProjectContext exposes data mode, loading and error state')
|
||||
: fail('ProjectContext must expose project data mode, loading and error state');
|
||||
routes.includes('ProjectRouteSync') && routes.includes('routeProjectExists') && routes.includes('setActiveProjectId(routeProjectId)')
|
||||
? pass('Project routes follow known project route changes')
|
||||
: fail('Project route sync must follow known project route changes without activating unknown slugs');
|
||||
|
||||
const projectsPage = read('src/features/projects/pages/Projects.tsx');
|
||||
projectsPage.includes('Array.isArray(project.featureGroups)')
|
||||
? pass('Projects page guards featureGroups before stats')
|
||||
: fail('Projects page must guard featureGroups before stats');
|
||||
projectsPage.includes('project.workspaceState?.focusAreas?.length')
|
||||
? pass('Projects page guards focusAreas length')
|
||||
: fail('Projects page must guard focusAreas length');
|
||||
projectsPage.includes('Database projects') && projectsPage.includes('Reload projects')
|
||||
? pass('Projects page exposes real data mode and reload affordance')
|
||||
: fail('Projects page must expose real data mode and reload affordance');
|
||||
|
||||
const workspacePage = read('src/features/projects/pages/Workspace.tsx');
|
||||
workspacePage.includes('Database-backed project context') && workspacePage.includes('Reload project')
|
||||
? pass('Workspace exposes real project context and reload affordance')
|
||||
: fail('Workspace must expose real project context and reload affordance');
|
||||
|
||||
const failures = checks.filter((check) => !check.ok);
|
||||
if (failures.length) {
|
||||
console.error('ProjectBrain frontend runtime readiness: FAIL');
|
||||
for (const check of checks) console.error(`${check.ok ? '✓' : '✗'} ${check.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('ProjectBrain frontend runtime readiness: PASS');
|
||||
for (const check of checks) console.log(`✓ ${check.message}`);
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
|
||||
const exists = (file) => fs.existsSync(path.join(root, file));
|
||||
const failures = [];
|
||||
const requireFile = (file, reason) => {
|
||||
if (!exists(file)) failures.push(`${file}: ${reason}`);
|
||||
};
|
||||
const requireIncludes = (file, needle, reason) => {
|
||||
if (!exists(file) || !read(file).includes(needle)) failures.push(`${file}: ${reason}`);
|
||||
};
|
||||
const requireExcludes = (file, needle, reason) => {
|
||||
if (exists(file) && read(file).includes(needle)) failures.push(`${file}: ${reason}`);
|
||||
};
|
||||
const requireBefore = (file, firstNeedle, secondNeedle, reason) => {
|
||||
if (!exists(file)) {
|
||||
failures.push(`${file}: ${reason}`);
|
||||
return;
|
||||
}
|
||||
const content = read(file);
|
||||
const firstIndex = content.indexOf(firstNeedle);
|
||||
const secondIndex = content.indexOf(secondNeedle);
|
||||
if (firstIndex < 0 || secondIndex < 0 || firstIndex > secondIndex) failures.push(`${file}: ${reason}`);
|
||||
};
|
||||
|
||||
requireFile("src/features/start/pages/GuidedStart.tsx", "Guided Start page must exist.");
|
||||
requireFile("src/shared/lib/guidedStart.ts", "Guided Start deterministic idea helper must exist.");
|
||||
requireFile("src/shared/lib/projectStartFlow.ts", "Unified project start flow helper must exist.");
|
||||
requireIncludes("src/app/routes.tsx", 'path="/start"', "Guided Start route must be registered.");
|
||||
requireIncludes("src/app/routes.tsx", 'path="/projects/new"', "Project setup compatibility route must stay registered.");
|
||||
requireIncludes("src/app/routes.tsx", '<GuidedStart />', "Root route should land on Guided Start.");
|
||||
requireIncludes("src/app/routes.tsx", "ProjectSetupFlow", "Project setup compatibility route should use the shared setup component.");
|
||||
requireIncludes("src/shared/lib/navigation.tsx", 'label: "Nieuw project"', "Navigation should expose the unified Start flow.");
|
||||
requireBefore("src/shared/lib/navigation.tsx", 'label: "Projecten"', 'label: "Nieuw project"', "Navigation should list Projects before Start for returning users.");
|
||||
requireIncludes("src/features/projects/pages/FirstRunProjectWizard.tsx", 'searchParams.get("projectType")', "Wizard should accept guided-start projectType prefill.");
|
||||
requireIncludes("src/features/projects/pages/FirstRunProjectWizard.tsx", 'searchParams.get("firstSlice")', "Wizard should accept first-slice prefill.");
|
||||
requireIncludes("src/features/projects/pages/FirstRunProjectWizard.tsx", "Project maken", "Project setup page should identify itself as part of the unified start flow.");
|
||||
requireIncludes("src/features/projects/pages/FirstRunProjectWizard.tsx", "recommendation.recommendedSkills.length", "Project setup should derive execution profiles automatically.");
|
||||
requireIncludes("src/shared/lib/guidedStart.ts", ".replace(/[-_/]+/g", "Guided Start should split hyphenated/slashed terms such as self-hosted and repo-builder.");
|
||||
requireIncludes("src/features/start/pages/GuidedStart.tsx", 'navigate(`/projects/new?', "All new project paths should use the shared setup route.");
|
||||
requireIncludes("src/features/start/pages/GuidedStart.tsx", "projectEntryPath(continuationProject.id)", "Existing project continuation should use the canonical project entry route.");
|
||||
requireIncludes("src/app/routes.tsx", "resolveProjectResume", "Canonical project entry should resolve to the live overview or next required project step.");
|
||||
requireIncludes("src/shared/lib/projectWizard.ts", "recommendSkillIdsForProjectProfile", "Project creation should use the Codex skill catalog for recommendations.");
|
||||
requireIncludes("src/shared/lib/model.ts", "recommendedSkills?: string[]", "First-run state should persist recommended Codex skills.");
|
||||
requireIncludes("src/features/projects/components/FirstRunLaunchpad.tsx", "firstImplementationTaskReady", "The guided launchpad should show status-aware first execution guidance.");
|
||||
requireIncludes("src/features/projects/components/FirstRunLaunchpad.tsx", "Eerste implementatietaak maken", "Launchpad should create the first implementation task.");
|
||||
requireIncludes("src/features/projects/components/FirstRunLaunchpad.tsx", "Eerste taak beoordelen", "Launchpad should become status-aware after the first task exists.");
|
||||
requireIncludes("src/features/projects/components/FirstRunLaunchpad.tsx", "firstImplementationTaskReady", "The launchpad should derive first-task status from project review data.");
|
||||
requireIncludes("src/shared/lib/projectFirstRunGenerationCommands.ts", "first-implementation-task-${projectId}", "First implementation task id should be stable per project.");
|
||||
requireIncludes("src/shared/contexts/ProjectContext.tsx", "decideStarterReviewItem", "Starter review decisions should persist back to project data.");
|
||||
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "item.attribution?.approvalState", "Review Queue should derive starter review status from project approval state.");
|
||||
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "decideStarterReviewItem", "Review Queue should persist starter item actions to ProjectContext.");
|
||||
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "Bij acceptatie kan dit:", "Review Queue should show an impact preview before accepting an item.");
|
||||
requireIncludes("src/features/projects/pages/Projects.tsx", "Projectcatalogus", "Projects page should present existing projects as the primary returning-user catalog.");
|
||||
requireIncludes("src/features/projects/pages/Projects.tsx", "Ga verder waar je stopte", "Projects page should surface the current project continuation path.");
|
||||
requireIncludes("src/features/projects/pages/Projects.tsx", "Nieuw project", "Projects page should send new projects to the unified Start flow.");
|
||||
requireIncludes("src/features/projects/pages/Projects.tsx", "sortedProjects", "Projects page should sort project memories by current relevance.");
|
||||
requireIncludes("src/features/projects/pages/Projects.tsx", "getProjectOperatingStep", "Projects page should use shared next-action logic.");
|
||||
requireIncludes("src/shared/lib/projectStartFlow.ts", "source-material", "Start flow must support source material.");
|
||||
requireIncludes("src/shared/lib/projectStartFlow.ts", "existing-repo", "Start flow must support existing repositories.");
|
||||
requireIncludes("src/shared/lib/projectStartFlow.ts", "direct-project", "Start flow must support direct project creation.");
|
||||
requireIncludes("src/shared/lib/projectStartFlow.ts", "newProjectStartFlowEntries", "New-project entries should be separated from existing-project continuation.");
|
||||
requireIncludes("src/shared/lib/projectStartFlow.ts", "Vaardigheden", "Start flow must include the Skills step.");
|
||||
requireIncludes("src/shared/lib/projectStartFlow.ts", "import-repository-state", "Existing repository projects should next-action into repository intake.");
|
||||
requireIncludes("src/shared/lib/projectStartFlow.ts", "Momentopname van repository maken", "Existing repository projects should progress from intake to repo state snapshot.");
|
||||
requireIncludes("src/shared/lib/projectWizard.ts", "Import repository state in Repo Builder", "Project setup should mention repository intake when an existing repo is provided.");
|
||||
requireIncludes("src/features/projects/pages/Workspace.tsx", "resolveProjectWorkState", "Workspace should derive continuation from canonical delivery and Auto state.");
|
||||
requireIncludes("src/features/projects/pages/Workspace.tsx", "onClick={() => navigate(workState.primaryRoute)}", "Workspace should route its primary continuation through the shared operating step.");
|
||||
requireIncludes("src/components/layout/Sidebar.tsx", "staticProjectStep", "Sidebar should prioritize explicit attention and defer live status to overview.");
|
||||
requireIncludes("src/app/routes.tsx", "/projects/:projectId/skills", "Unified project flow should include Skill Library route.");
|
||||
requireIncludes("src/shared/components/ProjectFlowRail.tsx", 'id: "skills"', "Unified project flow rail should include Codex Skills.");
|
||||
requireExcludes("src/features/projects/pages/Projects.tsx", "New Project Wizard", "Projects page should not keep the old competing wizard CTA.");
|
||||
requireExcludes("src/features/projects/pages/Projects.tsx", "Open Wizard", "Projects page should not keep a separate wizard CTA.");
|
||||
requireExcludes("src/features/start/pages/GuidedStart.tsx", "Skip to wizard", "Guided Start should not present a separate wizard escape hatch.");
|
||||
requireExcludes("src/features/brainstorm/pages/BrainstormLab.tsx", "Open wizard", "Brainstorm project conversion should open project setup, not a separate wizard concept.");
|
||||
requireExcludes("src/features/brainstorm/pages/BrainstormLab.tsx", "first-run wizard", "Brainstorm copy should use unified Start/project setup language.");
|
||||
requireIncludes("src/features/brainstorm/pages/BrainstormLab.tsx", "Open Review Queue", "Brainstorm review action should state that it opens Review Queue.");
|
||||
requireExcludes("src/features/projects/components/FirstRunLaunchpad.tsx", "The wizard created", "Launchpad should describe project setup output instead of a separate wizard.");
|
||||
requireExcludes("src/features/projects/pages/FirstRunProjectWizard.tsx", "The wizard will", "Project setup page should not reintroduce old wizard language.");
|
||||
|
||||
if (failures.length) {
|
||||
console.error("ProjectBrain guided start readiness: FAIL");
|
||||
failures.forEach((failure) => console.error(`- ${failure}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("ProjectBrain guided start readiness: PASS");
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
const requiredFiles = [
|
||||
"src/shared/lib/model.ts",
|
||||
"src/shared/lib/handoffReview.ts",
|
||||
"src/shared/lib/safeData.ts",
|
||||
"src/shared/contexts/ProjectContext.tsx",
|
||||
"src/features/handoffs/pages/CodexHandoffs.tsx",
|
||||
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
|
||||
"src/features/execution/pages/ExecutionCenter.tsx",
|
||||
"src/features/prompts/pages/PromptGenerator.tsx",
|
||||
"src/features/repo-builder/pages/RepoBuilder.tsx",
|
||||
"src/features/history/pages/ProjectHistory.tsx",
|
||||
"src/features/audit/pages/ProjectAuditCenter.tsx",
|
||||
"src/features/projects/pages/Workspace.tsx",
|
||||
"src/shared/lib/projectStartFlow.ts",
|
||||
"package.json",
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
|
||||
}
|
||||
|
||||
const read = (file) => readFileSync(file, "utf8");
|
||||
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
|
||||
const model = text["src/shared/lib/model.ts"] ?? "";
|
||||
const helper = text["src/shared/lib/handoffReview.ts"] ?? "";
|
||||
const safeData = text["src/shared/lib/safeData.ts"] ?? "";
|
||||
const context = text["src/shared/contexts/ProjectContext.tsx"] ?? "";
|
||||
const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? "";
|
||||
const feedbackPage = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? "";
|
||||
const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? "";
|
||||
const promptBuilder = text["src/features/prompts/pages/PromptGenerator.tsx"] ?? "";
|
||||
const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? "";
|
||||
const history = text["src/features/history/pages/ProjectHistory.tsx"] ?? "";
|
||||
const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? "";
|
||||
const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? "";
|
||||
const startFlow = text["src/shared/lib/projectStartFlow.ts"] ?? "";
|
||||
const pkg = text["package.json"] ?? "";
|
||||
|
||||
const mustContain = [
|
||||
[model, "export interface HandoffReview", "HandoffReview type missing."],
|
||||
[model, "export interface HandoffFileImpact", "HandoffFileImpact type missing."],
|
||||
[model, "export interface HandoffImpactRisk", "HandoffImpactRisk type missing."],
|
||||
[model, "export interface HandoffValidationCoverage", "HandoffValidationCoverage type missing."],
|
||||
[model, "export type HandoffReviewDecision", "HandoffReviewDecision type missing."],
|
||||
[model, "HandoffSimulationSource", "HandoffSimulationSource type missing."],
|
||||
[model, "handoffReviews?: HandoffReview[]", "Project model must persist handoff reviews."],
|
||||
[safeData, "handoffReviews: asArray(project.handoffReviews)", "Runtime normalization must preserve handoff reviews."],
|
||||
[helper, "generateHandoffReview", "handoffReview helper must generate reviews."],
|
||||
[helper, "simulateFileImpacts", "handoffReview helper must simulate file impacts."],
|
||||
[helper, "classifyHandoffFileAction", "handoffReview helper must classify file actions."],
|
||||
[helper, "scoreFileImpactRisk", "handoffReview helper must score file impact risk."],
|
||||
[helper, "buildValidationCoverage", "handoffReview helper must build validation coverage."],
|
||||
[helper, "buildRollbackCoverage", "handoffReview helper must build rollback coverage."],
|
||||
[helper, "getMissingSafeguards", "handoffReview helper must list missing safeguards."],
|
||||
[helper, "recommendHandoffReviewDecision", "handoffReview helper must recommend decisions."],
|
||||
[helper, "formatHandoffReviewMarkdown", "handoffReview helper must format markdown."],
|
||||
[helper, "not a live git diff", "Review helper must label simulation boundary."],
|
||||
[context, "generateHandoffReview", "ProjectContext must expose generateHandoffReview."],
|
||||
[context, "updateHandoffReviewDecision", "ProjectContext must update review decisions."],
|
||||
[context, "archiveHandoffReview", "ProjectContext must archive handoff reviews."],
|
||||
[context, "handoffReviewItems", "Project search must index handoff reviews."],
|
||||
[handoffs, "Generate impact review", "Codex Handoffs page must generate impact reviews."],
|
||||
[handoffs, "Create session pack", "Codex Handoffs page must create session packs after review."],
|
||||
[handoffs, "Refresh impact review", "Codex Handoffs page must refresh impact reviews."],
|
||||
[handoffs, "Copy review", "Codex Handoffs page must copy reviews."],
|
||||
[handoffs, "Download review", "Codex Handoffs page must download reviews."],
|
||||
[handoffs, "ProjectBrain-derived impact simulation, not a live git diff", "Codex Handoffs page must show simulation boundary."],
|
||||
[handoffs, "simulatedFileImpacts", "Codex Handoffs page must show simulated file impacts."],
|
||||
[handoffs, "missingSafeguards", "Codex Handoffs page must show missing safeguards."],
|
||||
[handoffs, "recommendedReviewDecision", "Codex Handoffs page must show recommended decision."],
|
||||
[feedbackPage, "Linked handoff", "Execution Feedback must link feedback to handoffs/reviews."],
|
||||
[execution, "Review impact", "Execution Center must show review impact action."],
|
||||
[execution, "Codex Session Pack", "Execution Center must show session pack handoff boundary."],
|
||||
[execution, "Recommended to approve the impact review before executing this prompt", "Execution Center must warn before unapproved execution."],
|
||||
[promptBuilder, "Pre-execution impact review", "Prompt Builder must include pre-execution review summary."],
|
||||
[promptBuilder, "missingSafeguards", "Prompt Builder must include missing safeguards."],
|
||||
[repoBuilder, "Handoff review status", "Repo Builder must show handoff review status."],
|
||||
[history, 'kind: "handoffReview"', "Project History must include handoff review records."],
|
||||
[history, 'kind: "sessionPack"', "Project History must include session pack records."],
|
||||
[audit, "Handoff review simulation", "Audit Center must show handoff review coverage."],
|
||||
[audit, "Codex session pack readiness", "Audit Center must show session pack readiness."],
|
||||
[workspace, "handoffsWithoutReview", "Workspace must detect handoffs without review."],
|
||||
[workspace, "fix safeguards", "Workspace must expose safeguard-fix next action."],
|
||||
[startFlow, "review-handoff-impact", "Project next actions must review handoff impact."],
|
||||
[startFlow, "fix-handoff-safeguards", "Project next actions must fix handoff safeguards."],
|
||||
[startFlow, "execute-approved-handoff", "Project next actions must execute approved handoffs."],
|
||||
[pkg, "audit:handoff-review", "package.json must expose audit:handoff-review."],
|
||||
[pkg, "check-handoff-review-readiness.mjs", "package.json must wire handoff review readiness script."],
|
||||
];
|
||||
|
||||
for (const [haystack, needle, message] of mustContain) {
|
||||
if (!haystack.includes(needle)) failures.push(message);
|
||||
}
|
||||
|
||||
if (!pkg.includes("audit:handoff-review") || !pkg.includes("audit:all")) {
|
||||
failures.push("audit:all must include the handoff review readiness check.");
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("ProjectBrain handoff review readiness: FAIL");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("ProjectBrain handoff review readiness: PASS");
|
||||
@@ -0,0 +1,50 @@
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
|
||||
const requiredFiles = [
|
||||
"src/features/history/pages/ProjectHistory.tsx",
|
||||
"src/app/routes.tsx",
|
||||
"src/shared/lib/navigation.tsx",
|
||||
"src/shared/contexts/ProjectContext.tsx",
|
||||
"src/shared/components/ProjectFlowRail.tsx",
|
||||
"src/features/projects/pages/Workspace.tsx",
|
||||
];
|
||||
|
||||
const fail = (message) => {
|
||||
console.error(`ProjectBrain history center readiness: FAIL - ${message}`);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file)) fail(`missing ${file}`);
|
||||
}
|
||||
|
||||
const read = (file) => readFileSync(file, "utf8");
|
||||
const page = read("src/features/history/pages/ProjectHistory.tsx");
|
||||
const routes = read("src/app/routes.tsx");
|
||||
const nav = read("src/shared/lib/navigation.tsx");
|
||||
const context = read("src/shared/contexts/ProjectContext.tsx");
|
||||
const historyCommands = read("src/shared/lib/projectHistoryCommands.ts");
|
||||
const rail = read("src/shared/components/ProjectFlowRail.tsx");
|
||||
const workspace = read("src/features/projects/pages/Workspace.tsx");
|
||||
const routeRegistry = read("src/shared/lib/routeRegistry.ts");
|
||||
const pkg = read("package.json");
|
||||
|
||||
if (!routes.includes('/projects/:projectId/history')) fail("history route is not registered");
|
||||
if (!routes.includes('ProjectHistory')) fail("ProjectHistory page is not imported in routes");
|
||||
if (!nav.includes('getPrimaryPathNavigationItems') || !nav.includes('projectRoutePath(projectId, key)')) fail("Project activity/history is missing from primary navigation");
|
||||
if (!routeRegistry.includes('key: "activity"') || !routeRegistry.includes('segment: "history"')) fail("Project activity/history is missing from the canonical route registry");
|
||||
if (!nav.includes('/history')) fail("history navigation path is missing");
|
||||
if (!context.includes('deleteProjectHistoryEntry')) fail("context lacks history entry removal action");
|
||||
if (!historyCommands.includes('hiddenHistoryEntryIds')) fail("project hidden history ids are not persisted");
|
||||
if (!page.includes('downloadText')) fail("history page lacks download support");
|
||||
if (!page.includes('navigator.clipboard')) fail("history page lacks copy/export support");
|
||||
if (!page.includes('Uit geschiedenis verwijderen')) fail("history page lacks remove action");
|
||||
if (!page.includes('getSnapshotsForProject') || !page.includes('getBuildsForProject')) fail("history page does not include snapshots and builds");
|
||||
if (!page.includes('project.sourceLibrary') || !page.includes('project.mediaAssets')) fail("history page does not include source/media history");
|
||||
if (!page.includes('project.starterReviewItems') || !page.includes('project.brainstorms')) fail("history page does not include review/brainstorm history");
|
||||
if (!rail.includes('id: "history"')) fail("project flow rail lacks history step");
|
||||
if (!nav.includes('/history')) fail("workspace navigation lacks history access");
|
||||
if (!pkg.includes('audit:history-center')) fail("package scripts lack audit:history-center");
|
||||
if (!pkg.includes('audit:source-library') || !pkg.includes('audit:guided-start')) fail("audit chain lost previous ProjectBrain gates");
|
||||
|
||||
console.log("ProjectBrain history center readiness: PASS");
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const failures = [];
|
||||
const warnings = [];
|
||||
const ignoredArtifacts = [];
|
||||
const showLocalArtifacts =
|
||||
process.env.PROJECTBRAIN_AUDIT_VERBOSE_LOCAL_ARTIFACTS === '1';
|
||||
|
||||
const gitLines = (args) => {
|
||||
try {
|
||||
return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const isTracked = (path) => gitLines(['ls-files', '--', path]).length > 0;
|
||||
const isIgnored = (path) => gitLines(['check-ignore', '--', path]).length > 0;
|
||||
const reportArtifact = (path, kind = 'path') => {
|
||||
const absolute = join(root, path);
|
||||
if (!existsSync(absolute)) return;
|
||||
if (isTracked(path)) {
|
||||
failures.push(`Forbidden tracked release artifact/${kind} exists: ${path}`);
|
||||
} else if (isIgnored(path)) {
|
||||
ignoredArtifacts.push(`Ignored local artifact/${kind} present but not release-blocking: ${path}`);
|
||||
} else {
|
||||
failures.push(`Forbidden unignored release artifact/${kind} exists: ${path}`);
|
||||
}
|
||||
};
|
||||
|
||||
const forbiddenPaths = [
|
||||
'node_modules',
|
||||
'backend/node_modules',
|
||||
'dist',
|
||||
'backend/dist',
|
||||
'checkpoints',
|
||||
'src/App.css',
|
||||
'src/globals.css',
|
||||
];
|
||||
|
||||
for (const path of forbiddenPaths) {
|
||||
reportArtifact(path);
|
||||
}
|
||||
|
||||
const forbiddenFilePatterns = [/^vite-.*\.log$/, /\.zip$/i, /\.log$/i];
|
||||
const rootFiles = existsSync(root) ? readdirSync(root) : [];
|
||||
for (const file of rootFiles) {
|
||||
if (forbiddenFilePatterns.some((pattern) => pattern.test(file))) {
|
||||
reportArtifact(file, 'file');
|
||||
}
|
||||
}
|
||||
|
||||
const textFilesToScan = [
|
||||
'package.json',
|
||||
'vite.config.ts',
|
||||
'index.html',
|
||||
'README.md',
|
||||
'backend/README.md',
|
||||
];
|
||||
const forbiddenText = [/componentTagger/i, /component-tagger/i];
|
||||
for (const path of textFilesToScan) {
|
||||
const absolute = join(root, path);
|
||||
if (!existsSync(absolute)) {
|
||||
warnings.push(`Expected scan file missing: ${path}`);
|
||||
continue;
|
||||
}
|
||||
const content = readFileSync(absolute, 'utf8');
|
||||
for (const pattern of forbiddenText) {
|
||||
if (pattern.test(content)) {
|
||||
failures.push(`Forbidden branding/tooling text ${pattern} found in ${path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const expectedFiles = [
|
||||
'.env.example',
|
||||
'backend/.env.example',
|
||||
'Dockerfile.frontend',
|
||||
'backend/Dockerfile',
|
||||
'docker-compose.yml',
|
||||
'docker-compose.dev.yml',
|
||||
'docker-compose.release.yml',
|
||||
'scripts/stage-release-oci.sh',
|
||||
'docs/PROJECTBRAIN_DOCUMENTATION_INDEX.md',
|
||||
'docs/PROJECTBRAIN_OPERATOR_RELEASE_GUIDE.md',
|
||||
'docs/FULL_VERSION_ROADMAP.md',
|
||||
'docs/VERTICAL_SLICE_CORE_FLOW_PLAN.md',
|
||||
'public/site.webmanifest',
|
||||
'public/favicon.svg',
|
||||
'public/favicon.ico',
|
||||
];
|
||||
for (const path of expectedFiles) {
|
||||
if (!existsSync(join(root, path))) {
|
||||
warnings.push(`Expected project hygiene file missing: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
const workflowPaths = ['.gitea/workflows/ci.yml', '.gitea/workflows/release.yml'];
|
||||
const workflow = workflowPaths
|
||||
.map((path) => readFileSync(join(root, path), 'utf8'))
|
||||
.join('\n');
|
||||
for (const marker of [
|
||||
'file: Dockerfile.unraid',
|
||||
'Record immutable archive digests',
|
||||
'Scan Unraid release image',
|
||||
'archive-sha256.txt',
|
||||
'trivy-unraid.json',
|
||||
'VITE_PROJECTBRAIN_COMMIT=${{ github.sha }}',
|
||||
'org.opencontainers.image.revision=${{ github.sha }}',
|
||||
]) {
|
||||
if (!workflow.includes(marker)) failures.push(`Release workflow is missing required artifact evidence marker: ${marker}`);
|
||||
}
|
||||
if (workflow.includes('Dockerfile.backend')) {
|
||||
failures.push('Release workflow references nonexistent Dockerfile.backend; use backend/Dockerfile.');
|
||||
}
|
||||
if (/aquasec\/trivy(?::[\w.-]+)(?!@sha256:)/.test(workflow) || !workflow.includes('aquasec/trivy@sha256:')) {
|
||||
failures.push('Trivy containers must be pinned to an immutable sha256 digest.');
|
||||
}
|
||||
if (/uses:\s+(?!docker:\/\/)[^\s@]+@(?![a-f0-9]{40}(?:\s|$))/.test(workflow)) {
|
||||
failures.push('External workflow actions must be pinned to immutable 40-character commit SHAs.');
|
||||
}
|
||||
|
||||
const maxLargeFileBytes = 5 * 1024 * 1024;
|
||||
const ignoredDirs = new Set(['.git', 'node_modules', 'backend/node_modules']);
|
||||
function walk(dir) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const absolute = join(dir, entry);
|
||||
const rel = relative(root, absolute).replaceAll('\\\\', '/');
|
||||
if (ignoredDirs.has(rel) || ignoredDirs.has(entry)) continue;
|
||||
const stat = statSync(absolute);
|
||||
if (stat.isDirectory()) {
|
||||
walk(absolute);
|
||||
} else if (stat.size > maxLargeFileBytes) {
|
||||
warnings.push(`Large file in source tree (${Math.round(stat.size / 1024 / 1024)}MB): ${rel}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root);
|
||||
|
||||
console.log('ProjectBrain release hygiene check');
|
||||
console.log('====================================');
|
||||
if (ignoredArtifacts.length) {
|
||||
if (showLocalArtifacts) {
|
||||
console.log('\nLocal ignored artifacts:');
|
||||
for (const artifact of ignoredArtifacts) console.log(`- ${artifact}`);
|
||||
}
|
||||
}
|
||||
if (warnings.length) {
|
||||
console.log('\nWarnings:');
|
||||
for (const warning of warnings) console.log(`- ${warning}`);
|
||||
}
|
||||
if (failures.length) {
|
||||
console.log('\nFailures:');
|
||||
for (const failure of failures) console.log(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nPASS: no blocking release hygiene issues found.');
|
||||
@@ -0,0 +1,156 @@
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
|
||||
const requiredFiles = [
|
||||
'src/features/repo-builder/pages/RepoBuilder.tsx',
|
||||
'src/app/routes.tsx',
|
||||
'src/shared/lib/navigation.tsx',
|
||||
'src/shared/components/ProjectFlowRail.tsx',
|
||||
'src/shared/contexts/ProjectContext.tsx',
|
||||
'src/shared/lib/model.ts',
|
||||
'src/shared/lib/safeData.ts',
|
||||
'src/shared/lib/codexSkills.ts',
|
||||
'src/shared/lib/repositoryIntake.ts',
|
||||
'src/shared/lib/projectSearch.ts',
|
||||
'src/shared/lib/projectOperatingStep.ts',
|
||||
'src/features/history/pages/ProjectHistory.tsx',
|
||||
'src/features/execution-feedback/pages/ExecutionFeedback.tsx',
|
||||
'src/shared/lib/buildLoop.ts',
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
|
||||
}
|
||||
|
||||
const read = (file) => readFileSync(file, 'utf8');
|
||||
const repoPage = read('src/features/repo-builder/pages/RepoBuilder.tsx');
|
||||
const routes = read('src/app/routes.tsx');
|
||||
const navigation = read('src/shared/lib/navigation.tsx');
|
||||
const flowRail = read('src/shared/components/ProjectFlowRail.tsx');
|
||||
const context = read('src/shared/contexts/ProjectContext.tsx');
|
||||
const model = read('src/shared/lib/model.ts');
|
||||
const safeData = read('src/shared/lib/safeData.ts');
|
||||
const skills = read('src/shared/lib/codexSkills.ts');
|
||||
const repositoryIntake = read('src/shared/lib/repositoryIntake.ts');
|
||||
const projectSearch = read('src/shared/lib/projectSearch.ts');
|
||||
const operatingStep = read('src/shared/lib/projectOperatingStep.ts');
|
||||
const history = read('src/features/history/pages/ProjectHistory.tsx');
|
||||
const pkg = read('package.json');
|
||||
|
||||
const mustContain = [
|
||||
[repoPage, 'Repositorybasis genereren', 'Repo Builder page must generate foundation blueprints.'],
|
||||
[repoPage, 'Uitbreiding uit selectie maken', 'Repo Builder must generate targeted expansion briefs.'],
|
||||
[repoPage, 'Ontwerpdocumentatie voor deze uitbreiding', 'Repo Builder must explicitly use design documentation as repo-expansion input.'],
|
||||
[repoPage, 'Actieve Codex-handoff', 'Repo Builder must expose an active Codex handoff.'],
|
||||
[repoPage, 'Repository changes', 'Repo Builder must show file-level repository changes.'],
|
||||
[repoPage, 'goedgekeurde ontwerpdocumentatie', 'Repo Builder must explain its place in the reviewed project flow.'],
|
||||
[routes, '/projects/:projectId/repo-builder', 'Repo Builder route missing.'],
|
||||
[navigation, 'label: "Repository"', 'Repository navigation item missing.'],
|
||||
[navigation, '`${base}/repo-builder`', 'Local repository navigation must route to Repo Builder.'],
|
||||
[flowRail, 'Repo Builder', 'Repo Builder flow rail step missing.'],
|
||||
[flowRail, 'shape the repo', 'Flow rail must place Repo Builder logically before delivery.'],
|
||||
[context, 'generateRepoBlueprint', 'Project context must expose generateRepoBlueprint.'],
|
||||
[context, 'archiveRepoBlueprint', 'Project context must expose archiveRepoBlueprint.'],
|
||||
[context, 'generateRepoExpansionBrief', 'Project context must expose generateRepoExpansionBrief.'],
|
||||
[context, 'archiveRepoExpansionBrief', 'Project context must expose archiveRepoExpansionBrief.'],
|
||||
[projectSearch, 'repoExpansionBriefItems', 'Search index must include repo expansion briefs.'],
|
||||
[model, 'export interface RepoBlueprint', 'RepoBlueprint model missing.'],
|
||||
[model, 'export interface RepoExpansionBrief', 'RepoExpansionBrief model missing.'],
|
||||
[model, 'repoBlueprints?: RepoBlueprint[]', 'Project model must persist repo blueprints.'],
|
||||
[model, 'repoExpansionBriefs?: RepoExpansionBrief[]', 'Project model must persist repo expansion briefs.'],
|
||||
[safeData, 'repoBlueprints: asArray(project.repoBlueprints)', 'Runtime normalization must preserve repo blueprints.'],
|
||||
[safeData, 'repoExpansionBriefs: asArray(project.repoExpansionBriefs)', 'Runtime normalization must preserve repo expansion briefs.'],
|
||||
[history, 'Repo Builder / foundation', 'History must surface foundation repo blueprint entries.'],
|
||||
[history, 'repoExpansionEntries', 'History must surface repo expansion brief entries.'],
|
||||
[read('src/shared/lib/buildLoop.ts'), 'getBuildLoopStatus', 'Repository shaping must retain technical build-loop guidance.'],
|
||||
[operatingStep, 'Nieuwe opdracht geven', 'Daily guidance must allow direct assignments.'],
|
||||
[operatingStep, 'activeWork', 'Daily guidance must account for active assignments.'],
|
||||
[operatingStep, 'attentionCount', 'Daily guidance must account for explicit human attention.'],
|
||||
[repoPage, 'Repository-deliverypakket', 'Repo Builder must expose delivery packages as the final repo handoff layer.'],
|
||||
[repoPage, 'Deliverypakket genereren', 'Repo Builder must generate Codex-ready delivery packages.'],
|
||||
[repoPage, 'File manifest', 'Repo delivery packages must show a file manifest.'],
|
||||
[repoPage, 'Validatieplan', 'Repo delivery packages must show a validation plan.'],
|
||||
[context, 'generateRepoDeliveryPackage', 'Project context must expose generateRepoDeliveryPackage.'],
|
||||
[context, 'archiveRepoDeliveryPackage', 'Project context must expose archiveRepoDeliveryPackage.'],
|
||||
[projectSearch, 'repoDeliveryPackageItems', 'Search index must include repo delivery packages.'],
|
||||
[model, 'export interface RepoDeliveryPackage', 'RepoDeliveryPackage model missing.'],
|
||||
[model, 'repoDeliveryPackages?: RepoDeliveryPackage[]', 'Project model must persist repo delivery packages.'],
|
||||
[safeData, 'repoDeliveryPackages: asArray(project.repoDeliveryPackages)', 'Runtime normalization must preserve repo delivery packages.'],
|
||||
[history, 'repoDeliveryPackageEntries', 'History must surface repo delivery package entries.'],
|
||||
[repoPage, 'Repositorywerkplan', 'Repo Builder must expose repo work plans as the sequencing layer.'],
|
||||
[repoPage, 'Werkplan genereren', 'Repo Builder must generate sequenced repo work plans.'],
|
||||
[repoPage, 'Aanbevolen volgende handoff', 'Repo work plans must show the next recommended handoff.'],
|
||||
[context, 'generateRepoWorkPlan', 'Project context must expose generateRepoWorkPlan.'],
|
||||
[context, 'archiveRepoWorkPlan', 'Project context must expose archiveRepoWorkPlan.'],
|
||||
[projectSearch, 'repoWorkPlanItems', 'Search index must include repo work plans.'],
|
||||
[model, 'export interface RepoWorkPlan', 'RepoWorkPlan model missing.'],
|
||||
[model, 'repoWorkPlans?: RepoWorkPlan[]', 'Project model must persist repo work plans.'],
|
||||
[safeData, 'repoWorkPlans: asArray(project.repoWorkPlans)', 'Runtime normalization must preserve repo work plans.'],
|
||||
[history, 'repoWorkPlanEntries', 'History must surface repo work plan entries.'],
|
||||
[repoPage, 'Repositorystatus', 'Repo Builder must expose repo state snapshots as the repository inventory layer.'],
|
||||
[repoPage, 'Repositorystatus vernieuwen', 'Repo Builder must let users refresh the repo state snapshot.'],
|
||||
[repoPage, 'Een afgeleide inventaris van bekende en geplande bestanden, geraakte gebieden, hiaten', 'Repo state snapshot must explain inventory, planned files, touched areas and gaps.'],
|
||||
[context, 'generateRepoStateSnapshot', 'Project context must expose generateRepoStateSnapshot.'],
|
||||
[context, 'archiveRepoStateSnapshot', 'Project context must expose archiveRepoStateSnapshot.'],
|
||||
[projectSearch, 'repoStateSnapshotItems', 'Search index must include repo state snapshots.'],
|
||||
[model, 'export interface RepoStateSnapshot', 'RepoStateSnapshot model missing.'],
|
||||
[model, 'repoStateSnapshots?: RepoStateSnapshot[]', 'Project model must persist repo state snapshots.'],
|
||||
[safeData, 'repoStateSnapshots: asArray(project.repoStateSnapshots)', 'Runtime normalization must preserve repo state snapshots.'],
|
||||
[history, 'repoStateSnapshotEntries', 'History must surface repo state snapshot entries.'],
|
||||
[repoPage, 'Skill set', 'Repo Builder must expose active project skills.'],
|
||||
[repoPage, 'formatSkillsForCodexHandoff', 'Repo Builder exports must include selected skill blocks.'],
|
||||
[context, 'skillIds', 'Repo generation must persist linked skill ids.'],
|
||||
[model, 'skillIds?: ID[]', 'Repo artifacts must support linked skill ids.'],
|
||||
[skills, 'getSkillGapsForRepoState', 'Repo state snapshots must be able to derive skill gaps.'],
|
||||
[repoPage, 'Bestaande repository inventariseren', 'Repo Builder must expose existing repository intake.'],
|
||||
[repoPage, 'ProjectBrain gebruikt dit als geïmporteerde informatie, niet als bewijs van de live-bestanden.', 'Repo Builder must separate imported repository evidence from live truth.'],
|
||||
[repoPage, 'Uitbreidingsbrief maken', 'Repo Builder must create expansion briefs from repository gaps.'],
|
||||
[repoPage, 'Deliverypakket maken', 'Repo Builder must create delivery packages from repository gaps.'],
|
||||
[repositoryIntake, 'deriveRepositoryGaps', 'Repository intake helper must derive repository gaps.'],
|
||||
[repositoryIntake, 'mapRepositoryEntriesToSkills', 'Repository intake helper must map imported files to Codex skills.'],
|
||||
[projectSearch, 'repositoryIntakeItems', 'Search index must include repository intakes.'],
|
||||
[context, 'linkedRepositoryIntakeIds', 'Repo artifacts must keep repository intake references.'],
|
||||
[model, 'export interface RepositoryIntake', 'RepositoryIntake model missing.'],
|
||||
[model, 'repositoryIntakes?: RepositoryIntake[]', 'Project model must persist repository intakes.'],
|
||||
[safeData, 'repositoryIntakes: asArray(project.repositoryIntakes)', 'Runtime normalization must preserve repository intakes.'],
|
||||
[history, 'repoIntakeEntries', 'History must surface repository intake entries.'],
|
||||
[pkg, 'audit:skills', 'package.json must expose audit:skills.'],
|
||||
[pkg, 'audit:repository-intake', 'package.json must expose audit:repository-intake.'],
|
||||
[repoPage, 'Codex-handoff maken', 'Repo Builder must create Codex handoffs from repo artifacts.'],
|
||||
[repoPage, 'Handoff vanuit werkplan maken', 'Repo Builder must create Codex handoffs from work plans.'],
|
||||
[repoPage, 'Handoff vanuit status maken', 'Repo Builder must create Codex handoffs from repo state snapshots.'],
|
||||
[repoPage, 'Controle van handoffs', 'Repo Builder must show handoff review status.'],
|
||||
[repoPage, 'impactcontroles', 'Repo Builder must show impact review count.'],
|
||||
[repoPage, 'feedbackitems', 'Repo Builder must show execution feedback count.'],
|
||||
[repoPage, 'Naar de buildloop', 'Repo Builder must link to Build Loop.'],
|
||||
[context, 'generateCodexHandoff', 'Project context must expose generateCodexHandoff.'],
|
||||
[context, 'generateHandoffReview', 'Project context must expose generateHandoffReview.'],
|
||||
[context, 'saveCodexExecutionFeedback', 'Project context must expose saveCodexExecutionFeedback.'],
|
||||
[model, 'export interface CodexHandoff', 'CodexHandoff model missing.'],
|
||||
[model, 'export interface HandoffReview', 'HandoffReview model missing.'],
|
||||
[model, 'export interface CodexExecutionFeedback', 'CodexExecutionFeedback model missing.'],
|
||||
[safeData, 'codexHandoffs: asArray(project.codexHandoffs)', 'Runtime normalization must preserve Codex handoffs.'],
|
||||
[safeData, 'handoffReviews: asArray(project.handoffReviews)', 'Runtime normalization must preserve handoff reviews.'],
|
||||
[safeData, 'codexExecutionFeedback: asArray(project.codexExecutionFeedback)', 'Runtime normalization must preserve execution feedback.'],
|
||||
[pkg, 'audit:handoffs', 'package.json must expose audit:handoffs.'],
|
||||
[pkg, 'audit:handoff-review', 'package.json must expose audit:handoff-review.'],
|
||||
[pkg, 'audit:execution-feedback', 'package.json must expose audit:execution-feedback.'],
|
||||
[pkg, 'audit:build-loop', 'package.json must expose audit:build-loop.'],
|
||||
[pkg, 'audit:repo-builder', 'package.json must expose audit:repo-builder.'],
|
||||
];
|
||||
|
||||
for (const [haystack, needle, message] of mustContain) {
|
||||
if (!haystack.includes(needle)) failures.push(message);
|
||||
}
|
||||
|
||||
if (!repoPage.includes('Open reviewitems blijven zichtbaar') || !repoPage.includes('alleen goedgekeurde invoer mag de uitvoering sturen') || !repoPage.includes('uiteindelijke overdrachtsgrens')) {
|
||||
failures.push('Repo Builder must communicate review-gated behavior, accepted input boundaries and the final delivery-package handoff boundary.');
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error('ProjectBrain repo builder readiness: FAIL');
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('ProjectBrain repo builder readiness: PASS');
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
const requiredFiles = [
|
||||
"src/shared/lib/model.ts",
|
||||
"src/shared/lib/repositoryIntake.ts",
|
||||
"src/shared/lib/safeData.ts",
|
||||
"src/shared/lib/projectStartFlow.ts",
|
||||
"src/shared/lib/projectWizard.ts",
|
||||
"src/shared/contexts/ProjectContext.tsx",
|
||||
"src/features/repo-builder/pages/RepoBuilder.tsx",
|
||||
"src/features/history/pages/ProjectHistory.tsx",
|
||||
"src/features/audit/pages/ProjectAuditCenter.tsx",
|
||||
"src/features/projects/pages/Workspace.tsx",
|
||||
"src/shared/components/ProjectFlowRail.tsx",
|
||||
"package.json",
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
|
||||
}
|
||||
|
||||
const read = (file) => readFileSync(file, "utf8");
|
||||
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
|
||||
|
||||
const model = text["src/shared/lib/model.ts"] ?? "";
|
||||
const helper = text["src/shared/lib/repositoryIntake.ts"] ?? "";
|
||||
const safeData = text["src/shared/lib/safeData.ts"] ?? "";
|
||||
const startFlow = text["src/shared/lib/projectStartFlow.ts"] ?? "";
|
||||
const wizard = text["src/shared/lib/projectWizard.ts"] ?? "";
|
||||
const context = text["src/shared/contexts/ProjectContext.tsx"] ?? "";
|
||||
const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? "";
|
||||
const history = text["src/features/history/pages/ProjectHistory.tsx"] ?? "";
|
||||
const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? "";
|
||||
const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? "";
|
||||
const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? "";
|
||||
const pkg = text["package.json"] ?? "";
|
||||
|
||||
const mustContain = [
|
||||
[model, "export interface RepositoryIntake", "RepositoryIntake model missing."],
|
||||
[model, "export interface RepositoryTreeEntry", "RepositoryTreeEntry model missing."],
|
||||
[model, "export interface RepositoryGap", "RepositoryGap model missing."],
|
||||
[model, "RepositorySkillMapping", "RepositorySkillMapping model missing."],
|
||||
[model, "RepositoryImportMode", "RepositoryImportMode model missing."],
|
||||
[model, "repositoryIntakes?: RepositoryIntake[]", "Project model must persist repository intakes."],
|
||||
[model, "linkedRepositoryIntakeIds?: ID[]", "Repo artifacts must preserve linked repository intake ids."],
|
||||
[safeData, "repositoryIntakes: asArray(project.repositoryIntakes)", "Runtime normalization must preserve repository intakes."],
|
||||
[helper, "parseRepositoryTreeInput", "Repository intake parser helper missing."],
|
||||
[helper, "classifyRepositoryEntry", "Repository entry classifier missing."],
|
||||
[helper, "detectRepositoryStack", "Repository stack detector missing."],
|
||||
[helper, "detectPackageManagers", "Package manager detector missing."],
|
||||
[helper, "detectFrameworks", "Framework detector missing."],
|
||||
[helper, "mapRepositoryEntriesToSkills", "Repository-to-skill mapping helper missing."],
|
||||
[helper, "deriveRepositoryGaps", "Repository gap derivation helper missing."],
|
||||
[helper, "formatRepositoryIntakeMarkdown", "Repository intake export formatter missing."],
|
||||
[helper, "recommendRepoBuilderActionsFromIntake", "Repository intake recommendation helper missing."],
|
||||
[helper, "frontend-react-vite-build", "Skill mapping must cover React/Vite evidence."],
|
||||
[helper, "docker-runtime-hardening", "Skill mapping must cover Docker evidence."],
|
||||
[helper, "database-migration", "Skill mapping must cover database evidence."],
|
||||
[helper, "documentation-system-build", "Skill mapping must cover documentation evidence."],
|
||||
[helper, "qa-qc-review", "Skill mapping must cover test evidence."],
|
||||
[helper, "Imported repository evidence is not live filesystem truth", "Intake export must label imported evidence as non-live truth."],
|
||||
[repoBuilder, "Existing repository intake", "Repo Builder must expose Existing repository intake section."],
|
||||
[repoBuilder, "Paste a file tree, package manifest, repo summary or current-state notes", "Repo Builder must explain repository intake source types."],
|
||||
[repoBuilder, "will not treat it as live disk truth", "Repo Builder must communicate the intake trust boundary."],
|
||||
[repoBuilder, "saveRepositoryIntake", "Repo Builder must save repository intakes."],
|
||||
[repoBuilder, "archiveRepositoryIntake", "Repo Builder must archive repository intakes."],
|
||||
[repoBuilder, "formatRepositoryIntakeMarkdown", "Repo Builder must copy/download intake markdown."],
|
||||
[repoBuilder, "Create expansion brief", "Repository gaps must create expansion briefs."],
|
||||
[repoBuilder, "Create delivery package", "Repository gaps must create delivery packages."],
|
||||
[repoBuilder, "Create Codex handoff", "Repository gaps must create Codex handoffs."],
|
||||
[context, "generateCodexHandoff", "ProjectContext must generate Codex handoffs from repository evidence."],
|
||||
[context, "generateHandoffReview", "ProjectContext must generate impact reviews for handoffs from repository evidence."],
|
||||
[context, "repositoryIntakeItems", "Project search index must include repository intakes."],
|
||||
[context, "saveRepositoryIntake", "ProjectContext must expose saveRepositoryIntake."],
|
||||
[context, "archiveRepositoryIntake", "ProjectContext must expose archiveRepositoryIntake."],
|
||||
[context, "createRepoExpansionFromGap", "ProjectContext must create expansion briefs from repository gaps."],
|
||||
[context, "createRepoDeliveryPackageFromGap", "ProjectContext must create delivery packages from repository gaps."],
|
||||
[context, "deriveRepositoryGaps", "Repo state snapshots must derive repository gaps from intakes."],
|
||||
[context, "imported files", "Repo state snapshots must include imported file evidence."],
|
||||
[context, "not live disk truth", "Generated handoffs must keep imported evidence separate from live truth."],
|
||||
[history, "repoIntakeEntries", "Project History must include repository intake records."],
|
||||
[history, "Repo Builder / repository intake", "History entries must label repository intake source."],
|
||||
[audit, "Repository intake coverage", "Audit Center must show repository intake coverage."],
|
||||
[workspace, "repositoryIntakeCount", "Workspace must count repository intakes."],
|
||||
[workspace, "repositoryGapCount", "Workspace must count repository gaps."],
|
||||
[startFlow, "import-repository-state", "Unified start next-actions must include repository intake for existing repo projects."],
|
||||
[startFlow, "Generate repo state snapshot", "Unified start next-actions must lead from intake to repo state snapshot."],
|
||||
[wizard, "Import repository state in Repo Builder", "First-run wizard recommendations must mention repository intake for existing repos."],
|
||||
[flowRail, "Import repo evidence, derive repo state, resolve gaps", "Flow rail must describe repository intake/state/gap/delivery flow."],
|
||||
[pkg, "audit:repository-intake", "package.json must expose audit:repository-intake."],
|
||||
[pkg, "check-repository-intake-readiness.mjs", "package.json must wire repository intake readiness script."],
|
||||
];
|
||||
|
||||
for (const [haystack, needle, message] of mustContain) {
|
||||
if (!haystack.includes(needle)) failures.push(message);
|
||||
}
|
||||
|
||||
if (!pkg.includes("audit:repository-intake") || !pkg.includes("audit:all")) {
|
||||
failures.push("audit:all must include the repository intake readiness check.");
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("ProjectBrain repository intake readiness: FAIL");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("ProjectBrain repository intake readiness: PASS");
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = process.cwd();
|
||||
const failures = [];
|
||||
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
|
||||
const exists = (file) => fs.existsSync(path.join(root, file));
|
||||
const requireFile = (file) => {
|
||||
if (!exists(file)) failures.push(`Missing required file: ${file}`);
|
||||
};
|
||||
const requireIncludes = (file, needle, message) => {
|
||||
if (!exists(file) || !read(file).includes(needle)) failures.push(`${file}: ${message}`);
|
||||
};
|
||||
|
||||
[
|
||||
"backend/src/routes/runtimePersistence.ts",
|
||||
"backend/src/repositories/runtimeRepository.ts",
|
||||
"backend/src/routes/p7Runtime.ts",
|
||||
"src/shared/api/realClient.ts",
|
||||
"src/shared/hooks/useProjectData.ts",
|
||||
"src/features/operations/pages/ReviewQueue.tsx",
|
||||
"package.json",
|
||||
].forEach(requireFile);
|
||||
|
||||
requireIncludes("backend/src/routes/runtimePersistence.ts", "/api/projects/:projectId/review-queue/:reviewItemId/materialize-truth", "Review materialization endpoint must exist.");
|
||||
requireIncludes("backend/src/routes/runtimePersistence.ts", "/api/projects/:projectId/accepted-truth/:truthItemId/project-updates", "Project update creation endpoint must exist.");
|
||||
requireIncludes("backend/src/routes/runtimePersistence.ts", "/api/projects/:projectId/project-updates/:projectUpdateId/apply", "Project update apply endpoint must exist.");
|
||||
requireIncludes("backend/src/routes/p7Runtime.ts", "/api/projects/:projectId/current-state/generate", "Current state generation endpoint must exist.");
|
||||
requireIncludes("backend/src/repositories/runtimeRepository.ts", "materializeReviewItemAsTruthInDatabase", "Repository must materialize review items as accepted truth.");
|
||||
requireIncludes("backend/src/repositories/runtimeRepository.ts", "createProjectUpdateFromTruthInDatabase", "Repository must prepare project updates from accepted truth.");
|
||||
requireIncludes("backend/src/repositories/runtimeRepository.ts", "applyProjectUpdateInDatabase", "Repository must apply project updates.");
|
||||
requireIncludes("src/shared/api/realClient.ts", "materializeReviewItemAsTruth", "Real client must expose materialize review as truth.");
|
||||
requireIncludes("src/shared/api/realClient.ts", "createProjectUpdateFromTruth", "Real client must expose project update creation.");
|
||||
requireIncludes("src/shared/api/realClient.ts", "applyProjectUpdate", "Real client must expose project update apply.");
|
||||
requireIncludes("src/shared/api/realClient.ts", "generateCurrentStateReport", "Real client must expose current state generation.");
|
||||
requireIncludes("src/shared/hooks/useProjectData.ts", "useProjectAcceptedTruthData", "Async hooks must expose accepted truth.");
|
||||
requireIncludes("src/shared/hooks/useProjectData.ts", "useProjectProjectUpdatesData", "Async hooks must expose project updates.");
|
||||
requireIncludes("src/shared/hooks/useProjectData.ts", "useProjectCurrentStateReportsData", "Async hooks must expose current state reports.");
|
||||
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "Live review closure", "Review Queue must expose live closure bridge.");
|
||||
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "Accept + close selected review", "Review Queue must provide explicit closure action.");
|
||||
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "closeSelectedReviewIntoState", "Review Queue must run the closure workflow.");
|
||||
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "realClient.runtimePersistence.materializeReviewItemAsTruth", "Closure workflow must materialize accepted truth.");
|
||||
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "realClient.runtimePersistence.createProjectUpdateFromTruth", "Closure workflow must prepare project updates.");
|
||||
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "realClient.runtimePersistence.applyProjectUpdate", "Closure workflow must apply project updates.");
|
||||
requireIncludes("src/features/operations/pages/ReviewQueue.tsx", "realClient.runtimeV7.generateCurrentStateReport", "Closure workflow must regenerate current state.");
|
||||
requireIncludes("package.json", "audit:review-closure", "package.json must expose audit:review-closure.");
|
||||
requireIncludes("package.json", "check-review-closure-readiness.mjs", "package.json must wire the review closure readiness script.");
|
||||
|
||||
const scripts = JSON.parse(read("package.json")).scripts ?? {};
|
||||
const experienceAudit = scripts["audit:experience"] ?? "";
|
||||
const allAudit = scripts["audit:all"] ?? "";
|
||||
const reviewClosureIndex = experienceAudit.indexOf("audit:review-closure");
|
||||
const buildLoopIndex = experienceAudit.indexOf("audit:build-loop");
|
||||
if (!allAudit.includes("audit:experience") || reviewClosureIndex < 0 || buildLoopIndex < 0 || reviewClosureIndex > buildLoopIndex) {
|
||||
failures.push("package.json: audit:all must run the experience gate with review closure before build-loop readiness.");
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("ProjectBrain review closure readiness: FAIL");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("ProjectBrain review closure readiness: PASS");
|
||||
@@ -0,0 +1,440 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const failures = [];
|
||||
const warnings = [];
|
||||
|
||||
const requiredFiles = [
|
||||
'src/features/projects/pages/RuntimeReadiness.tsx',
|
||||
'src/shared/hooks/useAsyncResource.ts',
|
||||
'src/shared/hooks/useProjectData.ts',
|
||||
'src/shared/api/realClient.ts',
|
||||
'docs/RUNTIME_ROUND_1_REAL_MODE_PILOT.md',
|
||||
'docs/RUNTIME_ROUND_2_REAL_READ_PILOT.md',
|
||||
'docs/RUNTIME_ROUND_3_REAL_READ_PILOT.md',
|
||||
'backend/scripts/smoke-real-read-pilot.mjs',
|
||||
'backend/scripts/smoke-vertical-slice.mjs',
|
||||
'docs/RUNTIME_ROUND_4_VERTICAL_SLICE_SMOKE.md',
|
||||
'docs/P2_LOCAL_RUNTIME_VERIFICATION.md',
|
||||
'docs/RUNTIME_ROUND_5_BACKEND_HEALTH_AND_SUITE.md',
|
||||
'backend/scripts/smoke-runtime-suite.mjs',
|
||||
'src/features/operations/lib/operationRuntime.ts',
|
||||
'src/features/operations/pages/RuntimeStateManager.tsx',
|
||||
'docs/RUNTIME_ROUND_7_SHARED_LOCAL_OPERATING_STATE.md',
|
||||
'docs/RUNTIME_ROUND_8_LOCAL_RUNTIME_PORTABILITY.md',
|
||||
'docs/RUNTIME_ROUND_10_LOCAL_TRUTH_LEDGER.md',
|
||||
'src/features/operations/pages/RuntimeTruthLedger.tsx',
|
||||
'src/features/operations/pages/RuntimeHandoffBuilder.tsx',
|
||||
'docs/RUNTIME_ROUND_11_HANDOFF_BUNDLES.md',
|
||||
'src/features/operations/pages/RuntimeFlowProgress.tsx',
|
||||
'docs/RUNTIME_ROUND_12_FLOW_PROGRESS_AND_CODEX_HANDOFF.md',
|
||||
'docs/RUNTIME_ROUND_13_GUIDED_VERTICAL_SLICE_RUNNER.md',
|
||||
'src/features/operations/pages/RuntimeMissionControl.tsx',
|
||||
'docs/RUNTIME_ROUND_14_MISSION_CONTROL.md',
|
||||
'src/features/operations/pages/RuntimeTraceInspector.tsx',
|
||||
'docs/RUNTIME_ROUND_15_TRACE_INSPECTOR.md',
|
||||
'src/features/operations/pages/RuntimeTodoBoard.tsx',
|
||||
'docs/RUNTIME_ROUND_16_TODO_BOARD.md',
|
||||
'src/features/operations/pages/RuntimeDeliveryPlan.tsx',
|
||||
'docs/RUNTIME_ROUND_17_DELIVERY_PLAN.md',
|
||||
'src/features/operations/pages/RuntimeExecutionQueue.tsx',
|
||||
'docs/RUNTIME_ROUND_18_EXECUTION_QUEUE.md',
|
||||
'src/features/operations/pages/RuntimeGateChecklist.tsx',
|
||||
'src/features/operations/pages/RuntimeGateEvidence.tsx',
|
||||
'docs/RUNTIME_ROUND_19_P2_GATE_CHECKLIST.md',
|
||||
'docs/RUNTIME_ROUND_20_P2_SMOKE_EVIDENCE.md',
|
||||
'src/features/operations/pages/RuntimeP3ReadinessAdvisor.tsx',
|
||||
'docs/RUNTIME_ROUND_21_P3_READINESS_ADVISOR.md',
|
||||
'src/features/operations/pages/RuntimeP3ImplementationPlanner.tsx',
|
||||
'docs/RUNTIME_ROUND_22_P3_IMPLEMENTATION_PLANNER.md',
|
||||
'src/features/operations/pages/RuntimeWorkSessionPanel.tsx',
|
||||
'docs/RUNTIME_ROUND_23_WORK_SESSIONS.md',
|
||||
'docs/RUNTIME_ROUND_24_REAL_MODE_FEATURE_DETAIL_PILOT.md',
|
||||
'docs/STABILIZATION_PASS_V37.md',
|
||||
'src/features/operations/pages/RuntimeContextPromptPilot.tsx',
|
||||
'docs/RUNTIME_ROUND_25_CONTEXT_PROMPT_REAL_PILOT.md',
|
||||
'docs/STABILIZATION_PASS_V38.md',
|
||||
'src/features/operations/pages/RuntimeProjectUpdates.tsx',
|
||||
'docs/RUNTIME_ROUND_26_PROJECT_UPDATE_MATERIALIZATION.md',
|
||||
'docs/STABILIZATION_PASS_V39.md',
|
||||
'src/features/operations/pages/RuntimeFeatureOverlay.tsx',
|
||||
'docs/RUNTIME_ROUND_27_FEATURE_MAP_OVERLAY.md',
|
||||
'docs/STABILIZATION_PASS_V40.md',
|
||||
'docs/RUNTIME_ROUND_28_CAPTURE_REVIEW_REAL_PILOT.md',
|
||||
'docs/STABILIZATION_PASS_V43.md',
|
||||
'docs/PERSISTENCE_P6_TRUTH_UPDATES_OPERATING_LOG.md',
|
||||
'docs/STABILIZATION_PASS_V44_P6_TRUTH_UPDATES_LOG.md',
|
||||
'backend/scripts/smoke-p6.mjs',
|
||||
'src/features/operations/pages/RuntimePersistencePilot.tsx',
|
||||
'docs/RUNTIME_ROUND_29_P6_FRONTEND_PERSISTENCE_PILOT.md',
|
||||
'docs/STABILIZATION_PASS_V45.md',
|
||||
'src/features/operations/pages/RuntimeDeliveryPersistencePilot.tsx',
|
||||
'docs/PERSISTENCE_P7_CURRENT_STATE_HANDOFF_CODEX.md',
|
||||
'docs/RUNTIME_ROUND_30_P7_DELIVERY_PERSISTENCE_PILOT.md',
|
||||
'docs/STABILIZATION_PASS_V46_P7_DELIVERY_PERSISTENCE.md',
|
||||
'backend/scripts/smoke-p7.mjs',
|
||||
'backend/scripts/smoke-p8.mjs',
|
||||
'src/features/operations/pages/RuntimeKnowledgePersistencePilot.tsx',
|
||||
'docs/PERSISTENCE_P8_DOCUMENTATION_KNOWLEDGE_MEMORY.md',
|
||||
'docs/RUNTIME_ROUND_31_P8_KNOWLEDGE_PERSISTENCE_PILOT.md',
|
||||
'docs/STABILIZATION_PASS_V47_P8_KNOWLEDGE_MEMORY_BRANDING.md',
|
||||
];
|
||||
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(join(root, file))) failures.push(`Missing runtime-readiness file: ${file}`);
|
||||
}
|
||||
|
||||
const routeFile = join(root, 'src/app/routes.tsx');
|
||||
if (existsSync(routeFile)) {
|
||||
const routes = readFileSync(routeFile, 'utf8');
|
||||
if (!routes.includes('/projects/:projectId/runtime')) failures.push('Runtime readiness route is not registered.');
|
||||
} else {
|
||||
failures.push('Missing src/app/routes.tsx');
|
||||
}
|
||||
|
||||
const runtimeReadinessFile = join(root, 'src/features/projects/pages/RuntimeReadiness.tsx');
|
||||
if (existsSync(runtimeReadinessFile)) {
|
||||
const runtimeReadiness = readFileSync(runtimeReadinessFile, 'utf8');
|
||||
for (const marker of ['Tower deployment runbook', 'Deployment update path', 'git pull --ff-only', 'scripts/deploy-unraid-single.sh', 'Configurable public URL']) {
|
||||
if (!runtimeReadiness.includes(marker)) warnings.push(`RuntimeReadiness missing deployment runbook marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sidebarFile = join(root, 'src/components/layout/Sidebar.tsx');
|
||||
if (existsSync(sidebarFile)) {
|
||||
const sidebar = readFileSync(sidebarFile, 'utf8');
|
||||
const navigationFile = join(root, 'src/shared/lib/navigation.tsx');
|
||||
const navigation = existsSync(navigationFile) ? readFileSync(navigationFile, 'utf8') : '';
|
||||
if (!sidebar.includes('/runtime') && !navigation.includes('/runtime')) warnings.push('Navigation does not expose Runtime Readiness.');
|
||||
} else {
|
||||
failures.push('Missing Sidebar.tsx');
|
||||
}
|
||||
|
||||
const realClientFile = join(root, 'src/shared/api/realClient.ts');
|
||||
if (existsSync(realClientFile)) {
|
||||
const realClient = readFileSync(realClientFile, 'utf8');
|
||||
for (const marker of ['health: healthApi', 'unwrapPaginatedItems', 'unwrapWorkspaceState', 'mapBackendFeature', 'mapBackendEvidence', 'listFeatures', 'listEvidence', 'listEvidenceForFeature', 'listContextPacks', 'listChecks', 'listBuildRuns', 'listSnapshots', 'listKnowledge', 'listCaptureItems', 'createCaptureItem', 'classifyCaptureItem', 'sendCaptureToReview', 'listReviewItems', 'acceptReviewItem', 'convertReviewItem', 'listActions', 'runtimePersistence', 'listAcceptedTruth', 'materializeReviewItemAsTruth', 'listProjectUpdates', 'createProjectUpdateFromTruth', 'applyProjectUpdate', 'listOperatingLog', 'createOperatingLogEntry', 'runtimeV7', 'listCurrentStateReports', 'generateCurrentStateReport', 'listHandoffBundles', 'createHandoffBundle', 'listCodexTasks', 'createCodexTaskFromHandoff', 'importCodexOutput', 'createKnowledge', 'updateKnowledge', 'createMemoryRule', 'updateMemoryRule', 'listDocumentationItems', 'createDocumentationItem', 'updateDocumentationItem']) {
|
||||
if (!realClient.includes(marker)) warnings.push(`realClient missing expected marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const projectDataHooksFile = join(root, 'src/shared/hooks/useProjectData.ts');
|
||||
if (existsSync(projectDataHooksFile)) {
|
||||
const hooks = readFileSync(projectDataHooksFile, 'utf8');
|
||||
for (const marker of ['useBackendHealthData', 'useProjectFeatureGroupsData', 'useProjectFeaturesData', 'useProjectFeatureDetailData', 'useProjectEvidenceData', 'useFeatureEvidenceData', 'useProjectContextPacksData', 'useProjectChecksData', 'useProjectBuildRunsData', 'useProjectSnapshotsData', 'useProjectKnowledgeData', 'useProjectDocumentationItemsData', 'useProjectCaptureItemsData', 'useProjectReviewQueueData', 'useProjectAcceptedTruthData', 'useProjectProjectUpdatesData', 'useProjectOperatingLogData', 'useProjectCurrentStateReportsData', 'useProjectHandoffBundlesData', 'useProjectCodexTasksData', 'useProjectAIActionsData']) {
|
||||
if (!hooks.includes(marker)) warnings.push(`useProjectData missing expected runtime hook: ${marker}`);
|
||||
}
|
||||
} else {
|
||||
failures.push('Missing src/shared/hooks/useProjectData.ts');
|
||||
}
|
||||
|
||||
|
||||
const featureMapFile = join(root, 'src/features/features/pages/FeatureMap.tsx');
|
||||
if (existsSync(featureMapFile)) {
|
||||
const featureMap = readFileSync(featureMapFile, 'utf8');
|
||||
for (const marker of ['Feature data source', 'useProjectFeatureGroupsData', 'Reload features', 'RuntimeFeatureOverlay']) {
|
||||
if (!featureMap.includes(marker)) warnings.push(`FeatureMap missing real-mode pilot marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
const featureDetailFile = join(root, 'src/features/features/pages/FeatureDetail.tsx');
|
||||
if (existsSync(featureDetailFile)) {
|
||||
const featureDetail = readFileSync(featureDetailFile, 'utf8');
|
||||
for (const marker of ['Feature detail data source', 'useProjectFeatureDetailData', 'useFeatureEvidenceData', 'Reload detail']) {
|
||||
if (!featureDetail.includes(marker)) warnings.push(`FeatureDetail missing real-mode pilot marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
const operationRuntimeFile = join(root, 'src/features/operations/lib/operationRuntime.ts');
|
||||
if (existsSync(operationRuntimeFile)) {
|
||||
const runtime = readFileSync(operationRuntimeFile, 'utf8');
|
||||
for (const marker of ['exportRuntime', 'exportRuntimeMarkdown', 'importRuntime', 'sendCaptureToReview', 'truthItems', 'RuntimeTruthItem', 'RuntimeHandoffBundle', 'createHandoffBundle', 'handoffBundles', 'runGuidedVerticalSlice', 'runtimeTraceRows', 'exportRuntimeTraceMarkdown', 'RuntimeTodoItem', 'todoItems', 'addTodo', 'toggleTodo', 'createTodosFromPendingReviews', 'exportTodoMarkdown', 'RuntimeDeliveryGate', 'runtimeDeliveryGates', 'createTodosFromTraceGaps', 'createTodosFromDeliveryGaps', 'completeDeliveryGate', 'exportDeliveryPlanMarkdown', 'RuntimeWorkSession', 'workSessions', 'startWorkSession', 'completeWorkSession', 'exportWorkSessionMarkdown', 'RuntimeProjectUpdate', 'projectUpdates', 'materializeAcceptedTruth', 'applyProjectUpdate', 'exportProjectUpdatesMarkdown']) {
|
||||
if (!runtime.includes(marker)) warnings.push(`operationRuntime missing expected portability marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
const truthLedgerFile = join(root, 'src/features/operations/pages/RuntimeTruthLedger.tsx');
|
||||
if (existsSync(truthLedgerFile)) {
|
||||
const ledger = readFileSync(truthLedgerFile, 'utf8');
|
||||
for (const marker of ['Export truth markdown', 'Create context handoff', 'Create Codex handoff']) {
|
||||
if (!ledger.includes(marker)) warnings.push(`RuntimeTruthLedger missing expected control: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
const codexPreviewFile = join(root, 'src/features/operations/pages/CodexTaskPreview.tsx');
|
||||
if (existsSync(codexPreviewFile)) {
|
||||
const codex = readFileSync(codexPreviewFile, 'utf8');
|
||||
for (const marker of ['Create runtime handoff', 'Runtime handoff attached', 'Runtime Handoff Context']) {
|
||||
if (!codex.includes(marker)) warnings.push(`CodexTaskPreview missing expected runtime-handoff marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeStateManagerFile = join(root, 'src/features/operations/pages/RuntimeStateManager.tsx');
|
||||
if (existsSync(runtimeStateManagerFile)) {
|
||||
const manager = readFileSync(runtimeStateManagerFile, 'utf8');
|
||||
for (const marker of ['Export JSON', 'Export Markdown', 'Import JSON', 'Reset local runtime', 'Handoffs']) {
|
||||
if (!manager.includes(marker)) warnings.push(`RuntimeStateManager missing expected control: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
const missionControlFile = join(root, 'src/features/operations/pages/RuntimeMissionControl.tsx');
|
||||
if (existsSync(missionControlFile)) {
|
||||
const mission = readFileSync(missionControlFile, 'utf8');
|
||||
for (const marker of ['Runtime Mission Control', 'Run full local flow', 'Create Codex handoff', 'Copy mission markdown', 'Import Codex output preview']) {
|
||||
if (!mission.includes(marker)) warnings.push(`RuntimeMissionControl missing expected marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const traceInspectorFile = join(root, 'src/features/operations/pages/RuntimeTraceInspector.tsx');
|
||||
if (existsSync(traceInspectorFile)) {
|
||||
const trace = readFileSync(traceInspectorFile, 'utf8');
|
||||
for (const marker of ['Runtime Trace Inspector', 'Seed trace example', 'Copy trace markdown', 'Create trace handoff']) {
|
||||
if (!trace.includes(marker)) warnings.push(`RuntimeTraceInspector missing expected marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const todoBoardFile = join(root, 'src/features/operations/pages/RuntimeTodoBoard.tsx');
|
||||
if (existsSync(todoBoardFile)) {
|
||||
const todoBoard = readFileSync(todoBoardFile, 'utf8');
|
||||
for (const marker of ['Runtime Todo Board', 'Create todos from reviews', 'Export todo markdown', 'Add manual todo']) {
|
||||
if (!todoBoard.includes(marker)) warnings.push(`RuntimeTodoBoard missing expected marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
const executionQueueFile = join(root, 'src/features/operations/pages/RuntimeExecutionQueue.tsx');
|
||||
if (existsSync(executionQueueFile)) {
|
||||
const execution = readFileSync(executionQueueFile, 'utf8');
|
||||
for (const marker of ['Runtime Execution Queue', 'Prepare execution burst', 'Mark top todo done', 'Export execution queue', 'Create execution handoff']) {
|
||||
if (!execution.includes(marker)) warnings.push(`RuntimeExecutionQueue missing expected marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
const deliveryPlanFile = join(root, 'src/features/operations/pages/RuntimeDeliveryPlan.tsx');
|
||||
if (existsSync(deliveryPlanFile)) {
|
||||
const delivery = readFileSync(deliveryPlanFile, 'utf8');
|
||||
for (const marker of ['Runtime Delivery Plan', 'Create gate todos', 'Create trace-gap todos', 'Export delivery plan']) {
|
||||
if (!delivery.includes(marker)) warnings.push(`RuntimeDeliveryPlan missing expected marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
const flowProgressFile = join(root, 'src/features/operations/pages/RuntimeFlowProgress.tsx');
|
||||
if (existsSync(flowProgressFile)) {
|
||||
const flow = readFileSync(flowProgressFile, 'utf8');
|
||||
for (const marker of ['Runtime Vertical Slice Progress', 'Create suggested handoff', 'Run guided local slice', 'Next best runtime step']) {
|
||||
if (!flow.includes(marker)) warnings.push(`RuntimeFlowProgress missing expected marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
const handoffBuilderFile = join(root, 'src/features/operations/pages/RuntimeHandoffBuilder.tsx');
|
||||
if (existsSync(handoffBuilderFile)) {
|
||||
const builder = readFileSync(handoffBuilderFile, 'utf8');
|
||||
for (const marker of ['Create handoff bundle', 'Copy latest markdown', 'Send latest to Review Queue']) {
|
||||
if (!builder.includes(marker)) warnings.push(`RuntimeHandoffBuilder missing expected control: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const gateChecklistFile = join(root, 'src/features/operations/pages/RuntimeGateChecklist.tsx');
|
||||
if (existsSync(gateChecklistFile)) {
|
||||
const gate = readFileSync(gateChecklistFile, 'utf8');
|
||||
for (const marker of ['P2 Database Gate Checklist', 'Copy commands', 'Create todos for incomplete steps', 'Mark gate complete']) {
|
||||
if (!gate.includes(marker)) warnings.push(`RuntimeGateChecklist missing expected marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const gateEvidenceFile = join(root, 'src/features/operations/pages/RuntimeGateEvidence.tsx');
|
||||
if (existsSync(gateEvidenceFile)) {
|
||||
const evidence = readFileSync(gateEvidenceFile, 'utf8');
|
||||
for (const marker of ['P2 Smoke Evidence Recorder', 'Save evidence', 'Export evidence', 'Evidence history']) {
|
||||
if (!evidence.includes(marker)) warnings.push(`RuntimeGateEvidence missing expected marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const p3AdvisorFile = join(root, 'src/features/operations/pages/RuntimeP3ReadinessAdvisor.tsx');
|
||||
if (existsSync(p3AdvisorFile)) {
|
||||
const p3 = readFileSync(p3AdvisorFile, 'utf8');
|
||||
for (const marker of ['Feature/Evidence parity proof', 'Create parity proof todos', 'Create parity handoff', 'Export readiness', 'Mark P2 gate complete']) {
|
||||
if (!p3.includes(marker)) warnings.push(`RuntimeP3ReadinessAdvisor missing expected marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const workSessionFile = join(root, 'src/features/operations/pages/RuntimeWorkSessionPanel.tsx');
|
||||
if (existsSync(workSessionFile)) {
|
||||
const sessions = readFileSync(workSessionFile, 'utf8');
|
||||
for (const marker of ['Runtime Work Sessions', 'Start next work session', 'Complete active session', 'Export sessions']) {
|
||||
if (!sessions.includes(marker)) warnings.push(`RuntimeWorkSessionPanel missing expected marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const projectUpdatesFile = join(root, 'src/features/operations/pages/RuntimeProjectUpdates.tsx');
|
||||
if (existsSync(projectUpdatesFile)) {
|
||||
const updates = readFileSync(projectUpdatesFile, 'utf8');
|
||||
for (const marker of ['Runtime Project Updates', 'Prepare from truth', 'Apply ready', 'Export updates']) {
|
||||
if (!updates.includes(marker)) warnings.push(`RuntimeProjectUpdates missing expected marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const runtimePersistencePilotFile = join(root, 'src/features/operations/pages/RuntimePersistencePilot.tsx');
|
||||
if (existsSync(runtimePersistencePilotFile)) {
|
||||
const pilot = readFileSync(runtimePersistencePilotFile, 'utf8');
|
||||
for (const marker of ['Runtime Persistence Pilot', 'Materialize truth', 'Prepare updates', 'Apply ready', 'Add log note', 'realClient.runtimePersistence']) {
|
||||
if (!pilot.includes(marker)) warnings.push(`RuntimePersistencePilot missing expected marker: ${marker}`);
|
||||
}
|
||||
} else {
|
||||
warnings.push('Missing RuntimePersistencePilot component.');
|
||||
}
|
||||
|
||||
|
||||
|
||||
const runtimeDeliveryPersistencePilotFile = join(root, 'src/features/operations/pages/RuntimeDeliveryPersistencePilot.tsx');
|
||||
if (existsSync(runtimeDeliveryPersistencePilotFile)) {
|
||||
const pilot = readFileSync(runtimeDeliveryPersistencePilotFile, 'utf8');
|
||||
for (const marker of ['Runtime Delivery Persistence Pilot', 'Generate Current State', 'Create Handoff', 'Create Codex Task', 'Import Codex Output', 'realClient.runtimeV7']) {
|
||||
if (!pilot.includes(marker)) warnings.push(`RuntimeDeliveryPersistencePilot missing expected marker: ${marker}`);
|
||||
}
|
||||
} else {
|
||||
warnings.push('Missing RuntimeDeliveryPersistencePilot component.');
|
||||
}
|
||||
|
||||
const runtimeFeatureOverlayFile = join(root, 'src/features/operations/pages/RuntimeFeatureOverlay.tsx');
|
||||
if (existsSync(runtimeFeatureOverlayFile)) {
|
||||
const overlay = readFileSync(runtimeFeatureOverlayFile, 'utf8');
|
||||
for (const marker of ['Runtime Feature Overlay', 'Prepare updates', 'Apply ready', 'Create handoff', 'Export']) {
|
||||
if (!overlay.includes(marker)) warnings.push(`RuntimeFeatureOverlay missing expected marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
const packageFile = join(root, 'package.json');
|
||||
if (existsSync(packageFile)) {
|
||||
const pkg = readFileSync(packageFile, 'utf8');
|
||||
if (!pkg.includes('smoke:backend:real-read-pilot')) warnings.push('Root package is missing smoke:backend:real-read-pilot script.');
|
||||
if (!pkg.includes('smoke:backend:vertical-slice')) warnings.push('Root package is missing smoke:backend:vertical-slice script.');
|
||||
if (!pkg.includes('smoke:backend:runtime-suite')) warnings.push('Root package is missing smoke:backend:runtime-suite script.');
|
||||
}
|
||||
|
||||
console.log('ProjectBrain runtime readiness check');
|
||||
console.log('====================================');
|
||||
if (warnings.length) {
|
||||
console.log('\nWarnings:');
|
||||
for (const warning of warnings) console.log(`- ${warning}`);
|
||||
}
|
||||
|
||||
const p3ImplementationPlannerFile = join(root, 'src/features/operations/pages/RuntimeP3ImplementationPlanner.tsx');
|
||||
if (existsSync(p3ImplementationPlannerFile)) {
|
||||
const planner = readFileSync(p3ImplementationPlannerFile, 'utf8');
|
||||
for (const marker of ['Feature/Evidence parity reference', 'Create parity todos', 'Create parity handoff', 'Export parity plan']) {
|
||||
if (!planner.includes(marker)) warnings.push(`RuntimeP3ImplementationPlanner missing expected marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const contextPromptPilotFile = join(root, 'src/features/operations/pages/RuntimeContextPromptPilot.tsx');
|
||||
if (existsSync(contextPromptPilotFile)) {
|
||||
const pilot = readFileSync(contextPromptPilotFile, 'utf8');
|
||||
for (const marker of ['Runtime Context -> Prompt Pilot', 'Generate runtime prompt', 'realClient.prompts.generatePromptFromContextPack', 'useProjectContextPacksData']) {
|
||||
if (!pilot.includes(marker)) warnings.push(`RuntimeContextPromptPilot missing expected marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
const promptGeneratorFile = join(root, 'src/features/prompts/pages/PromptGenerator.tsx');
|
||||
if (existsSync(promptGeneratorFile)) {
|
||||
const prompt = readFileSync(promptGeneratorFile, 'utf8');
|
||||
for (const marker of ['Prompt data source', 'realClient.prompts.generatePromptFromContextPack', 'useProjectContextPacksData']) {
|
||||
if (!prompt.includes(marker)) warnings.push(`PromptGenerator missing real prompt marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
const contextPacksPageFile = join(root, 'src/features/context-packs/pages/ContextPacks.tsx');
|
||||
if (existsSync(contextPacksPageFile)) {
|
||||
const packs = readFileSync(contextPacksPageFile, 'utf8');
|
||||
for (const marker of ['Context pack data source', 'useProjectContextPacksData', 'Fastify R4 read endpoint']) {
|
||||
if (!packs.includes(marker)) warnings.push(`ContextPacks missing real context-pack marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const captureCenterFile = join(root, 'src/features/operations/pages/CaptureCenter.tsx');
|
||||
if (existsSync(captureCenterFile)) {
|
||||
const captureCenter = readFileSync(captureCenterFile, 'utf8');
|
||||
for (const marker of ['Capture data source', 'Real API pilot', 'createCaptureItem', 'classifyCaptureItem', 'sendCaptureToReview']) {
|
||||
if (!captureCenter.includes(marker)) warnings.push(`CaptureCenter missing real API pilot marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
const reviewQueueFile = join(root, 'src/features/operations/pages/ReviewQueue.tsx');
|
||||
if (existsSync(reviewQueueFile)) {
|
||||
const reviewQueue = readFileSync(reviewQueueFile, 'utf8');
|
||||
for (const marker of ['Review Queue data source', 'Real API pilot', 'acceptReviewItem', 'convertReviewItem', 'mergeReviewItem']) {
|
||||
if (!reviewQueue.includes(marker)) warnings.push(`ReviewQueue missing real API pilot marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const backendPackageFile = join(root, 'backend/package.json');
|
||||
if (existsSync(backendPackageFile)) {
|
||||
const backendPackage = readFileSync(backendPackageFile, 'utf8');
|
||||
for (const marker of ['smoke:p6', 'smoke-p6.mjs', 'smoke:p7', 'smoke-p7.mjs']) {
|
||||
if (!backendPackage.includes(marker)) warnings.push(`backend package missing P6 marker: ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
const runtimePersistenceRouteFile = join(root, 'backend/src/routes/runtimePersistence.ts');
|
||||
if (existsSync(runtimePersistenceRouteFile)) {
|
||||
const runtimePersistence = readFileSync(runtimePersistenceRouteFile, 'utf8');
|
||||
for (const marker of ['accepted-truth', 'project-updates', 'operating-log', 'materialize-truth']) {
|
||||
if (!runtimePersistence.includes(marker)) warnings.push(`runtimePersistence route missing P6 marker: ${marker}`);
|
||||
}
|
||||
} else {
|
||||
warnings.push('Missing P6 runtimePersistence route file.');
|
||||
}
|
||||
|
||||
|
||||
|
||||
const p7RuntimeRouteFile = join(root, 'backend/src/routes/p7Runtime.ts');
|
||||
if (existsSync(p7RuntimeRouteFile)) {
|
||||
const p7Runtime = readFileSync(p7RuntimeRouteFile, 'utf8');
|
||||
for (const marker of ['current-state', 'handoff-bundles', 'codex-tasks', 'import-output']) {
|
||||
if (!p7Runtime.includes(marker)) warnings.push(`p7Runtime route missing P7 marker: ${marker}`);
|
||||
}
|
||||
} else {
|
||||
warnings.push('Missing P7 p7Runtime route file.');
|
||||
}
|
||||
|
||||
|
||||
const knowledgePilotFile = join(root, 'src/features/operations/pages/RuntimeKnowledgePersistencePilot.tsx');
|
||||
if (existsSync(knowledgePilotFile)) {
|
||||
const pilot = readFileSync(knowledgePilotFile, 'utf8');
|
||||
for (const marker of ['Runtime Knowledge Persistence Pilot', 'Persist documentation', 'Persist knowledge', 'Persist memory rule']) {
|
||||
if (!pilot.includes(marker)) warnings.push(`RuntimeKnowledgePersistencePilot missing expected marker: ${marker}`);
|
||||
}
|
||||
} else {
|
||||
failures.push('Missing RuntimeKnowledgePersistencePilot.tsx');
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.log('\nFailures:');
|
||||
for (const failure of failures) console.log(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nPASS: runtime readiness scaffolding is present.');
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
const requiredFiles = [
|
||||
"src/shared/lib/codexSessionPackExport.ts",
|
||||
"src/shared/lib/downloads.ts",
|
||||
"src/features/session-packs/pages/SessionPacks.tsx",
|
||||
"src/features/handoffs/pages/CodexHandoffs.tsx",
|
||||
"src/features/build-loop/pages/BuildLoopDashboard.tsx",
|
||||
"src/shared/lib/buildLoop.ts",
|
||||
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
|
||||
"src/shared/lib/codexExecutionFeedback.ts",
|
||||
"src/features/prompts/pages/PromptGenerator.tsx",
|
||||
"src/features/audit/pages/ProjectAuditCenter.tsx",
|
||||
"src/shared/contexts/ProjectContext.tsx",
|
||||
"pnpm-lock.yaml",
|
||||
"package.json",
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
|
||||
}
|
||||
|
||||
const read = (file) => readFileSync(file, "utf8");
|
||||
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
|
||||
|
||||
const exportHelper = text["src/shared/lib/codexSessionPackExport.ts"] ?? "";
|
||||
const downloads = text["src/shared/lib/downloads.ts"] ?? "";
|
||||
const sessionPacks = text["src/features/session-packs/pages/SessionPacks.tsx"] ?? "";
|
||||
const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? "";
|
||||
const buildLoopPage = text["src/features/build-loop/pages/BuildLoopDashboard.tsx"] ?? "";
|
||||
const buildLoop = text["src/shared/lib/buildLoop.ts"] ?? "";
|
||||
const feedback = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? "";
|
||||
const feedbackHelper = text["src/shared/lib/codexExecutionFeedback.ts"] ?? "";
|
||||
const promptBuilder = text["src/features/prompts/pages/PromptGenerator.tsx"] ?? "";
|
||||
const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? "";
|
||||
const context = text["src/shared/contexts/ProjectContext.tsx"] ?? "";
|
||||
const lockfile = text["pnpm-lock.yaml"] ?? "";
|
||||
const pkg = text["package.json"] ?? "";
|
||||
|
||||
const mustContain = [
|
||||
[exportHelper, "buildCodexSessionPackFiles", "Export helper must build bundle files."],
|
||||
[exportHelper, "formatAllowedFilesManifest", "Export helper must format allowed files manifest."],
|
||||
[exportHelper, "formatForbiddenChangesManifest", "Export helper must format forbidden changes manifest."],
|
||||
[exportHelper, "formatValidationPlanMarkdown", "Export helper must format validation plan."],
|
||||
[exportHelper, "formatRollbackPlanMarkdown", "Export helper must format rollback plan."],
|
||||
[exportHelper, "formatFeedbackTemplateMarkdown", "Export helper must format feedback template."],
|
||||
[exportHelper, "formatSessionPackReadme", "Export helper must format quick-start README."],
|
||||
[exportHelper, "formatImportableFeedbackTemplate", "Export helper must format importable feedback template."],
|
||||
[exportHelper, "downloadCodexSessionPackBundle", "Export helper must expose bundle download."],
|
||||
[exportHelper, "buildZipEntriesFromSessionPack", "Export helper must expose ZIP entries helper."],
|
||||
[exportHelper, "buildCodexSessionPackZipBlob", "Export helper must expose ZIP Blob builder."],
|
||||
[exportHelper, "downloadCodexSessionPackZip", "Export helper must expose ZIP download."],
|
||||
[exportHelper, "getCodexSessionPackZipFilename", "Export helper must expose ZIP filename helper."],
|
||||
[exportHelper, "zipExport: true", "Bundle manifest must state ZIP export is available."],
|
||||
[exportHelper, "fflate", "Export helper must use the selected browser-safe ZIP dependency."],
|
||||
[exportHelper, "codex-session-pack.md", "Bundle must include markdown handoff file."],
|
||||
[exportHelper, "codex-session-pack.json", "Bundle must include JSON file."],
|
||||
[exportHelper, "allowed-files.txt", "Bundle must include allowed files manifest."],
|
||||
[exportHelper, "forbidden-changes.md", "Bundle must include forbidden changes manifest."],
|
||||
[exportHelper, "validation-plan.md", "Bundle must include validation plan."],
|
||||
[exportHelper, "rollback-plan.md", "Bundle must include rollback plan."],
|
||||
[exportHelper, "feedback-template.md", "Bundle must include feedback template."],
|
||||
[exportHelper, "README_RUN_THIS_CODEX_SESSION.md", "Bundle must include quick-start README."],
|
||||
[downloads, "downloadTextFile", "Shared download helper must expose downloadTextFile."],
|
||||
[downloads, "downloadJsonFile", "Shared download helper must expose downloadJsonFile."],
|
||||
[downloads, "downloadBlobFile", "Shared download helper must expose downloadBlobFile."],
|
||||
[sessionPacks, "Bundle contents", "Session Packs page must show bundle contents."],
|
||||
[sessionPacks, "Download instructions/scripts ZIP", "Session Packs page must expose clear instructions/scripts ZIP download action."],
|
||||
[sessionPacks, "Export session pack files", "Session Packs page must expose honest session pack file export action."],
|
||||
[sessionPacks, "Copy feedback template", "Session Packs page must copy feedback template."],
|
||||
[sessionPacks, "Download feedback template", "Session Packs page must download feedback template."],
|
||||
[sessionPacks, "Copy quick-start README", "Session Packs page must copy quick-start README."],
|
||||
[sessionPacks, "ZIP export contains only", "Session Packs page must document ZIP contents."],
|
||||
[sessionPacks, "Export session pack files", "Session Packs page must keep individual-file fallback."],
|
||||
[handoffs, "Download ZIP", "Handoffs page must expose ZIP export when session pack exists."],
|
||||
[handoffs, "Export session pack files", "Handoffs page must export session pack files."],
|
||||
[handoffs, "controlled input for a Codex build session", "Handoffs page must explain session pack purpose."],
|
||||
[buildLoopPage, "Download ZIP", "Build Loop page must expose latest session pack ZIP."],
|
||||
[buildLoopPage, "Bundle readiness", "Build Loop page must show bundle readiness."],
|
||||
[buildLoopPage, "Feedback template readiness", "Build Loop page must show feedback template readiness."],
|
||||
[buildLoop, "Bundle readiness", "Build Loop markdown must include bundle readiness."],
|
||||
[buildLoop, "Feedback template readiness", "Build Loop markdown must include feedback template readiness."],
|
||||
[feedback, "Use this feedback template", "Execution Feedback must use session pack template."],
|
||||
[feedback, "Download template", "Execution Feedback must download template."],
|
||||
[feedback, "Insert template into report field", "Execution Feedback must insert template into report field."],
|
||||
[feedbackHelper, "Validation output", "Feedback parser/formatter must recognize validation output heading."],
|
||||
[feedbackHelper, "Final status", "Feedback parser/formatter must recognize final status heading."],
|
||||
[promptBuilder, "Use session pack prompt", "Prompt Builder must use session pack prompt."],
|
||||
[promptBuilder, "Copy feedback template", "Prompt Builder must copy feedback template."],
|
||||
[audit, "Missing allowed/rollback", "Audit Center must show allowed/rollback bundle readiness."],
|
||||
[context, "validation-plan.md", "Search index must include bundle files."],
|
||||
[pkg, '"fflate"', "package.json must include selected ZIP dependency."],
|
||||
[pkg, "audit:session-pack-export", "package.json must expose audit:session-pack-export."],
|
||||
[pkg, "check-session-pack-export-readiness.mjs", "package.json must wire session pack export readiness script."],
|
||||
[lockfile, "fflate", "pnpm lockfile must include selected ZIP dependency."],
|
||||
];
|
||||
|
||||
for (const [haystack, needle, message] of mustContain) {
|
||||
if (!haystack.includes(needle)) failures.push(message);
|
||||
}
|
||||
|
||||
if (sessionPacks.includes("Download Bundle / ZIP")) {
|
||||
failures.push("Session Packs page must not imply a real ZIP is downloaded.");
|
||||
}
|
||||
|
||||
if (exportHelper.includes("zipExport: false") || exportHelper.includes("reasonZipIsNotIncluded")) {
|
||||
failures.push("Bundle manifest must no longer document ZIP export as unavailable.");
|
||||
}
|
||||
|
||||
if (!pkg.includes("audit:session-pack-export") || !pkg.includes("audit:all")) {
|
||||
failures.push("audit:all must include session pack export readiness.");
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("ProjectBrain session pack export readiness: FAIL");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("ProjectBrain session pack export readiness: PASS");
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
const requiredFiles = [
|
||||
"src/shared/lib/model.ts",
|
||||
"src/shared/lib/codexSessionPack.ts",
|
||||
"src/shared/lib/safeData.ts",
|
||||
"src/shared/contexts/ProjectContext.tsx",
|
||||
"src/app/routes.tsx",
|
||||
"src/shared/lib/navigation.tsx",
|
||||
"src/shared/components/ProjectFlowRail.tsx",
|
||||
"src/features/session-packs/pages/SessionPacks.tsx",
|
||||
"src/features/handoffs/pages/CodexHandoffs.tsx",
|
||||
"src/features/build-loop/pages/BuildLoopDashboard.tsx",
|
||||
"src/shared/lib/buildLoop.ts",
|
||||
"src/shared/lib/buildLoopWalkthrough.ts",
|
||||
"src/features/execution/pages/ExecutionCenter.tsx",
|
||||
"src/features/prompts/pages/PromptGenerator.tsx",
|
||||
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
|
||||
"src/features/history/pages/ProjectHistory.tsx",
|
||||
"src/features/audit/pages/ProjectAuditCenter.tsx",
|
||||
"src/features/projects/pages/Workspace.tsx",
|
||||
"package.json",
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
|
||||
}
|
||||
|
||||
const read = (file) => readFileSync(file, "utf8");
|
||||
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
|
||||
|
||||
const model = text["src/shared/lib/model.ts"] ?? "";
|
||||
const helper = text["src/shared/lib/codexSessionPack.ts"] ?? "";
|
||||
const safeData = text["src/shared/lib/safeData.ts"] ?? "";
|
||||
const context = text["src/shared/contexts/ProjectContext.tsx"] ?? "";
|
||||
const routes = text["src/app/routes.tsx"] ?? "";
|
||||
const navigation = text["src/shared/lib/navigation.tsx"] ?? "";
|
||||
const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? "";
|
||||
const page = text["src/features/session-packs/pages/SessionPacks.tsx"] ?? "";
|
||||
const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? "";
|
||||
const buildLoopPage = text["src/features/build-loop/pages/BuildLoopDashboard.tsx"] ?? "";
|
||||
const buildLoop = text["src/shared/lib/buildLoop.ts"] ?? "";
|
||||
const walkthrough = text["src/shared/lib/buildLoopWalkthrough.ts"] ?? "";
|
||||
const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? "";
|
||||
const promptBuilder = text["src/features/prompts/pages/PromptGenerator.tsx"] ?? "";
|
||||
const feedback = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? "";
|
||||
const history = text["src/features/history/pages/ProjectHistory.tsx"] ?? "";
|
||||
const audit = text["src/features/audit/pages/ProjectAuditCenter.tsx"] ?? "";
|
||||
const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? "";
|
||||
const pkg = text["package.json"] ?? "";
|
||||
|
||||
const mustContain = [
|
||||
[model, "export interface CodexSessionPack", "CodexSessionPack type missing."],
|
||||
[model, "export interface CodexSessionPackSection", "CodexSessionPackSection type missing."],
|
||||
[model, "export interface CodexSessionPackFileReference", "CodexSessionPackFileReference type missing."],
|
||||
[model, "export interface CodexSessionPackValidationPlan", "CodexSessionPackValidationPlan type missing."],
|
||||
[model, "export interface CodexSessionPackFeedbackTemplate", "CodexSessionPackFeedbackTemplate type missing."],
|
||||
[model, "export type CodexSessionPackExportFormat", "CodexSessionPackExportFormat type missing."],
|
||||
[model, "codexSessionPacks?: CodexSessionPack[]", "Project model must persist session packs."],
|
||||
[safeData, "codexSessionPacks: asArray(project.codexSessionPacks)", "Runtime normalization must preserve session packs."],
|
||||
[helper, "generateCodexSessionPack", "Session pack helper must generate packs."],
|
||||
[helper, "buildSessionPackContext", "Session pack helper must build context."],
|
||||
[helper, "buildSessionPackValidationPlan", "Session pack helper must build validation plan."],
|
||||
[helper, "buildSessionPackFeedbackTemplate", "Session pack helper must build feedback template."],
|
||||
[helper, "formatCodexSessionPackMarkdown", "Session pack helper must format markdown."],
|
||||
[helper, "formatCodexSessionPackJson", "Session pack helper must format JSON."],
|
||||
[helper, "scoreCodexSessionPackQuality", "Session pack helper must score quality."],
|
||||
[helper, "getCodexSessionPackWarnings", "Session pack helper must produce warnings."],
|
||||
[helper, "generated from context pack", "Session pack export must keep context-pack provenance language."],
|
||||
[context, "generateCodexSessionPack", "ProjectContext must expose session pack generation."],
|
||||
[context, "archiveCodexSessionPack", "ProjectContext must expose session pack archival."],
|
||||
[context, "sessionPackItems", "Project search must index session packs."],
|
||||
[routes, "/projects/:projectId/session-packs", "Session Packs route missing."],
|
||||
[routes, "SessionPacks", "Session Packs page must be imported in routes."],
|
||||
[navigation, "Session Packs", "Navigation must expose Session Packs."],
|
||||
[flowRail, 'id: "session-packs"', "Project Flow Rail must include Session Packs."],
|
||||
[page, "Session Packs", "Session Packs page must exist."],
|
||||
[page, "Copy markdown", "Session Packs page must copy markdown."],
|
||||
[page, "Download markdown", "Session Packs page must download markdown."],
|
||||
[page, "Copy JSON", "Session Packs page must copy JSON."],
|
||||
[page, "Download JSON", "Session Packs page must download JSON."],
|
||||
[page, "Download instructions/scripts ZIP", "Session Packs page must expose clear instructions/scripts ZIP export."],
|
||||
[page, "Export session pack files", "Session Packs page must expose bundle file export."],
|
||||
[page, "Bundle contents", "Session Packs page must show bundle contents."],
|
||||
[page, "Validation plan", "Session Packs page must show validation plan."],
|
||||
[page, "Feedback template", "Session Packs page must show feedback template."],
|
||||
[handoffs, "Create session pack", "Codex Handoffs must create session packs."],
|
||||
[handoffs, "Open session pack", "Codex Handoffs must open session packs."],
|
||||
[handoffs, "Copy session pack", "Codex Handoffs must copy session packs."],
|
||||
[handoffs, "Download session pack", "Codex Handoffs must download session packs."],
|
||||
[handoffs, "Download ZIP", "Codex Handoffs must download session pack ZIP."],
|
||||
[handoffs, "Export session pack files", "Codex Handoffs must export session pack files."],
|
||||
[buildLoop, "Create Codex session pack", "Build Loop must recommend creating session packs."],
|
||||
[buildLoop, "Run session and add feedback", "Build Loop must recommend feedback after session pack."],
|
||||
[buildLoop, "latestSessionPack", "Build Loop status must expose latest session pack."],
|
||||
[buildLoopPage, "Session pack", "Build Loop page must show session pack status."],
|
||||
[walkthrough, "create-session-pack", "Build Loop walkthrough must include create-session-pack step."],
|
||||
[walkthrough, "session-packs", "Build Loop walkthrough must explain Session Packs module."],
|
||||
[execution, "Codex Session Pack", "Execution Center must show recommended session pack."],
|
||||
[execution, "Run in Codex", "Execution Center must expose session packs as runnable Codex input."],
|
||||
[promptBuilder, "Codex Session Pack source", "Prompt Builder must expose session pack source."],
|
||||
[promptBuilder, "Use session pack prompt", "Prompt Builder must load session pack markdown."],
|
||||
[feedback, "Linked session pack", "Execution Feedback must link session packs."],
|
||||
[feedback, "Session pack feedback boundary", "Execution Feedback must show pack feedback template."],
|
||||
[feedback, "Paste Codex report generated from this session pack", "Execution Feedback textarea must adapt to linked pack."],
|
||||
[history, 'kind: "sessionPack"', "Project History must include session pack records."],
|
||||
[audit, "Codex session pack readiness", "Audit Center must show session pack coverage."],
|
||||
[audit, "Approved handoffs without pack", "Audit Center must count handoffs without session pack."],
|
||||
[workspace, "Session Packs", "Workspace must expose Session Packs."],
|
||||
[workspace, "activeSessionPacks", "Workspace must inspect active session packs."],
|
||||
[pkg, "audit:session-packs", "package.json must expose audit:session-packs."],
|
||||
[pkg, "check-session-pack-readiness.mjs", "package.json must wire session pack readiness script."],
|
||||
];
|
||||
|
||||
for (const [haystack, needle, message] of mustContain) {
|
||||
if (!haystack.includes(needle)) failures.push(message);
|
||||
}
|
||||
|
||||
if (page.includes("Download Bundle / ZIP")) {
|
||||
failures.push("Session Packs page must use no-ZIP wording for bundle file export.");
|
||||
}
|
||||
|
||||
if (!pkg.includes("audit:session-packs") || !pkg.includes("audit:all")) {
|
||||
failures.push("audit:all must include the session pack readiness check.");
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("ProjectBrain session pack readiness: FAIL");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("ProjectBrain session pack readiness: PASS");
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
const requiredFiles = [
|
||||
"src/shared/lib/model.ts",
|
||||
"src/shared/lib/codexSkills.ts",
|
||||
"src/shared/lib/repositoryIntake.ts",
|
||||
"src/shared/lib/codexHandoff.ts",
|
||||
"src/shared/lib/projectWizard.ts",
|
||||
"src/shared/lib/safeData.ts",
|
||||
"src/shared/contexts/ProjectContext.tsx",
|
||||
"src/features/skills/pages/SkillLibrary.tsx",
|
||||
"src/features/repo-builder/pages/RepoBuilder.tsx",
|
||||
"src/features/execution/pages/ExecutionCenter.tsx",
|
||||
"src/features/prompts/pages/PromptGenerator.tsx",
|
||||
"src/features/history/pages/ProjectHistory.tsx",
|
||||
"src/features/projects/pages/Workspace.tsx",
|
||||
"src/shared/components/ProjectFlowRail.tsx",
|
||||
"src/app/routes.tsx",
|
||||
"src/shared/lib/navigation.tsx",
|
||||
"package.json",
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
|
||||
}
|
||||
|
||||
const read = (file) => readFileSync(file, "utf8");
|
||||
const fileText = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
|
||||
const catalog = fileText["src/shared/lib/codexSkills.ts"] ?? "";
|
||||
const repositoryIntake = fileText["src/shared/lib/repositoryIntake.ts"] ?? "";
|
||||
const codexHandoff = fileText["src/shared/lib/codexHandoff.ts"] ?? "";
|
||||
const model = fileText["src/shared/lib/model.ts"] ?? "";
|
||||
const routes = fileText["src/app/routes.tsx"] ?? "";
|
||||
const navigation = fileText["src/shared/lib/navigation.tsx"] ?? "";
|
||||
const context = fileText["src/shared/contexts/ProjectContext.tsx"] ?? "";
|
||||
const skillPage = fileText["src/features/skills/pages/SkillLibrary.tsx"] ?? "";
|
||||
const repoBuilder = fileText["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? "";
|
||||
const execution = fileText["src/features/execution/pages/ExecutionCenter.tsx"] ?? "";
|
||||
const promptBuilder = fileText["src/features/prompts/pages/PromptGenerator.tsx"] ?? "";
|
||||
const history = fileText["src/features/history/pages/ProjectHistory.tsx"] ?? "";
|
||||
const projectWizard = fileText["src/shared/lib/projectWizard.ts"] ?? "";
|
||||
const safeData = fileText["src/shared/lib/safeData.ts"] ?? "";
|
||||
const flowRail = fileText["src/shared/components/ProjectFlowRail.tsx"] ?? "";
|
||||
const workspace = fileText["src/features/projects/pages/Workspace.tsx"] ?? "";
|
||||
const pkg = fileText["package.json"] ?? "";
|
||||
|
||||
const mustContain = [
|
||||
[model, "export interface CodexSkill", "CodexSkill model missing."],
|
||||
[model, "ProjectSkillSelection", "Project skill selection model missing."],
|
||||
[model, "codexSkills?: ProjectSkillSelection[]", "Project model must persist skill selections."],
|
||||
[catalog, "getBuiltInCodexSkills", "Built-in skill catalog helper missing."],
|
||||
[catalog, "recommendSkillsForProject", "Project skill recommendation helper missing."],
|
||||
[catalog, "formatSkillsForCodexHandoff", "Codex handoff formatter missing."],
|
||||
[safeData, "codexSkills: asArray(project.codexSkills)", "Runtime normalization must preserve project skills."],
|
||||
[projectWizard, "Recommended Codex skills", "Project creation output must include recommended Codex skills."],
|
||||
[context, "setProjectSkillEnabled", "ProjectContext must allow enabling/disabling project skills."],
|
||||
[context, "applyRecommendedProjectSkills", "ProjectContext must apply recommended skills."],
|
||||
[context, "codexSkillItems", "Global search must include Codex skills."],
|
||||
[routes, "/projects/:projectId/skills", "Skill Library route missing."],
|
||||
[routes, "SkillLibrary", "Skill Library page missing from routes."],
|
||||
[navigation, "Codex Skills", "Navigation must expose Codex Skills."],
|
||||
[flowRail, 'id: "skills"', "Project flow rail must include the Skills step."],
|
||||
[workspace, "Codex Skills", "Workspace must expose Codex Skills."],
|
||||
[skillPage, "Built-in skills", "Skill Library must show built-in skills."],
|
||||
[skillPage, "Recommended", "Skill Library must show recommended skills."],
|
||||
[skillPage, "Active project skills", "Skill Library must show active project skills."],
|
||||
[skillPage, "requiredContext", "Skill detail must expose required context."],
|
||||
[skillPage, "validationCommands", "Skill detail must expose validation commands."],
|
||||
[skillPage, "promptInstructions", "Skill detail must expose prompt instructions."],
|
||||
[repoBuilder, "Skill set", "Repo Builder must expose a Skill set panel."],
|
||||
[repoBuilder, "formatSkillsForCodexHandoff", "Repo Builder handoff/export must include skill blocks."],
|
||||
[repoBuilder, "skillIds", "Repo Builder artifacts must persist skill links."],
|
||||
[repositoryIntake, "mapRepositoryEntriesToSkills", "Repository intake must map imported repo evidence to Codex skills."],
|
||||
[repositoryIntake, "linkedSkillIds", "Repository intakes must retain linked skill ids."],
|
||||
[repoBuilder, "Matched skills", "Repository intake detail must show matched skills."],
|
||||
[codexHandoff, "promptInstructions", "Codex handoffs must include skill prompt instructions."],
|
||||
[codexHandoff, "qualityChecklist", "Codex handoffs must include skill quality checklist."],
|
||||
[codexHandoff, "riskNotes", "Codex handoffs must include skill risk notes."],
|
||||
[codexHandoff, "typicalFiles", "Codex handoffs must include skill typical files."],
|
||||
[execution, "Active Codex Skills", "Execution Center must show active skills."],
|
||||
[execution, "formatSkillsForCodexHandoff", "Execution brief must include skill blocks."],
|
||||
[promptBuilder, "Active Codex Skills", "Prompt Builder must show active skills."],
|
||||
[promptBuilder, "withSkillBlock", "Prompt Builder must append skill blocks to generated prompts."],
|
||||
[history, 'kind: "skill"', "History must include skill records."],
|
||||
[pkg, "audit:skills", "package.json must expose audit:skills."],
|
||||
[pkg, "check-skill-library-readiness.mjs", "package.json must wire the skill readiness script."],
|
||||
];
|
||||
|
||||
for (const [haystack, needle, message] of mustContain) {
|
||||
if (!haystack.includes(needle)) failures.push(message);
|
||||
}
|
||||
|
||||
const requiredSkillIds = [
|
||||
"repo-foundation-build",
|
||||
"frontend-react-vite-build",
|
||||
"backend-api-build",
|
||||
"database-migration",
|
||||
"docker-runtime-hardening",
|
||||
"qa-qc-review",
|
||||
"documentation-system-build",
|
||||
"repo-expansion-planning",
|
||||
"codex-handoff-packaging",
|
||||
"ui-ux-polish-pass",
|
||||
"geoai-backend-build",
|
||||
"postgis-migration",
|
||||
"raster-pipeline",
|
||||
"frontend-maplibre",
|
||||
"geospatial-qa-qc-review",
|
||||
"ml-pipeline-prototyping",
|
||||
"etl-data-ingestion",
|
||||
"auth-security-hardening",
|
||||
];
|
||||
|
||||
for (const skillId of requiredSkillIds) {
|
||||
if (!catalog.includes(`id: "${skillId}"`)) failures.push(`Built-in skill missing: ${skillId}`);
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("ProjectBrain skill library readiness: FAIL");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("ProjectBrain skill library readiness: PASS");
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
|
||||
const exists = (file) => fs.existsSync(path.join(root, file));
|
||||
const failures = [];
|
||||
const requireFile = (file, reason) => { if (!exists(file)) failures.push(`${file}: ${reason}`); };
|
||||
const requireIncludes = (file, needle, reason) => {
|
||||
if (!exists(file) || !read(file).includes(needle)) failures.push(`${file}: ${reason}`);
|
||||
};
|
||||
|
||||
requireFile("src/features/source-library/pages/SourceLibrary.tsx", "Source Library page must exist.");
|
||||
requireFile("src/shared/lib/projectSourceCommands.ts", "Source intake commands must exist.");
|
||||
requireFile("src/shared/lib/projectOperatingStep.ts", "Shared project operating-step helper must exist.");
|
||||
requireIncludes("src/app/routes.tsx", 'path="/projects/:projectId/source-library"', "Source Library route must be registered.");
|
||||
requireIncludes("src/shared/lib/navigation.tsx", "Source Library", "Navigation should expose Source Library.");
|
||||
requireIncludes("src/shared/lib/model.ts", "ProjectSourceItem", "Project model must preserve original source material.");
|
||||
requireIncludes("src/shared/lib/model.ts", "ProjectSourceAsset", "Project model must preserve extracted media/document assets.");
|
||||
requireIncludes("src/shared/lib/safeData.ts", "sourceLibrary: asArray", "Runtime normalization should keep sourceLibrary arrays safe.");
|
||||
requireIncludes("src/shared/lib/safeData.ts", "mediaAssets: asArray", "Runtime normalization should keep mediaAssets arrays safe.");
|
||||
requireIncludes("src/shared/contexts/ProjectContext.tsx", "addProjectSource", "ProjectContext should create source-library items.");
|
||||
requireIncludes("src/shared/contexts/ProjectContext.tsx", "promoteSourceToReview", "ProjectContext should promote sources to Review Queue.");
|
||||
requireIncludes("src/shared/lib/projectSourceCommands.ts", "const inferAssets", "Source intake should extract media/document asset candidates.");
|
||||
requireIncludes("src/shared/lib/projectSourceCommands.ts", "mediaAssets: [...assets", "Extracted source assets should persist with project media.");
|
||||
requireIncludes("src/features/operations/pages/CaptureCenter.tsx", "Source Library", "Capture Center should link to Source Library.");
|
||||
requireIncludes("src/features/operations/pages/DocumentationHub.tsx", "Source-backed documentation intake", "Documentation Hub should make the source/docs link visible.");
|
||||
requireIncludes("src/shared/lib/projectOperatingStep.ts", "Nieuwe opdracht geven", "The shared next-step layer should allow direct assignments without mandatory extra sources.");
|
||||
requireIncludes("src/shared/components/ProjectFlowRail.tsx", "source-library", "Project flow rail should include the source-library step.");
|
||||
|
||||
if (failures.length) {
|
||||
console.error("ProjectBrain source library readiness: FAIL");
|
||||
failures.forEach((failure) => console.error(`- ${failure}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("ProjectBrain source library readiness: PASS");
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const manifest = JSON.parse(await readFile(resolve(root, "package.json"), "utf8"));
|
||||
const command = manifest.scripts?.typecheck;
|
||||
const required = ["tsconfig.app.json", "tsconfig.node.json"];
|
||||
const missing = required.filter((config) => !command?.includes(`-p ${config}`));
|
||||
|
||||
if (missing.length) {
|
||||
console.error(`TypeScript gate does not compile: ${missing.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const config of required) {
|
||||
const content = await readFile(resolve(root, config), "utf8");
|
||||
if (!/"noEmit"\s*:\s*true/.test(content) && !command.includes("--noEmit")) {
|
||||
console.error(`TypeScript gate may emit files for ${config}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("TypeScript gate covers application and Node configurations.");
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
const requiredFiles = [
|
||||
"unraid/install.sh",
|
||||
"unraid/projectbrain.xml",
|
||||
"unraid/projectbrain-icon.png",
|
||||
"unraid/README.md",
|
||||
"docker-compose.yml",
|
||||
"Dockerfile.unraid",
|
||||
"docker/nginx.single.conf",
|
||||
"scripts/deploy-unraid.sh",
|
||||
"scripts/deploy-unraid-single.sh",
|
||||
"scripts/unraid-single-entrypoint.sh",
|
||||
"docker/unraid/README.md",
|
||||
"docker/unraid/projectbrain.env.example",
|
||||
"docker/unraid/projectbrain-container.env.example",
|
||||
"docker/unraid/projectbrain-compose-template.xml",
|
||||
"docker/unraid/projectbrain-container-template.xml",
|
||||
"docker/unraid/projectbrain-icon.png",
|
||||
"docs/DOCKER_UNRAID_SETUP.md",
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
const read = (file) => readFileSync(file, "utf8");
|
||||
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
|
||||
}
|
||||
|
||||
const compose = existsSync("docker-compose.yml") ? read("docker-compose.yml") : "";
|
||||
const env = existsSync("docker/unraid/projectbrain.env.example") ? read("docker/unraid/projectbrain.env.example") : "";
|
||||
const containerEnv = existsSync("docker/unraid/projectbrain-container.env.example") ? read("docker/unraid/projectbrain-container.env.example") : "";
|
||||
const xml = existsSync("docker/unraid/projectbrain-compose-template.xml") ? read("docker/unraid/projectbrain-compose-template.xml") : "";
|
||||
const containerXml = existsSync("docker/unraid/projectbrain-container-template.xml") ? read("docker/unraid/projectbrain-container-template.xml") : "";
|
||||
const icon = existsSync("docker/unraid/projectbrain-icon.png") ? "png-icon" : "";
|
||||
const readme = existsSync("docker/unraid/README.md") ? read("docker/unraid/README.md") : "";
|
||||
const docs = existsSync("docs/DOCKER_UNRAID_SETUP.md") ? read("docs/DOCKER_UNRAID_SETUP.md") : "";
|
||||
const deploy = existsSync("scripts/deploy-unraid.sh") ? read("scripts/deploy-unraid.sh") : "";
|
||||
const singleDockerfile = existsSync("Dockerfile.unraid") ? read("Dockerfile.unraid") : "";
|
||||
const singleNginx = existsSync("docker/nginx.single.conf") ? read("docker/nginx.single.conf") : "";
|
||||
const singleEntry = existsSync("scripts/unraid-single-entrypoint.sh") ? read("scripts/unraid-single-entrypoint.sh") : "";
|
||||
const singleDeploy = existsSync("scripts/deploy-unraid-single.sh") ? read("scripts/deploy-unraid-single.sh") : "";
|
||||
const unraidInstall = existsSync("unraid/install.sh") ? read("unraid/install.sh") : "";
|
||||
const unraidTemplate = existsSync("unraid/projectbrain.xml") ? read("unraid/projectbrain.xml") : "";
|
||||
const unraidIcon = existsSync("unraid/projectbrain-icon.png") ? "png-icon" : "";
|
||||
const unraidReadme = existsSync("unraid/README.md") ? read("unraid/README.md") : "";
|
||||
|
||||
const mustContain = [
|
||||
[compose, "${PROJECTBRAIN_WEB_PORT:-1209}:80", "docker-compose.yml must keep the frontend host port configurable."],
|
||||
[compose, "${POSTGRES_PASSWORD:?", "Production compose must require a PostgreSQL password."],
|
||||
[compose, "${PROJECTBRAIN_OWNER_PASSWORD:?", "Production compose must require the owner password."],
|
||||
[compose, "${PROJECTBRAIN_AUTH_SECRET:?", "Production compose must require the authentication secret."],
|
||||
[unraidInstall, "templates-user", "Root Unraid installer must write a DockerMan template."],
|
||||
[unraidInstall, "dockerMan/images", "Root Unraid installer must write a DockerMan icon."],
|
||||
[unraidInstall, "git clone", "Root Unraid installer must support installing from Gitea."],
|
||||
[unraidInstall, "deploy-unraid-single.sh", "Root Unraid installer must deploy the single-container app."],
|
||||
[unraidTemplate, "<Name>projectbrain</Name>", "Root Unraid template name must match the running container name so DockerMan can show Edit."],
|
||||
[unraidTemplate, "<Repository>projectbrain-unraid:latest</Repository>", "Root Unraid template must reference the built ProjectBrain image."],
|
||||
[unraidTemplate, "Target=\"80\"", "Root Unraid template must expose container port 80."],
|
||||
[unraidTemplate, "projectbrain-icon.png", "Root Unraid template must reference the PNG icon."],
|
||||
[unraidTemplate, "<ExtraParams>--label net.unraid.docker.managed=dockerman", "Root Unraid template must create DockerMan-managed containers."],
|
||||
[unraidTemplate, "Target=\"PROJECTBRAIN_OWNER_PASSWORD\" Default=\"\"", "Root Unraid template must require an explicit owner password."],
|
||||
[unraidTemplate, "Target=\"PROJECTBRAIN_AUTH_SECRET\" Default=\"\"", "Root Unraid template must require an explicit authentication secret."],
|
||||
[unraidTemplate, "Target=\"POSTGRES_PASSWORD\" Default=\"\"", "Root Unraid template must not ship an embedded PostgreSQL password."],
|
||||
[unraidIcon, "png-icon", "Root Unraid PNG icon must exist."],
|
||||
[unraidReadme, "Docker > Add Container > Template: ProjectBrain", "Root Unraid README must explain where the template appears."],
|
||||
[env, "PROJECTBRAIN_WEB_PORT=1209", "Unraid env template must expose PROJECTBRAIN_WEB_PORT."],
|
||||
[env, "VITE_PROJECTBRAIN_CLIENT_MODE=real", "Unraid env template must default the frontend to real mode."],
|
||||
[env, "PROJECTBRAIN_DATA_SOURCE=database", "Unraid env template must default backend data source to database."],
|
||||
[containerEnv, "PROJECTBRAIN_IMAGE=projectbrain-unraid:latest", "Single-container env template must expose the local image name."],
|
||||
[containerEnv, "PROJECTBRAIN_DOCKER_NETWORK=auto", "Single-container env template must default Docker network detection to auto."],
|
||||
[containerEnv, "PROJECTBRAIN_STOP_COMPOSE_APP_SERVICES=true", "Single-container env template must stop old compose app containers by default."],
|
||||
[containerEnv, "PROJECTBRAIN_USE_EXTERNAL_DATABASE=false", "Single-container env template must default to embedded Postgres."],
|
||||
[containerEnv, "PROJECTBRAIN_DEPLOY_NO_CACHE=false", "Single-container env template must default to cached deploys."],
|
||||
[containerEnv, "PROJECTBRAIN_MIN_DOCKER_FREE_MB=2048", "Single-container env template must expose Docker storage threshold."],
|
||||
[containerEnv, "PROJECTBRAIN_REQUIRE_FRESH_BACKUP=false", "Single-container env template must expose backup freshness gate."],
|
||||
[containerEnv, "POSTGRES_PASSWORD=", "Single-container env template must expose the embedded Postgres secret input."],
|
||||
[xml, "<Name>ProjectBrain</Name>", "Unraid XML template must name ProjectBrain."],
|
||||
[xml, "PROJECTBRAIN_WEB_PORT", "Unraid XML template must expose the web port variable."],
|
||||
[xml, "<WebUI>http://[IP]:[PORT:1209]</WebUI>", "Unraid XML template must define the WebUI URL."],
|
||||
[xml, "projectbrain-icon.png", "Unraid XML template must reference the PNG icon asset."],
|
||||
[xml, "POSTGRES_PASSWORD", "Unraid XML template must expose Postgres password."],
|
||||
[containerXml, "<Name>projectbrain</Name>", "Single-container XML template name must match the running container name."],
|
||||
[containerXml, "<Repository>projectbrain-unraid:latest</Repository>", "Single-container XML template must point at the local ProjectBrain image."],
|
||||
[containerXml, "<ExtraParams>--label net.unraid.docker.managed=dockerman", "Single-container XML template must create DockerMan-managed containers."],
|
||||
[containerXml, "Target=\"80\"", "Single-container XML template must expose container port 80."],
|
||||
[containerXml, "Target=\"/var/lib/projectbrain\"", "Single-container XML template must map persistent appdata."],
|
||||
[containerXml, "Target=\"PROJECTBRAIN_OWNER_PASSWORD\" Default=\"\"", "Single-container XML template must require an explicit owner password."],
|
||||
[containerXml, "Target=\"PROJECTBRAIN_AUTH_SECRET\" Default=\"\"", "Single-container XML template must require an explicit authentication secret."],
|
||||
[containerXml, "Target=\"POSTGRES_PASSWORD\" Default=\"\"", "Single-container XML template must not ship an embedded PostgreSQL password."],
|
||||
[containerXml, "projectbrain-icon.png", "Single-container XML template must reference the PNG icon asset."],
|
||||
[icon, "png-icon", "Unraid PNG icon must exist."],
|
||||
[singleDockerfile, "nginx.single.conf", "Single-container Dockerfile must use the single-container nginx config."],
|
||||
[singleDockerfile, "unraid-single-entrypoint.sh", "Single-container Dockerfile must use the single-container entrypoint."],
|
||||
[singleDockerfile, "HEALTHCHECK", "Single-container Dockerfile must define a healthcheck."],
|
||||
[singleNginx, "127.0.0.1:4000", "Single-container nginx config must proxy API calls to the internal backend."],
|
||||
[singleEntry, "DATABASE_URL is required", "Single-container entrypoint must fail clearly without DATABASE_URL."],
|
||||
[singleEntry, "POSTGRES_PASSWORD is required for embedded PostgreSQL", "Single-container entrypoint must fail closed without an embedded PostgreSQL password."],
|
||||
[singleEntry, "PROJECTBRAIN_USE_EXTERNAL_DATABASE", "Single-container entrypoint must default to embedded Postgres unless external DB is requested."],
|
||||
[singleEntry, "initdb", "Single-container entrypoint must initialize embedded Postgres."],
|
||||
[singleEntry, "nginx -g", "Single-container entrypoint must start nginx."],
|
||||
[readme, "Change The External Port", "Unraid README must explain port changes."],
|
||||
[readme, "Single Container", "Unraid README must document the single-container template."],
|
||||
[readme, "scripts/deploy-unraid.sh", "Unraid README must reference the deploy helper."],
|
||||
[readme, "scripts/deploy-unraid-single.sh", "Unraid README must reference the single-container deploy helper."],
|
||||
[readme, "docker compose up -d --build", "Unraid README must include restart command."],
|
||||
[docs, "projectbrain.env.example", "Docker/Unraid docs must reference the Unraid env template."],
|
||||
[docs, "projectbrain-container-template.xml", "Docker/Unraid docs must reference the single-container XML template."],
|
||||
[docs, "scripts/deploy-unraid.sh", "Docker/Unraid docs must reference the deploy helper."],
|
||||
[docs, "scripts/deploy-unraid-single.sh", "Docker/Unraid docs must reference the single-container deploy helper."],
|
||||
[deploy, "git pull --ff-only", "Unraid deploy helper must pull the latest repository state."],
|
||||
[deploy, "projectbrain.env.example", "Unraid deploy helper must apply the env template."],
|
||||
[deploy, "docker compose build --no-cache", "Unraid deploy helper must rebuild the stack."],
|
||||
[deploy, "api/system/readiness", "Unraid deploy helper must check readiness."],
|
||||
[singleDeploy, "Dockerfile.unraid", "Single-container deploy helper must build Dockerfile.unraid."],
|
||||
[singleDeploy, "PROJECTBRAIN_DEPLOY_NO_CACHE", "Single-container deploy helper must make no-cache configurable."],
|
||||
[singleDeploy, "docker build -f Dockerfile.unraid", "Single-container deploy helper must allow cached builds by default."],
|
||||
[singleDeploy, "Docker storage preflight", "Single-container deploy helper must show Docker storage preflight."],
|
||||
[singleDeploy, "docker system df", "Single-container deploy helper must print Docker storage usage."],
|
||||
[singleDeploy, "PROJECTBRAIN_MIN_DOCKER_FREE_MB", "Single-container deploy helper must expose Docker free-space threshold."],
|
||||
[singleDeploy, "backup freshness preflight", "Single-container deploy helper must check backup freshness."],
|
||||
[singleDeploy, "PROJECTBRAIN_REQUIRE_FRESH_BACKUP", "Single-container deploy helper must expose strict backup gate."],
|
||||
[singleDeploy, "git rev-parse --short HEAD", "Single-container deploy helper must report the current git commit."],
|
||||
[singleDeploy, "started ${CONTAINER_NAME}", "Single-container deploy helper must report container replacement id."],
|
||||
[singleDeploy, "projectbrain-postgres", "Single-container deploy helper must auto-detect the Postgres container network."],
|
||||
[singleDeploy, "projectbrain-frontend projectbrain-api", "Single-container deploy helper must remove old compose app containers for live cutover."],
|
||||
[singleDeploy, "projectbrain-postgres", "Single-container deploy helper must remove the old standalone ProjectBrain Postgres container."],
|
||||
[singleDeploy, "projectbrain-icon.png", "Single-container deploy helper must set the Unraid PNG icon label."],
|
||||
[singleDeploy, "docker run -d", "Single-container deploy helper must run one app container."],
|
||||
[singleDeploy, "projectbrain-container.env.example", "Single-container deploy helper must apply the container env template."],
|
||||
[singleDeploy, "api/system/readiness", "Single-container deploy helper must check readiness."],
|
||||
];
|
||||
|
||||
for (const [haystack, needle, message] of mustContain) {
|
||||
if (!haystack.includes(needle)) failures.push(message);
|
||||
}
|
||||
|
||||
for (const secretName of ["POSTGRES_PASSWORD", "PROJECTBRAIN_OWNER_PASSWORD", "PROJECTBRAIN_AUTH_SECRET"]) {
|
||||
if (new RegExp(`^${secretName}=.+$`, "m").test(containerEnv)) failures.push(`Single-container env template must not ship a default ${secretName}.`);
|
||||
}
|
||||
|
||||
for (const [templateName, template] of [["root", unraidTemplate], ["single-container", containerXml]]) {
|
||||
if (/Target="POSTGRES_PASSWORD"\s+Default="projectbrain"/i.test(template)) failures.push(`${templateName} Unraid template must not ship the historical PostgreSQL password.`);
|
||||
if (/Target="PROJECTBRAIN_OWNER_PASSWORD"\s+Default="[^"]+"/i.test(template)) failures.push(`${templateName} Unraid template must not ship an owner password.`);
|
||||
if (/Target="PROJECTBRAIN_AUTH_SECRET"\s+Default="[^"]+"/i.test(template)) failures.push(`${templateName} Unraid template must not ship an authentication secret.`);
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("ProjectBrain Unraid template readiness: FAIL");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("ProjectBrain Unraid template readiness: PASS");
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const manifestPath = resolve(root, "config/v1-contract-freeze.json");
|
||||
const files = ["backend/prisma/schema.prisma", "packages/contracts/index.js", "packages/contracts/index.d.ts", "docs/openapi/projectbrain-v1.json"];
|
||||
const migrations = (await readdir(resolve(root, "backend/prisma/migrations"), { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
||||
const hashes = Object.fromEntries(await Promise.all(files.map(async (name) => [name, createHash("sha256").update(await readFile(resolve(root, name))).digest("hex")])));
|
||||
const current = { version: "1.0.0", migrations, hashes };
|
||||
if (process.argv.includes("--write")) {
|
||||
await writeFile(manifestPath, `${JSON.stringify(current, null, 2)}\n`);
|
||||
console.log(`Wrote v1 contract freeze manifest with ${migrations.length} migrations.`);
|
||||
} else {
|
||||
const frozen = JSON.parse(await readFile(manifestPath, "utf8"));
|
||||
if (JSON.stringify(frozen) !== JSON.stringify(current)) {
|
||||
console.error("The frozen v1 schema/API surface changed. Review compatibility and run pnpm freeze:v1 only for an approved release decision.");
|
||||
console.error("Current canonical v1 snapshot:");
|
||||
console.error(JSON.stringify(current, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`v1 contract freeze verified: ${files.length} canonical surfaces and ${migrations.length} migrations.`);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
|
||||
const requiredFiles = [
|
||||
"src/app/routes.tsx",
|
||||
"src/shared/lib/navigation.tsx",
|
||||
"src/shared/components/ProjectFlowRail.tsx",
|
||||
"src/shared/components/BuildLoopActionRail.tsx",
|
||||
"src/shared/components/ProjectRouteFallback.tsx",
|
||||
"src/shared/lib/projectOperatingStep.ts",
|
||||
"src/shared/lib/projectStartFlow.ts",
|
||||
"src/shared/lib/routeRegistry.ts",
|
||||
"src/features/projects/pages/Projects.tsx",
|
||||
"src/features/projects/pages/Workspace.tsx",
|
||||
"src/features/build-loop/pages/BuildLoopDashboard.tsx",
|
||||
"src/features/repo-builder/pages/RepoBuilder.tsx",
|
||||
"src/features/handoffs/pages/CodexHandoffs.tsx",
|
||||
"src/features/session-packs/pages/SessionPacks.tsx",
|
||||
"src/features/execution/pages/ExecutionCenter.tsx",
|
||||
"src/features/execution-feedback/pages/ExecutionFeedback.tsx",
|
||||
"src/features/operations/pages/ReviewQueue.tsx",
|
||||
"package.json",
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file)) failures.push(`Missing required file: ${file}`);
|
||||
}
|
||||
|
||||
const read = (file) => readFileSync(file, "utf8");
|
||||
const text = Object.fromEntries(requiredFiles.filter(existsSync).map((file) => [file, read(file)]));
|
||||
const listSourceFiles = (dir) => {
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir).flatMap((entry) => {
|
||||
const path = `${dir}/${entry}`;
|
||||
const stat = statSync(path);
|
||||
if (stat.isDirectory()) return listSourceFiles(path);
|
||||
return /\.(ts|tsx|js|jsx)$/.test(path) ? [path] : [];
|
||||
});
|
||||
};
|
||||
const sourceText = Object.fromEntries(listSourceFiles("src").map((file) => [file, read(file)]));
|
||||
|
||||
const routes = text["src/app/routes.tsx"] ?? "";
|
||||
const navigation = text["src/shared/lib/navigation.tsx"] ?? "";
|
||||
const flowRail = text["src/shared/components/ProjectFlowRail.tsx"] ?? "";
|
||||
const buildLoopActionRail = text["src/shared/components/BuildLoopActionRail.tsx"] ?? "";
|
||||
const projectRouteFallback = text["src/shared/components/ProjectRouteFallback.tsx"] ?? "";
|
||||
const operatingStep = text["src/shared/lib/projectOperatingStep.ts"] ?? "";
|
||||
const startFlow = text["src/shared/lib/projectStartFlow.ts"] ?? "";
|
||||
const routeRegistry = text["src/shared/lib/routeRegistry.ts"] ?? "";
|
||||
const projects = text["src/features/projects/pages/Projects.tsx"] ?? "";
|
||||
const workspace = text["src/features/projects/pages/Workspace.tsx"] ?? "";
|
||||
const buildLoop = text["src/features/build-loop/pages/BuildLoopDashboard.tsx"] ?? "";
|
||||
const repoBuilder = text["src/features/repo-builder/pages/RepoBuilder.tsx"] ?? "";
|
||||
const handoffs = text["src/features/handoffs/pages/CodexHandoffs.tsx"] ?? "";
|
||||
const sessionPacks = text["src/features/session-packs/pages/SessionPacks.tsx"] ?? "";
|
||||
const execution = text["src/features/execution/pages/ExecutionCenter.tsx"] ?? "";
|
||||
const feedback = text["src/features/execution-feedback/pages/ExecutionFeedback.tsx"] ?? "";
|
||||
const reviewQueue = text["src/features/operations/pages/ReviewQueue.tsx"] ?? "";
|
||||
const pkg = text["package.json"] ?? "";
|
||||
|
||||
const mustContain = [
|
||||
[routes, 'projectRoutePattern("workspace")', "Workspace route missing."],
|
||||
[routes, "/projects/:projectId/build-loop", "Build Loop route missing."],
|
||||
[routes, "/projects/:projectId/source-library", "Source Library route missing."],
|
||||
[routes, "/projects/:projectId/review-queue", "Review Queue route missing."],
|
||||
[routes, "/projects/:projectId/skills", "Skill Library route missing."],
|
||||
[routes, "/projects/:projectId/repo-builder", "Repo Builder route missing."],
|
||||
[routes, "/projects/:projectId/handoffs", "Codex Handoffs route missing."],
|
||||
[routes, "/projects/:projectId/session-packs", "Session Packs route missing."],
|
||||
[routes, "/projects/:projectId/execution", "Execution Center route missing."],
|
||||
[routes, "/projects/:projectId/execution-feedback", "Execution Feedback route missing."],
|
||||
[routes, "/projects/:projectId/history", "History route missing."],
|
||||
[routes, "/projects/:projectId/audit", "Audit Center route missing."],
|
||||
[routes, "/projects/:projectId/prompts", "Prompt Builder route missing."],
|
||||
[navigation, "getPrimaryPathNavigationItems", "Primary navigation must expose the workspace that owns the project flow."],
|
||||
[routeRegistry, 'key: "workspace"', "Canonical route registry must expose the workspace that owns the project flow."],
|
||||
[flowRail, 'id: "build-loop"', "Project Flow Rail must expose Build Loop."],
|
||||
[buildLoopActionRail, "Build-loop closure path", "Build Loop action rail must name the handoff-to-current-state path."],
|
||||
[buildLoopActionRail, "execution output is evidence only", "Build Loop action rail must preserve review-before-truth language."],
|
||||
[buildLoopActionRail, "/runtime", "Build Loop action rail must link Current State/Runtime endpoint."],
|
||||
[projectRouteFallback, "Projecten zoeken", "Project route fallback must guide users back to projects."],
|
||||
[projectRouteFallback, "Nieuw project starten", "Project route fallback must guide users back to the unified start flow."],
|
||||
[routes, "routeProjectExists", "Route sync must avoid activating unknown project slugs."],
|
||||
[routes, "ProjectRouteSync", "The app router must synchronize valid project routes."],
|
||||
[navigation, "getProjectIdFromPath", "Project route parsing must be shared with navigation."],
|
||||
[projects, "getProjectOperatingStep", "Projects cards must derive the shared next safe action."],
|
||||
[projects, "operatingState.label", "Projects must make the recommended continuation label explicit."],
|
||||
[projects, "operatingState.description", "Projects must make the recommended continuation explanation explicit."],
|
||||
[buildLoop, "getBuildLoopStatus", "Technical Build Loop must derive its state from the shared helper."],
|
||||
[buildLoop, "getProjectReleaseReadiness", "Technical Build Loop must retain release gates."],
|
||||
[startFlow, "Repository structureren", "Project start flow must expose repository shaping."],
|
||||
[workspace, "Technische details", "Workspace must progressively disclose technical execution."],
|
||||
[navigation, 'level: "advanced"', "Navigation must keep specialist modules outside the primary path."],
|
||||
[workspace, 'data-page-primary="true"', "Workspace must expose one state-driven primary action."],
|
||||
[buildLoop, "Technische route openen", "Build Loop must provide linked module CTA."],
|
||||
[buildLoop, "navigate(step.route)", "Build Loop must link each technical stage to its destination."],
|
||||
[buildLoop, "navigate(gate.route)", "Build Loop must expose destinations for release evidence gates."],
|
||||
[buildLoop, "getFirstTimeBuildLoopSteps", "Build Loop must derive its technical stage navigation."],
|
||||
[buildLoop, "ProjectRouteFallback", "Build Loop must use the shared project fallback."],
|
||||
[repoBuilder, "Huidige fase:", "Repo Builder must expose the current repository phase."],
|
||||
[repoBuilder, "Repository-informatie importeren", "Repo Builder must expose repository intake as the first task in its flow."],
|
||||
[repoBuilder, "ProjectBrain gebruikt dit als geïmporteerde informatie, niet als bewijs van de live-bestanden.", "Repo Builder must show the imported-evidence boundary."],
|
||||
[repoBuilder, "Dit is een ProjectBrain-simulatie, geen live git-diff.", "Repo Builder must label the impact-review simulation boundary."],
|
||||
[handoffs, "Review impact", "Codex Handoffs must expose Review impact flow language."],
|
||||
[handoffs, "Handoff quality gate", "Codex Handoffs must expose the quality gate."],
|
||||
[handoffs, "ProjectBrain-derived impact simulation, not a live git diff", "Codex Handoffs must show impact simulation boundary."],
|
||||
[handoffs, "Download ZIP", "Codex Handoffs must expose real session-pack ZIP export."],
|
||||
[handoffs, "Export session pack files", "Codex Handoffs must keep individual session-pack file export."],
|
||||
[handoffs, "controlled input for a Codex build session", "Codex Handoffs must describe session packs as controlled input."],
|
||||
[handoffs, "BuildLoopActionRail", "Codex Handoffs must show the shared build-loop action rail."],
|
||||
[handoffs, "ProjectRouteFallback", "Codex Handoffs must use the shared project fallback."],
|
||||
[sessionPacks, "A session pack is controlled input for a Codex build session", "Session Packs must show controlled input boundary."],
|
||||
[sessionPacks, "Session pack execution path", "Session Packs must expose the execution path."],
|
||||
[sessionPacks, "Download instructions/scripts ZIP", "Session Packs must expose clear instructions/scripts ZIP download."],
|
||||
[sessionPacks, "Export session pack files", "Session Packs must use honest export label."],
|
||||
[sessionPacks, "Create Codex handoff", "Session Packs empty state must link back to handoffs."],
|
||||
[sessionPacks, "Return to Build Loop", "Session Packs empty state must link back to Build Loop."],
|
||||
[sessionPacks, "ProjectRouteFallback", "Session Packs must use the shared project fallback."],
|
||||
[execution, "Run in Codex", "Execution Center must expose Run in Codex step language."],
|
||||
[execution, "ProjectRouteFallback", "Execution Center must use the shared project fallback."],
|
||||
[feedback, "Pasted Codex reports are user-provided execution evidence", "Execution Feedback must show user-provided evidence boundary."],
|
||||
[feedback, "Create follow-up handoff", "Execution Feedback must expose follow-up handoff action."],
|
||||
[feedback, "BuildLoopActionRail", "Execution Feedback must show the shared build-loop action rail."],
|
||||
[feedback, "ProjectRouteFallback", "Execution Feedback must use the shared project fallback."],
|
||||
[reviewQueue, "BuildLoopActionRail", "Review Queue must show the shared build-loop action rail."],
|
||||
[reviewQueue, "Close evidence into project state", "Review Queue must explain closure into project state."],
|
||||
[reviewQueue, "ProjectRouteFallback", "Review Queue must use the shared project fallback."],
|
||||
[pkg, "audit:visual-flow", "package.json must expose audit:visual-flow."],
|
||||
[pkg, "check-visual-flow-readiness.mjs", "package.json must wire visual flow readiness."],
|
||||
];
|
||||
|
||||
for (const [haystack, needle, message] of mustContain) {
|
||||
if (!haystack.includes(needle)) failures.push(message);
|
||||
}
|
||||
|
||||
for (const [file, contents] of Object.entries(sourceText)) {
|
||||
if (file.startsWith("src/") && contents.includes("Download Bundle / ZIP")) {
|
||||
failures.push(`${file} still contains misleading Download Bundle / ZIP copy.`);
|
||||
}
|
||||
if (file.startsWith("src/") && /existing (lint )?warnings remain/i.test(contents)) {
|
||||
failures.push(`${file} still contains stale existing-warnings copy.`);
|
||||
}
|
||||
if (file.startsWith("src/features/") && contents.includes("<div>Project not found</div>")) {
|
||||
failures.push(`${file} still contains a bare project-not-found fallback.`);
|
||||
}
|
||||
}
|
||||
|
||||
const scripts = JSON.parse(pkg || "{}").scripts ?? {};
|
||||
const auditAll = scripts["audit:all"] ?? "";
|
||||
const experienceAudit = scripts["audit:experience"] ?? "";
|
||||
if (!auditAll.includes("audit:experience") || !experienceAudit.includes("audit:visual-flow")) {
|
||||
failures.push("audit:all must include the experience gate with visual flow readiness.");
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("ProjectBrain visual flow readiness: FAIL");
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("ProjectBrain visual flow readiness: PASS");
|
||||
@@ -0,0 +1,157 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const exists = (file) => fs.existsSync(path.join(root, file));
|
||||
const checks = [];
|
||||
const pass = (name, ok, detail = '') => checks.push({ name, ok, detail });
|
||||
|
||||
const blueprint = read('src/features/blueprint/pages/BlueprintEngine.tsx');
|
||||
pass('Blueprint ListChecks import is present', blueprint.includes('ListChecks'));
|
||||
pass('Blueprint route should not trigger missing ListChecks ReferenceError', /import\s*\{[^}]*ListChecks[^}]*\}\s*from\s*["']lucide-react["']/.test(blueprint));
|
||||
|
||||
const appLayout = read('src/components/layout/AppLayout.tsx');
|
||||
pass('Main shell has compact responsive header offset', appLayout.includes('pt-6') && appLayout.includes('lg:pt-10'));
|
||||
pass('Main shell prevents page-level horizontal overflow', appLayout.includes('overflow-x-hidden') && appLayout.includes('min-w-0'));
|
||||
|
||||
const pageHeader = read('src/components/ui/PageHeader.tsx');
|
||||
pass('PageHeader wraps actions on mobile', pageHeader.includes('flex-wrap') && pageHeader.includes('md:w-auto') && pageHeader.includes('break-words'));
|
||||
|
||||
const routes = read('src/app/routes.tsx');
|
||||
pass('Route scroll reset is enabled', routes.includes('ScrollToTop') && routes.includes('data-projectbrain-main'));
|
||||
pass('Route loading fallback explains project loading', routes.includes('Project laden…') && routes.includes('aria-label="ProjectBrain-pagina laden"'));
|
||||
|
||||
const sidebar = read('src/components/layout/Sidebar.tsx');
|
||||
pass('Sidebar descriptions are hidden on smaller desktop widths', sidebar.includes('xl:block'));
|
||||
|
||||
const prompt = read('src/features/prompts/pages/PromptGenerator.tsx');
|
||||
pass('Prompt Builder starts with task input', prompt.includes('What should AI/Codex do?'));
|
||||
pass('Prompt Builder uses multiline output area', prompt.includes('min-h-40 font-mono text-xs'));
|
||||
|
||||
const knowledge = read('src/features/knowledge/pages/KnowledgeVault.tsx');
|
||||
pass('Knowledge Vault avoids preview wording in primary actions', !knowledge.includes('preview'));
|
||||
|
||||
const audit = read('src/features/audit/pages/ProjectAuditCenter.tsx');
|
||||
pass('Audit Center treats old priority labels as historical', audit.includes('was {finding.severity}'));
|
||||
|
||||
const header = read('src/components/layout/Header.tsx');
|
||||
pass('Header New Project menu navigates to unified Start', header.includes('navigate("/start")'));
|
||||
pass('Header exposes runtime status popover', header.includes('Runtimestatus') && header.includes('useBackendSystemReadinessData'));
|
||||
pass('Header runtime status links to Runtime page', header.includes('Runtimepagina') && header.includes('/runtime'));
|
||||
pass('Header labels unavailable backend as local mode', header.includes('Lokale modus') && !header.includes('degraded: "Degraded"'));
|
||||
|
||||
const commandMenu = read('src/components/layout/CommandMenu.tsx');
|
||||
const commandMenuSearch = read('src/components/layout/commandMenuSearch.ts');
|
||||
pass('Command palette shortcut listens in capture phase', commandMenu.includes('window.addEventListener("keydown", down, { capture: true })'));
|
||||
pass(
|
||||
'Command palette prioritizes best-matching navigation while searching',
|
||||
commandMenu.includes('rankNavigationItems') &&
|
||||
commandMenu.includes('<CommandGroup heading="Beste overeenkomsten">') &&
|
||||
commandMenu.includes('{navigationSearchGroup}') &&
|
||||
commandMenuSearch.includes('getNavigationScore') &&
|
||||
commandMenuSearch.includes('label.startsWith(normalizedQuery)'),
|
||||
);
|
||||
pass('Command palette teaches route-oriented examples', commandMenu.includes('Zoek op repo, review, contextpack, historiek, bron of skill'));
|
||||
const commandUi = read('src/components/ui/command.tsx');
|
||||
pass('Command dialog has screenreader title and description', commandUi.includes('DialogTitle className="sr-only"') && commandUi.includes('DialogDescription className="sr-only"'));
|
||||
|
||||
const projectsPage = read('src/features/projects/pages/Projects.tsx');
|
||||
pass(
|
||||
'Projects mobile hero actions are full-width and stacked',
|
||||
projectsPage.includes('flex w-full flex-col gap-2 sm:w-auto sm:flex-row') &&
|
||||
projectsPage.includes('className="w-full sm:w-auto"') &&
|
||||
projectsPage.includes('{refreshing ? "Bijwerken…" : "Bijwerken"}') &&
|
||||
projectsPage.includes('disabled={refreshing}'),
|
||||
);
|
||||
pass('Projects progressively reveal large portfolios', projectsPage.includes('PROJECT_PAGE_SIZE') && projectsPage.includes('remainingProjectCount'));
|
||||
|
||||
const runtimeReadiness = read('src/features/projects/pages/RuntimeReadiness.tsx');
|
||||
pass(
|
||||
'System status prevents page-level mobile overflow',
|
||||
runtimeReadiness.includes('min-w-0 space-y-6 overflow-x-hidden') &&
|
||||
runtimeReadiness.includes('grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3'),
|
||||
);
|
||||
pass(
|
||||
'System status capability cards wrap long runtime evidence safely',
|
||||
runtimeReadiness.includes('className="min-w-0 overflow-hidden"') &&
|
||||
runtimeReadiness.includes('[overflow-wrap:anywhere]') &&
|
||||
runtimeReadiness.includes('max-w-[45%] shrink-0 whitespace-normal text-center'),
|
||||
);
|
||||
pass(
|
||||
'System status release/build proof avoids mobile overflow',
|
||||
runtimeReadiness.includes('min-w-0 overflow-hidden scroll-mt-24 border-primary/25') &&
|
||||
runtimeReadiness.includes('break-all font-semibold') &&
|
||||
runtimeReadiness.includes('break-all font-mono text-xs'),
|
||||
);
|
||||
|
||||
const review = read('src/features/operations/pages/ReviewQueue.tsx');
|
||||
pass('Review Queue bulk accept requires confirmation', review.includes('Confirm bulk accept'));
|
||||
|
||||
pass('V84 documentation exists', exists('docs/STABILIZATION_PASS_V84_VISUAL_RC_POLISH_2.md'));
|
||||
pass('V85 documentation exists', exists('docs/STABILIZATION_PASS_V85_RUNTIME_STATUS_POLISH.md'));
|
||||
pass('V87 correction documentation exists', exists('docs/STABILIZATION_PASS_V87_REAL_PRODUCT_IMPROVEMENT_CORRECTION.md'));
|
||||
pass('Audit Center no longer exposes V86 catalog tab', !audit.includes('V86 1000') && !audit.includes('megaImprovementItems'));
|
||||
const relationshipGraph = read('src/features/relationships/pages/RelationshipGraph.tsx');
|
||||
pass('Relationship Graph shows real suggestions instead of only empty state', relationshipGraph.includes('buildRelationshipSuggestions') && relationshipGraph.includes('suggestions.map'));
|
||||
const repoBuilder = read('src/features/repo-builder/pages/RepoBuilder.tsx');
|
||||
pass('Repo Builder mobile lifecycle has swipe affordance', repoBuilder.includes('Veeg zijwaarts om de latere fasen te bekijken.') && repoBuilder.includes('overflow-x-auto'));
|
||||
pass(
|
||||
'Repo Builder archive actions are confirmation-gated',
|
||||
repoBuilder.includes('ConfirmActionButton') &&
|
||||
repoBuilder.includes('title="Geselecteerde repository-intake archiveren?"') &&
|
||||
repoBuilder.includes('onConfirm={archiveSelectedRepositoryIntake}') &&
|
||||
repoBuilder.includes('title="Deze repositorybasis archiveren?"') &&
|
||||
repoBuilder.includes('onConfirm={archiveBlueprint}') &&
|
||||
repoBuilder.includes('title="Deze uitbreidingsbrief archiveren?"') &&
|
||||
repoBuilder.includes('onConfirm={archiveExpansion}') &&
|
||||
repoBuilder.includes('title="Dit deliverypakket archiveren?"') &&
|
||||
repoBuilder.includes('onConfirm={archiveDeliveryPackage}') &&
|
||||
repoBuilder.includes('title="Dit repositorywerkplan archiveren?"') &&
|
||||
repoBuilder.includes('onConfirm={archiveWorkPlan}') &&
|
||||
repoBuilder.includes('title="Deze repositorystatus archiveren?"') &&
|
||||
repoBuilder.includes('onConfirm={archiveStateSnapshot}'),
|
||||
);
|
||||
const sessionPacks = read('src/features/session-packs/pages/SessionPacks.tsx');
|
||||
pass('Session Packs exposes top next action for empty state', sessionPacks.includes('Create a Codex handoff before packaging a session.'));
|
||||
pass('Session Packs archive action is confirmation-gated', sessionPacks.includes('Archive this session pack?') && sessionPacks.includes('AlertDialogAction onClick={archiveSelected}'));
|
||||
const browserBridge = read('src/features/browser-bridge/pages/BrowserBridge.tsx');
|
||||
pass('Browser Bridge exposes top session-pack-required action', browserBridge.includes('Session pack required') && browserBridge.includes('Open Session Packs'));
|
||||
pass('Browser Bridge archive action is confirmation-gated', browserBridge.includes('Archive this browser bridge session?') && browserBridge.includes('AlertDialogAction onClick={archiveSession}'));
|
||||
const codexHandoffs = read('src/features/handoffs/pages/CodexHandoffs.tsx');
|
||||
pass('Codex handoff archive actions are confirmation-gated', codexHandoffs.includes('Archive this Codex handoff?') && codexHandoffs.includes('Archive this impact review?'));
|
||||
const runtimeStateManager = read('src/features/operations/pages/RuntimeStateManager.tsx');
|
||||
pass('Runtime reset uses accessible AlertDialog instead of native confirm', runtimeStateManager.includes('AlertDialogTitle') && !runtimeStateManager.includes('window.confirm'));
|
||||
pass('Compact runtime state manager hides import-only control', runtimeStateManager.includes('!compact ? (') && runtimeStateManager.includes('Import JSON'));
|
||||
const confirmActionButton = read('src/shared/components/ConfirmActionButton.tsx');
|
||||
pass('Shared confirm action button uses accessible AlertDialog', confirmActionButton.includes('AlertDialogTitle') && confirmActionButton.includes('confirmLabel'));
|
||||
const executionFeedback = read('src/features/execution-feedback/pages/ExecutionFeedback.tsx');
|
||||
pass('Execution Feedback archive action is confirmation-gated', executionFeedback.includes('Archive this execution feedback?'));
|
||||
const sourceLibrary = read('src/features/source-library/pages/SourceLibrary.tsx');
|
||||
pass(
|
||||
'Source Library archive action is confirmation-gated',
|
||||
sourceLibrary.includes('ConfirmActionButton') &&
|
||||
sourceLibrary.includes('title="Deze bron archiveren?"') &&
|
||||
sourceLibrary.includes('confirmLabel="Bron archiveren"') &&
|
||||
sourceLibrary.includes('onConfirm={() => archive(source.id)}'),
|
||||
);
|
||||
const projectHistory = read('src/features/history/pages/ProjectHistory.tsx');
|
||||
pass('Project History remove action is confirmation-gated', projectHistory.includes('Dit geschiedenisitem verbergen?'));
|
||||
const runtimeGateEvidence = read('src/features/operations/pages/RuntimeGateEvidence.tsx');
|
||||
pass('Runtime evidence reset is confirmation-gated', runtimeGateEvidence.includes('Reset P2 evidence history?'));
|
||||
const runtimeGateChecklist = read('src/features/operations/pages/RuntimeGateChecklist.tsx');
|
||||
pass('Runtime checklist reset is confirmation-gated', runtimeGateChecklist.includes('Reset P2 checklist?'));
|
||||
const runtimeTodoBoard = read('src/features/operations/pages/RuntimeTodoBoard.tsx');
|
||||
pass('Runtime todo removal is confirmation-gated', runtimeTodoBoard.includes('Remove this runtime todo?'));
|
||||
const packageJson = read('package.json');
|
||||
pass('Mega audit command is removed from package scripts', !packageJson.includes('audit:mega'));
|
||||
|
||||
const failed = checks.filter((check) => !check.ok);
|
||||
if (failed.length > 0) {
|
||||
console.error('ProjectBrain visual polish audit: FAIL');
|
||||
for (const check of failed) console.error(`- ${check.name}${check.detail ? `: ${check.detail}` : ''}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('ProjectBrain visual polish audit: PASS');
|
||||
for (const check of checks) console.log(`- ${check.name}`);
|
||||
@@ -0,0 +1,16 @@
|
||||
// Unknown identities and evidence from different container rows never prove a deployment.
|
||||
export const compareDeploymentCommits = (local, remote) => {
|
||||
const isCommit = (value) => /^[a-f0-9]{7,40}$/i.test(value ?? "");
|
||||
if (!isCommit(local) || !isCommit(remote)) return "unknown";
|
||||
return local.toLowerCase() === remote.toLowerCase() ? "current" : "stale";
|
||||
};
|
||||
|
||||
export const isRunningContainer = (output, name, requiredPort) => output
|
||||
.split(/\r?\n/)
|
||||
.some((line) => {
|
||||
const [actualName, ...fields] = line.trim().split(/\s+/);
|
||||
const details = fields.join(" ");
|
||||
return actualName === name && /^Up\b/.test(details)
|
||||
&& !/\((?:unhealthy|health: starting)\)/.test(details)
|
||||
&& (!requiredPort || details.includes(requiredPort));
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { compareDeploymentCommits, isRunningContainer } from "./deployment-proof-state.mjs";
|
||||
|
||||
test("missing identities cannot be reported as current", () => {
|
||||
for (const value of ["unknown", "", undefined, "not-a-sha"]) {
|
||||
assert.equal(compareDeploymentCommits(value, value), "unknown");
|
||||
assert.equal(compareDeploymentCommits("9deb553", value), "unknown");
|
||||
}
|
||||
});
|
||||
test("known revisions compare exactly", () => {
|
||||
assert.equal(compareDeploymentCommits("9deb553", "9DEB553"), "current");
|
||||
assert.equal(compareDeploymentCommits("9deb553", "a415d3f"), "stale");
|
||||
});
|
||||
test("frontend evidence must belong to its own running container", () => {
|
||||
const output = "projectbrain-frontend Exited 1\nother Up 1 hour 0.0.0.0:1209->80/tcp";
|
||||
assert.equal(isRunningContainer(output, "projectbrain-frontend", "1209->80/tcp"), false);
|
||||
});
|
||||
test("reject similarly named or unhealthy containers", () => {
|
||||
for (const row of ["projectbrain-api-old Up 1 hour", "projectbrain-api Up 1 hour (unhealthy)", "projectbrain-api Up 2 seconds (health: starting)"]) {
|
||||
assert.equal(isRunningContainer(row, "projectbrain-api"), false);
|
||||
}
|
||||
});
|
||||
test("accept exact running containers with matching ports", () => {
|
||||
assert.equal(isRunningContainer("projectbrain-frontend Up 1 hour (healthy) 0.0.0.0:1209->80/tcp\r\n", "projectbrain-frontend", "1209->80/tcp"), true);
|
||||
assert.equal(isRunningContainer("projectbrain-api Up 1 hour", "projectbrain-api"), true);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildOpenApiDocument } from "@projectbrain/contracts";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const target = resolve(root, "docs/openapi/projectbrain-v1.json");
|
||||
const generated = `${JSON.stringify(buildOpenApiDocument(), null, 2)}\n`;
|
||||
if (process.argv.includes("--check")) {
|
||||
const existing = await readFile(target, "utf8").catch(() => "");
|
||||
if (existing !== generated) { console.error("OpenAPI artifact is stale. Run pnpm docs:openapi."); process.exit(1); }
|
||||
console.log("OpenAPI artifact matches canonical contracts.");
|
||||
} else {
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await writeFile(target, generated);
|
||||
console.log(`Wrote ${target}`);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import { resolve, relative } from "node:path";
|
||||
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const outputFlag = process.argv.indexOf("--output");
|
||||
const output = resolve(root, outputFlag >= 0 ? process.argv[outputFlag + 1] : "release-artifacts");
|
||||
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
||||
const command = (program, args) => execFileSync(program, args, { cwd: root, encoding: "utf8", windowsHide: true }).trim();
|
||||
|
||||
const pnpmOutput = process.env.npm_execpath
|
||||
? command(process.execPath, [process.env.npm_execpath, "list", "--prod", "--json", "--depth", "Infinity"])
|
||||
: command("pnpm", ["list", "--prod", "--json", "--depth", "Infinity"]);
|
||||
const listed = JSON.parse(pnpmOutput);
|
||||
const components = new Map();
|
||||
const collect = (dependencies = {}) => {
|
||||
for (const [name, dependency] of Object.entries(dependencies)) {
|
||||
if (dependency.version) components.set(`${name}@${dependency.version}`, { type: "library", name, version: dependency.version, purl: `pkg:npm/${encodeURIComponent(name)}@${dependency.version}` });
|
||||
collect(dependency.dependencies);
|
||||
}
|
||||
};
|
||||
for (const project of listed) collect(project.dependencies);
|
||||
const packageJson = JSON.parse(await readFile(resolve(root, "package.json"), "utf8"));
|
||||
const serialNumber = `urn:uuid:${randomUUID()}`;
|
||||
const sbom = {
|
||||
bomFormat: "CycloneDX",
|
||||
specVersion: "1.6",
|
||||
serialNumber,
|
||||
version: 1,
|
||||
metadata: { component: { type: "application", name: packageJson.name, version: packageJson.version }, tools: { components: [{ type: "application", name: "ProjectBrain release metadata generator", version: "1.0.0" }] } },
|
||||
components: [...components.values()].sort((left, right) => left.purl.localeCompare(right.purl)),
|
||||
};
|
||||
|
||||
const artifactFiles = [];
|
||||
const collectFiles = async (directory) => {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const path = resolve(directory, entry.name);
|
||||
if (entry.isDirectory()) await collectFiles(path);
|
||||
else artifactFiles.push({ name: relative(root, path).replaceAll("\\", "/"), digest: { sha256: sha256(await readFile(path)) }, bytes: (await stat(path)).size });
|
||||
}
|
||||
};
|
||||
await collectFiles(resolve(root, "dist"));
|
||||
for (const name of ["Dockerfile.frontend", "Dockerfile.backend", "Dockerfile.unraid", "pnpm-lock.yaml"]) {
|
||||
const path = resolve(root, name);
|
||||
try { artifactFiles.push({ name, digest: { sha256: sha256(await readFile(path)) }, bytes: (await stat(path)).size }); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
||||
}
|
||||
artifactFiles.sort((left, right) => left.name.localeCompare(right.name));
|
||||
const revision = command("git", ["rev-parse", "HEAD"]);
|
||||
const provenance = {
|
||||
predicateType: "https://slsa.dev/provenance/v1",
|
||||
subject: artifactFiles,
|
||||
predicate: {
|
||||
buildDefinition: { buildType: "https://projectbrain.local/build/v1", externalParameters: { node: process.version, packageManager: packageJson.packageManager, revision }, resolvedDependencies: [{ uri: "git+local:projectbrain", digest: { sha1: revision } }] },
|
||||
runDetails: { builder: { id: "https://projectbrain.local/ci" }, metadata: { invocationId: process.env.GITHUB_RUN_ID ?? `local-${revision.slice(0, 12)}`, startedOn: new Date().toISOString(), sourceDirty: command("git", ["status", "--porcelain"]) !== "" } },
|
||||
},
|
||||
sbom: { file: "projectbrain.cdx.json", sha256: sha256(JSON.stringify(sbom)) },
|
||||
};
|
||||
|
||||
await mkdir(output, { recursive: true });
|
||||
await writeFile(resolve(output, "projectbrain.cdx.json"), `${JSON.stringify(sbom, null, 2)}\n`);
|
||||
await writeFile(resolve(output, "projectbrain.provenance.json"), `${JSON.stringify(provenance, null, 2)}\n`);
|
||||
console.log(`Generated ${components.size} component SBOM and provenance for ${artifactFiles.length} artifacts in ${output}.`);
|
||||
@@ -0,0 +1,76 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const root = process.cwd();
|
||||
const packages = [
|
||||
{ scope: "root", path: "package.json" },
|
||||
{ scope: "backend", path: "backend/package.json" },
|
||||
];
|
||||
|
||||
const classify = (name, command) => {
|
||||
const value = `${name} ${command}`.toLowerCase();
|
||||
if (value.includes("rollback")) return "rollback";
|
||||
if (value.includes("prisma migrate") || value.includes("db:deploy") || value.includes("db:migrate") || value.includes("db:status")) return "migration";
|
||||
if (value.includes("database") || value.includes("db:seed") || /^smoke:p[2-8]$/.test(name)) return "database integration";
|
||||
if (value.includes("deploy") || value.includes("docker") || value.includes("unraid")) return "deployment";
|
||||
if (value.includes("playwright") || value.includes("browser") || value.includes("clickthrough")) return "browser E2E";
|
||||
if (value.includes("axe") || value.includes("accessib")) return "accessibility";
|
||||
if (value.includes("audit") && (value.includes("security") || value.includes("secret") || value.includes("container"))) return "security";
|
||||
if (value.includes("backup") || value.includes("restore") || value.includes("recovery") || value.includes("freshness")) return "recovery";
|
||||
if (name.startsWith("smoke:")) return "API smoke";
|
||||
if (name.startsWith("test") || value.includes("vitest")) return "unit";
|
||||
if (name === "typecheck" || name === "lint" || name === "build" || name.startsWith("validate:")) return "static";
|
||||
if (name.startsWith("audit:") || name === "doctor") return "source guard";
|
||||
if (name.startsWith("release:")) return "deployment";
|
||||
return "static";
|
||||
};
|
||||
|
||||
const modeFor = (name, command, category) => {
|
||||
const value = `${name} ${command}`.toLowerCase();
|
||||
if (category === "database integration" || category === "migration") return "real database";
|
||||
if (value.includes("fixture")) return "fixture";
|
||||
if (value.includes("real")) return "real API (data mode depends on runtime)";
|
||||
if (category === "source guard" || category === "static" || category === "security") return "source only";
|
||||
return "runtime-dependent";
|
||||
};
|
||||
|
||||
const rows = [];
|
||||
for (const pkg of packages) {
|
||||
const json = JSON.parse(readFileSync(resolve(root, pkg.path), "utf8"));
|
||||
for (const [name, command] of Object.entries(json.scripts ?? {})) {
|
||||
const category = classify(name, command);
|
||||
rows.push({ scope: pkg.scope, name, command, category, mode: modeFor(name, command, category) });
|
||||
}
|
||||
}
|
||||
|
||||
rows.sort((a, b) => a.scope.localeCompare(b.scope) || a.name.localeCompare(b.name));
|
||||
|
||||
const escapeCell = (value) => String(value).replaceAll("|", "\\|").replaceAll("\n", " ");
|
||||
const lines = [
|
||||
"# ProjectBrain Validation Matrix",
|
||||
"",
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
"",
|
||||
"This inventory classifies every package script. Classification describes what the command actually proves, not what its name claims. Results and artifacts remain `Not run in current matrix` until a categorized runner records them.",
|
||||
"",
|
||||
"## Evidence policy",
|
||||
"",
|
||||
"Mock, fixture and real-database evidence are never interchangeable. A source guard cannot close a runtime, database, recovery, deployment or rollback gate.",
|
||||
"",
|
||||
"| Scope | Script | Category | Mode/environment | Command | Latest result | Evidence | Commit |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
...rows.map((row) => `| ${escapeCell(row.scope)} | \`${escapeCell(row.name)}\` | ${escapeCell(row.category)} | ${escapeCell(row.mode)} | \`${escapeCell(row.command)}\` | Not run in current matrix | — | — |`),
|
||||
"",
|
||||
"## Current release-evidence gaps",
|
||||
"",
|
||||
"The repository now contains unit, Fastify injection, disposable-PostgreSQL, Playwright/accessibility, recovery, secret and container-image gates. Script presence and local execution do not prove a release artifact.",
|
||||
"",
|
||||
"- The categorized Gitea workflow has not been observed on its remote runner for the candidate commit.",
|
||||
"- Built OCI image scans, SBOM/provenance and archive digests are encoded in CI but no successful remote artifact run is attached yet.",
|
||||
"- Clean-room target installation, target-host timed rollback and post-deploy telemetry remain unexecuted.",
|
||||
"- Independent usability/screen-reader and operator-runbook executions remain unsigned.",
|
||||
"",
|
||||
];
|
||||
|
||||
writeFileSync(resolve(root, "docs/VALIDATION_MATRIX.md"), lines.join("\n"), "utf8");
|
||||
console.log(`Wrote docs/VALIDATION_MATRIX.md with ${rows.length} script classifications.`);
|
||||
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -0,0 +1,206 @@
|
||||
# Controlled legacy deployment migration
|
||||
|
||||
`plan.py` is a **pure planner**. `execute.py prepare` is an explicit opt-in,
|
||||
build-only preparation command. Separate `cutover` and `recover` modes require
|
||||
`--apply` and the exact verified preparation identity. Tests use fixtures/offline
|
||||
Compose rendering. The first verified production migration is recorded below;
|
||||
test results alone must never be presented as proof of a production cutover.
|
||||
|
||||
Authoritative broker code: `Unraid-AppOps-Gateway/ops/gitea-deploy-broker/deploy.py`.
|
||||
Production installs it separately at
|
||||
`/mnt/pool/appdata/gitea-deploy-runners/controller/deploy.py`. Production projects
|
||||
configuration is intentionally outside Git. Compare normalized source before changes.
|
||||
|
||||
## Why standard retry/bootstrap cannot migrate these two
|
||||
|
||||
VacatureRadar and LumaOps have healthy legacy containers but schema3 failed state
|
||||
with no known live commit or artifacts. `capture_compose_rollback` rightly refuses
|
||||
unattested rollback. `bootstrap-legacy` requires exact historical successful legacy
|
||||
state and refuses schema3. Never invent a revision or a successful receipt.
|
||||
|
||||
Standard preflight overlays live appdata, and standard migration cleanup removes
|
||||
retained legacy containers. Neither is suitable for a rollback-retaining migration
|
||||
with unknown original provenance. Use a reviewed separate one-shot transaction.
|
||||
|
||||
## Required transaction design
|
||||
|
||||
1. Pin/validate exact Gitea commit and tree. Prepare a separate clean build context;
|
||||
external credentials remain outside it. Build before disrupting production.
|
||||
Require immutable OCI source/revision/tree/version/time labels, not container
|
||||
labels alone. Existing configurations do not enable these checks yet.
|
||||
2. Acquire the same project then global locks as the broker. Revalidate original
|
||||
container and immutable image IDs, healthy state, exact mount inventory, free
|
||||
disk space, candidate network/ports, and absence of attempt targets. Protect the
|
||||
original image with an immutable rollback reference. Persist a root-only journal
|
||||
and original configuration snapshot without exposing secrets in output.
|
||||
3. Stop the original, cold-copy and verify all writable data into new attempt-owned
|
||||
paths. VacatureRadar runs PostgreSQL17 and Django migrations on startup: restarting
|
||||
the old image on migrated data is NOT rollback. Its nested `local`, logs and media
|
||||
bind mounts share one cold copy. LumaOps has bind mounts plus an anonymous `/config`
|
||||
volume; include both with their nested destination semantics preserved.
|
||||
4. Retain/rename the exact original container. Start only the candidate under a
|
||||
distinct Compose project (`<slug>-managed`), preserving production name/ports.
|
||||
The candidate mounts clones exclusively. A distinct project prevents Compose from
|
||||
adopting/removing retained original containers or failing exact-container-set checks.
|
||||
5. Verify immutable built image IDs, provenance and Docker health plus representative
|
||||
HTTP behavior. Only then obtain the real broker receipt and commit broker config
|
||||
(exact source, external env, explicit cloned data paths, new project identity,
|
||||
OCI verification) and state. Keep the originals and cold-copy journal available.
|
||||
6. On any failure before commit, remove only recorded attempt-created container IDs
|
||||
after identity checks, restore broker configuration, and rename/restart the exact
|
||||
original container with original untouched data. Verify old health. A failed restore
|
||||
is reported as unknown/failed, never as healthy. Keep partial artifacts for recovery.
|
||||
|
||||
Process-crash recovery needs a durable phase journal and idempotent identity checks;
|
||||
exception handlers alone are insufficient. Source-tree, data, broker configuration,
|
||||
runtime and receipt transitions must be separately recorded. Do not automatically
|
||||
clean originals after success. Retention cleanup needs separate authorization.
|
||||
|
||||
`build_plan` accepts a strict sanitized runtime projection: `name`, `containerId`,
|
||||
`imageId`, `running`, `health`, `composeProject`, and `mounts` (type/source/destination/rw).
|
||||
Never feed or print full Docker inspect/environment output. This planner validates
|
||||
paths lexically; the executor also resolves roots, rejects symlinks/special files,
|
||||
verifies backup checksums/ownership and revalidates state while locks are held.
|
||||
|
||||
Run: `python -m unittest discover -s scripts/legacy-deployment-migration -p 'test_*.py'`.
|
||||
|
||||
## Build-only preparation
|
||||
|
||||
After the two tracked `docker-compose.migration.yml` files are reviewed and merged,
|
||||
`execute.py prepare <slug> <full-current-branch-sha> <unique-attempt> --container-id
|
||||
<expected-id> --image-id <expected-immutable-id> --apply` prepares on the controller.
|
||||
Without `--apply` it performs no work. It validates the reviewed broker artifact,
|
||||
current branch head and original healthy container identity, then holds the normal
|
||||
project/global locks while extracting an isolated exact source tree and building an
|
||||
candidate image through the exact merged Compose configuration. OCI labels are
|
||||
checked on its immutable image ID. Source, original runtime and mounts are rechecked
|
||||
after build. Runtime environment is captured privately for Compose interpolation;
|
||||
credentials remain outside the build context and are not supplied as build arguments.
|
||||
Root-only journal/logs retain interrupted/failed attempts; a repeated
|
||||
attempt refuses to overwrite them. No production configuration/state/data is written.
|
||||
|
||||
The build-only journal is NOT a live receipt. `cutover` starts that same image using
|
||||
Compose `up --no-build --pull never`, then normal broker verification binds the
|
||||
observed runtime to its prepared immutable image ID. No rebuild occurs during downtime.
|
||||
Only a genuine normal broker receipt can be committed.
|
||||
|
||||
Tracked Compose override field contract (all required, no live-data defaults):
|
||||
|
||||
- Shared: `MIGRATION_ATTEMPT`, `MIGRATION_IMAGE`, `MIGRATION_SOURCE_REVISION`, `MIGRATION_SOURCE_TREE`,
|
||||
`MIGRATION_BUILD_ID`, `MIGRATION_BUILD_DATE`.
|
||||
- VacatureRadar: `MIGRATION_LOCAL_ROOT`, `VACATURERADAR_ENV_FILE`.
|
||||
- LumaOps: `MIGRATION_CONFIG_ROOT` (anonymous `/config` clone),
|
||||
`MIGRATION_OPENRGB_ROOT`, `MIGRATION_LUMA_CONFIG_ROOT`, `MIGRATION_DATA_ROOT`,
|
||||
`MIGRATION_LOGS_ROOT`, `MIGRATION_ENV_FILE`.
|
||||
|
||||
Pass the protected external runtime env file as Compose `--env-file` as well,
|
||||
preserving base-file interpolation values. Before cutover validate rendered absolute
|
||||
mounts against the plan, reject originals, and verify `create_host_path:false` so a
|
||||
missing copy cannot silently become an empty data directory. The normal broker
|
||||
requires Compose files to exist in the committed tree, which is why these overrides
|
||||
are tracked app files rather than a generated external override.
|
||||
|
||||
## Cutover and recovery
|
||||
|
||||
Use the same slug, revision, attempt and expected original IDs as preparation:
|
||||
`execute.py cutover ... --apply`. Selected project configuration/state hashes,
|
||||
source, image, runtime env, original IDs/health/mounts and rendered clone mounts/ports
|
||||
are revalidated before stopping. Both normal deployment locks remain held throughout.
|
||||
|
||||
The durable journal records intent before `stopping`, `copying`, `renaming`,
|
||||
`deploying`, `verifying`, and `committing`. Cold copies use `cp -a --reflink=auto`,
|
||||
filesystem sync and SHA256/ownership/mode/xattr/hardlink verification. The original
|
||||
is retained under a distinct name and immutable image tag. Broker config/state and
|
||||
original container configuration are saved root-only. The candidate never mounts
|
||||
original data. The new broker project uses dynamic revision/tree/build placeholders
|
||||
so subsequent normal deployments do not reuse frozen provenance labels.
|
||||
|
||||
An ordinary failure attempts verified recovery automatically. After abrupt process
|
||||
termination use `execute.py recover ... --apply`: it accepts only the saved original
|
||||
or candidate config/state, checks rollback snapshot integrity, and removes only the
|
||||
exact owned candidate (nonce, Compose project, recorded ID where available). It
|
||||
restores original config/state, canonical name and original untouched data, then
|
||||
verifies original immutable image/mounts, Docker health and HTTP readiness. Candidate
|
||||
data, logs and all originals are retained. Unrelated containers/config/state or
|
||||
changed original data block automatic recovery rather than be overwritten/deleted.
|
||||
|
||||
Recovery after the final `committed` journal marker reverifies production and resumes activation; it
|
||||
does not roll back potentially newer application writes. Recovery before that marker
|
||||
restores the original, including a crash after receipt/config write but before final
|
||||
journal commit. Failed recovery remains explicitly `recovery_failed`, never healthy.
|
||||
|
||||
Independent review and actual prepared-image/maintenance-window checks are required
|
||||
before first invocation. Health paths were probed read-only and returned HTTP 200 on
|
||||
2026-09-09: VacatureRadar port1226 `/health/ready/`, LumaOps port1223 `/health/ready`.
|
||||
These endpoints supplement, not replace, immutable image and normal receipt checks.
|
||||
|
||||
VacatureRadar's reviewed source must contain both the supervised Celery startup hold
|
||||
and first-position HTTP maintenance middleware. The helper writes an attempt-bound
|
||||
`.migration-worker-hold` only into the verified cold clone before candidate start.
|
||||
Ordinary traffic receives 503; exact GET/HEAD readiness/liveness probes remain available.
|
||||
After durable commit, activation journals intent, removes only the matching marker,
|
||||
fsyncs its directory, and records completion. Activation failure stays committed:
|
||||
explicit recovery resumes release and never rolls back after workers can run.
|
||||
A missing marker without durable activation intent fails closed.
|
||||
|
||||
Both tracked overrides require `MIGRATION_NETWORK`, bound to the exact inspected
|
||||
original `<slug>_default` network. Different/additional original networks fail closed;
|
||||
the rendered candidate must retain that network. Network/device
|
||||
compatibility and app-specific migration behavior still require operator review;
|
||||
the helper is not a generalized transaction system for external services.
|
||||
|
||||
LumaOps has no application maintenance gate in this migration. Its candidate can
|
||||
serve requests and interact with RGB hardware before the receipt is committed.
|
||||
Cold-data rollback restores the original application/container/data, but cannot
|
||||
undo external hardware effects or preserve candidate writes from that interval.
|
||||
Perform its cutover in the approved maintenance window with no concurrent user
|
||||
changes; do not describe this as a zero-effect or zero-data-loss transaction.
|
||||
|
||||
## Handoff safety boundary (2026-09-09)
|
||||
|
||||
The guarded broker artifact expected by `execute.py` has normalized SHA256
|
||||
`b5a0467f53acba5c783781a22bf81cf99f8b96ae99535460ad6236fac038330d`.
|
||||
This reviewed broker revision was installed atomically on 2026-09-09 after
|
||||
AppOps PR30 passed both validations and merged as `219184267972c596e7b61bbb29f0fab1b6472c9b`.
|
||||
The previous controller is retained as `deploy.py.before-single-runtime-rollback-20260909`.
|
||||
The helper refuses to run until the host artifact matches. Candidate devices, capabilities, read-only root,
|
||||
privilege, security options and tmpfs must match the inspected original exactly.
|
||||
It rejects stale selected-project configuration after acquiring the project lock.
|
||||
Preparation also records selected-project configuration and original state hashes;
|
||||
cutover compares both before making any production change.
|
||||
|
||||
Simulated failure tests cover interruption after original stop, after rename and
|
||||
after receipt/config write before journal commit. Negative adapter tests cover
|
||||
unrelated candidate nonce/project/IDs and binding broker verification to the prepared
|
||||
immutable image. Offline Compose tests verify effective clone mounts and missing-input
|
||||
failures. These tests do not claim a live migration or an end-to-end Docker rollback.
|
||||
|
||||
The protected-main approval/release gate for the app overrides and other prerequisite
|
||||
branches must be satisfied before selecting a preparation SHA. Never run preparation
|
||||
against an unmerged workaround or claim these local tests prove a live migration.
|
||||
|
||||
## Verified production outcome, 2026-09-09
|
||||
|
||||
VacatureRadar was prepared and committed from merged revision
|
||||
`38e87678e1c5b6aa6de2cb49e49366fce8a3cbf2`, image
|
||||
`sha256:c410c5ec26deb0cbd6a2992d4741e6c356f537d087de7fade74f214a747cca5a`.
|
||||
The protected journal is under the controller's
|
||||
`legacy-migrations/vacatureradar/legacy-20260909-a/` directory and records
|
||||
`committed`, `activationIntent:true`, `activated:true`. The original container
|
||||
`VacatureRadar-legacy-legacy-20260909-a` remains stopped with its original data.
|
||||
Docker health, HTTP readiness, the login page and running Celery processes were
|
||||
checked after activation. Normal Gitea deployment run9748 succeeded against the
|
||||
same verified revision. No post-commit rollback was attempted.
|
||||
|
||||
The initial helper's Docker JSON template incorrectly grouped `index` arguments.
|
||||
Preparation rejected this before production changes. Revision `4d1fc0d` fixed it,
|
||||
with a regression test and successful read-only qualification against real Docker.
|
||||
The actual cutover used that reviewed helper revision. Three additional Linux
|
||||
storage tests used isolated temporary fixtures, not application data.
|
||||
|
||||
LumaOps has **not** been migrated. Its original runtime has `AUTH_ENABLED=false`
|
||||
and no admin token; the merged target requires authentication on its production
|
||||
network binding. Preparation stopped before building or changing production.
|
||||
Provisioning secure login needs an explicit operator decision; never disable the
|
||||
target's authentication guard or describe the old runtime as a verified new build.
|
||||
Failed preparation artifacts are retained; no LumaOps cutover journal exists.
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Explicit fixed-target preparation, journaled cutover and crash recovery.
|
||||
|
||||
Run on the reviewed Unraid controller host. Builds a verified exact-source candidate
|
||||
Preparation leaves live config/state/containers/data untouched. Cutover is a
|
||||
separate --apply operation requiring a reviewed, verified preparation. Originals
|
||||
and failed candidate data are retained; recover never undoes a committed migration.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from contextlib import ExitStack
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from plan import ATTEMPT, ID, SHA, TARGETS
|
||||
|
||||
|
||||
BROKER_PATH = Path("/mnt/pool/appdata/gitea-deploy-runners/controller/deploy.py")
|
||||
BROKER_SHA = "b5a0467f53acba5c783781a22bf81cf99f8b96ae99535460ad6236fac038330d"
|
||||
JOURNAL_ROOT = Path("/mnt/pool/appdata/gitea-deploy-runners/legacy-migrations")
|
||||
DOCKERFILES = {"vacatureradar": "Dockerfile.unraid", "lumaops": "Dockerfile"}
|
||||
|
||||
|
||||
def durable_json(path, payload):
|
||||
temporary = path.with_suffix(".tmp")
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(descriptor, "w") as stream:
|
||||
json.dump(payload, stream, sort_keys=True)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
descriptor = os.open(path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def require_real_child(path, parent):
|
||||
resolved = path.resolve()
|
||||
root = parent.resolve()
|
||||
if path.is_symlink() or resolved == root or not resolved.is_relative_to(root):
|
||||
raise ValueError("Candidate path is not a private child of the fixed root")
|
||||
return resolved
|
||||
|
||||
|
||||
def checked(command, *, log=None, timeout=30, env=None, cwd=None):
|
||||
# Do not inherit application credentials into builds or print raw subprocess output.
|
||||
environment = {key: os.environ[key] for key in ("PATH", "HOME", "DOCKER_CONFIG") if key in os.environ}
|
||||
result = subprocess.run(command, env=environment if env is None else env, cwd=cwd, stdout=log or subprocess.PIPE,
|
||||
stderr=log or subprocess.PIPE, text=log is None,
|
||||
timeout=timeout, check=False)
|
||||
if result.returncode:
|
||||
raise RuntimeError("Bounded preparation command failed; inspect the protected attempt log")
|
||||
return result.stdout if log is None else ""
|
||||
|
||||
|
||||
def docker_json_template(fields):
|
||||
# Go templates otherwise pass index/map/key as three separate json args.
|
||||
return "\n".join("{{json (" + field + ")}}" for field in fields)
|
||||
|
||||
|
||||
def original_identity(name, runner=checked):
|
||||
fields = [".Id", ".Image", ".State.Running", ".State.Health.Status",
|
||||
'index .Config.Labels "com.docker.compose.project"']
|
||||
template = docker_json_template(fields)
|
||||
values = runner(["docker", "inspect", "--format", template, name]).splitlines()
|
||||
if len(values) != len(fields):
|
||||
raise ValueError("Unexpected sanitized container inspection")
|
||||
return dict(zip(("containerId", "imageId", "running", "health", "composeProject"),
|
||||
(json.loads(value) for value in values)))
|
||||
|
||||
|
||||
def assert_original(observed, slug, container_id, image_id):
|
||||
if observed != {"containerId": container_id, "imageId": image_id,
|
||||
"running": True, "health": "healthy", "composeProject": slug}:
|
||||
raise ValueError("Original runtime identity/health drift; preparation refused")
|
||||
|
||||
|
||||
def mount_inventory(target, runner=checked):
|
||||
raw = json.loads(runner(["docker", "inspect", "--format", "{{json .Mounts}}", target]))
|
||||
return sorted(({"type": item["Type"], "source": item["Source"],
|
||||
"destination": item["Destination"], "rw": item["RW"]} for item in raw),
|
||||
key=lambda item: item["destination"])
|
||||
|
||||
|
||||
def expected_labels(metadata):
|
||||
return {
|
||||
"org.opencontainers.image.source": metadata["repository"],
|
||||
"org.opencontainers.image.revision": metadata["revision"],
|
||||
"org.opencontainers.image.source-tree": metadata["tree"],
|
||||
"org.opencontainers.image.version": metadata["id"],
|
||||
"org.opencontainers.image.created": metadata["builtAt"],
|
||||
}
|
||||
|
||||
|
||||
def verify_image(image, metadata, runner=checked):
|
||||
image_id = runner(["docker", "image", "inspect", "--format", "{{.Id}}", image]).strip()
|
||||
if not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id):
|
||||
raise ValueError("Candidate immutable image identity is invalid")
|
||||
for key, expected in expected_labels(metadata).items():
|
||||
actual = runner(["docker", "image", "inspect", "--format",
|
||||
'{{index .Config.Labels "' + key + '"}}', image_id]).strip()
|
||||
if actual != expected:
|
||||
raise ValueError("Candidate immutable provenance did not verify")
|
||||
return image_id
|
||||
|
||||
|
||||
def prepare(broker, slug, revision, attempt, container_id, image_id, runner=checked):
|
||||
if (slug not in TARGETS or not SHA.fullmatch(revision) or not ATTEMPT.fullmatch(attempt)
|
||||
or not ID.fullmatch(container_id) or not re.fullmatch(r"sha256:[0-9a-f]{64}", image_id)):
|
||||
raise ValueError("Invalid fixed target, commit, attempt or expected runtime identity")
|
||||
repository = f"jens/{slug}"
|
||||
original_name = TARGETS[slug][0]
|
||||
workspace = require_real_child(JOURNAL_ROOT / slug / attempt, JOURNAL_ROOT)
|
||||
source = require_real_child(broker.EXACT_SOURCE_ROOT / f"{slug}-migration-{attempt}", broker.EXACT_SOURCE_ROOT)
|
||||
with ExitStack() as locks:
|
||||
locks.enter_context(broker.exclusive_lock(broker.STATE_ROOT / "locks" / f"{slug}.lock", "migration project"))
|
||||
locks.enter_context(broker.exclusive_lock(broker.GLOBAL_DEPLOY_LOCK, "migration preparation build"))
|
||||
# Read config only after lock acquisition, unlike starting another broker process.
|
||||
project = dict(broker.load_projects()[repository])
|
||||
repo = broker.repository_path(repository, project)
|
||||
broker.validate_commit(repo, str(project["branch"]), revision)
|
||||
head = broker.output(["git", f"--git-dir={repo}", "rev-parse", f"refs/heads/{project['branch']}"])
|
||||
if head != revision:
|
||||
raise ValueError("Migration candidate must be the current configured branch head")
|
||||
assert_original(original_identity(original_name, runner), slug, container_id, image_id)
|
||||
if workspace.exists() or source.exists():
|
||||
raise ValueError("Attempt artifacts already exist; retain them and review before a new attempt")
|
||||
metadata = broker.deployment_metadata(repo, revision, repository)
|
||||
if not broker.git_path_exists(repo, revision, "docker-compose.migration.yml"):
|
||||
raise ValueError("Candidate commit does not contain the reviewed migration Compose contract")
|
||||
if not broker.git_path_exists(repo, revision, DOCKERFILES[slug]):
|
||||
raise ValueError("Candidate Dockerfile missing")
|
||||
workspace.mkdir(parents=True, mode=0o700)
|
||||
workspace.chmod(0o700)
|
||||
journal_path = workspace / "prepare.json"
|
||||
journal = {"schema": "legacy-prepare.v1", "phase": "preparing", "repository": repository,
|
||||
"revision": revision, "metadata": metadata, "source": str(source),
|
||||
"originalContainerId": container_id, "originalImageId": image_id,
|
||||
"originalMounts": mount_inventory(container_id, runner),
|
||||
"projectConfigSha256": hashlib.sha256(json.dumps(project, sort_keys=True).encode()).hexdigest(),
|
||||
"stateSha256": hashlib.sha256((broker.STATE_ROOT / f"{slug}.json").read_bytes()).hexdigest(),
|
||||
"originalProvenance": "unknown-legacy", "productionMutated": False}
|
||||
durable_json(journal_path, journal)
|
||||
try:
|
||||
broker.sync_source(repo, revision, source, slug, exact=True)
|
||||
if not broker.exact_source_matches_revision(repo, revision, source):
|
||||
raise ValueError("Candidate source is not the exact committed tree")
|
||||
from transaction import prepare_compose
|
||||
candidate, command, cwd, environment, plan, nonce = prepare_compose(
|
||||
broker, slug, revision, attempt, project, journal, workspace, runner)
|
||||
image = environment["MIGRATION_IMAGE"]
|
||||
journal.update(candidateProject=candidate, plan=plan, nonce=nonce)
|
||||
journal["runtimeEnvSha256"] = hashlib.sha256((workspace / "runtime.env").read_bytes()).hexdigest()
|
||||
journal.update(phase="building", candidateImage=image)
|
||||
durable_json(journal_path, journal)
|
||||
descriptor = os.open(workspace / "build.log", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(descriptor, "wb") as log:
|
||||
runner(command, cwd=cwd, env=environment, log=log, timeout=3600)
|
||||
log.flush()
|
||||
os.fsync(log.fileno())
|
||||
immutable = verify_image(image, metadata, runner)
|
||||
if not broker.exact_source_matches_revision(repo, revision, source):
|
||||
raise ValueError("Candidate source changed during build")
|
||||
assert_original(original_identity(original_name, runner), slug, container_id, image_id)
|
||||
if mount_inventory(container_id, runner) != journal["originalMounts"]:
|
||||
raise ValueError("Original mount inventory changed during preparation")
|
||||
journal.update(phase="build_verified", candidateImageId=immutable)
|
||||
durable_json(journal_path, journal)
|
||||
return {"phase": journal["phase"], "repository": repository, "revision": revision,
|
||||
"candidateImageId": immutable, "journal": str(journal_path),
|
||||
"productionMutated": False, "cutoverAvailable": True}
|
||||
except Exception:
|
||||
journal["phase"] = "preparation_failed"
|
||||
durable_json(journal_path, journal)
|
||||
raise
|
||||
|
||||
|
||||
def load_broker():
|
||||
if hashlib.sha256(BROKER_PATH.read_bytes().replace(b"\r\n", b"\n")).hexdigest() != BROKER_SHA:
|
||||
raise ValueError("Live broker source differs from the reviewed artifact")
|
||||
spec = importlib.util.spec_from_file_location("reviewed_deploy_broker", BROKER_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("mode", choices=["prepare", "cutover", "recover"])
|
||||
parser.add_argument("slug", choices=sorted(TARGETS))
|
||||
parser.add_argument("revision")
|
||||
parser.add_argument("attempt")
|
||||
parser.add_argument("--container-id", required=True)
|
||||
parser.add_argument("--image-id", required=True)
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if not args.apply:
|
||||
print(json.dumps({"apply": False, "mode": args.mode, "productionMutated": False}))
|
||||
return
|
||||
if not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||||
raise SystemExit("Run only as root on the configured Unraid controller host")
|
||||
try:
|
||||
broker = load_broker()
|
||||
if args.mode == "prepare":
|
||||
result = prepare(broker, args.slug, args.revision, args.attempt, args.container_id, args.image_id)
|
||||
else:
|
||||
from transaction import operate
|
||||
result = operate(broker, args.mode, args.slug, args.revision, args.attempt, args.container_id, args.image_id)
|
||||
except Exception:
|
||||
raise SystemExit("Operation failed. Inspect the protected attempt journal; do not assume production or rollback succeeded. Use explicit recover for an unfinished cutover.") from None
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Pure, fail-closed migration planning; this module cannot mutate Docker or files.
|
||||
|
||||
Accept only the sanitized inspect projection documented in README.md. A plan is
|
||||
not deployment authorization, a snapshot, a receipt, or proof of successful backup.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
|
||||
TARGETS = {
|
||||
"vacatureradar": ("VacatureRadar", "/mnt/pool/appdata/VacatureRadar"),
|
||||
"lumaops": ("lumaops", "/mnt/pool/appdata/lumaops"),
|
||||
}
|
||||
SHA = re.compile(r"[0-9a-f]{40}")
|
||||
ID = re.compile(r"[0-9a-f]{64}")
|
||||
ATTEMPT = re.compile(r"[a-z0-9][a-z0-9-]{7,47}")
|
||||
|
||||
|
||||
def _under(path, parent):
|
||||
value = PurePosixPath(path)
|
||||
return (
|
||||
value.is_absolute()
|
||||
and ".." not in value.parts
|
||||
and value != PurePosixPath(parent)
|
||||
and value.is_relative_to(parent)
|
||||
)
|
||||
|
||||
|
||||
def build_plan(slug, revision, attempt, observed):
|
||||
"""Bind one target to retained runtime identity and isolated candidate data."""
|
||||
if slug not in TARGETS or not SHA.fullmatch(revision) or not ATTEMPT.fullmatch(attempt):
|
||||
raise ValueError("Invalid fixed target, full revision, or attempt identifier")
|
||||
expected_name, appdata = TARGETS[slug]
|
||||
keys = {"name", "containerId", "imageId", "running", "health", "composeProject", "mounts"}
|
||||
if set(observed) != keys:
|
||||
raise ValueError("Expected only the sanitized runtime projection")
|
||||
if (
|
||||
observed["name"] != expected_name
|
||||
or observed["composeProject"] != slug
|
||||
or not ID.fullmatch(observed["containerId"])
|
||||
or not re.fullmatch(r"sha256:[0-9a-f]{64}", observed["imageId"])
|
||||
or observed["running"] is not True
|
||||
or observed["health"] != "healthy"
|
||||
):
|
||||
raise ValueError("Legacy runtime identity or health does not match")
|
||||
mounts = observed["mounts"]
|
||||
if not mounts or len(mounts) > 16:
|
||||
raise ValueError("Expected bounded persistent mounts")
|
||||
destinations = set()
|
||||
sources = set()
|
||||
for mount in mounts:
|
||||
if set(mount) != {"type", "source", "destination", "rw"}:
|
||||
raise ValueError("Unexpected mount fields")
|
||||
destination = mount["destination"]
|
||||
if not _under(destination, "/") or destination in destinations:
|
||||
raise ValueError("Invalid or duplicate mount destination")
|
||||
destinations.add(destination)
|
||||
source = mount["source"]
|
||||
if mount["type"] == "bind":
|
||||
valid = _under(source, appdata)
|
||||
elif mount["type"] == "volume":
|
||||
valid = bool(re.fullmatch(r"/var/lib/docker/volumes/[0-9a-f]{64}/_data", source))
|
||||
else:
|
||||
valid = False
|
||||
if not valid or mount["rw"] is not True:
|
||||
raise ValueError("Unreviewed source, mount type, or access mode")
|
||||
sources.add(source)
|
||||
# Clone a parent only once; preserve nested bind destinations against the
|
||||
# corresponding cloned subdirectory, not unrelated independent snapshots.
|
||||
roots = sorted(source for source in sources if not any(
|
||||
other != source and _under(source, other) for other in sources
|
||||
))
|
||||
candidate_root = f"/mnt/pool/appdata/gitea-deploy-runners/legacy-migrations/{slug}/{attempt}"
|
||||
clones = {source: f"{candidate_root}/data/{index}" for index, source in enumerate(roots)}
|
||||
candidate_mounts = []
|
||||
for mount in mounts:
|
||||
root = next(source for source in roots if mount["source"] == source or _under(mount["source"], source))
|
||||
relative = PurePosixPath(mount["source"]).relative_to(root)
|
||||
candidate_mounts.append({
|
||||
"source": str(PurePosixPath(clones[root]) / relative),
|
||||
"destination": mount["destination"],
|
||||
"type": "bind", "rw": True,
|
||||
})
|
||||
return {
|
||||
"schema": "legacy-migration-plan.v1", "executable": False,
|
||||
"repository": f"jens/{slug}", "candidateRevision": revision,
|
||||
"originalProvenance": "unknown-legacy", "originalRevision": None,
|
||||
"originalContainerId": observed["containerId"], "originalImageId": observed["imageId"],
|
||||
"originalName": expected_name, "retainedName": f"{expected_name}-legacy-{attempt}",
|
||||
"candidateComposeProject": f"{slug}-managed",
|
||||
"workspace": candidate_root,
|
||||
"coldCopyRoots": [{"original": root, "candidate": clones[root]} for root in roots],
|
||||
"candidateMounts": candidate_mounts,
|
||||
"requiredGates": [
|
||||
"exact-commit-and-tree-validated", "candidate-built-before-cutover",
|
||||
"immutable-oci-provenance-verified", "project-and-global-locks-held",
|
||||
"original-identity-revalidated", "legacy-stopped-before-data-copy",
|
||||
"cold-copy-checksums-verified", "original-data-never-mounted-by-candidate",
|
||||
"candidate-container-ids-recorded", "docker-and-http-health-verified",
|
||||
"real-broker-receipt-verified", "broker-config-and-state-committed",
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Private durable journal and fail-closed cold-copy verification primitives."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import tempfile
|
||||
|
||||
|
||||
def digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def private_write(path, data):
|
||||
"""Unique temporary files let explicit recovery proceed after process crashes."""
|
||||
path = Path(path)
|
||||
if path.is_symlink():
|
||||
raise ValueError("Refusing a symlink journal/configuration target")
|
||||
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}-", dir=path.parent)
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
descriptor = os.open(path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def private_json(path, value):
|
||||
private_write(path, json.dumps(value, sort_keys=True).encode())
|
||||
|
||||
|
||||
def dotenv(entries):
|
||||
"""Do not guess escaping for unsupported environment values; never log them."""
|
||||
import re
|
||||
result = {}
|
||||
for entry in entries:
|
||||
key, separator, value = entry.partition("=")
|
||||
if not separator or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
|
||||
raise ValueError("Unsupported runtime environment entry")
|
||||
if key in result or any(character in value for character in "\x00\n\r'\\"):
|
||||
raise ValueError("Runtime environment needs reviewed dotenv escaping")
|
||||
result[key] = value
|
||||
return "".join(f"{key}='{value}'\n" for key, value in sorted(result.items())).encode()
|
||||
|
||||
|
||||
def tree_manifest(root):
|
||||
"""Hash content plus ownership/modes/xattrs; reject symlinks and special files.
|
||||
|
||||
Preserving an unknown symlink could let the candidate write original data.
|
||||
Hardlink equivalence groups are compared without depending on inode numbers.
|
||||
"""
|
||||
root = Path(root)
|
||||
if root.is_symlink() or not root.is_dir():
|
||||
raise ValueError("Cold-copy root must be a real directory")
|
||||
records = {}
|
||||
inodes = {}
|
||||
for path in [root, *sorted(root.rglob("*"))]:
|
||||
info = path.lstat()
|
||||
relative = str(path.relative_to(root))
|
||||
if not stat.S_ISREG(info.st_mode) and not stat.S_ISDIR(info.st_mode):
|
||||
raise ValueError("Cold-copy tree contains an unreviewed symlink/special file")
|
||||
record = {"mode": stat.S_IMODE(info.st_mode), "uid": info.st_uid, "gid": info.st_gid}
|
||||
if stat.S_ISREG(info.st_mode):
|
||||
hasher = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
hasher.update(block)
|
||||
record.update(kind="file", size=info.st_size, sha256=hasher.hexdigest())
|
||||
group = (info.st_dev, info.st_ino)
|
||||
record["hardlink"] = inodes.setdefault(group, relative)
|
||||
else:
|
||||
record["kind"] = "directory"
|
||||
if hasattr(os, "listxattr"):
|
||||
record["xattrs"] = {name: digest(os.getxattr(path, name)) for name in sorted(os.listxattr(path))}
|
||||
records[relative] = record
|
||||
return records
|
||||
|
||||
|
||||
def clone_cold(source, target, runner):
|
||||
source, target = Path(source), Path(target)
|
||||
if target.exists() or target.is_symlink():
|
||||
raise ValueError("Cold-copy target already exists")
|
||||
before = tree_manifest(source)
|
||||
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
# Destination does not exist: cp -a copies root metadata as well as children.
|
||||
runner(["cp", "-a", "--reflink=auto", "--", str(source), str(target)], timeout=3600)
|
||||
runner(["sync", "-f", "--", str(target)], timeout=3600)
|
||||
copied = tree_manifest(target)
|
||||
if before != copied or before != tree_manifest(source):
|
||||
raise ValueError("Cold copy content/metadata does not match unchanged original")
|
||||
return digest(json.dumps(before, sort_keys=True).encode())
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Offline Compose rendering; never builds images or starts containers."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
|
||||
PROJECTS = Path(__file__).resolve().parents[3]
|
||||
CASES = {
|
||||
"VacatureRadar": ("app", ["docker-compose.unraid.yml"], {
|
||||
"MIGRATION_LOCAL_ROOT": "/migration-fixture/local",
|
||||
"VACATURERADAR_ENV_FILE": "/migration-fixture/runtime.env",
|
||||
}, {"/app/local", "/app/media", "/app/logs"}),
|
||||
"LumaOps": ("lumaops", ["docker-compose.yml", "docker-compose.unraid.yml"], {
|
||||
"MIGRATION_CONFIG_ROOT": "/migration-fixture/config",
|
||||
"MIGRATION_OPENRGB_ROOT": "/migration-fixture/openrgb",
|
||||
"MIGRATION_LUMA_CONFIG_ROOT": "/migration-fixture/luma-config",
|
||||
"MIGRATION_DATA_ROOT": "/migration-fixture/data",
|
||||
"MIGRATION_LOGS_ROOT": "/migration-fixture/logs",
|
||||
"MIGRATION_ENV_FILE": "/migration-fixture/runtime.env",
|
||||
}, {"/config", "/config/openrgb", "/config/lumaops", "/data", "/logs"}),
|
||||
}
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("docker"), "Docker Compose CLI is required; no daemon used")
|
||||
class ComposeTests(unittest.TestCase):
|
||||
def render(self, name, missing=None):
|
||||
_, files, variables, _ = CASES[name]
|
||||
environment = dict(os.environ)
|
||||
for key in list(environment):
|
||||
if key.startswith("MIGRATION_") or key.startswith("VACATURERADAR_"):
|
||||
environment.pop(key)
|
||||
environment.update({
|
||||
"MIGRATION_IMAGE": "migration-fixture:candidate",
|
||||
"MIGRATION_NETWORK": name.lower() + "_default",
|
||||
"MIGRATION_ATTEMPT": "fixture-migration-attempt",
|
||||
"MIGRATION_SOURCE_REVISION": "a" * 40,
|
||||
"MIGRATION_SOURCE_TREE": "b" * 40,
|
||||
"MIGRATION_BUILD_ID": "a" * 12,
|
||||
"MIGRATION_BUILD_DATE": "2026-09-09T00:00:00.000Z",
|
||||
"LUMAOPS_ADMIN_TOKEN": "fixture-only-not-a-production-token-000000",
|
||||
**variables,
|
||||
})
|
||||
if missing:
|
||||
environment.pop(missing)
|
||||
command = ["docker", "compose", "--env-file", os.devnull]
|
||||
for file in [*files, "docker-compose.migration.yml"]:
|
||||
command.extend(["-f", file])
|
||||
command.extend(["config", "--no-env-resolution", "--no-path-resolution", "--format", "json"])
|
||||
return subprocess.run(command, cwd=PROJECTS / name, env=environment,
|
||||
capture_output=True, text=True, timeout=20)
|
||||
|
||||
def test_merged_mounts_are_exclusively_explicit_clones(self):
|
||||
for name, (service, _, _, destinations) in CASES.items():
|
||||
with self.subTest(name=name):
|
||||
result = self.render(name)
|
||||
self.assertEqual(result.returncode, 0, "Fixture Compose rendering failed")
|
||||
config = json.loads(result.stdout)
|
||||
runtime = config["services"][service]
|
||||
self.assertEqual(runtime["network_mode"], name.lower() + "_default")
|
||||
self.assertEqual(config["name"], name.lower() + "-managed")
|
||||
self.assertEqual({m["target"] for m in runtime["volumes"]}, destinations)
|
||||
for mount in runtime["volumes"]:
|
||||
self.assertEqual(mount["type"], "bind")
|
||||
self.assertTrue(mount["source"].startswith("/migration-fixture/"))
|
||||
self.assertFalse(mount["bind"]["create_host_path"])
|
||||
labels = runtime["build"]["labels"]
|
||||
self.assertEqual(labels["org.opencontainers.image.revision"], "a" * 40)
|
||||
self.assertEqual(labels["org.opencontainers.image.source-tree"], "b" * 40)
|
||||
self.assertEqual(labels["org.opencontainers.image.source"], "jens/" + name.lower())
|
||||
|
||||
def test_missing_required_inputs_fail_closed(self):
|
||||
for name, (_, _, variables, _) in CASES.items():
|
||||
for variable in [*variables, "MIGRATION_NETWORK", "MIGRATION_IMAGE", "MIGRATION_ATTEMPT", "MIGRATION_SOURCE_REVISION",
|
||||
"MIGRATION_SOURCE_TREE", "MIGRATION_BUILD_ID", "MIGRATION_BUILD_DATE"]:
|
||||
with self.subTest(name=name, variable=variable):
|
||||
self.assertNotEqual(self.render(name, variable).returncode, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,49 @@
|
||||
import unittest
|
||||
|
||||
from execute import assert_original, docker_json_template, expected_labels, original_identity, verify_image
|
||||
|
||||
|
||||
class ExecuteTests(unittest.TestCase):
|
||||
def test_template_groups_function_arguments_before_json_encoding(self):
|
||||
self.assertEqual(
|
||||
docker_json_template(['.Id', 'index .Config.Labels "com.docker.compose.project"']),
|
||||
'{{json (.Id)}}\n{{json (index .Config.Labels "com.docker.compose.project")}}',
|
||||
)
|
||||
|
||||
def test_inspection_never_requests_environment(self):
|
||||
calls = []
|
||||
|
||||
def runner(command):
|
||||
calls.append(command)
|
||||
return '"' + "a" * 64 + '"\n"sha256:' + "b" * 64 + '"\ntrue\n"healthy"\n"lumaops"'
|
||||
|
||||
observed = original_identity("lumaops", runner)
|
||||
assert_original(observed, "lumaops", "a" * 64, "sha256:" + "b" * 64)
|
||||
self.assertNotIn(".Config.Env", str(calls))
|
||||
self.assertEqual(calls[0][:2], ["docker", "inspect"])
|
||||
|
||||
def test_runtime_drift_blocks(self):
|
||||
with self.assertRaises(ValueError):
|
||||
assert_original({}, "lumaops", "a" * 64, "sha256:" + "b" * 64)
|
||||
|
||||
def test_image_labels_are_checked_on_immutable_id(self):
|
||||
metadata = {"repository": "jens/lumaops", "revision": "a" * 40,
|
||||
"tree": "b" * 40, "id": "a" * 12, "builtAt": "2026-09-09T00:00:00.000Z"}
|
||||
labels = expected_labels(metadata)
|
||||
calls = []
|
||||
|
||||
def runner(command):
|
||||
calls.append(command)
|
||||
if command[-2] == "{{.Id}}":
|
||||
return "sha256:" + "c" * 64
|
||||
key = command[-2].split('"')[1]
|
||||
return labels[key]
|
||||
|
||||
self.assertEqual(verify_image("candidate", metadata, runner), "sha256:" + "c" * 64)
|
||||
self.assertTrue(all(command[-1] == "sha256:" + "c" * 64 for command in calls[1:]))
|
||||
with self.assertRaises(ValueError):
|
||||
verify_image("candidate", metadata, lambda command: "invalid")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,61 @@
|
||||
import unittest
|
||||
|
||||
from plan import build_plan
|
||||
|
||||
|
||||
def observation():
|
||||
return {
|
||||
"name": "VacatureRadar", "containerId": "a" * 64,
|
||||
"imageId": "sha256:" + "b" * 64, "running": True,
|
||||
"health": "healthy", "composeProject": "vacatureradar",
|
||||
"mounts": [
|
||||
{"type": "bind", "source": "/mnt/pool/appdata/VacatureRadar/local",
|
||||
"destination": "/app/local", "rw": True},
|
||||
{"type": "bind", "source": "/mnt/pool/appdata/VacatureRadar/local/logs",
|
||||
"destination": "/app/logs", "rw": True},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class PlanTests(unittest.TestCase):
|
||||
def test_nested_mounts_share_one_cold_copy(self):
|
||||
plan = build_plan("vacatureradar", "c" * 40, "migration-20260909", observation())
|
||||
self.assertEqual(len(plan["coldCopyRoots"]), 1)
|
||||
self.assertTrue(plan["candidateMounts"][1]["source"].endswith("/data/0/logs"))
|
||||
self.assertFalse(plan["executable"])
|
||||
self.assertIsNone(plan["originalRevision"])
|
||||
self.assertEqual(plan["originalProvenance"], "unknown-legacy")
|
||||
self.assertNotEqual(plan["candidateComposeProject"], "vacatureradar")
|
||||
|
||||
def test_rejects_runtime_drift_and_unsafe_paths(self):
|
||||
for key, value in [("name", "other"), ("containerId", "short"),
|
||||
("running", False), ("health", "starting")]:
|
||||
observed = observation()
|
||||
observed[key] = value
|
||||
with self.subTest(key=key), self.assertRaises(ValueError):
|
||||
build_plan("vacatureradar", "c" * 40, "migration-20260909", observed)
|
||||
for source in ["/", "/mnt/pool/appdata", "/mnt/pool/appdata/VacatureRadar/../other"]:
|
||||
observed = observation()
|
||||
observed["mounts"][0]["source"] = source
|
||||
with self.subTest(source=source), self.assertRaises(ValueError):
|
||||
build_plan("vacatureradar", "c" * 40, "migration-20260909", observed)
|
||||
|
||||
def test_rejects_secret_bearing_full_inspect(self):
|
||||
observed = observation()
|
||||
observed["Config"] = {"Env": ["fixture-only"]}
|
||||
with self.assertRaises(ValueError):
|
||||
build_plan("vacatureradar", "c" * 40, "migration-20260909", observed)
|
||||
|
||||
def test_anonymous_volume_is_cloned_not_reused(self):
|
||||
observed = observation()
|
||||
observed.update(name="lumaops", composeProject="lumaops", mounts=[
|
||||
{"type": "volume", "source": "/var/lib/docker/volumes/" + "d" * 64 + "/_data",
|
||||
"destination": "/config", "rw": True},
|
||||
])
|
||||
plan = build_plan("lumaops", "c" * 40, "migration-20260909", observed)
|
||||
self.assertEqual(plan["candidateMounts"][0]["type"], "bind")
|
||||
self.assertNotIn("/var/lib/docker/volumes/", plan["candidateMounts"][0]["source"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Linux filesystem qualification with isolated temporary data, never appdata."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from storage import clone_cold, private_write, tree_manifest
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "Linux ownership/fsync qualification")
|
||||
class StorageTests(unittest.TestCase):
|
||||
def test_private_replacement_is_durable_and_private(self):
|
||||
with tempfile.TemporaryDirectory(prefix="migration-storage-test-") as directory:
|
||||
path = Path(directory) / "journal.json"
|
||||
private_write(path, b"first")
|
||||
private_write(path, b"second")
|
||||
self.assertEqual(path.read_bytes(), b"second")
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
|
||||
target = Path(directory) / "linked"
|
||||
target.symlink_to(path)
|
||||
with self.assertRaises(ValueError):
|
||||
private_write(target, b"must not replace")
|
||||
self.assertEqual(path.read_bytes(), b"second")
|
||||
|
||||
def test_real_cold_copy_preserves_content_modes_and_hardlinks(self):
|
||||
with tempfile.TemporaryDirectory(prefix="migration-storage-test-") as directory:
|
||||
source = Path(directory) / "original"
|
||||
source.mkdir(mode=0o750)
|
||||
payload = source / "database"
|
||||
payload.write_bytes(b"representative fixture data")
|
||||
payload.chmod(0o640)
|
||||
os.link(payload, source / "hardlink")
|
||||
destination = Path(directory) / "candidate"
|
||||
|
||||
def runner(command, **kwargs):
|
||||
subprocess.run(command, check=True, capture_output=True, **kwargs)
|
||||
|
||||
checksum = clone_cold(source, destination, runner)
|
||||
self.assertEqual(tree_manifest(source), tree_manifest(destination))
|
||||
self.assertEqual(len(checksum), 64)
|
||||
self.assertNotEqual(payload.stat().st_ino, (destination / "database").stat().st_ino)
|
||||
(destination / "database").write_bytes(b"candidate mutation")
|
||||
self.assertEqual(payload.read_bytes(), b"representative fixture data")
|
||||
with self.assertRaises(ValueError):
|
||||
clone_cold(source, destination, runner)
|
||||
|
||||
def test_special_files_are_rejected_before_copy(self):
|
||||
with tempfile.TemporaryDirectory(prefix="migration-storage-test-") as directory:
|
||||
root = Path(directory)
|
||||
(root / "link").symlink_to("/tmp")
|
||||
with self.assertRaises(ValueError):
|
||||
tree_manifest(root)
|
||||
(root / "link").unlink()
|
||||
os.mkfifo(root / "fifo")
|
||||
with self.assertRaises(ValueError):
|
||||
tree_manifest(root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,289 @@
|
||||
"""No Docker/network calls: exercise crash phases and fail-closed recovery gates."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from storage import clone_cold, digest, dotenv, tree_manifest
|
||||
from transaction import (Production, STEPS, candidate_project,
|
||||
recover_transaction, run_transaction, validate_runtime_safety)
|
||||
|
||||
|
||||
class Crash(BaseException):
|
||||
pass
|
||||
|
||||
|
||||
class FakeOperations:
|
||||
def __init__(self, fail=None, crash=False):
|
||||
self.journal = {"phase": "snapshotted"}
|
||||
self.fail, self.crash = fail, crash
|
||||
self.original_running, self.renamed, self.candidate = True, False, False
|
||||
self.state, self.config = "original", "original"
|
||||
self.clones = False
|
||||
self.foreign = False
|
||||
self.saved = []
|
||||
|
||||
def save(self, phase):
|
||||
self.saved.append(phase)
|
||||
self.journal["phase"] = phase
|
||||
|
||||
def event(self, name):
|
||||
if self.fail == name:
|
||||
raise Crash() if self.crash else ValueError("fixture failure")
|
||||
|
||||
def stopping(self):
|
||||
self.original_running = False
|
||||
self.event("after_stop")
|
||||
|
||||
def copying(self):
|
||||
assert not self.original_running
|
||||
self.clones = True
|
||||
self.event("after_copy")
|
||||
|
||||
def renaming(self):
|
||||
self.renamed = True
|
||||
self.event("after_rename")
|
||||
|
||||
def deploying(self):
|
||||
self.candidate = True
|
||||
self.event("after_deploy")
|
||||
|
||||
def verifying(self):
|
||||
self.event("during_verify")
|
||||
|
||||
def committing(self):
|
||||
self.state = "candidate"
|
||||
self.event("after_receipt")
|
||||
self.config = "candidate"
|
||||
self.event("after_config")
|
||||
|
||||
def recover(self):
|
||||
if self.foreign:
|
||||
raise ValueError("foreign canonical container")
|
||||
self.candidate = False
|
||||
self.state = self.config = "original"
|
||||
self.renamed = False
|
||||
self.original_running = True
|
||||
|
||||
def verify_committed(self):
|
||||
assert self.state == self.config == "candidate"
|
||||
|
||||
def original(self, healthy=False):
|
||||
assert self.original_running
|
||||
|
||||
def http_health(self):
|
||||
pass
|
||||
|
||||
def activate(self):
|
||||
assert self.journal["phase"] == "committed"
|
||||
self.event("during_activation")
|
||||
self.journal["activated"] = True
|
||||
|
||||
|
||||
class TransactionTests(unittest.TestCase):
|
||||
def test_runtime_safety_parity_rejects_each_security_or_device_drift(self):
|
||||
host = {"Devices": [{"PathOnHost": "/dev/example", "PathInContainer": "/dev/example", "CgroupPermissions": "rwm"}],
|
||||
"CapAdd": ["CAP_CHOWN"], "CapDrop": ["ALL"], "ReadonlyRootfs": True,
|
||||
"SecurityOpt": ["no-new-privileges:true"], "Tmpfs": {"/tmp": "size=128m"}}
|
||||
service = {"devices": [{"source": "/dev/example", "target": "/dev/example", "permissions": "rwm"}],
|
||||
"cap_add": ["CHOWN"], "cap_drop": ["ALL"], "read_only": True,
|
||||
"security_opt": ["no-new-privileges:true"], "tmpfs": ["/tmp:size=128m"]}
|
||||
validate_runtime_safety(host, service)
|
||||
for key, value in (("devices", []), ("cap_add", []), ("cap_drop", []), ("read_only", False),
|
||||
("privileged", True), ("security_opt", []), ("tmpfs", [])):
|
||||
with self.subTest(key=key), self.assertRaises(ValueError):
|
||||
validate_runtime_safety(host, {**service, key: value})
|
||||
|
||||
def test_activation_failure_never_rolls_back_and_recovery_resumes(self):
|
||||
ops = FakeOperations(fail="during_activation")
|
||||
with self.assertRaises(ValueError):
|
||||
run_transaction(ops)
|
||||
self.assertEqual(ops.journal["phase"], "committed")
|
||||
self.assertTrue(ops.candidate)
|
||||
self.assertFalse(ops.original_running)
|
||||
ops.fail = None
|
||||
self.assertEqual(recover_transaction(ops), "committed")
|
||||
self.assertTrue(ops.journal["activated"])
|
||||
|
||||
def test_real_marker_release_requires_matching_nonce_and_resumes_after_unlink(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
ops = Production.__new__(Production)
|
||||
ops.workspace = Path(temporary).resolve()
|
||||
root = ops.workspace / "local"
|
||||
root.mkdir()
|
||||
ops.slug = "vacatureradar"
|
||||
ops.plan = {"candidateMounts": [{"destination": "/app/local", "source": str(root)}]}
|
||||
ops.journal = {"phase": "committed", "nonce": "correct", "candidateContainerId": "candidate"}
|
||||
ops.verify_committed = lambda: None
|
||||
ops.assert_candidate = lambda value: None
|
||||
ops.save = lambda phase: None
|
||||
marker = ops.hold_path()
|
||||
marker.write_bytes(b"wrong")
|
||||
with patch("transaction.private_read", side_effect=lambda path: path.read_bytes()):
|
||||
with self.assertRaises(ValueError):
|
||||
ops.activate()
|
||||
self.assertTrue(marker.exists())
|
||||
marker.write_bytes(ops.hold_bytes())
|
||||
with patch("transaction.os.open", return_value=123), patch("transaction.os.fsync"), patch("transaction.os.close"):
|
||||
ops.activate()
|
||||
self.assertFalse(marker.exists())
|
||||
self.assertTrue(ops.journal["activationIntent"])
|
||||
ops.activate()
|
||||
self.assertTrue(ops.journal["activated"])
|
||||
ops.journal.pop("activationIntent")
|
||||
with self.assertRaises(ValueError):
|
||||
ops.activate()
|
||||
|
||||
def test_intent_precedes_each_successful_phase(self):
|
||||
ops = FakeOperations()
|
||||
run_transaction(ops)
|
||||
self.assertEqual(ops.saved, [*STEPS, "committed"])
|
||||
self.assertFalse(ops.original_running)
|
||||
self.assertTrue(ops.renamed and ops.clones and ops.candidate)
|
||||
self.assertEqual(recover_transaction(ops), "committed")
|
||||
self.assertTrue(ops.candidate, "Recovery cannot discard writes after a committed migration")
|
||||
|
||||
def test_each_failure_restores_original_and_keeps_clones(self):
|
||||
for failure in ("after_stop", "after_copy", "after_rename", "after_deploy",
|
||||
"during_verify", "after_receipt", "after_config"):
|
||||
with self.subTest(failure=failure):
|
||||
ops = FakeOperations(fail=failure)
|
||||
with self.assertRaisesRegex(RuntimeError, "original runtime restored"):
|
||||
run_transaction(ops)
|
||||
self.assertTrue(ops.original_running)
|
||||
self.assertEqual(ops.state, "original")
|
||||
self.assertEqual(ops.config, "original")
|
||||
self.assertFalse(ops.candidate)
|
||||
self.assertEqual(ops.clones, failure != "after_stop")
|
||||
self.assertEqual(recover_transaction(ops), "rolled_back")
|
||||
|
||||
def test_abrupt_crash_after_stop_rename_and_receipt_requires_explicit_recovery(self):
|
||||
for failure in ("after_stop", "after_rename", "after_receipt", "after_config"):
|
||||
with self.subTest(failure=failure):
|
||||
ops = FakeOperations(fail=failure, crash=True)
|
||||
with self.assertRaises(Crash):
|
||||
run_transaction(ops)
|
||||
self.assertNotEqual(ops.journal["phase"], "committed")
|
||||
self.assertEqual(recover_transaction(ops), "rolled_back")
|
||||
self.assertTrue(ops.original_running)
|
||||
|
||||
def test_foreign_runtime_blocks_automatic_recovery(self):
|
||||
ops = FakeOperations(fail="during_verify")
|
||||
ops.foreign = True
|
||||
with self.assertRaisesRegex(RuntimeError, "requires explicit recovery"):
|
||||
run_transaction(ops)
|
||||
self.assertEqual(ops.journal["phase"], "recovery_failed")
|
||||
self.assertTrue(ops.candidate)
|
||||
|
||||
def test_production_candidate_gate_rejects_wrong_nonce_project_and_replaced_id(self):
|
||||
ops = Production.__new__(Production)
|
||||
ops.prepared = {"originalContainerId": "original"}
|
||||
ops.plan = {"candidateComposeProject": "lumaops-managed"}
|
||||
ops.journal = {"nonce": "expected", "candidateContainerId": "candidate"}
|
||||
ops.runner = object()
|
||||
for fields in (["candidate", "image", "lumaops-managed", "wrong"],
|
||||
["candidate", "image", "foreign-project", "expected"],
|
||||
["original", "image", "lumaops-managed", "expected"],
|
||||
["replacement", "image", "lumaops-managed", "expected"]):
|
||||
with self.subTest(fields=fields), patch("transaction.inspect_fields", return_value=fields):
|
||||
with self.assertRaises(ValueError):
|
||||
ops.assert_candidate(fields[0])
|
||||
|
||||
def test_future_broker_metadata_remains_dynamic(self):
|
||||
original = {"source_dir": "/old", "compose_files": ["docker-compose.unraid.yml"],
|
||||
"clean_untracked_paths": ["old"], "deploy_script": "old.sh"}
|
||||
prepared = {"source": "/exact/new"}
|
||||
plan = {"repository": "jens/vacatureradar", "candidateComposeProject": "vacatureradar-managed",
|
||||
"candidateMounts": [{"source": "/clone/local", "destination": "/app/local"}]}
|
||||
project = candidate_project(original, prepared, plan, "nonce", Path("/private/runtime.env"))
|
||||
self.assertTrue(project["exact_source"] and project["verify_oci_revision"])
|
||||
self.assertNotIn("deploy_script", project)
|
||||
self.assertEqual(project["environment"]["MIGRATION_SOURCE_REVISION"], "{revision}")
|
||||
self.assertEqual(project["environment"]["MIGRATION_SOURCE_TREE"], "{tree}")
|
||||
self.assertEqual(project["environment"]["MIGRATION_BUILD_ID"], "{build_id}")
|
||||
self.assertEqual(project["environment"]["MIGRATION_BUILD_DATE"], "{build_date}")
|
||||
|
||||
def test_dotenv_is_literal_and_rejects_unsupported_escaping(self):
|
||||
self.assertEqual(dotenv(["KEY=has $literal #value"]), b"KEY='has $literal #value'\n")
|
||||
for entries in (["KEY=one", "KEY=two"], ["BAD KEY=value"], ["KEY=has'quote"], ["KEY=line\nbreak"]):
|
||||
with self.assertRaises(ValueError):
|
||||
dotenv(entries)
|
||||
|
||||
def test_manifest_detects_content_or_mode_change(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
file = root / "data"
|
||||
file.write_bytes(b"fixture-only")
|
||||
before = digest(json.dumps(tree_manifest(root), sort_keys=True).encode())
|
||||
file.write_bytes(b"changed")
|
||||
after = digest(json.dumps(tree_manifest(root), sort_keys=True).encode())
|
||||
self.assertNotEqual(before, after)
|
||||
|
||||
def test_cold_copy_uses_archive_mode_and_rejects_metadata_mismatch(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
source, target = Path(temporary) / "source", Path(temporary) / "target"
|
||||
source.mkdir()
|
||||
calls = []
|
||||
identical = {".": {"mode": 448, "uid": 999, "gid": 999}}
|
||||
with patch("storage.tree_manifest", return_value=identical):
|
||||
clone_cold(source, target, lambda command, **kwargs: calls.append(command))
|
||||
self.assertEqual(calls[0][:4], ["cp", "-a", "--reflink=auto", "--"])
|
||||
with patch("storage.tree_manifest", side_effect=[identical, {}]):
|
||||
with self.assertRaisesRegex(ValueError, "content/metadata"):
|
||||
clone_cold(source, target, lambda *args, **kwargs: None)
|
||||
|
||||
def test_real_recovery_adapter_refuses_foreign_container_before_remove(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
config, state = b"{}", b'{"status":"failed"}'
|
||||
for name, value in [("original-projects.json", config), ("original-state.json", state),
|
||||
("projects.json", config), ("state.json", state)]:
|
||||
(workspace / name).write_bytes(value)
|
||||
ops = Production.__new__(Production)
|
||||
ops.workspace = workspace
|
||||
ops.broker = type("Broker", (), {"CONFIG_PATH": workspace / "projects.json"})()
|
||||
ops.state_path = workspace / "state.json"
|
||||
ops.prepared = {"originalContainerId": "original"}
|
||||
ops.plan = {"originalName": "lumaops", "candidateComposeProject": "lumaops-managed"}
|
||||
ops.journal = {"originalConfigSha256": digest(config), "candidateConfigSha256": "other",
|
||||
"originalStateSha256": digest(state), "nonce": "expected"}
|
||||
ops.original = lambda **kwargs: {"running": False}
|
||||
ops.canonical_id = lambda name: "foreign"
|
||||
calls = []
|
||||
ops.runner = lambda command, **kwargs: calls.append(command)
|
||||
with patch("transaction.inspect_fields", return_value=["foreign", "image", "lumaops-managed", "wrong"]):
|
||||
with self.assertRaisesRegex(ValueError, "unrelated"):
|
||||
ops.recover()
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_verification_binds_normal_broker_receipt_to_prepared_image(self):
|
||||
ops = Production.__new__(Production)
|
||||
ops.journal = {"candidateContainerId": "candidate"}
|
||||
ops.slug = "lumaops"
|
||||
ops.plan = {"originalName": "lumaops", "candidateMounts": []}
|
||||
ops.prepared = {"metadata": {"fixture": "metadata"}, "candidateImageId": "immutable", "revision": "rev"}
|
||||
ops.project, ops.repository, ops.runner = {}, "jens/lumaops", None
|
||||
ops.assert_candidate = lambda value: None
|
||||
ops.http_health = lambda: None
|
||||
ops.save = lambda value: None
|
||||
seen = []
|
||||
|
||||
class Broker:
|
||||
def verify_deployment(self, project, metadata, **kwargs):
|
||||
seen.append(kwargs)
|
||||
return {"fixture": "real-broker-receipt"}
|
||||
|
||||
def validate_recorded_receipt(self, *args):
|
||||
return None
|
||||
|
||||
ops.broker = Broker()
|
||||
with patch("transaction.mounts", return_value=[]), patch("transaction.inspect_fields", return_value=[{"lumaops_default": {}}]):
|
||||
ops.verifying()
|
||||
self.assertEqual(seen, [{"expected_image_ids": {"lumaops": "immutable"}}])
|
||||
self.assertEqual(ops.journal["receipt"], {"fixture": "real-broker-receipt"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,517 @@
|
||||
"""Journaled legacy cutover through normal broker functions; fixed two targets only.
|
||||
|
||||
No provenance is written for the original runtime. Recovery retains every data
|
||||
clone and original image/container. Completed migrations are never automatically
|
||||
rolled back: new writes may have reached candidate data after commit.
|
||||
"""
|
||||
|
||||
from contextlib import ExitStack
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shutil
|
||||
import time
|
||||
from urllib.request import urlopen
|
||||
|
||||
from execute import (JOURNAL_ROOT, assert_original, checked, docker_json_template, mount_inventory, original_identity,
|
||||
require_real_child, verify_image)
|
||||
from plan import ATTEMPT, SHA, TARGETS, build_plan
|
||||
from storage import clone_cold, digest, dotenv, private_json, private_write, tree_manifest
|
||||
|
||||
|
||||
STEPS = ("stopping", "copying", "renaming", "deploying", "verifying", "committing")
|
||||
|
||||
|
||||
def run_transaction(ops):
|
||||
"""Write intent before every side effect, allowing recovery after abrupt exit."""
|
||||
try:
|
||||
for phase in STEPS:
|
||||
ops.save(phase)
|
||||
getattr(ops, phase)()
|
||||
ops.save("committed")
|
||||
except Exception:
|
||||
try:
|
||||
ops.recover()
|
||||
ops.save("rolled_back")
|
||||
except Exception:
|
||||
ops.save("recovery_failed")
|
||||
raise RuntimeError("Migration failed and rollback requires explicit recovery") from None
|
||||
raise RuntimeError("Migration failed; original runtime restored and candidate data retained") from None
|
||||
# Outside rollback scope: releasing workers permits new external effects/data.
|
||||
ops.activate()
|
||||
|
||||
|
||||
def recover_transaction(ops):
|
||||
if ops.journal["phase"] == "committed":
|
||||
ops.verify_committed()
|
||||
ops.activate()
|
||||
return "committed"
|
||||
if ops.journal["phase"] == "rolled_back":
|
||||
ops.original(healthy=True)
|
||||
ops.http_health()
|
||||
return "rolled_back"
|
||||
ops.recover()
|
||||
ops.save("rolled_back")
|
||||
return "rolled_back"
|
||||
|
||||
|
||||
def inspect_fields(runner, target, expressions):
|
||||
template = docker_json_template(expressions)
|
||||
return [json.loads(line) for line in runner(["docker", "inspect", "--format", template, target]).splitlines()]
|
||||
|
||||
|
||||
def mounts(runner, target):
|
||||
raw = inspect_fields(runner, target, [".Mounts"])[0]
|
||||
return sorted(({"type": item["Type"], "source": item["Source"],
|
||||
"destination": item["Destination"], "rw": item["RW"]} for item in raw),
|
||||
key=lambda item: item["destination"])
|
||||
|
||||
|
||||
def candidate_project(original, prepared, plan, nonce, env_path):
|
||||
slug = plan["repository"].split("/")[1]
|
||||
by_target = {item["destination"]: item["source"] for item in plan["candidateMounts"]}
|
||||
names = ({"MIGRATION_LOCAL_ROOT": "/app/local"} if slug == "vacatureradar" else {
|
||||
"MIGRATION_CONFIG_ROOT": "/config", "MIGRATION_OPENRGB_ROOT": "/config/openrgb",
|
||||
"MIGRATION_LUMA_CONFIG_ROOT": "/config/lumaops", "MIGRATION_DATA_ROOT": "/data",
|
||||
"MIGRATION_LOGS_ROOT": "/logs"})
|
||||
environment = {**dict(original.get("environment", {})),
|
||||
**{key: by_target[target] for key, target in names.items()},
|
||||
"MIGRATION_ATTEMPT": nonce, "MIGRATION_IMAGE": f"gitea-managed/{slug}:{{revision}}",
|
||||
"MIGRATION_NETWORK": slug + "_default",
|
||||
"MIGRATION_SOURCE_REVISION": "{revision}", "MIGRATION_SOURCE_TREE": "{tree}",
|
||||
"MIGRATION_BUILD_ID": "{build_id}", "MIGRATION_BUILD_DATE": "{build_date}",
|
||||
"MIGRATION_ENV_FILE": str(env_path), "VACATURERADAR_ENV_FILE": str(env_path)}
|
||||
result = dict(original)
|
||||
for key in ("deploy_script", "archive_deploy_script", "manual", "clean_untracked_paths", "legacy_containers"):
|
||||
result.pop(key, None)
|
||||
result.update(source_dir=prepared["source"], compose_project=plan["candidateComposeProject"],
|
||||
exact_source=True, verify_oci_revision=True, env_source=str(env_path),
|
||||
environment=environment,
|
||||
compose_files=[*original["compose_files"], "docker-compose.migration.yml"])
|
||||
return result
|
||||
|
||||
|
||||
def render_contract(broker, project, prepared, plan, runner):
|
||||
cwd, command = broker.compose_command(project)
|
||||
environment = broker.deployment_environment(project, prepared["revision"], plan["repository"], prepared["metadata"])
|
||||
rendered = json.loads(runner(command + ["config", "--format", "json"], cwd=cwd, env=environment))
|
||||
services = rendered.get("services", {})
|
||||
if len(services) != 1:
|
||||
raise ValueError("Migration supports exactly one reviewed application container")
|
||||
service = next(iter(services.values()))
|
||||
if service.get("container_name") != plan["originalName"]:
|
||||
raise ValueError("Candidate canonical name differs from the plan")
|
||||
network = plan["repository"].split("/")[1] + "_default"
|
||||
original_networks = inspect_fields(runner, prepared["originalContainerId"], [".NetworkSettings.Networks"])[0]
|
||||
if set(original_networks) != {network} or service.get("network_mode") != network:
|
||||
raise ValueError("Candidate network must exactly preserve the reviewed original network")
|
||||
if prepared.get("originalNetwork") not in {None, network}:
|
||||
raise ValueError("Original network changed since preparation")
|
||||
host = inspect_fields(runner, prepared["originalContainerId"], [".HostConfig"])[0]
|
||||
validate_runtime_safety(host, service)
|
||||
expected = {item["destination"]: item["source"] for item in plan["candidateMounts"]}
|
||||
observed = {}
|
||||
for item in service.get("volumes", []):
|
||||
if item.get("type") != "bind" or item.get("read_only") or item.get("bind", {}).get("create_host_path") is not False:
|
||||
raise ValueError("Candidate contains an unreviewed writable mount")
|
||||
observed[item["target"]] = item["source"]
|
||||
if observed != expected or len(observed) != len(service.get("volumes", [])):
|
||||
raise ValueError("Rendered candidate mounts do not exactly match cold clones")
|
||||
bindings = inspect_fields(runner, prepared["originalContainerId"], [".HostConfig.PortBindings"])[0]
|
||||
old_ports = sorted((port, item.get("HostIp") or "0.0.0.0", str(item["HostPort"]))
|
||||
for port, items in bindings.items() for item in items or [])
|
||||
new_ports = sorted((f"{item['target']}/{item.get('protocol', 'tcp')}", item.get("host_ip") or "0.0.0.0", str(item["published"]))
|
||||
for item in service.get("ports", []))
|
||||
if old_ports != new_ports:
|
||||
raise ValueError("Candidate published ports differ from the original runtime")
|
||||
if service.get("labels", {}).get("io.itworx.migration-attempt") != environment["MIGRATION_ATTEMPT"]:
|
||||
raise ValueError("Candidate ownership correlation label is absent")
|
||||
return cwd, command, environment
|
||||
|
||||
|
||||
def validate_runtime_safety(host, service):
|
||||
"""Compare nonsecret hardware/security bindings; unsupported shapes fail closed."""
|
||||
old_devices = sorted((item["PathOnHost"], item["PathInContainer"], item["CgroupPermissions"])
|
||||
for item in host.get("Devices") or [])
|
||||
new_devices = sorted((item["source"], item["target"], item.get("permissions", "rwm"))
|
||||
for item in service.get("devices", []))
|
||||
if old_devices != new_devices or host.get("DeviceRequests") or host.get("DeviceCgroupRules"):
|
||||
raise ValueError("Candidate device contract differs or is unsupported")
|
||||
for actual, desired in (("CapAdd", "cap_add"), ("CapDrop", "cap_drop")):
|
||||
normalize = lambda values: sorted(value.upper().removeprefix("CAP_") for value in values or [])
|
||||
if normalize(host.get(actual)) != normalize(service.get(desired)):
|
||||
raise ValueError("Candidate capability contract differs")
|
||||
for actual, desired in (("ReadonlyRootfs", "read_only"), ("Privileged", "privileged")):
|
||||
if bool(host.get(actual)) != bool(service.get(desired)):
|
||||
raise ValueError("Candidate filesystem/privilege contract differs")
|
||||
if sorted(host.get("SecurityOpt") or []) != sorted(service.get("security_opt") or []):
|
||||
raise ValueError("Candidate security options differ")
|
||||
new_tmpfs = {}
|
||||
for item in service.get("tmpfs") or []:
|
||||
target, separator, options = item.partition(":")
|
||||
if target in new_tmpfs:
|
||||
raise ValueError("Duplicate candidate tmpfs target")
|
||||
new_tmpfs[target] = options if separator else ""
|
||||
if (host.get("Tmpfs") or {}) != new_tmpfs:
|
||||
raise ValueError("Candidate tmpfs contract differs")
|
||||
|
||||
|
||||
def prepare_compose(broker, slug, revision, attempt, original, prepared, workspace, runner):
|
||||
if slug == "vacatureradar":
|
||||
source = Path(prepared["source"])
|
||||
required = {
|
||||
"scripts/migration_worker_gate.py": (".migration-worker-hold", "while check():"),
|
||||
"deployment/unraid/supervisord.conf": ("migration_worker_gate.py worker", "migration_worker_gate.py scheduler"),
|
||||
"config/settings.py": ("apps.core.middleware.MigrationMaintenanceMiddleware",),
|
||||
"apps/core/middleware.py": ("class MigrationMaintenanceMiddleware", "migration_maintenance"),
|
||||
}
|
||||
for filename, fragments in required.items():
|
||||
content = (source / filename).read_text()
|
||||
if any(fragment not in content for fragment in fragments):
|
||||
raise ValueError("Exact source lacks the reviewed maintenance contract")
|
||||
observed = {"name": TARGETS[slug][0], **original_identity(prepared["originalContainerId"], runner),
|
||||
"mounts": prepared["originalMounts"]}
|
||||
plan = build_plan(slug, revision, attempt, observed)
|
||||
if Path(plan["workspace"]).resolve() != workspace:
|
||||
raise ValueError("Plan workspace differs from the protected preparation directory")
|
||||
nonce = secrets.token_hex(16)
|
||||
env_path = workspace / "runtime.env"
|
||||
entries = inspect_fields(runner, prepared["originalContainerId"], [".Config.Env"])[0]
|
||||
private_write(env_path, dotenv(entries))
|
||||
prepared["originalNetwork"] = slug + "_default"
|
||||
candidate = candidate_project(original, prepared, plan, nonce, env_path)
|
||||
repo = broker.repository_path(plan["repository"], original)
|
||||
broker.validate_project_config(repo, candidate, revision)
|
||||
cwd, command, environment = render_contract(broker, candidate, prepared, plan, runner)
|
||||
return candidate, command + ["build"], cwd, environment, plan, nonce
|
||||
|
||||
|
||||
class Production:
|
||||
def __init__(self, broker, slug, prepared, workspace, journal, runner=checked):
|
||||
self.broker, self.slug, self.prepared = broker, slug, prepared
|
||||
self.workspace, self.journal, self.runner = workspace, journal, runner
|
||||
self.repository = f"jens/{slug}"
|
||||
self.plan = journal["plan"]
|
||||
self.project = dict(journal["candidateProject"])
|
||||
self.project.update(_repository_name=self.repository,
|
||||
_repository_path=journal["repositoryPath"],
|
||||
_deployment_metadata=prepared["metadata"])
|
||||
self.state_path = broker.STATE_ROOT / f"{slug}.json"
|
||||
|
||||
def save(self, phase):
|
||||
self.journal["phase"] = phase
|
||||
private_json(self.workspace / "cutover.json", self.journal)
|
||||
|
||||
def canonical_id(self, name):
|
||||
result = self.runner(["docker", "container", "ls", "--all", "--quiet", "--no-trunc",
|
||||
"--filter", f"name=^/{name}$"]).strip().splitlines()
|
||||
if len(result) > 1:
|
||||
raise ValueError("Ambiguous container identity")
|
||||
return result[0] if result else None
|
||||
|
||||
def original(self, healthy=False):
|
||||
container_id = self.prepared["originalContainerId"]
|
||||
observed = original_identity(container_id, self.runner)
|
||||
if observed["imageId"] != self.prepared["originalImageId"] or observed["composeProject"] != self.slug:
|
||||
raise ValueError("Original immutable runtime identity changed")
|
||||
if mounts(self.runner, container_id) != self.journal["originalMounts"]:
|
||||
raise ValueError("Original mounts changed")
|
||||
networks = inspect_fields(self.runner, container_id, [".NetworkSettings.Networks"])[0]
|
||||
if set(networks) != {self.slug + "_default"}:
|
||||
raise ValueError("Original network changed")
|
||||
if healthy:
|
||||
assert_original(observed, self.slug, container_id, self.prepared["originalImageId"])
|
||||
return observed
|
||||
|
||||
def stopping(self):
|
||||
self.original(healthy=True)
|
||||
self.runner(["docker", "stop", "--time", "60", self.prepared["originalContainerId"]], timeout=90)
|
||||
if self.original()["running"]:
|
||||
raise ValueError("Original did not stop")
|
||||
|
||||
def copying(self):
|
||||
if self.original()["running"]:
|
||||
raise ValueError("Cold copy requires stopped original")
|
||||
checksums = {}
|
||||
for item in self.plan["coldCopyRoots"]:
|
||||
original = Path(item["original"])
|
||||
if original.is_symlink() or original.resolve() != original:
|
||||
raise ValueError("Unreviewed symlink in original cold-copy root")
|
||||
target = require_real_child(Path(item["candidate"]), self.workspace)
|
||||
checksums[item["original"]] = clone_cold(original, target, self.runner)
|
||||
self.journal["coldCopyChecksums"] = checksums
|
||||
self.save("copying")
|
||||
if self.slug == "vacatureradar":
|
||||
marker = self.hold_path()
|
||||
if marker.exists() or marker.is_symlink():
|
||||
raise ValueError("Cold clone already contains a maintenance hold")
|
||||
private_write(marker, self.hold_bytes())
|
||||
|
||||
def hold_path(self):
|
||||
roots = [item["source"] for item in self.plan["candidateMounts"]
|
||||
if item["destination"] == "/app/local"]
|
||||
if len(roots) != 1:
|
||||
raise ValueError("Maintenance hold requires one exact cloned local root")
|
||||
return require_real_child(Path(roots[0]) / ".migration-worker-hold", self.workspace)
|
||||
|
||||
def hold_bytes(self):
|
||||
return ("legacy-migration.v1:" + self.journal["nonce"] + "\n").encode()
|
||||
|
||||
def verify_hold(self):
|
||||
if self.slug == "vacatureradar" and private_read(self.hold_path()) != self.hold_bytes():
|
||||
raise ValueError("Maintenance hold identity does not match this attempt")
|
||||
|
||||
def activate(self):
|
||||
if self.journal.get("phase") != "committed":
|
||||
raise ValueError("Activation requires a durable committed transaction")
|
||||
self.verify_committed()
|
||||
self.assert_candidate(self.journal["candidateContainerId"])
|
||||
if self.slug == "vacatureradar":
|
||||
marker = self.hold_path()
|
||||
if marker.exists() or marker.is_symlink():
|
||||
self.verify_hold()
|
||||
self.journal["activationIntent"] = True
|
||||
self.save("committed")
|
||||
marker.unlink()
|
||||
descriptor = os.open(marker.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
elif not self.journal.get("activationIntent"):
|
||||
raise ValueError("Maintenance hold disappeared before activation intent")
|
||||
self.journal["activated"] = True
|
||||
self.save("committed")
|
||||
|
||||
def renaming(self):
|
||||
if self.canonical_id(self.plan["retainedName"]):
|
||||
raise ValueError("Retained container name is already occupied")
|
||||
if self.canonical_id(self.plan["originalName"]) != self.prepared["originalContainerId"]:
|
||||
raise ValueError("Canonical container identity changed")
|
||||
self.runner(["docker", "rename", self.prepared["originalContainerId"], self.plan["retainedName"]])
|
||||
|
||||
def deploying(self):
|
||||
self.verify_hold()
|
||||
if self.canonical_id(self.plan["originalName"]):
|
||||
raise ValueError("Canonical name was claimed before candidate deployment")
|
||||
# True first deploy relative to the distinct managed Compose project.
|
||||
if self.broker.capture_compose_rollback(self.project) is not None:
|
||||
raise ValueError("Candidate project is not a first deployment")
|
||||
self.broker.revalidate_deployment_candidate(Path(self.journal["repositoryPath"]), self.project,
|
||||
self.prepared["revision"], self.repository, self.prepared["metadata"])
|
||||
cwd, command, environment = render_contract(self.broker, self.project, self.prepared, self.plan, self.runner)
|
||||
image_id = verify_image(environment["MIGRATION_IMAGE"], self.prepared["metadata"], self.runner)
|
||||
if image_id != self.prepared["candidateImageId"]:
|
||||
raise ValueError("Prepared image tag changed before candidate start")
|
||||
self.runner(command + ["up", "--detach", "--no-build", "--pull", "never"],
|
||||
cwd=cwd, env=environment, timeout=180)
|
||||
container_id = self.canonical_id(self.plan["originalName"])
|
||||
if not container_id:
|
||||
raise ValueError("Candidate deployment produced no canonical container")
|
||||
self.assert_candidate(container_id)
|
||||
self.journal["candidateContainerId"] = container_id
|
||||
self.save("deploying")
|
||||
|
||||
def assert_candidate(self, container_id):
|
||||
values = inspect_fields(self.runner, container_id, [".Id", ".Image",
|
||||
'index .Config.Labels "com.docker.compose.project"',
|
||||
'index .Config.Labels "io.itworx.migration-attempt"'])
|
||||
if (values[0] != container_id or container_id == self.prepared["originalContainerId"]
|
||||
or values[2] != self.plan["candidateComposeProject"] or values[3] != self.journal["nonce"]):
|
||||
raise ValueError("Refusing an unrelated candidate container")
|
||||
recorded = self.journal.get("candidateContainerId")
|
||||
if recorded and recorded != container_id:
|
||||
raise ValueError("Recorded candidate container was replaced")
|
||||
|
||||
def http_health(self):
|
||||
path = "http://127.0.0.1:1226/health/ready/" if self.slug == "vacatureradar" else "http://127.0.0.1:1223/health/ready"
|
||||
with urlopen(path, timeout=10) as response:
|
||||
if response.status != 200 or response.geturl() != path:
|
||||
raise ValueError("Representative readiness probe failed")
|
||||
|
||||
def verifying(self):
|
||||
self.verify_hold()
|
||||
self.assert_candidate(self.journal["candidateContainerId"])
|
||||
expected = sorted(self.plan["candidateMounts"], key=lambda item: item["destination"])
|
||||
if mounts(self.runner, self.journal["candidateContainerId"]) != expected:
|
||||
raise ValueError("Candidate mounts differ from the isolated cold-copy plan")
|
||||
networks = inspect_fields(self.runner, self.journal["candidateContainerId"], [".NetworkSettings.Networks"])[0]
|
||||
if set(networks) != {self.slug + "_default"}:
|
||||
raise ValueError("Candidate runtime network differs from the original")
|
||||
receipt = self.broker.verify_deployment(self.project, self.prepared["metadata"],
|
||||
expected_image_ids={self.plan["originalName"]: self.prepared["candidateImageId"]})
|
||||
self.broker.validate_recorded_receipt(self.project, receipt, self.repository, self.prepared["revision"])
|
||||
self.http_health()
|
||||
self.journal["receipt"] = receipt
|
||||
self.save("verifying")
|
||||
|
||||
def committing(self):
|
||||
self.broker.write_state(self.repository, self.project, self.prepared["revision"], "success",
|
||||
"Controlled legacy migration passed immutable image and readiness verification",
|
||||
live_receipt=self.journal["receipt"], attempt_metadata=self.prepared["metadata"])
|
||||
# Never alter another project's configuration. Both deployment locks held.
|
||||
current = self.broker.CONFIG_PATH.read_bytes()
|
||||
if digest(current) != self.journal["originalConfigSha256"]:
|
||||
raise ValueError("Broker configuration changed during cutover")
|
||||
private_write(self.broker.CONFIG_PATH, (self.workspace / "candidate-projects.json").read_bytes())
|
||||
self.verify_committed()
|
||||
|
||||
def verify_committed(self):
|
||||
if digest(self.broker.CONFIG_PATH.read_bytes()) != self.journal["candidateConfigSha256"]:
|
||||
raise ValueError("Committed broker configuration no longer matches")
|
||||
receipt = self.broker.verified_current_receipt(self.repository, self.project,
|
||||
self.prepared["revision"], self.prepared["metadata"]["tree"])
|
||||
if receipt is None:
|
||||
raise ValueError("Committed runtime receipt does not reverify")
|
||||
self.http_health()
|
||||
|
||||
def recover(self):
|
||||
if self.journal.get("phase") == "committed" or self.journal.get("activationIntent"):
|
||||
raise ValueError("Committed activation must never roll back")
|
||||
for filename, key in (("original-projects.json", "originalConfigSha256"),
|
||||
("original-state.json", "originalStateSha256")):
|
||||
if digest((self.workspace / filename).read_bytes()) != self.journal[key]:
|
||||
raise ValueError("Protected rollback snapshot integrity failed")
|
||||
config = self.broker.CONFIG_PATH.read_bytes()
|
||||
if digest(config) not in {self.journal["originalConfigSha256"], self.journal["candidateConfigSha256"]}:
|
||||
raise ValueError("Unrelated broker configuration change blocks automatic recovery")
|
||||
state = self.state_path.read_bytes()
|
||||
if digest(state) != self.journal["originalStateSha256"]:
|
||||
payload = json.loads(state)
|
||||
if payload.get("live_receipt") != self.journal.get("receipt") or not self.journal.get("receipt"):
|
||||
raise ValueError("Unrelated deployment state blocks automatic recovery")
|
||||
self.original()
|
||||
if not self.journal.get("originalRestartIntent"):
|
||||
for source, expected in self.journal.get("coldCopyChecksums", {}).items():
|
||||
if digest(json.dumps(tree_manifest(source), sort_keys=True).encode()) != expected:
|
||||
raise ValueError("Original data changed after verified cold copy")
|
||||
container_id = self.canonical_id(self.plan["originalName"])
|
||||
if container_id and container_id != self.prepared["originalContainerId"]:
|
||||
self.assert_candidate(container_id)
|
||||
# Persist discovered ownership before removing a crash-created container.
|
||||
self.journal["candidateContainerId"] = container_id
|
||||
self.save("recovering")
|
||||
self.runner(["docker", "rm", "--force", container_id], timeout=90)
|
||||
if self.canonical_id(self.plan["originalName"]) not in {None, self.prepared["originalContainerId"]}:
|
||||
raise ValueError("Canonical name is occupied by unrelated runtime")
|
||||
current_name = inspect_fields(self.runner, self.prepared["originalContainerId"], [".Name"])[0].lstrip("/")
|
||||
if current_name not in {self.plan["originalName"], self.plan["retainedName"]}:
|
||||
raise ValueError("Original runtime was renamed by another operator")
|
||||
if current_name != self.plan["originalName"]:
|
||||
self.runner(["docker", "rename", self.prepared["originalContainerId"], self.plan["originalName"]])
|
||||
private_write(self.broker.CONFIG_PATH, (self.workspace / "original-projects.json").read_bytes())
|
||||
private_write(self.state_path, (self.workspace / "original-state.json").read_bytes())
|
||||
self.journal["originalRestartIntent"] = True
|
||||
self.save("restoring_original")
|
||||
if not self.original()["running"]:
|
||||
self.runner(["docker", "start", self.prepared["originalContainerId"]], timeout=90)
|
||||
deadline = time.monotonic() + 240
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
self.original(healthy=True)
|
||||
self.http_health()
|
||||
return
|
||||
except (ValueError, OSError):
|
||||
time.sleep(3)
|
||||
raise ValueError("Original runtime recovery health did not verify")
|
||||
|
||||
|
||||
def private_read(path):
|
||||
import os
|
||||
import stat
|
||||
info = path.lstat()
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) & 0o077:
|
||||
raise ValueError("Expected a root-only regular transaction file")
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
def operate(broker, mode, slug, revision, attempt, container_id, image_id, runner=checked):
|
||||
if mode not in {"cutover", "recover"} or slug not in TARGETS or not SHA.fullmatch(revision) or not ATTEMPT.fullmatch(attempt):
|
||||
raise ValueError("Invalid fixed migration operation")
|
||||
workspace = require_real_child(JOURNAL_ROOT / slug / attempt, JOURNAL_ROOT)
|
||||
repository = f"jens/{slug}"
|
||||
with ExitStack() as locks:
|
||||
locks.enter_context(broker.exclusive_lock(broker.STATE_ROOT / "locks" / f"{slug}.lock", "migration project"))
|
||||
locks.enter_context(broker.exclusive_lock(broker.GLOBAL_DEPLOY_LOCK, "migration production"))
|
||||
prepared_bytes = private_read(workspace / "prepare.json")
|
||||
prepared = json.loads(prepared_bytes)
|
||||
if (prepared.get("phase") != "build_verified" or prepared.get("repository") != repository
|
||||
or prepared.get("revision") != revision or prepared.get("originalContainerId") != container_id
|
||||
or prepared.get("originalImageId") != image_id):
|
||||
raise ValueError("Preparation identity does not match the requested operation")
|
||||
journal_path = workspace / "cutover.json"
|
||||
if mode == "recover":
|
||||
journal = json.loads(private_read(journal_path))
|
||||
if journal.get("preparedSha256") != digest(prepared_bytes):
|
||||
raise ValueError("Prepared artifact changed after transaction snapshot")
|
||||
ops = Production(broker, slug, prepared, workspace, journal, runner)
|
||||
phase = recover_transaction(ops)
|
||||
return {"phase": phase, "repository": repository, "candidateDataRetained": True}
|
||||
if journal_path.exists():
|
||||
raise ValueError("Transaction already exists; use explicit recover, never repeat cutover")
|
||||
projects_bytes = broker.CONFIG_PATH.read_bytes()
|
||||
projects = json.loads(projects_bytes)
|
||||
original = projects[repository]
|
||||
state_bytes = (broker.STATE_ROOT / f"{slug}.json").read_bytes()
|
||||
if (digest(json.dumps(original, sort_keys=True).encode()) != prepared.get("projectConfigSha256")
|
||||
or digest(state_bytes) != prepared.get("stateSha256")):
|
||||
raise ValueError("Original broker config/state changed since preparation")
|
||||
if broker.state_live_revision(json.loads(state_bytes)):
|
||||
raise ValueError("This transaction only supports unattested legacy first deployments")
|
||||
repo = broker.repository_path(repository, original)
|
||||
broker.validate_commit(repo, str(original["branch"]), revision)
|
||||
if broker.output(["git", f"--git-dir={repo}", "rev-parse", f"refs/heads/{original['branch']}"]) != revision:
|
||||
raise ValueError("Prepared revision is no longer the configured branch head")
|
||||
source = require_real_child(Path(prepared["source"]), broker.EXACT_SOURCE_ROOT)
|
||||
if not broker.exact_source_matches_revision(repo, revision, source):
|
||||
raise ValueError("Prepared source changed before cutover")
|
||||
if verify_image(prepared["candidateImage"], prepared["metadata"], runner) != prepared["candidateImageId"]:
|
||||
raise ValueError("Prepared immutable image changed")
|
||||
assert_original(original_identity(container_id, runner), slug, container_id, image_id)
|
||||
if mount_inventory(container_id, runner) != prepared["originalMounts"]:
|
||||
raise ValueError("Original mounts changed after preparation")
|
||||
if digest(private_read(workspace / "runtime.env")) != prepared["runtimeEnvSha256"]:
|
||||
raise ValueError("Private runtime environment changed after preparation")
|
||||
plan = build_plan(slug, revision, attempt, {"name": TARGETS[slug][0],
|
||||
**original_identity(container_id, runner), "mounts": prepared["originalMounts"]})
|
||||
if plan != prepared["plan"]:
|
||||
raise ValueError("Cold-copy plan changed after preparation")
|
||||
candidate = candidate_project(original, prepared, plan, prepared["nonce"], workspace / "runtime.env")
|
||||
if candidate != prepared["candidateProject"]:
|
||||
raise ValueError("Candidate project differs from the build configuration")
|
||||
broker.validate_project_config(repo, candidate, revision)
|
||||
render_contract(broker, candidate, prepared, plan, runner)
|
||||
required = 8 * 1024**3
|
||||
for item in plan["coldCopyRoots"]:
|
||||
root = Path(item["original"])
|
||||
if root.is_symlink() or root.resolve() != root or not root.is_dir():
|
||||
raise ValueError("Cold-copy source is not an unchanged real directory")
|
||||
required += sum(path.stat().st_size for path in root.rglob("*") if path.is_file())
|
||||
if Path(item["candidate"]).exists():
|
||||
raise ValueError("Cold-copy target already exists")
|
||||
if shutil.disk_usage(workspace).free < required:
|
||||
raise ValueError("Insufficient space for retained cold clones plus safety reserve")
|
||||
updated = {**projects, repository: candidate}
|
||||
candidate_bytes = json.dumps(updated, sort_keys=True).encode()
|
||||
private_write(workspace / "original-projects.json", projects_bytes)
|
||||
private_write(workspace / "original-state.json", state_bytes)
|
||||
private_write(workspace / "candidate-projects.json", candidate_bytes)
|
||||
# Configuration may contain secrets: stored privately, never in user output.
|
||||
snapshot = inspect_fields(runner, container_id, [".Config", ".HostConfig", ".NetworkSettings.Networks"])
|
||||
private_json(workspace / "original-container.json", snapshot)
|
||||
journal = {"schema": "legacy-cutover.v1", "phase": "snapshotted", "plan": plan,
|
||||
"preparedSha256": digest(prepared_bytes), "candidateProject": candidate,
|
||||
"repositoryPath": str(repo), "nonce": prepared["nonce"], "originalMounts": prepared["originalMounts"],
|
||||
"originalConfigSha256": digest(projects_bytes), "originalStateSha256": digest(state_bytes),
|
||||
"candidateConfigSha256": digest(candidate_bytes)}
|
||||
ops = Production(broker, slug, prepared, workspace, journal, runner)
|
||||
ops.http_health()
|
||||
if ops.canonical_id(plan["retainedName"]) or broker.compose_project_container_names(plan["candidateComposeProject"]):
|
||||
raise ValueError("Attempt runtime/project is already occupied")
|
||||
runner(["docker", "image", "tag", image_id, f"gitea-legacy-rollback/{slug}:{prepared['nonce']}"])
|
||||
ops.save("snapshotted")
|
||||
run_transaction(ops)
|
||||
return {"phase": "committed", "repository": repository, "revision": revision,
|
||||
"originalContainerRetained": True, "originalDataRetained": True,
|
||||
"receiptVerified": True, "journal": str(journal_path)}
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const root = process.cwd();
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const asJson = args.has("--json");
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
|
||||
const exists = (file) => fs.existsSync(path.join(root, file));
|
||||
const normalizePath = (file) => file.replaceAll(path.sep, "/");
|
||||
const walk = (dir, filter = () => true) => {
|
||||
const out = [];
|
||||
const base = path.join(root, dir);
|
||||
if (!fs.existsSync(base)) return out;
|
||||
const stack = [base];
|
||||
while (stack.length) {
|
||||
const current = stack.pop();
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (!["node_modules", "dist", ".git", ".next", "coverage"].includes(entry.name)) stack.push(full);
|
||||
} else if (filter(full)) {
|
||||
out.push(normalizePath(path.relative(root, full)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.sort();
|
||||
};
|
||||
|
||||
const sourceFiles = walk("src", (file) => /\.(ts|tsx)$/.test(file));
|
||||
const backendFiles = walk("backend/src", (file) => /\.(ts|tsx)$/.test(file));
|
||||
const docs = walk("docs", (file) => file.endsWith(".md"));
|
||||
const routeText = exists("src/app/routes.tsx") ? read("src/app/routes.tsx") : "";
|
||||
const allTextFiles = [...sourceFiles, ...backendFiles].filter((file) => exists(file));
|
||||
const implementationFiles = allTextFiles.filter((file) => ![
|
||||
"src/shared/lib/mock-data.ts",
|
||||
"src/shared/lib/projectAudit.ts",
|
||||
].includes(file));
|
||||
const countPattern = (files, pattern) => files.reduce((count, file) => count + ((read(file).match(pattern) ?? []).length), 0);
|
||||
const filesWith = (files, pattern) => files.filter((file) => pattern.test(read(file)));
|
||||
const firstMatches = (files, pattern, limit = 20) => {
|
||||
const out = [];
|
||||
for (const file of files) {
|
||||
const lines = read(file).split(/\r?\n/);
|
||||
for (const [index, line] of lines.entries()) {
|
||||
if (pattern.test(line)) out.push({ file, line: index + 1, text: line.trim().slice(0, 180) });
|
||||
pattern.lastIndex = 0;
|
||||
if (out.length >= limit) return out;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const rawConsoleMatches = firstMatches(implementationFiles, /console\./g, 50);
|
||||
const containedConsoleMatches = rawConsoleMatches.filter((match) => normalizePath(match.file) === "src/shared/lib/appLogger.ts");
|
||||
const uncontainedConsoleMatches = rawConsoleMatches.filter((match) => normalizePath(match.file) !== "src/shared/lib/appLogger.ts");
|
||||
const directMockDataFiles = filesWith(implementationFiles, /mockData/).filter((file) => file !== "src/shared/lib/projectbrainSelectors.ts");
|
||||
const anyCastMatches = firstMatches(implementationFiles, /\bas any\b/g, 50);
|
||||
const localhostMatches = firstMatches(implementationFiles, /localhost|127\.0\.0\.1/g, 50);
|
||||
const localStorageMatches = firstMatches(implementationFiles, /localStorage/g, 50);
|
||||
const auditCatalogText = exists("src/shared/lib/projectAudit.ts") ? read("src/shared/lib/projectAudit.ts") : "";
|
||||
const queuedAuditItems = (auditCatalogText.match(/queued_for_/g) ?? []).length;
|
||||
const v59AuditItems = (auditCatalogText.match(/implemented_in_v59/g) ?? []).length;
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
totals: {
|
||||
frontendFiles: sourceFiles.length,
|
||||
backendFiles: backendFiles.length,
|
||||
docs: docs.length,
|
||||
routes: (routeText.match(/<Route\s/g) ?? []).length,
|
||||
},
|
||||
signals: {
|
||||
todoFixme: countPattern(implementationFiles, /TODO|FIXME|HACK|XXX/g),
|
||||
directMockDataFiles: directMockDataFiles.length,
|
||||
consoleUsage: countPattern(implementationFiles, /console\./g),
|
||||
uncontainedConsoleUsage: uncontainedConsoleMatches.length,
|
||||
localStorageUsage: countPattern(implementationFiles, /localStorage/g),
|
||||
anyCasts: countPattern(implementationFiles, /\bas any\b/g),
|
||||
localhostReferences: countPattern(implementationFiles, /localhost|127\.0\.0\.1/g),
|
||||
queuedAuditItems,
|
||||
v59AuditItems,
|
||||
},
|
||||
details: {
|
||||
directMockDataFiles,
|
||||
uncontainedConsoleMatches,
|
||||
containedConsoleMatches,
|
||||
anyCastMatches,
|
||||
localStorageMatches,
|
||||
localhostMatches,
|
||||
},
|
||||
gates: {
|
||||
auditCenterRoute: routeText.includes("/audit"),
|
||||
auditCatalog: exists("src/shared/lib/projectAudit.ts"),
|
||||
auditDocs: exists("docs/PROJECTBRAIN_CSI_AUDIT_200_FINDINGS.md"),
|
||||
blueprintEngineRoute: routeText.includes("/blueprint"),
|
||||
v57Docs: exists("docs/STABILIZATION_PASS_V57_FULL_CSI_AUDIT.md"),
|
||||
v58Docs: exists("docs/STABILIZATION_PASS_V58_QUEUE_BURNDOWN.md"),
|
||||
v59Docs: exists("docs/STABILIZATION_PASS_V59_AUDIT_QUEUE_COMPLETION.md"),
|
||||
appLogger: exists("src/shared/lib/appLogger.ts"),
|
||||
},
|
||||
};
|
||||
|
||||
const pass =
|
||||
report.gates.auditCenterRoute &&
|
||||
report.gates.auditCatalog &&
|
||||
report.gates.auditDocs &&
|
||||
report.gates.appLogger &&
|
||||
report.totals.routes > 20 &&
|
||||
report.signals.uncontainedConsoleUsage === 0 &&
|
||||
report.signals.directMockDataFiles <= 1 &&
|
||||
report.signals.anyCasts <= 3 &&
|
||||
report.signals.localhostReferences === 0 &&
|
||||
report.signals.queuedAuditItems === 0 &&
|
||||
report.gates.v59Docs;
|
||||
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify({ pass, ...report }, null, 2));
|
||||
} else {
|
||||
console.log(`ProjectBrain CSI audit: ${pass ? "PASS" : "WARN"}`);
|
||||
console.log(`Frontend files: ${report.totals.frontendFiles}`);
|
||||
console.log(`Backend files: ${report.totals.backendFiles}`);
|
||||
console.log(`Docs: ${report.totals.docs}`);
|
||||
console.log(`Routes: ${report.totals.routes}`);
|
||||
console.log(`TODO/FIXME/HACK/XXX markers: ${report.signals.todoFixme}`);
|
||||
console.log(`Implementation files with direct mockData usage: ${report.signals.directMockDataFiles}`);
|
||||
console.log(`console.* usages: ${report.signals.consoleUsage} (${report.signals.uncontainedConsoleUsage} uncontained)`);
|
||||
console.log(`localStorage usages: ${report.signals.localStorageUsage}`);
|
||||
console.log(`'as any' casts: ${report.signals.anyCasts}`);
|
||||
console.log(`localhost references: ${report.signals.localhostReferences}`);
|
||||
console.log(`queued audit items: ${report.signals.queuedAuditItems}`);
|
||||
console.log(`V59 audit completions: ${report.signals.v59AuditItems}`);
|
||||
console.log(`Audit Center route: ${report.gates.auditCenterRoute ? "yes" : "no"}`);
|
||||
console.log(`Audit catalog: ${report.gates.auditCatalog ? "yes" : "no"}`);
|
||||
console.log(`Audit docs: ${report.gates.auditDocs ? "yes" : "no"}`);
|
||||
console.log(`App logger: ${report.gates.appLogger ? "yes" : "no"}`);
|
||||
console.log(`V59 docs: ${report.gates.v59Docs ? "yes" : "no"}`);
|
||||
if (!pass) {
|
||||
if (directMockDataFiles.length) console.log(`Direct mockData files: ${directMockDataFiles.join(", ")}`);
|
||||
if (uncontainedConsoleMatches.length) console.log(`Uncontained console usage: ${uncontainedConsoleMatches.map((m) => `${m.file}:${m.line}`).join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!pass) process.exitCode = 1;
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const root = process.cwd();
|
||||
const errors = [];
|
||||
const localArtifacts = [];
|
||||
const info = [];
|
||||
const showLocalArtifacts =
|
||||
process.env.PROJECTBRAIN_AUDIT_VERBOSE_LOCAL_ARTIFACTS === "1";
|
||||
|
||||
const requireFile = (path, label = path) => {
|
||||
if (!existsSync(join(root, path))) errors.push(`Missing ${label}: ${path}`);
|
||||
};
|
||||
|
||||
const requireText = (path, text, label = text) => {
|
||||
const fullPath = join(root, path);
|
||||
if (!existsSync(fullPath)) {
|
||||
errors.push(`Missing file for text check: ${path}`);
|
||||
return;
|
||||
}
|
||||
const content = readFileSync(fullPath, "utf8");
|
||||
if (!content.includes(text)) errors.push(`Missing ${label} in ${path}`);
|
||||
};
|
||||
|
||||
const warnIfExists = (path, label = path) => {
|
||||
if (existsSync(join(root, path))) localArtifacts.push(`Generated or legacy artifact present: ${label}`);
|
||||
};
|
||||
|
||||
const rootPackage = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
const backendPackage = JSON.parse(readFileSync(join(root, "backend/package.json"), "utf8"));
|
||||
|
||||
const parseWorkspaceAllowBuilds = (workspaceYaml) => {
|
||||
const allowed = new Set();
|
||||
const allowBuildsIndex = workspaceYaml.indexOf("allowBuilds:");
|
||||
if (allowBuildsIndex === -1) return allowed;
|
||||
const lines = workspaceYaml.slice(allowBuildsIndex).split("\n").slice(1);
|
||||
for (const line of lines) {
|
||||
if (/^[^\s#]/.test(line)) break;
|
||||
const match = line.match(/^\s*[\'"]?([^\'":]+)[\'"]?\s*:\s*(true|false)\s*(?:#.*)?$/);
|
||||
if (match && match[2] === "true") allowed.add(match[1].trim());
|
||||
}
|
||||
return allowed;
|
||||
};
|
||||
const workspaceAllowBuilds = existsSync(join(root, "pnpm-workspace.yaml"))
|
||||
? parseWorkspaceAllowBuilds(readFileSync(join(root, "pnpm-workspace.yaml"), "utf8"))
|
||||
: new Set();
|
||||
|
||||
if (rootPackage.packageManager !== "pnpm@11.5.2") {
|
||||
errors.push("Root package.json must pin packageManager to pnpm@11.5.2 so Docker/Corepack does not float to a different pnpm release.");
|
||||
}
|
||||
const requiredPnpmBuiltDeps = ["@prisma/client", "@prisma/engines", "@swc/core", "esbuild", "prisma"];
|
||||
for (const dependency of requiredPnpmBuiltDeps) {
|
||||
if (!workspaceAllowBuilds.has(dependency)) {
|
||||
errors.push(`pnpm-workspace.yaml allowBuilds must approve ${dependency} for Docker builds with pnpm 11.`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("ProjectBrain doctor");
|
||||
console.log("===================");
|
||||
|
||||
[
|
||||
"README.md",
|
||||
".env.example",
|
||||
"backend/.env.example",
|
||||
"docs/PROJECTBRAIN_DOCUMENTATION_INDEX.md",
|
||||
"docs/FULL_VERSION_ROADMAP.md",
|
||||
"docs/VERTICAL_SLICE_CORE_FLOW_PLAN.md",
|
||||
"docs/PROJECTBRAIN_STATUS_AND_GATES.md",
|
||||
"docs/P2_LOCAL_RUNTIME_VERIFICATION.md",
|
||||
"docs/RUNTIME_ROUND_5_BACKEND_HEALTH_AND_SUITE.md",
|
||||
"docs/PROJECTBRAIN_RUNTIME_COMMANDS.md",
|
||||
"backend/scripts/smoke-p2.mjs",
|
||||
"backend/scripts/smoke-fixture-api.mjs",
|
||||
"backend/scripts/smoke-real-read-pilot.mjs",
|
||||
"backend/scripts/smoke-vertical-slice.mjs",
|
||||
"backend/scripts/smoke-runtime-suite.mjs",
|
||||
"backend/scripts/docker-entrypoint.sh",
|
||||
"scripts/check-database-readiness.mjs",
|
||||
"public/site.webmanifest",
|
||||
"public/favicon.svg",
|
||||
"public/favicon.ico",
|
||||
].forEach((file) => requireFile(file));
|
||||
|
||||
[
|
||||
["validate:all", "root validation"],
|
||||
["audit:release", "release hygiene audit"],
|
||||
["audit:runtime", "runtime readiness audit"],
|
||||
["audit:database", "database readiness audit"],
|
||||
["smoke:backend:fixtures", "fixture smoke"],
|
||||
["smoke:backend:real-read-pilot", "real-read pilot smoke"],
|
||||
["smoke:backend:vertical-slice", "vertical slice smoke"],
|
||||
["smoke:backend:runtime-suite", "runtime suite smoke"],
|
||||
["smoke:backend:database-suite", "database suite smoke"],
|
||||
["smoke:backend:brainstorms", "Brainstorm CPU smoke"],
|
||||
["smoke:backend:initiatives", "Initiative smoke"],
|
||||
].forEach(([script, label]) => {
|
||||
if (!rootPackage.scripts?.[script]) errors.push(`Missing ${label} script: ${script}`);
|
||||
});
|
||||
|
||||
[
|
||||
["smoke:p2", "P2 database smoke"],
|
||||
["smoke:fixtures", "fixture smoke"],
|
||||
["smoke:real-read-pilot", "real read smoke"],
|
||||
["smoke:vertical-slice", "vertical slice smoke"],
|
||||
["smoke:runtime-suite", "runtime suite smoke"],
|
||||
["smoke:database-suite", "database suite smoke"],
|
||||
["smoke:brainstorms", "Brainstorm CPU smoke"],
|
||||
["smoke:initiatives", "Initiative smoke"],
|
||||
["db:deploy", "Prisma migrate deploy"],
|
||||
["db:status", "Prisma migration status"],
|
||||
["db:validate", "Prisma schema validate"],
|
||||
].forEach(([script, label]) => {
|
||||
if (!backendPackage.scripts?.[script]) errors.push(`Missing backend ${label} script: ${script}`);
|
||||
});
|
||||
|
||||
requireText("index.html", "<title>ProjectBrain</title>", "ProjectBrain title");
|
||||
requireText("index.html", "ProjectBrain", "ProjectBrain browser metadata");
|
||||
requireText("src/features/projects/pages/RuntimeReadiness.tsx", "Backend health", "backend health card");
|
||||
requireText("src/features/projects/pages/RuntimeReadiness.tsx", "Database readiness", "database readiness card");
|
||||
requireText("src/features/projects/pages/RuntimeReadiness.tsx", "Vertical slice smoke", "vertical slice smoke card");
|
||||
requireText("src/shared/api/realClient.ts", "getHealth", "real backend health client");
|
||||
requireText("src/shared/api/realClient.ts", "getSystemReadiness", "real backend system readiness client");
|
||||
requireText("src/shared/hooks/useProjectData.ts", "useBackendHealthData", "backend health hook");
|
||||
requireText("src/shared/hooks/useProjectData.ts", "useBackendSystemReadinessData", "backend system readiness hook");
|
||||
requireText("docs/PROJECTBRAIN_STATUS_AND_GATES.md", "P2", "P2 gate documentation");
|
||||
|
||||
[
|
||||
"node_modules",
|
||||
"backend/node_modules",
|
||||
"dist",
|
||||
"backend/dist",
|
||||
"checkpoints",
|
||||
"src/App.css",
|
||||
"src/globals.css",
|
||||
].forEach((path) => warnIfExists(path));
|
||||
|
||||
info.push(`Package manager: ${rootPackage.packageManager ?? "not pinned"}`);
|
||||
info.push(`Approved pnpm workspace allowBuilds: ${Array.from(workspaceAllowBuilds).sort().join(", ") || "none"}`);
|
||||
info.push(`Frontend package: ${rootPackage.name}@${rootPackage.version}`);
|
||||
info.push(`Backend package: ${backendPackage.name}@${backendPackage.version}`);
|
||||
info.push("Recommended local command order:");
|
||||
info.push(" 1. pnpm install");
|
||||
info.push(" 2. pnpm run audit:database");
|
||||
info.push(" 3. pnpm run validate:all");
|
||||
info.push(" 4. pnpm run audit:all");
|
||||
info.push(" 5. docker compose up -d --build");
|
||||
info.push(" 6. pnpm run smoke:backend:system-readiness");
|
||||
info.push(" 7. pnpm run smoke:backend:runtime-suite && pnpm --filter projectbrain-api run smoke:blueprints");
|
||||
info.push(" 8. pnpm run smoke:backend:database-suite");
|
||||
|
||||
for (const line of info) console.log(`INFO ${line}`);
|
||||
if (showLocalArtifacts) {
|
||||
for (const line of localArtifacts) console.log(`INFO ${line}`);
|
||||
}
|
||||
for (const line of errors) console.error(`FAIL ${line}`);
|
||||
|
||||
if (errors.length) {
|
||||
console.error(`\nFAIL: ${errors.length} blocking doctor issue(s) found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("\nPASS: ProjectBrain doctor found no blocking runtime/release scaffold issues.");
|
||||
@@ -0,0 +1,72 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const baseUrl = process.env.PROJECTBRAIN_SMOKE_URL ?? "http://127.0.0.1:1209";
|
||||
const projectId = process.env.PROJECTBRAIN_SMOKE_PROJECT ?? "widefrog";
|
||||
const archiveOut = process.env.PROJECTBRAIN_PROOF_OUT;
|
||||
const skipSmokes = process.argv.includes("--skip-smokes") || process.env.PROJECTBRAIN_RELEASE_SKIP_SMOKES === "1";
|
||||
const includeDeploymentProof = process.argv.includes("--deployment-proof") || process.env.PROJECTBRAIN_RELEASE_DEPLOYMENT_PROOF === "1";
|
||||
const includeDossier = process.argv.includes("--dossier") || process.env.PROJECTBRAIN_RELEASE_DOSSIER === "1";
|
||||
|
||||
const run = (label, command, args, env = {}) => {
|
||||
console.log(`\n[release-bundle] ${label}`);
|
||||
const result = spawnSync(command, args, {
|
||||
env: { ...process.env, ...env },
|
||||
shell: process.platform === "win32",
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${label} failed with exit code ${result.status}`);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
console.log("ProjectBrain release command bundle");
|
||||
console.log(`Base URL: ${baseUrl}`);
|
||||
console.log(`Project: ${projectId}`);
|
||||
console.log(`Smoke mode: ${skipSmokes ? "skipped" : "enabled"}`);
|
||||
console.log(`Deployment proof: ${includeDeploymentProof ? "enabled" : "skipped"}`);
|
||||
console.log(`Evidence dossier: ${includeDossier ? "enabled" : "skipped"}`);
|
||||
|
||||
const proofEnv = {
|
||||
PROJECTBRAIN_SMOKE_URL: baseUrl,
|
||||
PROJECTBRAIN_SMOKE_PROJECT: projectId,
|
||||
PROJECTBRAIN_PROOF_WRITE: "1",
|
||||
PROJECTBRAIN_PROOF_RUN_SMOKES: skipSmokes ? "0" : "1",
|
||||
};
|
||||
if (archiveOut) proofEnv.PROJECTBRAIN_PROOF_OUT = archiveOut;
|
||||
|
||||
run("Create release proof snapshot", "pnpm", ["run", "release:proof-report"], proofEnv);
|
||||
run("Index release proof snapshots", "pnpm", ["run", "release:proof-index", "--", "--limit=5"], {
|
||||
PROJECTBRAIN_SMOKE_PROJECT: projectId,
|
||||
});
|
||||
run("Verify release proof freshness", "pnpm", ["run", "release:proof-freshness"], {
|
||||
PROJECTBRAIN_SMOKE_PROJECT: projectId,
|
||||
});
|
||||
run("Create release signoff file", "pnpm", ["run", "release:signoff-file"], {
|
||||
PROJECTBRAIN_SMOKE_PROJECT: projectId,
|
||||
});
|
||||
if (includeDeploymentProof) {
|
||||
run("Create remote deployment proof", "pnpm", ["run", "release:deployment-proof"], {
|
||||
PROJECTBRAIN_DEPLOY_URL: baseUrl,
|
||||
PROJECTBRAIN_DEPLOY_PROOF_WRITE: "1",
|
||||
});
|
||||
}
|
||||
if (includeDossier) {
|
||||
run("Create release evidence dossier", "pnpm", ["run", "release:dossier"], {
|
||||
PROJECTBRAIN_SMOKE_PROJECT: projectId,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("\nRelease command bundle passed.");
|
||||
console.log(includeDossier
|
||||
? "Release proof is fresh, signoff and deployment proof passed, and a release evidence dossier was written."
|
||||
: includeDeploymentProof
|
||||
? "Release proof is fresh, a signoff file was written and remote deployment proof passed."
|
||||
: "Release proof is fresh for the current commit and a signoff file was written.");
|
||||
} catch (error) {
|
||||
console.error("\nRelease command bundle failed:");
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
console.error("\nNext step: rerun with live URL and smokes enabled, then inspect the proof report above.");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { compareDeploymentCommits, isRunningContainer } from "./deployment-proof-state.mjs";
|
||||
|
||||
const baseUrl = process.env.PROJECTBRAIN_DEPLOY_URL ?? process.env.PROJECTBRAIN_SMOKE_URL ?? "http://127.0.0.1:1209";
|
||||
const sshTarget = process.env.PROJECTBRAIN_DEPLOY_SSH_TARGET ?? "root@127.0.0.1";
|
||||
const deployPath = process.env.PROJECTBRAIN_DEPLOY_PATH ?? "/mnt/pool/appdata/brain";
|
||||
const sshKey = process.env.PROJECTBRAIN_DEPLOY_SSH_KEY ?? path.join(os.homedir(), ".ssh", "widefrog_unraid_deploy");
|
||||
const shouldWrite = process.argv.includes("--write") || process.env.PROJECTBRAIN_DEPLOY_PROOF_WRITE === "1";
|
||||
const explicitOutputArg = process.argv.find((arg) => arg.startsWith("--out="));
|
||||
const explicitOutputPath = explicitOutputArg?.slice("--out=".length) || process.env.PROJECTBRAIN_DEPLOY_PROOF_OUT;
|
||||
|
||||
const run = (command, args) => spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
shell: false,
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const getGitCommit = () => {
|
||||
const result = run("git", ["rev-parse", "--short", "HEAD"]);
|
||||
return result.status === 0 ? result.stdout.trim() : "unknown";
|
||||
};
|
||||
|
||||
const createDefaultProofPath = (commit) => {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
return `checkpoints/deployment-proof/projectbrain-deployment-proof-${commit}-${stamp}.md`;
|
||||
};
|
||||
|
||||
const checkHttp = async (urlPath) => {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${urlPath}`, { signal: AbortSignal.timeout(10_000) });
|
||||
const body = await response.text();
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: String(response.status),
|
||||
body: body.slice(0, 260).replace(/\s+/g, " ").trim(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unreachable",
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const runSsh = (remoteCommand) => {
|
||||
if (!fs.existsSync(sshKey)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "missing-key",
|
||||
stdout: "",
|
||||
stderr: `SSH key not found: ${sshKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = spawnSync("ssh", [
|
||||
"-i",
|
||||
sshKey,
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=8",
|
||||
sshTarget,
|
||||
remoteCommand,
|
||||
], {
|
||||
encoding: "utf8",
|
||||
shell: false,
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
status: String(result.status),
|
||||
stdout: result.stdout?.trim() ?? "",
|
||||
stderr: result.stderr?.trim() || result.error?.message || "",
|
||||
};
|
||||
};
|
||||
|
||||
const currentCommit = getGitCommit();
|
||||
const health = await checkHttp("/api/health");
|
||||
const shellProof = runSsh([
|
||||
`echo deployed_commit=$(git -C ${deployPath} rev-parse --short HEAD 2>/dev/null || echo unknown)`,
|
||||
"echo containers_begin",
|
||||
"docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep projectbrain || true",
|
||||
"echo containers_end",
|
||||
].join(" && "));
|
||||
|
||||
const deployedCommitMatch = shellProof.stdout.match(/^deployed_commit=(.+)$/m);
|
||||
const deployedCommit = deployedCommitMatch?.[1]?.trim() ?? "unknown";
|
||||
const containerBlock = shellProof.stdout.match(/containers_begin\n([\s\S]*?)\n?containers_end/)?.[1]?.trim() ?? "";
|
||||
const hasFrontend = isRunningContainer(containerBlock, "projectbrain-frontend", "1209->80/tcp");
|
||||
const hasApi = isRunningContainer(containerBlock, "projectbrain-api");
|
||||
const hasPostgres = isRunningContainer(containerBlock, "projectbrain-postgres");
|
||||
const commitFreshness = compareDeploymentCommits(currentCommit, deployedCommit);
|
||||
const verdict = health.ok && shellProof.ok && commitFreshness === "current" && hasFrontend && hasApi && hasPostgres
|
||||
? "PASS"
|
||||
: "ATTENTION REQUIRED";
|
||||
const proofPath = explicitOutputPath || createDefaultProofPath(currentCommit);
|
||||
|
||||
const lines = [
|
||||
"# ProjectBrain Remote Deployment Proof",
|
||||
"",
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
`Local commit: ${currentCommit}`,
|
||||
`Deployment URL: ${baseUrl}`,
|
||||
`SSH target: ${sshTarget}`,
|
||||
`Deployment path: ${deployPath}`,
|
||||
`Remote commit: ${deployedCommit}`,
|
||||
`Commit freshness: ${commitFreshness}`,
|
||||
`Verdict: ${verdict}`,
|
||||
"",
|
||||
"## HTTP Health",
|
||||
"",
|
||||
`- Status: ${health.status}`,
|
||||
`- Result: ${health.ok ? "reachable" : "not reachable"}`,
|
||||
`- Body: ${health.body}`,
|
||||
"",
|
||||
"## Remote Runtime",
|
||||
"",
|
||||
`- SSH result: ${shellProof.ok ? "reachable" : shellProof.status}`,
|
||||
`- Frontend container: ${hasFrontend ? "up" : "not proven"}`,
|
||||
`- API container: ${hasApi ? "up" : "not proven"}`,
|
||||
`- Postgres container: ${hasPostgres ? "up" : "not proven"}`,
|
||||
"",
|
||||
"```text",
|
||||
containerBlock || shellProof.stderr || "No remote container output captured.",
|
||||
"```",
|
||||
"",
|
||||
"## Operator Commands",
|
||||
"",
|
||||
"```bash",
|
||||
"PROJECTBRAIN_DEPLOY_PROOF_WRITE=1 pnpm run release:deployment-proof",
|
||||
"PROJECTBRAIN_RELEASE_DEPLOYMENT_PROOF=1 pnpm run release:bundle",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
console.log(lines);
|
||||
|
||||
if (shouldWrite) {
|
||||
fs.mkdirSync(path.dirname(proofPath), { recursive: true });
|
||||
fs.writeFileSync(proofPath, `${lines}\n`, "utf8");
|
||||
console.log(`\nRemote deployment proof written to ${proofPath}`);
|
||||
}
|
||||
|
||||
if (verdict !== "PASS") {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const proofDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof";
|
||||
const signoffDir = process.env.PROJECTBRAIN_SIGNOFF_ARCHIVE_DIR ?? "checkpoints/release-signoff";
|
||||
const deploymentProofDir = process.env.PROJECTBRAIN_DEPLOY_PROOF_ARCHIVE_DIR ?? "checkpoints/deployment-proof";
|
||||
const dossierOut = process.env.PROJECTBRAIN_RELEASE_DOSSIER_OUT;
|
||||
|
||||
const run = (command, args) => spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
const getGitCommit = () => {
|
||||
const result = run("git", ["rev-parse", "--short", "HEAD"]);
|
||||
return result.status === 0 ? result.stdout.trim() : "unknown";
|
||||
};
|
||||
|
||||
const parseField = (text, label) => {
|
||||
const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m"));
|
||||
return match?.[1]?.trim() ?? "unknown";
|
||||
};
|
||||
|
||||
const displayPath = (filePath) => filePath.split(path.sep).join("/");
|
||||
|
||||
const listMarkdownFiles = (archiveDir) => {
|
||||
if (!fs.existsSync(archiveDir)) return [];
|
||||
|
||||
return fs.readdirSync(archiveDir)
|
||||
.filter((fileName) => fileName.endsWith(".md"))
|
||||
.map((fileName) => path.join(archiveDir, fileName))
|
||||
.filter((filePath) => fs.statSync(filePath).isFile());
|
||||
};
|
||||
|
||||
const sortNewestFirst = (items) => items.sort((a, b) => {
|
||||
const generatedDelta = Date.parse(b.generated) - Date.parse(a.generated);
|
||||
if (Number.isFinite(generatedDelta) && generatedDelta !== 0) return generatedDelta;
|
||||
return b.fileName.localeCompare(a.fileName);
|
||||
});
|
||||
|
||||
const parseArtifact = (filePath, type) => {
|
||||
const text = fs.readFileSync(filePath, "utf8");
|
||||
const generated = parseField(text, "Generated");
|
||||
const commit = type === "deployment"
|
||||
? parseField(text, "Local commit")
|
||||
: parseField(text, "Commit");
|
||||
|
||||
return {
|
||||
type,
|
||||
text,
|
||||
filePath,
|
||||
displayPath: displayPath(filePath),
|
||||
fileName: path.basename(filePath),
|
||||
generated,
|
||||
commit,
|
||||
verdict: parseField(text, "Verdict"),
|
||||
freshness: type === "deployment"
|
||||
? parseField(text, "Commit freshness")
|
||||
: parseField(text, "Freshness"),
|
||||
};
|
||||
};
|
||||
|
||||
const findLatestCurrentArtifact = (archiveDir, type, currentCommit) => {
|
||||
const artifacts = sortNewestFirst(listMarkdownFiles(archiveDir).map((filePath) => parseArtifact(filePath, type)));
|
||||
|
||||
return artifacts.find((artifact) => {
|
||||
if (artifact.commit !== currentCommit) return false;
|
||||
if (type === "proof") return artifact.verdict === "PASS";
|
||||
if (type === "signoff") return artifact.freshness === "current";
|
||||
if (type === "deployment") return artifact.verdict === "PASS" && artifact.freshness === "current";
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const indentArtifact = (artifact) => artifact.text
|
||||
.split("\n")
|
||||
.map((line) => line ? `> ${line}` : ">")
|
||||
.join("\n");
|
||||
|
||||
const createDefaultDossierPath = (commit) => {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
return `checkpoints/release-dossier/projectbrain-release-dossier-${commit}-${stamp}.md`;
|
||||
};
|
||||
|
||||
const currentCommit = getGitCommit();
|
||||
const proof = findLatestCurrentArtifact(proofDir, "proof", currentCommit);
|
||||
const signoff = findLatestCurrentArtifact(signoffDir, "signoff", currentCommit);
|
||||
const deployment = findLatestCurrentArtifact(deploymentProofDir, "deployment", currentCommit);
|
||||
const missing = [
|
||||
proof ? null : "current passing release proof",
|
||||
signoff ? null : "current release signoff",
|
||||
deployment ? null : "current passing deployment proof",
|
||||
].filter(Boolean);
|
||||
|
||||
if (missing.length) {
|
||||
console.error("Cannot create release evidence dossier: missing required current artifacts.");
|
||||
for (const item of missing) console.error(`- ${item}`);
|
||||
console.error("Run: PROJECTBRAIN_RELEASE_DEPLOYMENT_PROOF=1 pnpm run release:bundle");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dossierPath = dossierOut || createDefaultDossierPath(currentCommit);
|
||||
const dossier = [
|
||||
"# ProjectBrain Release Evidence Dossier",
|
||||
"",
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
`Commit: ${currentCommit}`,
|
||||
`Verdict: PASS`,
|
||||
"",
|
||||
"## Included Artifacts",
|
||||
"",
|
||||
"| Artifact | Generated | Source |",
|
||||
"| --- | --- | --- |",
|
||||
`| Release proof | ${proof.generated} | ${proof.displayPath} |`,
|
||||
`| Operator signoff | ${signoff.generated} | ${signoff.displayPath} |`,
|
||||
`| Deployment proof | ${deployment.generated} | ${deployment.displayPath} |`,
|
||||
"",
|
||||
"## Operator Summary",
|
||||
"",
|
||||
"- Release operator smoke suite passed for the current commit.",
|
||||
"- Release proof freshness is current.",
|
||||
"- Operator signoff file exists for the current commit.",
|
||||
"- Remote deployment proof passed for the current commit.",
|
||||
"- Tower health and ProjectBrain containers were proven by the deployment proof.",
|
||||
"",
|
||||
"## Release Proof",
|
||||
"",
|
||||
indentArtifact(proof),
|
||||
"",
|
||||
"## Operator Signoff",
|
||||
"",
|
||||
indentArtifact(signoff),
|
||||
"",
|
||||
"## Remote Deployment Proof",
|
||||
"",
|
||||
indentArtifact(deployment),
|
||||
"",
|
||||
"## Tower Commands",
|
||||
"",
|
||||
"```bash",
|
||||
"cd /mnt/pool/appdata/brain",
|
||||
"docker compose build --no-cache frontend",
|
||||
"docker compose up -d",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
fs.mkdirSync(path.dirname(dossierPath), { recursive: true });
|
||||
fs.writeFileSync(dossierPath, `${dossier}\n`, "utf8");
|
||||
|
||||
console.log(dossier);
|
||||
console.log(`\nRelease evidence dossier written to ${dossierPath}`);
|
||||
@@ -0,0 +1,155 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const proofDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof";
|
||||
const signoffDir = process.env.PROJECTBRAIN_SIGNOFF_ARCHIVE_DIR ?? "checkpoints/release-signoff";
|
||||
const keepArg = process.argv.find((arg) => arg.startsWith("--keep="));
|
||||
const keep = Number.parseInt(keepArg?.slice("--keep=".length) ?? process.env.PROJECTBRAIN_EVIDENCE_KEEP ?? "20", 10);
|
||||
const shouldPrune = process.argv.includes("--prune");
|
||||
const confirmed = process.argv.includes("--yes") || process.env.PROJECTBRAIN_EVIDENCE_PRUNE_CONFIRM === "1";
|
||||
|
||||
const run = (command, args) => spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
const getGitCommit = () => {
|
||||
const result = run("git", ["rev-parse", "--short", "HEAD"]);
|
||||
return result.status === 0 ? result.stdout.trim() : "unknown";
|
||||
};
|
||||
|
||||
const parseField = (text, label) => {
|
||||
const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m"));
|
||||
return match?.[1]?.trim() ?? "unknown";
|
||||
};
|
||||
|
||||
const displayPath = (filePath) => filePath.split(path.sep).join("/");
|
||||
|
||||
const safeKeep = Number.isFinite(keep) && keep > 0 ? keep : 20;
|
||||
|
||||
const listMarkdownFiles = (archiveDir, type) => {
|
||||
if (!fs.existsSync(archiveDir)) return [];
|
||||
|
||||
return fs.readdirSync(archiveDir)
|
||||
.filter((fileName) => fileName.endsWith(".md"))
|
||||
.map((fileName) => path.join(archiveDir, fileName))
|
||||
.filter((filePath) => fs.statSync(filePath).isFile())
|
||||
.map((filePath) => {
|
||||
const text = fs.readFileSync(filePath, "utf8");
|
||||
const stat = fs.statSync(filePath);
|
||||
const generated = parseField(text, "Generated");
|
||||
|
||||
return {
|
||||
type,
|
||||
filePath,
|
||||
displayPath: displayPath(filePath),
|
||||
fileName: path.basename(filePath),
|
||||
generated,
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
commit: parseField(text, "Commit"),
|
||||
verdict: parseField(text, "Verdict"),
|
||||
freshness: parseField(text, "Freshness"),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const sortNewestFirst = (items) => items.sort((a, b) => {
|
||||
const generatedDelta = Date.parse(b.generated) - Date.parse(a.generated);
|
||||
if (Number.isFinite(generatedDelta) && generatedDelta !== 0) return generatedDelta;
|
||||
|
||||
const modifiedDelta = Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt);
|
||||
if (Number.isFinite(modifiedDelta) && modifiedDelta !== 0) return modifiedDelta;
|
||||
|
||||
return b.fileName.localeCompare(a.fileName);
|
||||
});
|
||||
|
||||
const createArchive = (archiveDir, type) => {
|
||||
const items = sortNewestFirst(listMarkdownFiles(archiveDir, type));
|
||||
const keepers = items.slice(0, safeKeep);
|
||||
const candidates = items.slice(safeKeep);
|
||||
|
||||
return {
|
||||
type,
|
||||
archiveDir,
|
||||
items,
|
||||
keepers,
|
||||
candidates,
|
||||
};
|
||||
};
|
||||
|
||||
const deleteCandidate = (candidate, archiveDir) => {
|
||||
const resolvedArchiveDir = path.resolve(archiveDir);
|
||||
const resolvedFile = path.resolve(candidate.filePath);
|
||||
|
||||
if (!resolvedFile.startsWith(`${resolvedArchiveDir}${path.sep}`)) {
|
||||
throw new Error(`Refusing to delete outside archive: ${candidate.displayPath}`);
|
||||
}
|
||||
|
||||
if (!candidate.fileName.endsWith(".md")) {
|
||||
throw new Error(`Refusing to delete non-markdown artifact: ${candidate.displayPath}`);
|
||||
}
|
||||
|
||||
fs.unlinkSync(candidate.filePath);
|
||||
};
|
||||
|
||||
const currentCommit = getGitCommit();
|
||||
const archives = [
|
||||
createArchive(proofDir, "proof"),
|
||||
createArchive(signoffDir, "signoff"),
|
||||
];
|
||||
const candidates = archives.flatMap((archive) => archive.candidates.map((candidate) => ({
|
||||
...candidate,
|
||||
archiveDir: archive.archiveDir,
|
||||
})));
|
||||
|
||||
const lines = [
|
||||
"# ProjectBrain Release Evidence Retention",
|
||||
"",
|
||||
`Current commit: ${currentCommit}`,
|
||||
`Mode: ${shouldPrune ? confirmed ? "prune" : "dry-run prune" : "list"}`,
|
||||
`Keep newest per archive: ${safeKeep}`,
|
||||
"",
|
||||
"| Archive | Type | Snapshots | Kept | Prune candidates |",
|
||||
"| --- | --- | --- | --- | --- |",
|
||||
];
|
||||
|
||||
for (const archive of archives) {
|
||||
lines.push(`| ${archive.archiveDir} | ${archive.type} | ${archive.items.length} | ${archive.keepers.length} | ${archive.candidates.length} |`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
|
||||
if (!candidates.length) {
|
||||
lines.push("No release evidence cleanup candidates found.");
|
||||
} else {
|
||||
lines.push("## Cleanup Candidates");
|
||||
lines.push("");
|
||||
lines.push("| Generated | Type | Commit | Verdict/Freshness | File |");
|
||||
lines.push("| --- | --- | --- | --- | --- |");
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const state = candidate.type === "proof" ? candidate.verdict : candidate.freshness;
|
||||
lines.push(`| ${candidate.generated} | ${candidate.type} | ${candidate.commit} | ${state} | ${candidate.displayPath} |`);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldPrune && !confirmed) {
|
||||
lines.push("");
|
||||
lines.push("Dry-run only. To delete cleanup candidates, rerun with:");
|
||||
lines.push("");
|
||||
lines.push("```bash");
|
||||
lines.push("pnpm run release:evidence-prune -- --yes");
|
||||
lines.push("```");
|
||||
}
|
||||
|
||||
console.log(lines.join("\n"));
|
||||
|
||||
if (shouldPrune && confirmed) {
|
||||
for (const candidate of candidates) {
|
||||
deleteCandidate(candidate, candidate.archiveDir);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(`Deleted ${candidates.length} release evidence artifact${candidates.length === 1 ? "" : "s"}.`);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const baseUrl = process.env.PROJECTBRAIN_SMOKE_URL ?? "http://127.0.0.1:5173";
|
||||
const projectId = process.env.PROJECTBRAIN_SMOKE_PROJECT ?? "widefrog";
|
||||
const shouldRunSmokes = process.argv.includes("--run-smokes") || process.env.PROJECTBRAIN_PROOF_RUN_SMOKES === "1";
|
||||
const hasExplicitBaseUrl = Boolean(process.env.PROJECTBRAIN_SMOKE_URL);
|
||||
const shouldWriteReport = process.argv.includes("--write") || process.env.PROJECTBRAIN_PROOF_WRITE === "1";
|
||||
const explicitOutputArg = process.argv.find((arg) => arg.startsWith("--out="));
|
||||
const explicitOutputPath = explicitOutputArg?.slice("--out=".length) || process.env.PROJECTBRAIN_PROOF_OUT;
|
||||
|
||||
const evidenceRows = [
|
||||
{
|
||||
id: "RC63",
|
||||
label: "Release evidence lane click-through",
|
||||
script: "smoke:rc63-release-evidence-lane",
|
||||
proves: "Release evidence routes are reachable from the operator lane.",
|
||||
},
|
||||
{
|
||||
id: "RC60",
|
||||
label: "Review decision receipt recovery",
|
||||
script: "smoke:rc60-review-decision-loop",
|
||||
proves: "Review decisions can be recovered and inspected from history.",
|
||||
},
|
||||
{
|
||||
id: "RC73",
|
||||
label: "Release evidence capture assist",
|
||||
script: "smoke:rc73-release-evidence-capture-assist",
|
||||
proves: "Runtime evidence capture assists the operator instead of leaving proof manual-only.",
|
||||
},
|
||||
{
|
||||
id: "RC88",
|
||||
label: "Release Center cockpit",
|
||||
script: "smoke:rc88-release-center",
|
||||
proves: "Release Center renders the cockpit, archives, quality and operator actions.",
|
||||
},
|
||||
{
|
||||
id: "RC99",
|
||||
label: "Release checklist click-through",
|
||||
script: "smoke:rc99-release-checklist-clickthrough",
|
||||
proves: "Checklist links land on exact target sections and expose return paths.",
|
||||
},
|
||||
];
|
||||
|
||||
const run = (command, args, options = {}) => spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
...options,
|
||||
});
|
||||
|
||||
const readPackage = () => {
|
||||
const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8"));
|
||||
return packageJson.scripts ?? {};
|
||||
};
|
||||
|
||||
const getGitCommit = () => {
|
||||
const result = run("git", ["rev-parse", "--short", "HEAD"]);
|
||||
return result.status === 0 ? result.stdout.trim() : "unknown";
|
||||
};
|
||||
|
||||
const createDefaultReportPath = (commit) => {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
return `checkpoints/release-proof/projectbrain-release-proof-${commit}-${stamp}.md`;
|
||||
};
|
||||
|
||||
const checkLocalConfiguration = () => {
|
||||
const scripts = readPackage();
|
||||
const missing = [];
|
||||
|
||||
if (!scripts["smoke:release-operator"]) missing.push("smoke:release-operator");
|
||||
if (!scripts["release:proof-report"]) missing.push("release:proof-report");
|
||||
for (const row of evidenceRows) {
|
||||
if (!scripts[row.script]) missing.push(row.script);
|
||||
}
|
||||
|
||||
return missing;
|
||||
};
|
||||
|
||||
const checkLiveHealth = async () => {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/health`);
|
||||
const body = await response.text();
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
body: body.slice(0, 220),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unreachable",
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const runOperatorSmoke = () => {
|
||||
const result = run("pnpm", ["run", "smoke:release-operator"], {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
return result.status === 0;
|
||||
};
|
||||
|
||||
const formatTable = (rows) => {
|
||||
const header = "| Gate | Smoke | Proof |\n| --- | --- | --- |";
|
||||
const body = rows.map((row) => `| ${row.id} | \`${row.script}\` | ${row.proves} |`).join("\n");
|
||||
return `${header}\n${body}`;
|
||||
};
|
||||
|
||||
const localMissing = checkLocalConfiguration();
|
||||
let smokePassed = null;
|
||||
|
||||
if (shouldRunSmokes) {
|
||||
console.log("Running release operator smoke suite before generating proof report...\n");
|
||||
smokePassed = runOperatorSmoke();
|
||||
}
|
||||
|
||||
const liveHealth = await checkLiveHealth();
|
||||
const gitCommit = getGitCommit();
|
||||
const localConfigOk = localMissing.length === 0;
|
||||
const healthGateOk = liveHealth.ok || !hasExplicitBaseUrl;
|
||||
const verdict = localConfigOk && healthGateOk && (smokePassed !== false)
|
||||
? (smokePassed === true ? "PASS" : "CONFIGURED - SMOKE NOT RUN")
|
||||
: "ATTENTION REQUIRED";
|
||||
const reportPath = explicitOutputPath || createDefaultReportPath(gitCommit);
|
||||
|
||||
const report = [
|
||||
"# ProjectBrain Release Operator Proof Report",
|
||||
"",
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
`Commit: ${gitCommit}`,
|
||||
`Base URL: ${baseUrl}`,
|
||||
`Project: ${projectId}`,
|
||||
`Verdict: ${verdict}`,
|
||||
"",
|
||||
"## Live Health",
|
||||
"",
|
||||
`- Status: ${liveHealth.status}`,
|
||||
`- Result: ${liveHealth.ok ? "reachable" : hasExplicitBaseUrl ? "not reachable" : "not reachable in local default mode"}`,
|
||||
`- Body: ${liveHealth.body}`,
|
||||
"",
|
||||
"## Proof Coverage",
|
||||
"",
|
||||
formatTable(evidenceRows),
|
||||
"",
|
||||
"## Local Configuration",
|
||||
"",
|
||||
localConfigOk
|
||||
? "- Required release smoke scripts are registered."
|
||||
: `- Missing scripts: ${localMissing.map((name) => `\`${name}\``).join(", ")}`,
|
||||
`- Smoke execution: ${smokePassed === true ? "passed" : smokePassed === false ? "failed" : "not run"}`,
|
||||
"",
|
||||
"## Operator Commands",
|
||||
"",
|
||||
"```bash",
|
||||
"pnpm run release:proof-report",
|
||||
"pnpm run release:proof-report -- --write",
|
||||
"PROJECTBRAIN_PROOF_RUN_SMOKES=1 pnpm run release:proof-report",
|
||||
"PROJECTBRAIN_PROOF_RUN_SMOKES=1 PROJECTBRAIN_PROOF_WRITE=1 pnpm run release:proof-report",
|
||||
"PROJECTBRAIN_SMOKE_URL=http://127.0.0.1:1209 pnpm run smoke:release-operator",
|
||||
"```",
|
||||
"",
|
||||
"## Remaining Manual Gates",
|
||||
"",
|
||||
"- Confirm the intended release commit/tag.",
|
||||
"- Confirm any environment-specific deployment notes.",
|
||||
"- Confirm operator signoff after the smoke suite is green.",
|
||||
].join("\n");
|
||||
|
||||
console.log(report);
|
||||
|
||||
if (shouldWriteReport) {
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(reportPath, `${report}\n`, "utf8");
|
||||
console.log(`\nRelease proof report written to ${reportPath}`);
|
||||
}
|
||||
|
||||
if (!localConfigOk || !healthGateOk || smokePassed === false) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const archiveDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof";
|
||||
const allowStale = process.argv.includes("--allow-stale") || process.env.PROJECTBRAIN_PROOF_ALLOW_STALE === "1";
|
||||
|
||||
const run = (command, args) => spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
const getGitCommit = () => {
|
||||
const result = run("git", ["rev-parse", "--short", "HEAD"]);
|
||||
return result.status === 0 ? result.stdout.trim() : "unknown";
|
||||
};
|
||||
|
||||
const parseField = (text, label) => {
|
||||
const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m"));
|
||||
return match?.[1]?.trim() ?? "unknown";
|
||||
};
|
||||
|
||||
const displayPath = (filePath) => filePath.split(path.sep).join("/");
|
||||
|
||||
const parseProof = (filePath) => {
|
||||
const text = fs.readFileSync(filePath, "utf8");
|
||||
return {
|
||||
filePath,
|
||||
displayPath: displayPath(filePath),
|
||||
fileName: path.basename(filePath),
|
||||
generated: parseField(text, "Generated"),
|
||||
commit: parseField(text, "Commit"),
|
||||
project: parseField(text, "Project"),
|
||||
verdict: parseField(text, "Verdict"),
|
||||
smokeExecution: text.match(/^- Smoke execution:\s*(.+)$/m)?.[1]?.trim() ?? "unknown",
|
||||
};
|
||||
};
|
||||
|
||||
const listProofFiles = () => {
|
||||
if (!fs.existsSync(archiveDir)) return [];
|
||||
|
||||
return fs.readdirSync(archiveDir)
|
||||
.filter((fileName) => fileName.endsWith(".md"))
|
||||
.map((fileName) => path.join(archiveDir, fileName))
|
||||
.filter((filePath) => fs.statSync(filePath).isFile());
|
||||
};
|
||||
|
||||
const currentCommit = getGitCommit();
|
||||
const proofs = listProofFiles()
|
||||
.map(parseProof)
|
||||
.sort((a, b) => Date.parse(b.generated) - Date.parse(a.generated) || b.fileName.localeCompare(a.fileName));
|
||||
const latestPassing = proofs.find((proof) => proof.verdict === "PASS");
|
||||
const freshness = !latestPassing
|
||||
? "missing"
|
||||
: latestPassing.commit === currentCommit
|
||||
? "current"
|
||||
: "stale";
|
||||
|
||||
const lines = [
|
||||
"# ProjectBrain Release Proof Freshness",
|
||||
"",
|
||||
`Archive: ${archiveDir}`,
|
||||
`Current commit: ${currentCommit}`,
|
||||
`Latest passing proof: ${latestPassing ? latestPassing.displayPath : "none"}`,
|
||||
`Proof commit: ${latestPassing?.commit ?? "none"}`,
|
||||
`Freshness: ${freshness}`,
|
||||
`Strict mode: ${allowStale ? "disabled" : "enabled"}`,
|
||||
"",
|
||||
];
|
||||
|
||||
if (latestPassing) {
|
||||
lines.push("| Generated | Verdict | Project | Smoke | File |");
|
||||
lines.push("| --- | --- | --- | --- | --- |");
|
||||
lines.push(`| ${latestPassing.generated} | ${latestPassing.verdict} | ${latestPassing.project} | ${latestPassing.smokeExecution} | ${latestPassing.displayPath} |`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (freshness !== "current") {
|
||||
lines.push("Create a fresh passing proof for the current commit with:");
|
||||
lines.push("");
|
||||
lines.push("```bash");
|
||||
lines.push("PROJECTBRAIN_SMOKE_URL=http://127.0.0.1:1209 PROJECTBRAIN_PROOF_RUN_SMOKES=1 PROJECTBRAIN_PROOF_WRITE=1 pnpm run release:proof-report");
|
||||
lines.push("```");
|
||||
}
|
||||
|
||||
console.log(lines.join("\n"));
|
||||
|
||||
if (!allowStale && freshness !== "current") {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const archiveDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof";
|
||||
const limitArg = process.argv.find((arg) => arg.startsWith("--limit="));
|
||||
const limit = Number.parseInt(limitArg?.slice("--limit=".length) ?? process.env.PROJECTBRAIN_PROOF_INDEX_LIMIT ?? "10", 10);
|
||||
|
||||
const parseField = (text, label) => {
|
||||
const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m"));
|
||||
return match?.[1]?.trim() ?? "unknown";
|
||||
};
|
||||
|
||||
const displayPath = (filePath) => filePath.split(path.sep).join("/");
|
||||
|
||||
const parseProof = (filePath) => {
|
||||
const text = fs.readFileSync(filePath, "utf8");
|
||||
const stat = fs.statSync(filePath);
|
||||
|
||||
return {
|
||||
filePath,
|
||||
displayPath: displayPath(filePath),
|
||||
fileName: path.basename(filePath),
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
generated: parseField(text, "Generated"),
|
||||
commit: parseField(text, "Commit"),
|
||||
baseUrl: parseField(text, "Base URL"),
|
||||
project: parseField(text, "Project"),
|
||||
verdict: parseField(text, "Verdict"),
|
||||
smokeExecution: text.match(/^- Smoke execution:\s*(.+)$/m)?.[1]?.trim() ?? "unknown",
|
||||
};
|
||||
};
|
||||
|
||||
const listProofFiles = () => {
|
||||
if (!fs.existsSync(archiveDir)) return [];
|
||||
|
||||
return fs.readdirSync(archiveDir)
|
||||
.filter((fileName) => fileName.endsWith(".md"))
|
||||
.map((fileName) => path.join(archiveDir, fileName))
|
||||
.filter((filePath) => fs.statSync(filePath).isFile());
|
||||
};
|
||||
|
||||
const proofs = listProofFiles()
|
||||
.map(parseProof)
|
||||
.sort((a, b) => Date.parse(b.generated) - Date.parse(a.generated) || b.fileName.localeCompare(a.fileName));
|
||||
|
||||
const latestPassing = proofs.find((proof) => proof.verdict === "PASS");
|
||||
const selectedProofs = proofs.slice(0, Number.isFinite(limit) && limit > 0 ? limit : 10);
|
||||
|
||||
const lines = [
|
||||
"# ProjectBrain Release Proof Index",
|
||||
"",
|
||||
`Archive: ${archiveDir}`,
|
||||
`Proof snapshots: ${proofs.length}`,
|
||||
`Latest passing proof: ${latestPassing ? latestPassing.displayPath : "none"}`,
|
||||
"",
|
||||
];
|
||||
|
||||
if (!proofs.length) {
|
||||
lines.push("No release proof snapshots found.");
|
||||
lines.push("");
|
||||
lines.push("Create one with:");
|
||||
lines.push("");
|
||||
lines.push("```bash");
|
||||
lines.push("PROJECTBRAIN_PROOF_WRITE=1 pnpm run release:proof-report");
|
||||
lines.push("```");
|
||||
} else {
|
||||
lines.push("| Generated | Verdict | Commit | Project | Smoke | File |");
|
||||
lines.push("| --- | --- | --- | --- | --- | --- |");
|
||||
for (const proof of selectedProofs) {
|
||||
lines.push(`| ${proof.generated} | ${proof.verdict} | ${proof.commit} | ${proof.project} | ${proof.smokeExecution} | ${proof.displayPath} |`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(lines.join("\n"));
|
||||
@@ -0,0 +1,227 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const baseUrl = process.env.PROJECTBRAIN_DEPLOY_URL ?? process.env.PROJECTBRAIN_SMOKE_URL ?? "http://127.0.0.1:1209";
|
||||
const gitRemote = process.env.PROJECTBRAIN_RELEASE_REMOTE ?? "gitea";
|
||||
const gitBranch = process.env.PROJECTBRAIN_RELEASE_BRANCH ?? "main";
|
||||
const sshTarget = process.env.PROJECTBRAIN_DEPLOY_SSH_TARGET ?? "root@127.0.0.1";
|
||||
const deployPath = process.env.PROJECTBRAIN_DEPLOY_PATH ?? "/mnt/pool/appdata/brain";
|
||||
const sshKey = process.env.PROJECTBRAIN_DEPLOY_SSH_KEY ?? path.join(os.homedir(), ".ssh", "widefrog_unraid_deploy");
|
||||
const requestedTag = process.env.PROJECTBRAIN_ROLLBACK_TAG;
|
||||
const shouldWrite = process.argv.includes("--write") || process.env.PROJECTBRAIN_ROLLBACK_DRILL_WRITE === "1";
|
||||
const explicitOutputArg = process.argv.find((arg) => arg.startsWith("--out="));
|
||||
const explicitOutputPath = explicitOutputArg?.slice("--out=".length) || process.env.PROJECTBRAIN_ROLLBACK_DRILL_OUT;
|
||||
|
||||
const run = (command, args, options = {}) => spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
...options,
|
||||
});
|
||||
|
||||
const git = (args) => {
|
||||
const result = spawnSync("git", args, {
|
||||
encoding: "utf8",
|
||||
shell: false,
|
||||
});
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
value: result.stdout.trim(),
|
||||
error: result.stderr.trim(),
|
||||
};
|
||||
};
|
||||
|
||||
const runSsh = (remoteCommand) => {
|
||||
if (!fs.existsSync(sshKey)) {
|
||||
return {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: `SSH key not found: ${sshKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = spawnSync("ssh", [
|
||||
"-i",
|
||||
sshKey,
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=8",
|
||||
sshTarget,
|
||||
remoteCommand,
|
||||
], {
|
||||
encoding: "utf8",
|
||||
shell: false,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
stdout: result.stdout.trim(),
|
||||
stderr: result.stderr.trim(),
|
||||
};
|
||||
};
|
||||
|
||||
const checkHttp = async (urlPath) => {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${urlPath}`);
|
||||
const body = await response.text();
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: String(response.status),
|
||||
body: body.slice(0, 220).replace(/\s+/g, " ").trim(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unreachable",
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const latestReleaseTag = () => {
|
||||
const result = git(["tag", "--list", "projectbrain-rc-*", "--sort=-creatordate"]);
|
||||
return result.value.split(/\r?\n/).find(Boolean) ?? "";
|
||||
};
|
||||
|
||||
const createDefaultReportPath = (tagName) => {
|
||||
const safeTag = tagName.replace(/[^a-zA-Z0-9._-]/g, "-");
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
return `checkpoints/rollback-drill/projectbrain-rollback-drill-${safeTag}-${stamp}.md`;
|
||||
};
|
||||
|
||||
run("git", ["fetch", "--tags", gitRemote]);
|
||||
|
||||
const tagName = requestedTag || latestReleaseTag();
|
||||
const currentCommit = git(["rev-parse", "--short", "HEAD"]).value || "unknown";
|
||||
const tagCommit = tagName ? git(["rev-parse", "--short", `${tagName}^{commit}`]) : { ok: false, value: "", error: "No release tag found." };
|
||||
const tagFullCommit = tagName ? git(["rev-parse", `${tagName}^{commit}`]) : { ok: false, value: "", error: "No release tag found." };
|
||||
const remoteTag = tagName ? run("git", ["ls-remote", "--tags", gitRemote, tagName]) : { status: 1, stdout: "", stderr: "No release tag found." };
|
||||
const status = git(["status", "--porcelain"]);
|
||||
const health = await checkHttp("/api/health");
|
||||
const tower = tagName
|
||||
? runSsh([
|
||||
`git -C ${deployPath} fetch --tags ${gitRemote}`,
|
||||
`echo tower_head=$(git -C ${deployPath} rev-parse --short HEAD 2>/dev/null || echo unknown)`,
|
||||
`echo tower_tag=$(git -C ${deployPath} rev-parse --short ${tagName}^{commit} 2>/dev/null || echo unknown)`,
|
||||
"echo containers_begin",
|
||||
"docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep projectbrain || true",
|
||||
"echo containers_end",
|
||||
].join(" && "))
|
||||
: { ok: false, stdout: "", stderr: "No release tag found." };
|
||||
|
||||
const towerHead = tower.stdout.match(/^tower_head=(.+)$/m)?.[1]?.trim() ?? "unknown";
|
||||
const towerTag = tower.stdout.match(/^tower_tag=(.+)$/m)?.[1]?.trim() ?? "unknown";
|
||||
const containerBlock = tower.stdout.match(/containers_begin\n([\s\S]*?)\n?containers_end/)?.[1]?.trim() ?? "";
|
||||
const hasFrontend = containerBlock.includes("projectbrain-frontend") && containerBlock.includes("1209->80/tcp");
|
||||
const hasApi = containerBlock.includes("projectbrain-api") && /projectbrain-api\s+Up/.test(containerBlock);
|
||||
const hasPostgres = containerBlock.includes("projectbrain-postgres") && /projectbrain-postgres\s+Up/.test(containerBlock);
|
||||
const reportPath = createDefaultReportPath(tagName || "missing-tag");
|
||||
const outputPath = explicitOutputPath || reportPath;
|
||||
|
||||
const gates = [
|
||||
{
|
||||
label: "Release tag selected",
|
||||
ok: Boolean(tagName),
|
||||
detail: tagName || "missing",
|
||||
},
|
||||
{
|
||||
label: "Local release tag resolves",
|
||||
ok: Boolean(tagName) && tagCommit.ok,
|
||||
detail: tagCommit.value || tagCommit.error || "missing",
|
||||
},
|
||||
{
|
||||
label: "Gitea release tag exists",
|
||||
ok: remoteTag.status === 0 && remoteTag.stdout.includes(`refs/tags/${tagName}`),
|
||||
detail: remoteTag.status === 0 ? "present" : remoteTag.stderr.trim(),
|
||||
},
|
||||
{
|
||||
label: "Tower release tag resolves",
|
||||
ok: tower.ok && towerTag === tagCommit.value,
|
||||
detail: `${towerTag} tower / ${tagCommit.value || "unknown"} local`,
|
||||
},
|
||||
{
|
||||
label: "Tower HEAD captured",
|
||||
ok: tower.ok && towerHead !== "unknown",
|
||||
detail: `${towerHead} tower / ${tagCommit.value || "unknown"} rollback target`,
|
||||
},
|
||||
{
|
||||
label: "Tracked worktree clean",
|
||||
ok: status.ok && status.value.trim() === "",
|
||||
detail: status.value.trim() || "clean",
|
||||
},
|
||||
{
|
||||
label: "Live health reachable",
|
||||
ok: health.ok,
|
||||
detail: `${health.status} ${health.body}`,
|
||||
},
|
||||
{
|
||||
label: "ProjectBrain containers running",
|
||||
ok: hasFrontend && hasApi && hasPostgres,
|
||||
detail: `frontend=${hasFrontend ? "up" : "missing"} api=${hasApi ? "up" : "missing"} postgres=${hasPostgres ? "up" : "missing"}`,
|
||||
},
|
||||
];
|
||||
|
||||
const passed = gates.every((gate) => gate.ok);
|
||||
const rollbackCommand = `ssh -i ~/.ssh/widefrog_unraid_deploy ${sshTarget} "cd ${deployPath} && git fetch --tags ${gitRemote} && git switch --detach ${tagName} && docker compose build --no-cache frontend && docker compose up -d"`;
|
||||
const restoreMainCommand = `ssh -i ~/.ssh/widefrog_unraid_deploy ${sshTarget} "cd ${deployPath} && git switch ${gitBranch} && git pull --ff-only && docker compose build --no-cache frontend && docker compose up -d"`;
|
||||
const lines = [
|
||||
"# ProjectBrain Rollback Drill",
|
||||
"",
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
`Selected tag: ${tagName || "missing"}`,
|
||||
`Tag commit: ${tagCommit.value || "unknown"}`,
|
||||
`Current commit: ${currentCommit}`,
|
||||
`Tower HEAD: ${towerHead}`,
|
||||
`Deployment URL: ${baseUrl}`,
|
||||
`Verdict: ${passed ? "PASS" : "ATTENTION REQUIRED"}`,
|
||||
"",
|
||||
"## Gates",
|
||||
"",
|
||||
"| Gate | Result | Detail |",
|
||||
"| --- | --- | --- |",
|
||||
...gates.map((gate) => `| ${gate.label} | ${gate.ok ? "PASS" : "FAIL"} | ${String(gate.detail).replace(/\|/g, "\\|")} |`),
|
||||
"",
|
||||
"## Container Snapshot",
|
||||
"",
|
||||
"```text",
|
||||
containerBlock || tower.stderr || "No container output captured.",
|
||||
"```",
|
||||
"",
|
||||
"## Rollback Commands",
|
||||
"",
|
||||
"These commands are printed for operator review. This drill does not execute checkout, rebuild or rollback.",
|
||||
"",
|
||||
"Rollback/redeploy selected tag:",
|
||||
"",
|
||||
"```bash",
|
||||
rollbackCommand,
|
||||
"```",
|
||||
"",
|
||||
"Restore main after drill:",
|
||||
"",
|
||||
"```bash",
|
||||
restoreMainCommand,
|
||||
"```",
|
||||
"",
|
||||
"## Local Verification",
|
||||
"",
|
||||
"```bash",
|
||||
"pnpm run release:deployment-proof",
|
||||
"pnpm run release:tag-gate",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
console.log(lines);
|
||||
|
||||
if (shouldWrite) {
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, `${lines}\n`, "utf8");
|
||||
console.log(`\nRollback drill report written to ${outputPath}`);
|
||||
}
|
||||
|
||||
if (!passed) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const archiveDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof";
|
||||
const signoffOut = process.env.PROJECTBRAIN_SIGNOFF_OUT;
|
||||
|
||||
const run = (command, args) => spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
const getGitCommit = () => {
|
||||
const result = run("git", ["rev-parse", "--short", "HEAD"]);
|
||||
return result.status === 0 ? result.stdout.trim() : "unknown";
|
||||
};
|
||||
|
||||
const parseField = (text, label) => {
|
||||
const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m"));
|
||||
return match?.[1]?.trim() ?? "unknown";
|
||||
};
|
||||
|
||||
const displayPath = (filePath) => filePath.split(path.sep).join("/");
|
||||
|
||||
const parseProof = (filePath) => {
|
||||
const text = fs.readFileSync(filePath, "utf8");
|
||||
return {
|
||||
filePath,
|
||||
displayPath: displayPath(filePath),
|
||||
fileName: path.basename(filePath),
|
||||
generated: parseField(text, "Generated"),
|
||||
commit: parseField(text, "Commit"),
|
||||
baseUrl: parseField(text, "Base URL"),
|
||||
project: parseField(text, "Project"),
|
||||
verdict: parseField(text, "Verdict"),
|
||||
smokeExecution: text.match(/^- Smoke execution:\s*(.+)$/m)?.[1]?.trim() ?? "unknown",
|
||||
};
|
||||
};
|
||||
|
||||
const listProofFiles = () => {
|
||||
if (!fs.existsSync(archiveDir)) return [];
|
||||
|
||||
return fs.readdirSync(archiveDir)
|
||||
.filter((fileName) => fileName.endsWith(".md"))
|
||||
.map((fileName) => path.join(archiveDir, fileName))
|
||||
.filter((filePath) => fs.statSync(filePath).isFile());
|
||||
};
|
||||
|
||||
const createDefaultSignoffPath = (commit) => {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
return `checkpoints/release-signoff/projectbrain-release-signoff-${commit}-${stamp}.md`;
|
||||
};
|
||||
|
||||
const currentCommit = getGitCommit();
|
||||
const proofs = listProofFiles()
|
||||
.map(parseProof)
|
||||
.sort((a, b) => Date.parse(b.generated) - Date.parse(a.generated) || b.fileName.localeCompare(a.fileName));
|
||||
const latestPassing = proofs.find((proof) => proof.verdict === "PASS");
|
||||
const freshness = latestPassing?.commit === currentCommit ? "current" : latestPassing ? "stale" : "missing";
|
||||
|
||||
if (freshness !== "current") {
|
||||
console.error("Cannot create release signoff file: latest passing proof is not current.");
|
||||
console.error(`Current commit: ${currentCommit}`);
|
||||
console.error(`Proof commit: ${latestPassing?.commit ?? "none"}`);
|
||||
console.error("Run: PROJECTBRAIN_SMOKE_URL=http://127.0.0.1:1209 pnpm run release:bundle");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const signoffPath = signoffOut || createDefaultSignoffPath(currentCommit);
|
||||
const signoff = [
|
||||
"# ProjectBrain Release Operator Signoff",
|
||||
"",
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
`Commit: ${currentCommit}`,
|
||||
`Project: ${latestPassing.project}`,
|
||||
`Base URL: ${latestPassing.baseUrl}`,
|
||||
`Proof snapshot: ${latestPassing.displayPath}`,
|
||||
`Proof generated: ${latestPassing.generated}`,
|
||||
`Proof verdict: ${latestPassing.verdict}`,
|
||||
`Smoke execution: ${latestPassing.smokeExecution}`,
|
||||
`Freshness: ${freshness}`,
|
||||
"",
|
||||
"## Technical Gates",
|
||||
"",
|
||||
"- [x] Release proof report exists for the current commit.",
|
||||
"- [x] Release operator smoke suite passed.",
|
||||
"- [x] Release proof freshness is current.",
|
||||
"- [x] Proof snapshot is stored as a local ignored artifact.",
|
||||
"",
|
||||
"## Manual Operator Signoff",
|
||||
"",
|
||||
"- [ ] Confirm intended release commit/tag.",
|
||||
"- [ ] Confirm deployment target and public URL.",
|
||||
"- [ ] Confirm environment-specific notes are captured.",
|
||||
"- [ ] Confirm rollback/restart command is known.",
|
||||
"- [ ] Confirm final human go/no-go decision.",
|
||||
"",
|
||||
"## Tower Commands",
|
||||
"",
|
||||
"```bash",
|
||||
"cd /mnt/pool/appdata/brain",
|
||||
"docker compose build --no-cache frontend",
|
||||
"docker compose up -d",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
fs.mkdirSync(path.dirname(signoffPath), { recursive: true });
|
||||
fs.writeFileSync(signoffPath, `${signoff}\n`, "utf8");
|
||||
|
||||
console.log(signoff);
|
||||
console.log(`\nRelease signoff file written to ${signoffPath}`);
|
||||
@@ -0,0 +1,256 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const proofDir = process.env.PROJECTBRAIN_PROOF_ARCHIVE_DIR ?? "checkpoints/release-proof";
|
||||
const signoffDir = process.env.PROJECTBRAIN_SIGNOFF_ARCHIVE_DIR ?? "checkpoints/release-signoff";
|
||||
const deploymentProofDir = process.env.PROJECTBRAIN_DEPLOY_PROOF_ARCHIVE_DIR ?? "checkpoints/deployment-proof";
|
||||
const dossierDir = process.env.PROJECTBRAIN_RELEASE_DOSSIER_DIR ?? "checkpoints/release-dossier";
|
||||
const baseUrl = process.env.PROJECTBRAIN_DEPLOY_URL ?? process.env.PROJECTBRAIN_SMOKE_URL ?? "http://127.0.0.1:1209";
|
||||
const gitRemote = process.env.PROJECTBRAIN_RELEASE_REMOTE ?? "gitea";
|
||||
const gitBranch = process.env.PROJECTBRAIN_RELEASE_BRANCH ?? "main";
|
||||
const sshTarget = process.env.PROJECTBRAIN_DEPLOY_SSH_TARGET ?? "root@127.0.0.1";
|
||||
const deployPath = process.env.PROJECTBRAIN_DEPLOY_PATH ?? "/mnt/pool/appdata/brain";
|
||||
const sshKey = process.env.PROJECTBRAIN_DEPLOY_SSH_KEY ?? path.join(os.homedir(), ".ssh", "widefrog_unraid_deploy");
|
||||
const requestedTag = process.env.PROJECTBRAIN_RELEASE_TAG;
|
||||
const shouldWrite = process.argv.includes("--write") || process.env.PROJECTBRAIN_TAG_GATE_WRITE === "1";
|
||||
const explicitOutputArg = process.argv.find((arg) => arg.startsWith("--out="));
|
||||
const explicitOutputPath = explicitOutputArg?.slice("--out=".length) || process.env.PROJECTBRAIN_TAG_GATE_OUT;
|
||||
|
||||
const run = (command, args, options = {}) => spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
...options,
|
||||
});
|
||||
|
||||
const getGitOutput = (args) => {
|
||||
const result = run("git", args);
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
value: result.stdout.trim(),
|
||||
error: result.stderr.trim(),
|
||||
};
|
||||
};
|
||||
|
||||
const parseField = (text, label) => {
|
||||
const match = text.match(new RegExp(`^${label}:\\s*(.+)$`, "m"));
|
||||
return match?.[1]?.trim() ?? "unknown";
|
||||
};
|
||||
|
||||
const displayPath = (filePath) => filePath.split(path.sep).join("/");
|
||||
|
||||
const listMarkdownFiles = (archiveDir) => {
|
||||
if (!fs.existsSync(archiveDir)) return [];
|
||||
|
||||
return fs.readdirSync(archiveDir)
|
||||
.filter((fileName) => fileName.endsWith(".md"))
|
||||
.map((fileName) => path.join(archiveDir, fileName))
|
||||
.filter((filePath) => fs.statSync(filePath).isFile());
|
||||
};
|
||||
|
||||
const sortNewestFirst = (items) => items.sort((a, b) => {
|
||||
const generatedDelta = Date.parse(b.generated) - Date.parse(a.generated);
|
||||
if (Number.isFinite(generatedDelta) && generatedDelta !== 0) return generatedDelta;
|
||||
return b.fileName.localeCompare(a.fileName);
|
||||
});
|
||||
|
||||
const parseArtifact = (filePath, type) => {
|
||||
const text = fs.readFileSync(filePath, "utf8");
|
||||
const commit = type === "deployment" ? parseField(text, "Local commit") : parseField(text, "Commit");
|
||||
|
||||
return {
|
||||
type,
|
||||
filePath,
|
||||
displayPath: displayPath(filePath),
|
||||
fileName: path.basename(filePath),
|
||||
generated: parseField(text, "Generated"),
|
||||
commit,
|
||||
verdict: parseField(text, "Verdict"),
|
||||
freshness: type === "deployment" ? parseField(text, "Commit freshness") : parseField(text, "Freshness"),
|
||||
};
|
||||
};
|
||||
|
||||
const findCurrentArtifact = (archiveDir, type, currentCommit) => {
|
||||
const artifacts = sortNewestFirst(listMarkdownFiles(archiveDir).map((filePath) => parseArtifact(filePath, type)));
|
||||
|
||||
return artifacts.find((artifact) => {
|
||||
if (artifact.commit !== currentCommit) return false;
|
||||
if (type === "proof") return artifact.verdict === "PASS";
|
||||
if (type === "signoff") return artifact.freshness === "current";
|
||||
if (type === "deployment") return artifact.verdict === "PASS" && artifact.freshness === "current";
|
||||
if (type === "dossier") return artifact.verdict === "PASS";
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const checkHttp = async (urlPath) => {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${urlPath}`);
|
||||
const body = await response.text();
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: String(response.status),
|
||||
body: body.slice(0, 220).replace(/\s+/g, " ").trim(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unreachable",
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const runSsh = (remoteCommand) => {
|
||||
if (!fs.existsSync(sshKey)) {
|
||||
return {
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: `SSH key not found: ${sshKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = spawnSync("ssh", [
|
||||
"-i",
|
||||
sshKey,
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=8",
|
||||
sshTarget,
|
||||
remoteCommand,
|
||||
], {
|
||||
encoding: "utf8",
|
||||
shell: false,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
stdout: result.stdout.trim(),
|
||||
stderr: result.stderr.trim(),
|
||||
};
|
||||
};
|
||||
|
||||
const createDefaultTagName = (commit) => {
|
||||
const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "");
|
||||
return `projectbrain-rc-${stamp}-${commit}`;
|
||||
};
|
||||
|
||||
const createDefaultReportPath = (commit) => {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
return `checkpoints/tag-gate/projectbrain-tag-ready-${commit}-${stamp}.md`;
|
||||
};
|
||||
|
||||
const currentCommit = getGitOutput(["rev-parse", "--short", "HEAD"]).value || "unknown";
|
||||
const fullCurrentCommit = getGitOutput(["rev-parse", "HEAD"]).value || "unknown";
|
||||
const status = getGitOutput(["status", "--porcelain"]);
|
||||
const fetchRemote = run("git", ["fetch", gitRemote, gitBranch]);
|
||||
const remoteRef = `${gitRemote}/${gitBranch}`;
|
||||
const remoteCommit = getGitOutput(["rev-parse", "--short", remoteRef]).value || "unknown";
|
||||
const remoteFullCommit = getGitOutput(["rev-parse", remoteRef]).value || "unknown";
|
||||
const health = await checkHttp("/api/health");
|
||||
const remoteShell = runSsh(`git -C ${deployPath} rev-parse --short HEAD 2>/dev/null || echo unknown`);
|
||||
const towerCommit = remoteShell.stdout.trim() || "unknown";
|
||||
const tagName = requestedTag || createDefaultTagName(currentCommit);
|
||||
const reportPath = explicitOutputPath || createDefaultReportPath(currentCommit);
|
||||
|
||||
const proof = findCurrentArtifact(proofDir, "proof", currentCommit);
|
||||
const signoff = findCurrentArtifact(signoffDir, "signoff", currentCommit);
|
||||
const deployment = findCurrentArtifact(deploymentProofDir, "deployment", currentCommit);
|
||||
const dossier = findCurrentArtifact(dossierDir, "dossier", currentCommit);
|
||||
|
||||
const gates = [
|
||||
{
|
||||
label: "Tracked worktree clean",
|
||||
ok: status.ok && status.value.trim() === "",
|
||||
detail: status.value.trim() || "clean",
|
||||
},
|
||||
{
|
||||
label: `Fetched ${gitRemote}/${gitBranch}`,
|
||||
ok: fetchRemote.status === 0,
|
||||
detail: fetchRemote.status === 0 ? "fetched" : fetchRemote.stderr.trim(),
|
||||
},
|
||||
{
|
||||
label: "Gitea remote matches local commit",
|
||||
ok: fullCurrentCommit === remoteFullCommit,
|
||||
detail: `${currentCommit} local / ${remoteCommit} remote`,
|
||||
},
|
||||
{
|
||||
label: "Tower deployment matches local commit",
|
||||
ok: towerCommit === currentCommit,
|
||||
detail: `${currentCommit} local / ${towerCommit} tower`,
|
||||
},
|
||||
{
|
||||
label: "Live health reachable",
|
||||
ok: health.ok,
|
||||
detail: `${health.status} ${health.body}`,
|
||||
},
|
||||
{
|
||||
label: "Current passing release proof exists",
|
||||
ok: Boolean(proof),
|
||||
detail: proof?.displayPath ?? "missing",
|
||||
},
|
||||
{
|
||||
label: "Current operator signoff exists",
|
||||
ok: Boolean(signoff),
|
||||
detail: signoff?.displayPath ?? "missing",
|
||||
},
|
||||
{
|
||||
label: "Current passing deployment proof exists",
|
||||
ok: Boolean(deployment),
|
||||
detail: deployment?.displayPath ?? "missing",
|
||||
},
|
||||
{
|
||||
label: "Current release evidence dossier exists",
|
||||
ok: Boolean(dossier),
|
||||
detail: dossier?.displayPath ?? "missing",
|
||||
},
|
||||
];
|
||||
|
||||
const passed = gates.every((gate) => gate.ok);
|
||||
const lines = [
|
||||
"# ProjectBrain Release Tag Ready Gate",
|
||||
"",
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
`Commit: ${currentCommit}`,
|
||||
`Remote: ${remoteRef}`,
|
||||
`Tower URL: ${baseUrl}`,
|
||||
`Suggested tag: ${tagName}`,
|
||||
`Verdict: ${passed ? "PASS" : "ATTENTION REQUIRED"}`,
|
||||
"",
|
||||
"## Gates",
|
||||
"",
|
||||
"| Gate | Result | Detail |",
|
||||
"| --- | --- | --- |",
|
||||
...gates.map((gate) => `| ${gate.label} | ${gate.ok ? "PASS" : "FAIL"} | ${String(gate.detail).replace(/\|/g, "\\|")} |`),
|
||||
"",
|
||||
"## Tag Commands",
|
||||
"",
|
||||
"Review the suggested tag name before running these commands:",
|
||||
"",
|
||||
"```bash",
|
||||
`git tag -a ${tagName} -m "ProjectBrain release ${tagName}"`,
|
||||
`git push ${gitRemote} ${tagName}`,
|
||||
"```",
|
||||
"",
|
||||
"## Recovery",
|
||||
"",
|
||||
"```bash",
|
||||
"PROJECTBRAIN_RELEASE_DEPLOYMENT_PROOF=1 PROJECTBRAIN_RELEASE_DOSSIER=1 pnpm run release:bundle",
|
||||
"pnpm run release:tag-gate",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
console.log(lines);
|
||||
|
||||
if (shouldWrite) {
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(reportPath, `${lines}\n`, "utf8");
|
||||
console.log(`\nRelease tag ready report written to ${reportPath}`);
|
||||
}
|
||||
|
||||
if (!passed) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const outputPath = process.env.PROJECTBRAIN_USER_NOTES_OUT;
|
||||
const shouldWrite = process.argv.includes("--write") || process.env.PROJECTBRAIN_USER_NOTES_WRITE === "1";
|
||||
|
||||
const run = (command, args) => spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
const git = (args) => {
|
||||
const result = run("git", args);
|
||||
return result.status === 0 ? result.stdout.trim() : "unknown";
|
||||
};
|
||||
|
||||
const latestReleaseTag = () => {
|
||||
const result = run("git", ["tag", "--list", "projectbrain-rc-*", "--sort=-creatordate"]);
|
||||
return result.status === 0 ? result.stdout.trim().split(/\r?\n/).find(Boolean) ?? "untagged" : "untagged";
|
||||
};
|
||||
|
||||
const createDefaultOutputPath = (tagName) => {
|
||||
const safeTag = tagName.replace(/[^a-zA-Z0-9._-]/g, "-");
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
return `checkpoints/user-release-notes/projectbrain-user-release-notes-${safeTag}-${stamp}.md`;
|
||||
};
|
||||
|
||||
const commit = git(["rev-parse", "--short", "HEAD"]);
|
||||
const tagName = process.env.PROJECTBRAIN_RELEASE_TAG ?? latestReleaseTag();
|
||||
const out = outputPath || createDefaultOutputPath(tagName);
|
||||
|
||||
const notes = [
|
||||
"# ProjectBrain User Release Notes",
|
||||
"",
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
`Release tag: ${tagName}`,
|
||||
`Current commit: ${commit}`,
|
||||
"",
|
||||
"## What ProjectBrain Is For",
|
||||
"",
|
||||
"ProjectBrain is a project operating system for shaping, expanding and releasing software projects. It is optimized for turning ideas, existing repositories, source material and execution feedback into structured project memory, Codex-ready handoffs and release evidence.",
|
||||
"",
|
||||
"The current release is strongest as a local/operator-driven project builder. It combines project planning, repo shaping, session packaging, evidence review and release-readiness checks in one workspace.",
|
||||
"",
|
||||
"## Main User Flows",
|
||||
"",
|
||||
"### Start Or Open A Project",
|
||||
"",
|
||||
"- Use the Projects page to open an existing project.",
|
||||
"- Use Start when creating or importing a new project direction.",
|
||||
"- Existing projects remain the primary path for daily use; new-project setup is available when needed.",
|
||||
"",
|
||||
"### Shape A Repository",
|
||||
"",
|
||||
"- Use Repo Builder to inspect repository direction, gaps, state snapshots and delivery packages.",
|
||||
"- Use skill-aware handoff content to make Codex work more repeatable.",
|
||||
"- Use repo state and evidence instead of relying on memory from previous sessions.",
|
||||
"",
|
||||
"### Prepare Codex Work",
|
||||
"",
|
||||
"- Use Context Packs, Prompt Builder, Codex Handoffs and Session Packs to create a clear execution package.",
|
||||
"- Use Browser Bridge when work must be copied into ChatGPT/Codex manually without API keys.",
|
||||
"- Use Execution Feedback to bring results back into ProjectBrain as reviewable evidence.",
|
||||
"",
|
||||
"### Review And Close Work",
|
||||
"",
|
||||
"- Use Review Queue and History to inspect decisions, receipts and follow-up evidence.",
|
||||
"- Use Build Loop and Runtime Readiness to track release-relevant validation.",
|
||||
"- Use Release Center to collect proof before tagging or deploying.",
|
||||
"",
|
||||
"## Release And Operator Capabilities",
|
||||
"",
|
||||
"This release includes a complete local release-operator chain:",
|
||||
"",
|
||||
"- release smoke suite;",
|
||||
"- proof report generation;",
|
||||
"- proof freshness gate;",
|
||||
"- operator signoff file;",
|
||||
"- remote deployment proof;",
|
||||
"- release evidence dossier;",
|
||||
"- tag-ready gate;",
|
||||
"- rollback drill.",
|
||||
"",
|
||||
"Useful commands:",
|
||||
"",
|
||||
"```bash",
|
||||
"pnpm run release:bundle",
|
||||
"PROJECTBRAIN_RELEASE_DEPLOYMENT_PROOF=1 PROJECTBRAIN_RELEASE_DOSSIER=1 pnpm run release:bundle",
|
||||
"pnpm run release:tag-gate",
|
||||
"pnpm run release:rollback-drill",
|
||||
"pnpm run release:user-notes",
|
||||
"```",
|
||||
"",
|
||||
"## Deployment Notes",
|
||||
"",
|
||||
"The known live deployment target is:",
|
||||
"",
|
||||
"```text",
|
||||
"http://127.0.0.1:1209",
|
||||
"```",
|
||||
"",
|
||||
"Tower build/start commands:",
|
||||
"",
|
||||
"```bash",
|
||||
"cd /mnt/pool/appdata/brain",
|
||||
"docker compose build --no-cache frontend",
|
||||
"docker compose up -d",
|
||||
"```",
|
||||
"",
|
||||
"## What Remains Local Or Operator-Driven",
|
||||
"",
|
||||
"- Release evidence snapshots are local ignored files under `checkpoints/`.",
|
||||
"- Several release commands are intended to run from the developer/operator workstation, not from inside the Unraid container.",
|
||||
"- The rollback drill prints commands but does not execute a rollback.",
|
||||
"- Tagging remains an explicit operator action.",
|
||||
"",
|
||||
"## Known Boundaries",
|
||||
"",
|
||||
"- This release does not claim full multi-user auth.",
|
||||
"- This release does not replace all local/mock project surfaces with database-backed behavior.",
|
||||
"- This release does not automate AI calls or require API keys.",
|
||||
"- This release does not commit generated release evidence artifacts.",
|
||||
"",
|
||||
"## Recommended First Use",
|
||||
"",
|
||||
"1. Open an existing project from Projects.",
|
||||
"2. Review Workspace and Repo Builder for current direction.",
|
||||
"3. Generate or inspect a Session Pack before asking Codex to make changes.",
|
||||
"4. Capture execution feedback after a coding session.",
|
||||
"5. Use Release Center and Runtime Readiness before tagging.",
|
||||
"",
|
||||
"## Support Checklist",
|
||||
"",
|
||||
"When something looks wrong, collect:",
|
||||
"",
|
||||
"- current URL;",
|
||||
"- project id;",
|
||||
"- browser console error if present;",
|
||||
"- latest `pnpm run release:deployment-proof` output;",
|
||||
"- latest `pnpm run release:rollback-drill` output if deployment state is involved.",
|
||||
].join("\n");
|
||||
|
||||
console.log(notes);
|
||||
|
||||
if (shouldWrite) {
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||
fs.writeFileSync(out, `${notes}\n`, "utf8");
|
||||
console.log(`\nUser release notes written to ${out}`);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const steps = [
|
||||
{
|
||||
label: "Release evidence lane click-through",
|
||||
script: "scripts/smoke-rc63-release-evidence-lane.mjs",
|
||||
},
|
||||
{
|
||||
label: "Review decision receipt recovery",
|
||||
script: "scripts/smoke-rc60-review-decision-loop.mjs",
|
||||
},
|
||||
{
|
||||
label: "Release evidence capture assist",
|
||||
script: "scripts/smoke-rc73-release-evidence-capture-assist.mjs",
|
||||
},
|
||||
{
|
||||
label: "Release Center cockpit",
|
||||
script: "scripts/smoke-rc88-release-center.mjs",
|
||||
},
|
||||
{
|
||||
label: "Release checklist click-through",
|
||||
script: "scripts/smoke-rc99-release-checklist-clickthrough.mjs",
|
||||
},
|
||||
];
|
||||
|
||||
const runStep = (step) => {
|
||||
console.log(`\n[release-operator] ${step.label}`);
|
||||
const result = spawnSync(process.execPath, [step.script], {
|
||||
env: process.env,
|
||||
shell: false,
|
||||
detached: process.platform === "win32",
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error(`${step.label} failed with exit code ${result.status}`);
|
||||
};
|
||||
|
||||
try {
|
||||
console.log("ProjectBrain release operator smoke suite");
|
||||
console.log(`Base URL: ${process.env.PROJECTBRAIN_SMOKE_URL ?? "default smoke URL"}`);
|
||||
console.log(`Project: ${process.env.PROJECTBRAIN_SMOKE_PROJECT ?? "widefrog"}`);
|
||||
for (const step of steps) {
|
||||
runStep(step);
|
||||
}
|
||||
console.log("\nRelease operator smoke suite passed.");
|
||||
} catch (error) {
|
||||
console.error("\nRelease operator smoke suite failed:");
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo "Usage: $0 <40-character-commit-sha> <release-artifact-directory>" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
RELEASE_SHA="$1"
|
||||
ARTIFACT_DIR="${2%/}"
|
||||
case "${RELEASE_SHA}" in
|
||||
*[!0-9a-f]*|'') echo "Release SHA must contain exactly 40 lowercase hexadecimal characters." >&2; exit 64 ;;
|
||||
esac
|
||||
if [ "${#RELEASE_SHA}" -ne 40 ]; then
|
||||
echo "Release SHA must contain exactly 40 lowercase hexadecimal characters." >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
API_ARCHIVE="${ARTIFACT_DIR}/projectbrain-api-${RELEASE_SHA}.tar"
|
||||
WEB_ARCHIVE="${ARTIFACT_DIR}/projectbrain-web-${RELEASE_SHA}.tar"
|
||||
DIGEST_FILE="${ARTIFACT_DIR}/archive-sha256.txt"
|
||||
for path in "${API_ARCHIVE}" "${WEB_ARCHIVE}" "${DIGEST_FILE}"; do
|
||||
if [ ! -f "${path}" ]; then echo "Missing release artifact: ${path}" >&2; exit 66; fi
|
||||
done
|
||||
for command in sha256sum skopeo docker curl; do
|
||||
if ! command -v "${command}" >/dev/null 2>&1; then echo "Required command is unavailable: ${command}" >&2; exit 69; fi
|
||||
done
|
||||
|
||||
verify_digest() {
|
||||
archive="$1"
|
||||
filename="$(basename "${archive}")"
|
||||
expected="$(awk -v file="${filename}" '$2 == file || $2 ~ ("/" file "$") { print $1; exit }' "${DIGEST_FILE}")"
|
||||
actual="$(sha256sum "${archive}" | awk '{ print $1 }')"
|
||||
if [ -z "${expected}" ] || [ "${actual}" != "${expected}" ]; then
|
||||
echo "Digest mismatch for ${filename}." >&2
|
||||
exit 65
|
||||
fi
|
||||
printf '%s' "${actual}"
|
||||
}
|
||||
|
||||
API_DIGEST="$(verify_digest "${API_ARCHIVE}")"
|
||||
WEB_DIGEST="$(verify_digest "${WEB_ARCHIVE}")"
|
||||
skopeo copy "oci-archive:${API_ARCHIVE}" "docker-daemon:projectbrain-api:${RELEASE_SHA}"
|
||||
skopeo copy "oci-archive:${WEB_ARCHIVE}" "docker-daemon:projectbrain-web:${RELEASE_SHA}"
|
||||
|
||||
API_IMAGE_ID="$(docker image inspect --format '{{.Id}}' "projectbrain-api:${RELEASE_SHA}")"
|
||||
WEB_IMAGE_ID="$(docker image inspect --format '{{.Id}}' "projectbrain-web:${RELEASE_SHA}")"
|
||||
|
||||
: "${POSTGRES_PASSWORD:?set a non-default POSTGRES_PASSWORD in the environment or .env}"
|
||||
: "${PROJECTBRAIN_OWNER_PASSWORD:?set PROJECTBRAIN_OWNER_PASSWORD in the environment or .env}"
|
||||
: "${PROJECTBRAIN_AUTH_SECRET:?set PROJECTBRAIN_AUTH_SECRET in the environment or .env}"
|
||||
if [ "${POSTGRES_PASSWORD}" = "projectbrain" ]; then echo "Default PostgreSQL password is forbidden." >&2; exit 78; fi
|
||||
if [ "${#PROJECTBRAIN_OWNER_PASSWORD}" -lt 12 ]; then echo "Owner password must contain at least 12 characters." >&2; exit 78; fi
|
||||
if [ "${#PROJECTBRAIN_AUTH_SECRET}" -lt 32 ]; then echo "Auth secret must contain at least 32 characters." >&2; exit 78; fi
|
||||
|
||||
export PROJECTBRAIN_RELEASE_SHA="${RELEASE_SHA}"
|
||||
export PROJECTBRAIN_DEPLOY_SLOT="${PROJECTBRAIN_DEPLOY_SLOT:-stage}"
|
||||
export PROJECTBRAIN_WEB_PORT="${PROJECTBRAIN_STAGE_WEB_PORT:-1210}"
|
||||
COMPOSE_PROJECT="projectbrain-${PROJECTBRAIN_DEPLOY_SLOT}-${RELEASE_SHA%????????????????????????????????}"
|
||||
|
||||
docker compose \
|
||||
--project-name "${COMPOSE_PROJECT}" \
|
||||
--file docker-compose.yml \
|
||||
--file docker-compose.release.yml \
|
||||
config --quiet
|
||||
docker compose \
|
||||
--project-name "${COMPOSE_PROJECT}" \
|
||||
--file docker-compose.yml \
|
||||
--file docker-compose.release.yml \
|
||||
up --detach --no-build --pull never
|
||||
|
||||
READINESS_URL="http://127.0.0.1:${PROJECTBRAIN_WEB_PORT}/api/system/readiness"
|
||||
attempt=0
|
||||
until READINESS="$(curl --fail --silent --show-error "${READINESS_URL}")"; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "${attempt}" -ge 30 ]; then echo "Staged release did not become ready: ${READINESS_URL}" >&2; exit 70; fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
EVIDENCE_FILE="${ARTIFACT_DIR}/staged-deployment-${RELEASE_SHA}.txt"
|
||||
{
|
||||
echo "release_sha=${RELEASE_SHA}"
|
||||
echo "compose_project=${COMPOSE_PROJECT}"
|
||||
echo "web_port=${PROJECTBRAIN_WEB_PORT}"
|
||||
echo "api_archive_sha256=${API_DIGEST}"
|
||||
echo "web_archive_sha256=${WEB_DIGEST}"
|
||||
echo "api_image_id=${API_IMAGE_ID}"
|
||||
echo "web_image_id=${WEB_IMAGE_ID}"
|
||||
echo "readiness_url=${READINESS_URL}"
|
||||
echo "readiness=${READINESS}"
|
||||
echo "verified_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
} > "${EVIDENCE_FILE}"
|
||||
|
||||
echo "Staged immutable release ${RELEASE_SHA} as ${COMPOSE_PROJECT}."
|
||||
echo "Evidence: ${EVIDENCE_FILE}"
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
API_LOG="/tmp/projectbrain-api.log"
|
||||
PG_LOG="/tmp/projectbrain-postgres.log"
|
||||
ANALYSIS_WORKER_LOG="/tmp/projectbrain-analysis-worker.log"
|
||||
PROJECTBRAIN_DATA_DIR="${PROJECTBRAIN_DATA_DIR:-/var/lib/projectbrain}"
|
||||
PGDATA="${PROJECTBRAIN_POSTGRES_DATA_DIR:-${PROJECTBRAIN_DATA_DIR}/postgres}"
|
||||
POSTGRES_DB="${POSTGRES_DB:-projectbrain}"
|
||||
POSTGRES_USER="${POSTGRES_USER:-projectbrain}"
|
||||
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}"
|
||||
if [ "${PROJECTBRAIN_USE_EXTERNAL_DATABASE:-false}" != "true" ]; then
|
||||
: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required for embedded PostgreSQL}"
|
||||
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/${POSTGRES_DB}?schema=public"
|
||||
else
|
||||
export DATABASE_URL="${DATABASE_URL:?DATABASE_URL is required when PROJECTBRAIN_USE_EXTERNAL_DATABASE=true}"
|
||||
fi
|
||||
|
||||
mkdir -p "${PGDATA}"
|
||||
mkdir -p /run/postgresql
|
||||
chown -R postgres:postgres "${PROJECTBRAIN_DATA_DIR}"
|
||||
chown postgres:postgres /run/postgresql
|
||||
|
||||
if [ ! -s "${PGDATA}/PG_VERSION" ]; then
|
||||
echo "[ProjectBrain] Initializing embedded PostgreSQL data directory..."
|
||||
pwfile="/tmp/projectbrain-pgpass"
|
||||
printf '%s' "${POSTGRES_PASSWORD}" > "${pwfile}"
|
||||
chown postgres:postgres "${pwfile}"
|
||||
su postgres -c "initdb -D '${PGDATA}' -U '${POSTGRES_USER}' --pwfile='${pwfile}' --auth-host=md5 --auth-local=trust" >/tmp/projectbrain-initdb.log 2>&1 || {
|
||||
cat /tmp/projectbrain-initdb.log >&2 || true
|
||||
exit 1
|
||||
}
|
||||
rm -f "${pwfile}"
|
||||
fi
|
||||
|
||||
echo "[ProjectBrain] Starting embedded PostgreSQL..."
|
||||
su postgres -c "pg_ctl -D '${PGDATA}' -o \"-c listen_addresses='127.0.0.1'\" -w start" > "${PG_LOG}" 2>&1
|
||||
|
||||
if ! su postgres -c "psql -U '${POSTGRES_USER}' -d postgres -tAc \"SELECT 1 FROM pg_database WHERE datname='${POSTGRES_DB}'\"" | grep -q 1; then
|
||||
echo "[ProjectBrain] Creating database ${POSTGRES_DB}..."
|
||||
su postgres -c "createdb -U '${POSTGRES_USER}' '${POSTGRES_DB}'"
|
||||
fi
|
||||
|
||||
cd /app/backend
|
||||
|
||||
echo "[ProjectBrain] Starting API inside single-container runtime..."
|
||||
NODE_OPTIONS="--max-old-space-size=${PROJECTBRAIN_API_HEAP_MB:-512}" sh scripts/docker-entrypoint.sh > "${API_LOG}" 2>&1 &
|
||||
API_PID="$!"
|
||||
ANALYSIS_WORKER_SUPERVISOR_PID=""
|
||||
NGINX_PID=""
|
||||
|
||||
cleanup() {
|
||||
echo "[ProjectBrain] Stopping runtime..."
|
||||
if [ -n "${ANALYSIS_WORKER_SUPERVISOR_PID}" ]; then
|
||||
kill "${ANALYSIS_WORKER_SUPERVISOR_PID}" 2>/dev/null || true
|
||||
fi
|
||||
if [ -n "${NGINX_PID}" ]; then
|
||||
kill "${NGINX_PID}" 2>/dev/null || true
|
||||
fi
|
||||
kill "${API_PID}" 2>/dev/null || true
|
||||
su postgres -c "pg_ctl -D '${PGDATA}' -m fast stop" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup INT TERM
|
||||
|
||||
echo "[ProjectBrain] Waiting for API readiness..."
|
||||
attempt=0
|
||||
until curl -fsS "http://127.0.0.1:4000/api/system/readiness" >/dev/null 2>&1; do
|
||||
attempt=$((attempt + 1))
|
||||
if ! kill -0 "${API_PID}" 2>/dev/null; then
|
||||
echo "[ProjectBrain] API exited before readiness." >&2
|
||||
cat "${API_LOG}" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
if [ "${attempt}" -ge 60 ]; then
|
||||
echo "[ProjectBrain] API did not become ready in time." >&2
|
||||
cat "${API_LOG}" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
run_analysis_worker() (
|
||||
child_pid=""
|
||||
stop_worker() {
|
||||
if [ -n "${child_pid}" ]; then
|
||||
kill "${child_pid}" 2>/dev/null || true
|
||||
wait "${child_pid}" 2>/dev/null || true
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
trap stop_worker INT TERM
|
||||
while true; do
|
||||
echo "[ProjectBrain] Starting bounded repository-analysis worker..." >> "${ANALYSIS_WORKER_LOG}"
|
||||
NODE_OPTIONS="--max-old-space-size=${PROJECTBRAIN_ANALYSIS_WORKER_HEAP_MB:-384}" node dist/workers/analysisWorker.js >> "${ANALYSIS_WORKER_LOG}" 2>&1 &
|
||||
child_pid="$!"
|
||||
echo 500 > "/proc/${child_pid}/oom_score_adj" 2>/dev/null || true
|
||||
wait "${child_pid}" || true
|
||||
child_pid=""
|
||||
sleep 5
|
||||
done
|
||||
)
|
||||
run_analysis_worker &
|
||||
ANALYSIS_WORKER_SUPERVISOR_PID="$!"
|
||||
|
||||
echo "[ProjectBrain] API and analysis worker are ready. Starting nginx..."
|
||||
nginx -g "daemon off;" &
|
||||
NGINX_PID="$!"
|
||||
|
||||
wait "${NGINX_PID}"
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const outputFlag = process.argv.indexOf("--output");
|
||||
const output = resolve(root, outputFlag >= 0 ? process.argv[outputFlag + 1] : "release-artifacts");
|
||||
const sbomText = await readFile(resolve(output, "projectbrain.cdx.json"), "utf8");
|
||||
const provenance = JSON.parse(await readFile(resolve(output, "projectbrain.provenance.json"), "utf8"));
|
||||
const sbom = JSON.parse(sbomText);
|
||||
const canonicalSbom = `${JSON.stringify(sbom, null, 2)}\n`;
|
||||
const digest = createHash("sha256").update(JSON.stringify(sbom)).digest("hex");
|
||||
const failures = [];
|
||||
if (sbom.bomFormat !== "CycloneDX" || sbom.specVersion !== "1.6" || !Array.isArray(sbom.components) || sbom.components.length === 0) failures.push("SBOM is not a populated CycloneDX 1.6 document");
|
||||
if (sbomText !== canonicalSbom) failures.push("SBOM is not canonical pretty-printed JSON");
|
||||
if (provenance.predicateType !== "https://slsa.dev/provenance/v1" || !provenance.subject?.length) failures.push("Provenance has no SLSA v1 subjects");
|
||||
if (provenance.sbom?.sha256 !== digest) failures.push("Provenance SBOM digest does not match");
|
||||
if (failures.length) { console.error(failures.join("\n")); process.exit(1); }
|
||||
console.log(`Release metadata verified: ${sbom.components.length} components and ${provenance.subject.length} subjects.`);
|
||||
Reference in new issue
Block a user