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/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', 'setup-windows.ps1', 'START-FORGEFLOW-OVERLAY.ps1', 'update-windows.ps1', 'build-windows.ps1', 'UPDATE_FROM_0.3.2.md', 'scripts/apply-source-update.ps1', 'docs/ARCHITECTURE.md', 'docs/SECURITY.md', 'docs/ROADMAP.md', 'docs/SETUP_GUIDE.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.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.6.0') throw new Error(`Expected package version 0.6.0, got ${packageJson.version}.`); const sourceManifest = await readFile(path.join(root, 'SOURCE_MANIFEST.txt'), 'utf8'); if (!sourceManifest.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'); 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}`); } 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 ['Commit selected & push to Gitea', '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 (!styles.includes('.file-list { flex: 1 1 auto;') || !styles.includes('.main-canvas.repository-canvas')) { throw new Error('Changed-file scrolling constraints are missing.'); } for (const channel of ['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 ['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}.`);