433 lines
13 KiB
JavaScript
433 lines
13 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/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/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/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/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.9.1")
|
|
throw new Error(
|
|
`Expected package version 0.9.1, 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",
|
|
"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 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 = 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",
|
|
"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}.`,
|
|
);
|