Files
ForgeFlow/scripts/verify.mjs
T

395 lines
12 KiB
JavaScript

import { access, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import shellVerification from "../src/shared/shell-verification.cjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const required = [
"package.json",
"main.cjs",
"preload.cjs",
"src/renderer/index.html",
"src/renderer/styles.css",
"src/renderer/app.js",
"src/renderer/mock-bridge.js",
"src/renderer/assets/itworx-mark.png",
"src/renderer/assets/itworx-wordmark.png",
"src/renderer/assets/itworx-wordmark-light.png",
"src/renderer/assets/itworx-wordmark-dark.png",
"src/main/config-store.cjs",
"src/main/git-service.cjs",
"src/main/gitea-service.cjs",
"src/main/audit-service.cjs",
"src/main/configuration-backup.cjs",
"src/main/external-tools-service.cjs",
"src/main/repository-service.cjs",
"src/main/repository-monitor.cjs",
"src/main/deployment-service.cjs",
"src/main/unraid-deployment-service.cjs",
"src/main/ssh-service.cjs",
"src/main/update-service.cjs",
"src/main/diagnostics-service.cjs",
"src/main/preflight-service.cjs",
"src/main/log-redaction.cjs",
"src/main/ipc.cjs",
"src/shared/clone-target.cjs",
"src/shared/semver.cjs",
"src/shared/zip-writer.cjs",
"src/shared/tool-invocation.cjs",
"src/shared/shell-verification.cjs",
"START_HERE.md",
"README.md",
"SOURCE_MANIFEST.txt",
"src/shared/deployment-policy.cjs",
"scripts/acceptance.mjs",
"scripts/validate-installed-connections.cjs",
"scripts/publish-binary-release.cjs",
"scripts/write-release-checksums.mjs",
"scripts/prune-dist.mjs",
"scripts/generate-source-manifest.mjs",
"setup-windows.ps1",
"START-FORGEFLOW-OVERLAY.ps1",
"update-windows.ps1",
"build-windows.ps1",
"UPDATE_FROM_0.3.2.md",
"scripts/apply-source-update.ps1",
"scripts/apply-binary-update.ps1",
"docs/ARCHITECTURE.md",
"docs/SECURITY.md",
"docs/ROADMAP.md",
"docs/SETUP_GUIDE.md",
"docs/ACCEPTANCE.md",
"docs/RELEASE_NOTES_0.8.0.md",
"docs/RELEASE_NOTES_0.8.1.md",
"docs/RELEASE_NOTES_0.8.2.md",
"docs/RELEASE_NOTES_0.8.3.md",
"docs/RELEASE_NOTES_0.8.4.md",
"docs/RELEASE_NOTES_0.8.5.md",
"docs/RELEASE_NOTES_0.8.6.md",
"docs/UPDATING.md",
"docs/DIAGNOSTICS.md",
"docs/DEPLOYMENT_SETUP.md",
"docs/SSH_UNRAID_DEPLOYMENT.md",
"docs/LUMAOPS_SERVER_AUDIT.md",
"docs/STATUS_ENDPOINT.md",
"docs/TEST_MATRIX.md",
"docs/RELEASE_NOTES_0.4.0.md",
"docs/RELEASE_NOTES_0.4.1.md",
"docs/RELEASE_NOTES_0.4.2.md",
"docs/RELEASE_NOTES_0.4.3.md",
"docs/RELEASE_AUDIT_0.6.0.md",
"docs/RELEASE_NOTES_0.6.1.md",
"docs/RELEASE_NOTES_0.7.0.md",
"docs/RELEASE_NOTES_0.5.0.md",
"docs/RELEASE_NOTES_0.5.1.md",
"docs/RELEASE_NOTES_0.5.2.md",
"docs/RELEASE_NOTES_0.5.3.md",
"docs/RELEASE_NOTES_0.5.4.md",
"docs/RELEASE_NOTES_0.6.0.md",
"Publish-ForgeFlow-Release.ps1",
"docs/RELEASE_NOTES_0.4.4.md",
"docs/RELEASE_NOTES_0.4.5.md",
"examples/gitea-actions/deploy.yml",
"examples/gitea-actions/rollback.yml",
"examples/server/forgeflow-deploy",
"examples/server/forgeflow-targets.conf",
"examples/server/forgeflow-runner.sudoers",
"examples/server/status-example.json",
"build/icon.png",
"build/icon.ico",
];
for (const file of required) await access(path.join(root, file));
const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
if (packageJson.version !== "0.8.6")
throw new Error(
`Expected package version 0.8.6, got ${packageJson.version}.`,
);
const sourceManifest = await readFile(
path.join(root, "SOURCE_MANIFEST.txt"),
"utf8",
);
if (
!sourceManifest
.replace(/\r\n/g, "\n")
.startsWith(`ForgeFlow ${packageJson.version} source manifest\n`)
)
throw new Error("SOURCE_MANIFEST.txt does not match the package version.");
for (const group of ["dependencies", "devDependencies"]) {
for (const [name, version] of Object.entries(packageJson[group] || {})) {
if (/^[~^*]/.test(version))
throw new Error(
`${group} dependency ${name} must be pinned exactly, got ${version}.`,
);
}
}
if (packageJson.dependencies?.ssh2 !== "1.17.0")
throw new Error("ssh2 must remain pinned to 1.17.0.");
for (const script of ["start", "demo", "test", "verify", "check"]) {
if (!packageJson.scripts?.[script])
throw new Error(`Required npm script is missing: ${script}`);
}
if (
!packageJson.build?.win?.icon ||
!packageJson.build?.linux?.icon ||
!packageJson.build?.mac?.icon
) {
throw new Error("Package icon configuration is incomplete.");
}
async function collect(directory, extensions, output = []) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (["node_modules", "dist"].includes(entry.name)) continue;
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) await collect(absolute, extensions, output);
else if (extensions.has(path.extname(entry.name))) output.push(absolute);
}
return output;
}
const javascriptFiles = await collect(root, new Set([".js", ".cjs", ".mjs"]));
for (const file of javascriptFiles) {
const result = spawnSync(process.execPath, ["--check", file], {
encoding: "utf8",
});
if (result.status !== 0)
throw new Error(
`${path.relative(root, file)} failed syntax validation:\n${result.stderr}`,
);
}
const deploymentScript = await readFile(
path.join(root, "examples/server/forgeflow-deploy"),
"utf8",
);
shellVerification.validateShellScriptStructure(deploymentScript);
// The server deployment script targets Linux/Unraid. On Windows, different tools may
// register themselves as bash.exe (Git Bash, WSL launcher, MSYS), and several of
// those cannot reliably accept a script over stdin from Node. Publishing and applying
// a desktop update therefore never depend on a Windows Bash shim. Portable structural
// validation always runs; GNU Bash syntax validation additionally runs on non-Windows.
if (shellVerification.shouldRunExternalBash(process.platform)) {
const bashCheck =
shellVerification.bashSyntaxCheckFromTextInvocation(deploymentScript);
const shell = spawnSync(bashCheck.command, bashCheck.args, bashCheck.options);
if (shell.error)
throw new Error(
`Unable to start Bash for server deployment syntax validation: ${shell.error.message}`,
);
if (shell.status !== 0)
throw new Error(`Server deployment example failed bash syntax validation:
${shell.stderr || shell.stdout || "Bash returned a non-zero status."}`);
} else {
console.log(
"Windows: external Bash syntax validation skipped; portable server-script validation passed.",
);
}
JSON.parse(
await readFile(
path.join(root, "examples/server/status-example.json"),
"utf8",
),
);
const setupGuide = await readFile(
path.join(root, "docs/SETUP_GUIDE.md"),
"utf8",
);
const sshGuide = await readFile(
path.join(root, "docs/SSH_UNRAID_DEPLOYMENT.md"),
"utf8",
);
const audit = await readFile(
path.join(root, "docs/LUMAOPS_SERVER_AUDIT.md"),
"utf8",
);
const releaseNotes = await readFile(
path.join(root, "docs/RELEASE_NOTES_0.6.0.md"),
"utf8",
);
const updaterReleaseNotes = await readFile(
path.join(root, "docs/RELEASE_NOTES_0.6.1.md"),
"utf8",
);
if (
!setupGuide.includes("Gitea access token") ||
!setupGuide.includes("diagnostic bundle")
) {
throw new Error(
"Setup guide is missing required connection or diagnostics instructions.",
);
}
if (
!sshGuide.includes("/mnt/user/appdata") ||
!sshGuide.includes("host-key fingerprint")
) {
throw new Error(
"SSH / Unraid guide is missing its base path or host identity policy.",
);
}
if (
!audit.includes("d42d4a7f08240c478d07466e3fabec654dc71367") ||
!audit.includes("source/")
) {
throw new Error(
"LumaOps audit is missing the exact matching SHA or nested repository finding.",
);
}
for (const phrase of [
"DockerMan",
"HEAD.lock",
"deployment reconciliation",
"Portfolio",
"safety branch",
"high-contrast ITWorx",
]) {
if (!releaseNotes.includes(phrase))
throw new Error(`Release notes are missing: ${phrase}`);
}
for (const phrase of [
"Windows PowerShell 5.1",
"File.Replace",
"handshake-only",
"updateId",
]) {
if (!updaterReleaseNotes.includes(phrase))
throw new Error(`Updater release notes are missing: ${phrase}`);
}
const setupScript = await readFile(
path.join(root, "setup-windows.ps1"),
"utf8",
);
const sourceUpdateScript = await readFile(
path.join(root, "update-windows.ps1"),
"utf8",
);
for (const [name, script] of [
["setup-windows.ps1", setupScript],
["update-windows.ps1", sourceUpdateScript],
]) {
if (
!script.includes("$version = [string]$package.version") ||
!script.includes("npm ci --no-audit --no-fund")
)
throw new Error(
`${name} must use the package version dynamically and install from package-lock.json.`,
);
if (/v0\.4\.2|version -ne "0\.4\.2"/.test(script))
throw new Error(
`${name} still contains a stale hard-coded release version.`,
);
}
const updateHelperPath = path.join(root, "scripts/apply-source-update.ps1");
const updateHelperBytes = await readFile(updateHelperPath);
if (
updateHelperBytes[0] === 0xef &&
updateHelperBytes[1] === 0xbb &&
updateHelperBytes[2] === 0xbf
)
throw new Error("PowerShell update helper must not contain a UTF-8 BOM.");
const updateHelper = updateHelperBytes.toString("utf8");
if (
!updateHelper.trimStart().startsWith("param(") ||
updateHelper.trimStart().startsWith("\\")
)
throw new Error("PowerShell update helper must start directly with param(.");
const renderer = await readFile(path.join(root, "src/renderer/app.js"), "utf8");
const styles = await readFile(
path.join(root, "src/renderer/styles.css"),
"utf8",
);
const preload = await readFile(path.join(root, "preload.cjs"), "utf8");
const ipc = await readFile(path.join(root, "src/main/ipc.cjs"), "utf8");
for (const phrase of [
'data-action="commit-push"',
"checkForUpdates",
"saveServer",
"profile-provider",
"profile-icon-mode",
"itworx-mark.png",
"Repair DockerMan integration",
"Repository troubleshooting",
"repair-repository-sync",
]) {
if (!renderer.includes(phrase) && !preload.includes(phrase))
throw new Error(`Frontend integration is missing: ${phrase}`);
}
if (
!/\.file-list\s*\{[^}]*flex:\s*1 1 auto;/s.test(styles) ||
!styles.includes(".main-canvas.repository-canvas")
) {
throw new Error("Changed-file scrolling constraints are missing.");
}
for (const channel of [
"server:discover-existing",
"troubleshooter:scan",
"troubleshooter:repair",
"troubleshooter:auto-repair",
"updates:check",
"updates:download",
"updates:apply",
"server:save",
"server:test",
"server:inspect-project",
"repository:repair-git-locks",
"repository:repair-sync",
"deployment:apply-dockerman-metadata",
"deployment:reconcile",
]) {
if (!ipc.includes(channel))
throw new Error(`IPC registration is missing: ${channel}`);
}
const gitSource = await readFile(
path.join(root, "src/main/git-service.cjs"),
"utf8",
);
const unraidSource = await readFile(
path.join(root, "src/main/unraid-deployment-service.cjs"),
"utf8",
);
const publisher = await readFile(
path.join(root, "Publish-ForgeFlow-Release.ps1"),
"utf8",
);
for (const phrase of [
"HEAD.lock",
"backup-reset",
"repairSync",
"segments.includes('objects')",
]) {
if (!gitSource.includes(phrase))
throw new Error(`Git recovery implementation is missing: ${phrase}`);
}
for (const phrase of [
"discoverExisting",
"deriveDetectedProfile",
"docker inspect",
"net.unraid.docker.managed",
"dockerman",
"iconCacheRefresh",
"[PORT:",
"Superseded by live commit",
]) {
if (!unraidSource.includes(phrase))
throw new Error(`Unraid recovery implementation is missing: ${phrase}`);
}
for (const phrase of [
"git ls-remote origin",
"apply-source-update.ps1",
"without changing its version",
]) {
if (!publisher.includes(phrase))
throw new Error(`Publishing workflow is missing: ${phrase}`);
}
console.log(
`Verified ${required.length} required project files and ${javascriptFiles.length} JavaScript files for ForgeFlow ${packageJson.version}.`,
);