Release ForgeFlow 0.8.1

Add advanced Git and deployment workflows, secure backups and auditing, live Gitea integration, desktop notifications, connection validation, and the premium responsive UX refresh.
This commit is contained in:
NuklearRabbit
2026-07-26 00:42:17 +02:00
parent 971896a1d5
commit 4ad698c4eb
47 changed files with 9114 additions and 1648 deletions
+335 -86
View File
@@ -1,54 +1,140 @@
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';
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 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.6.1.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'
"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/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",
"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/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.6.1') throw new Error(`Expected package version 0.6.1, 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']) {
const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
if (packageJson.version !== "0.8.1")
throw new Error(
`Expected package version 0.8.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 (/^[~^*]/.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.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.');
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;
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);
@@ -56,13 +142,21 @@ async function collect(directory, extensions, output = []) {
return output;
}
const javascriptFiles = await collect(root, new Set(['.js', '.cjs', '.mjs']));
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 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');
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
@@ -71,66 +165,221 @@ shellVerification.validateShellScriptStructure(deploymentScript);
// 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 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.'}`);
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.');
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.');
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 (
!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.');
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 [
"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}`);
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 updateHelperPath = path.join(root, 'scripts/apply-source-update.ps1');
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(.');
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}`);
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 (!styles.includes('.file-list { flex: 1 1 auto;') || !styles.includes('.main-canvas.repository-canvas')) {
throw new Error('Changed-file scrolling constraints are missing.');
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 ['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}`);
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}`);
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 [
"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}`);
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}.`);
console.log(
`Verified ${required.length} required project files and ${javascriptFiles.length} JavaScript files for ForgeFlow ${packageJson.version}.`,
);