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/server-inventory.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/setup-update-signing-key.mjs", "scripts/sign-release-manifest.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/CURRENT_STATE.md", "docs/MUTATION_MODEL.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/RELEASE_NOTES_0.8.7.md", "docs/RELEASE_NOTES_0.8.8.md", "docs/RELEASE_NOTES_0.8.9.md", "docs/RELEASE_NOTES_0.9.0.md", "docs/RELEASE_NOTES_0.9.1.md", "docs/RELEASE_NOTES_0.9.2.md", "docs/RELEASE_NOTES_0.9.3.md", "docs/RELEASE_NOTES_0.9.4.md", "docs/RELEASE_NOTES_0.9.5.md", "docs/RELEASE_NOTES_0.10.0.md", "docs/RELEASE_NOTES_0.10.1.md", "docs/RELEASE_NOTES_0.10.2.md", "docs/RELEASE_NOTES_0.10.3.md", "docs/RELEASE_NOTES_0.10.4.md", "docs/RELEASE_NOTES_0.10.5.md", "docs/RELEASE_NOTES_0.10.6.md", "docs/RELEASE_NOTES_0.10.7.md", "docs/RELEASE_NOTES_0.10.8.md", "docs/RELEASE_NOTES_0.10.9.md", "docs/RELEASE_NOTES_0.10.10.md", "docs/RELEASE_NOTES_0.10.11.md", "docs/RELEASE_NOTES_0.10.12.md", "docs/RELEASE_NOTES_0.10.13.md", "docs/RELEASE_NOTES_0.10.14.md", "docs/RELEASE_NOTES_0.10.15.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", "build/update-signing-public.pem", ]; 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.10.15") throw new Error( `Expected package version 0.10.15, 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 Promise.all(["app.js", "diff-view.js", "views.js", "dialogs.js", "operations.js", "actions/shell.js", "actions/inventory.js", "actions/deployment-profile.js", "actions/deployment-operation.js", "actions/setup-and-settings.js", "actions/recovery.js", "actions/command.js", "events.js"].map((file) => readFile(path.join(root, "src/renderer", file), "utf8"), ))).join("\n"); 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 Promise.all(["ipc.cjs", "ipc/repository-handlers.cjs", "ipc/deployment-handlers.cjs", "ipc/operations-handlers.cjs"].map((file) => readFile(path.join(root, "src/main", file), "utf8"), ))).join("\n"); 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", "deployment:link-server-workload", ]) { if (!ipc.includes(channel)) throw new Error(`IPC registration is missing: ${channel}`); } const release090 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.0.md"), "utf8"); for (const phrase of [ "Push bundle", "manual wizard", "Monitor only", "DockerMan templates", "SHA-256", ]) { if (!release090.includes(phrase)) throw new Error(`0.9.0 release notes are missing: ${phrase}`); } const release091 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.1.md"), "utf8"); for (const phrase of [ "browser_download_url", "cross-origin", "manual installer", "in-app updates", ]) { if (!release091.includes(phrase)) throw new Error(`0.9.1 release notes are missing: ${phrase}`); } const release092 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.2.md"), "utf8"); for (const phrase of [ "Push bundle", "server password", "docker ps -a", "DockerMan", "zero counts", ]) { if (!release092.includes(phrase)) throw new Error(`0.9.2 release notes are missing: ${phrase}`); } const release093 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.3.md"), "utf8"); for (const phrase of [ "Direct copy", "Compose YAML", "linked automatically", "one-click", "no remote `git ls-remote`", ]) { if (!release093.includes(phrase)) throw new Error(`0.9.3 release notes are missing: ${phrase}`); } const release094 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.4.md"), "utf8"); for (const phrase of [ "real Compose files", "stale service hints", "force-recreate", "container ID", "previous container", ]) { if (!release094.includes(phrase)) throw new Error(`0.9.4 release notes are missing: ${phrase}`); } const release095 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.5.md"), "utf8"); for (const phrase of [ "Check / fix write access", "exact path, user, owner, group and mode", "preserves existing executable bits", "never implicitly executes `docker compose down`", "retains the backup evidence", ]) { if (!release095.includes(phrase)) throw new Error(`0.9.5 release notes are missing: ${phrase}`); } const release0100 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.0.md"), "utf8"); for (const phrase of [ "Server pull", "read-only deploy key", "automatic discovery", "Git Validator", "SSH host fingerprint", ]) { if (!release0100.includes(phrase)) throw new Error(`0.10.0 release notes are missing: ${phrase}`); } const release0101 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.1.md"), "utf8"); for (const phrase of ["certificate-free updates", "case-insensitive", "read-only deploy keys", "SHA-256"]) { if (!release0101.includes(phrase)) throw new Error(`0.10.1 release notes are missing: ${phrase}`); } const release0102 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.2.md"), "utf8"); for (const phrase of ["internal HTTP", "public HTTPS", "same-origin", "SHA-256"]) { if (!release0102.includes(phrase)) throw new Error(`0.10.2 release notes are missing: ${phrase}`); } const release0103 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.3.md"), "utf8"); for (const phrase of ["concurrently", "debounce", "animation frame", "Git Validator", "stale or forged"]) { if (!release0103.includes(phrase)) throw new Error(`0.10.3 release notes are missing: ${phrase}`); } const release0104 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.4.md"), "utf8"); for (const phrase of ["Windows PowerShell 5.1", "atomic status", "handshake-only", "existing installations"]) { if (!release0104.includes(phrase)) throw new Error(`0.10.4 release notes are missing: ${phrase}`); } const release0105 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.5.md"), "utf8"); for (const phrase of ["repository workspace", "resolved profile", "Link unresolved", "reconciliation", "server workload"]) { if (!release0105.includes(phrase)) throw new Error(`0.10.5 release notes are missing: ${phrase}`); } const release0106 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.6.md"), "utf8"); for (const phrase of ["detached", "PowerShell", "production Node spawn", "source updater", "one-time direct installation"]) { if (!release0106.includes(phrase)) throw new Error(`0.10.6 release notes are missing: ${phrase}`); } const release0107 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.7.md"), "utf8"); for (const phrase of ["exact provenance", "automatic", "repository sidebar", "DevRunbook", "no container changes"]) { if (!release0107.includes(phrase)) throw new Error(`0.10.7 release notes are missing: ${phrase}`); } const release0108 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.8.md"), "utf8"); for (const phrase of ["Get-FileHash", ".NET SHA-256", "PSModulePath", "binary", "source update helpers"]) { if (!release0108.includes(phrase)) throw new Error(`0.10.8 release notes are missing: ${phrase}`); } const release0109 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.9.md"), "utf8"); for (const phrase of ["containers without healthchecks", "single-instance", "exact Gitea commit", "deploy-ready", "no containers are changed"]) { if (!release0109.includes(phrase)) throw new Error(`0.10.9 release notes are missing: ${phrase}`); } const release01010 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.10.md"), "utf8"); for (const phrase of ["read-only deploy keys", "repository deployment root", "Compose working directory", "Fix write access", "exact Gitea commit"]) { if (!release01010.includes(phrase)) throw new Error(`0.10.10 release notes are missing: ${phrase}`); } const release01011 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.11.md"), "utf8"); for (const phrase of ["last-known-good", "closed output pipe", "linked checkout origin", "read-only deploy key", "browser test server"]) { if (!release01011.includes(phrase)) throw new Error(`0.10.11 release notes are missing: ${phrase}`); } const release01012 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.12.md"), "utf8"); for (const phrase of ["coalesced", "exact Gitea commit parity", "batched Docker inspect", "bounded worker pools", "stopped container"]) { if (!release01012.includes(phrase)) throw new Error(`0.10.12 release notes are missing: ${phrase}`); } const release01013 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.13.md"), "utf8"); for (const phrase of ["Gitea workspace sync", "recovery branch", "Stale deployment links", "Ed25519-signed release manifest", "Git-toolsgrid"]) { if (!release01013.includes(phrase)) throw new Error(`0.10.13 release notes are missing: ${phrase}`); } const release01014 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.14.md"), "utf8"); for (const phrase of ["Help center", "Gitea workspace sync", "repository context", "horizontal tab navigation", "84 browser flows"]) { if (!release01014.includes(phrase)) throw new Error(`0.10.14 release notes are missing: ${phrase}`); } const release01015 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.15.md"), "utf8"); for (const phrase of ["Workspace Sync", "Codex review manifest", "local-only", "source updater", "binary updater"]) { if (!release01015.includes(phrase)) throw new Error(`0.10.15 release notes are missing: ${phrase}`); } const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8"); for (const mode of ["server-git", "push-bundle", "monitor-only"]) { if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`); } const unraidDirectSource = (await Promise.all([ "unraid-deployment-service.cjs", "unraid-access-methods.cjs", "unraid-preflight-methods.cjs", "unraid-runtime-methods.cjs", "unraid-deployment-methods.cjs", "unraid-inventory-methods.cjs", "unraid-state-methods.cjs", ].map((file) => readFile(path.join(root, "src/main", file), "utf8")))).join("\n"); for (const requiredPhrase of [ "executePushBundle", "executeServerGitBundle", "configureServerGitAccess", "server-git-access", "git ls-remote --exit-code", "repository-scoped read-only deploy key", ]) { if (!unraidDirectSource.includes(requiredPhrase)) throw new Error(`Deployment source is missing: ${requiredPhrase}`); } const serverInventorySource = await readFile(path.join(root, "src/main/server-inventory.cjs"), "utf8"); for (const requiredPhrase of ["server-compose-file", "composeDefinitions", "remoteFolderCandidate"]) { if (!serverInventorySource.includes(requiredPhrase)) throw new Error(`Server inventory source is missing: ${requiredPhrase}`); } const giteaUpdateSource = await readFile(path.join(root, "src/main/gitea-service.cjs"), "utf8"); for (const phrase of [ "browser_download_url", "insecure cross-origin", "downloadReleaseAsset", ]) { if (!giteaUpdateSource.includes(phrase)) throw new Error(`0.9.1 updater repair is missing: ${phrase}`); } const gitSource = await readFile( path.join(root, "src/main/git-service.cjs"), "utf8", ); const unraidSource = unraidDirectSource; 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", "pushBundleScript", "linkServerWorkload", "deploymentMode", ]) { 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}.`, );