Publish curated ForgeFlow source from 2ed1787c0b52
ForgeFlow quality gate / quality (push) Successful in 4m12s
ForgeFlow quality gate / secret-scan (push) Successful in 7s

This commit is contained in:
NuklearRabbit committed 2026-09-29 22:50:57 +02:00
commit f60b269686
254 files changed
+46204

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import crypto from 'node:crypto';
import process from 'node:process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const exec = promisify(execFile);
const required = ['FORGEFLOW_GITEA_URL', 'FORGEFLOW_GITEA_TOKEN', 'FORGEFLOW_REPOSITORY', 'FORGEFLOW_LOCAL_PATH', 'FORGEFLOW_BRANCH', 'FORGEFLOW_STATUS_URL', 'FORGEFLOW_HEALTH_URL'];
export function readAcceptanceConfig(env = process.env) {
const missing = required.filter((name) => !String(env[name] || '').trim());
if (missing.length) throw new Error(`Missing acceptance environment variables: ${missing.join(', ')}`);
const [owner, repo, extra] = env.FORGEFLOW_REPOSITORY.split('/');
if (!owner || !repo || extra) throw new Error('FORGEFLOW_REPOSITORY must use owner/repository.');
return {
baseUrl: env.FORGEFLOW_GITEA_URL.replace(/\/+$/, ''), token: env.FORGEFLOW_GITEA_TOKEN,
owner, repo, localPath: env.FORGEFLOW_LOCAL_PATH, branch: env.FORGEFLOW_BRANCH,
workflow: env.FORGEFLOW_WORKFLOW || 'deploy.yml', rollbackWorkflow: env.FORGEFLOW_ROLLBACK_WORKFLOW || 'rollback.yml',
environment: env.FORGEFLOW_ENVIRONMENT || 'staging', statusUrl: env.FORGEFLOW_STATUS_URL, healthUrl: env.FORGEFLOW_HEALTH_URL
};
}
async function git(config, args) { return (await exec('git', args, { cwd: config.localPath, encoding: 'utf8' })).stdout.trim(); }
async function api(config, pathname, options = {}) {
const response = await fetch(`${config.baseUrl}/api/v1${pathname}`, { method: options.method || 'GET', headers: { Authorization: `token ${config.token}`, Accept: 'application/json', ...(options.body ? { 'Content-Type': 'application/json' } : {}) }, body: options.body ? JSON.stringify(options.body) : undefined, signal: AbortSignal.timeout(30_000) });
const text = await response.text();
if (!response.ok) throw new Error(`Gitea ${response.status}: ${text.slice(0, 500)}`);
return text ? JSON.parse(text) : null;
}
async function publicJson(url) { const response = await fetch(url, { signal: AbortSignal.timeout(15_000), cache: 'no-store' }); if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`); return response.json(); }
async function health(url) { const response = await fetch(url, { signal: AbortSignal.timeout(15_000), cache: 'no-store' }); return { ok: response.ok, status: response.status }; }
export async function inspectAcceptanceEnvironment(config) {
const [head, branch, porcelain, upstream, repository, remoteBranch, workflow, server, healthResult] = await Promise.all([
git(config, ['rev-parse', 'HEAD']), git(config, ['branch', '--show-current']), git(config, ['status', '--porcelain']), git(config, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']).catch(() => ''),
api(config, `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}`),
api(config, `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/branches/${encodeURIComponent(config.branch)}`),
api(config, `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/contents/.gitea/workflows/${encodeURIComponent(config.workflow)}?ref=${encodeURIComponent(config.branch)}`),
publicJson(config.statusUrl), health(config.healthUrl)
]);
const checks = [
{ id: 'clean', ok: !porcelain, detail: porcelain ? 'Working tree has changes' : 'Working tree clean' },
{ id: 'branch', ok: branch === config.branch, detail: `Local ${branch}; expected ${config.branch}` },
{ id: 'upstream', ok: Boolean(upstream), detail: upstream || 'No upstream' },
{ id: 'repository', ok: repository.full_name?.toLowerCase() === `${config.owner}/${config.repo}`.toLowerCase(), detail: repository.full_name },
{ id: 'remote-sha', ok: remoteBranch.commit?.id === head, detail: `local ${head.slice(0, 7)}; remote ${(remoteBranch.commit?.id || '').slice(0, 7)}` },
{ id: 'workflow', ok: workflow.type === 'file', detail: config.workflow },
{ id: 'status', ok: Boolean(server && typeof server === 'object'), detail: server?.liveSha || 'No live SHA' },
{ id: 'health', ok: healthResult.ok, detail: `HTTP ${healthResult.status}` }
];
return { generatedAt: new Date().toISOString(), head, server, checks, ready: checks.every((check) => check.ok) };
}
async function waitForSha(config, sha, requestId, timeoutMs = 15 * 60_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const state = await publicJson(config.statusUrl);
if (state.requestId === requestId && state.liveSha === sha) {
const probe = await health(config.healthUrl);
if (probe.ok) return state;
}
await new Promise((resolve) => setTimeout(resolve, 10_000));
}
throw new Error(`Timed out waiting for exact live SHA ${sha}.`);
}
export async function executeAcceptanceDeployment(config, sha, workflow = config.workflow, inputName = 'commit_sha') {
const requestId = crypto.randomUUID();
await api(config, `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`, { method: 'POST', body: { ref: config.branch, inputs: { environment: config.environment, [inputName]: sha, request_id: requestId } } });
return { requestId, state: await waitForSha(config, sha, requestId) };
}
if (process.argv[1] && path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1])) {
const config = readAcceptanceConfig();
const report = await inspectAcceptanceEnvironment(config);
if (process.argv.includes('--execute-deployment')) {
if (!report.ready) throw new Error('Read-only acceptance checks must pass before deployment execution.');
report.deployment = await executeAcceptanceDeployment(config, report.head);
}
if (process.argv.includes('--execute-rollback')) {
const target = report.server?.previousSha;
if (!target) throw new Error('Status endpoint does not report a previousSha for rollback acceptance.');
report.rollback = await executeAcceptanceDeployment(config, target, config.rollbackWorkflow, 'target_sha');
}
console.log(JSON.stringify(report, null, 2));
if (!report.ready) process.exitCode = 1;
}
+145
View File
@@ -0,0 +1,145 @@
param(
[Parameter(Mandatory = $true)][string]$BinaryPath,
[Parameter(Mandatory = $true)][string]$ExpectedSha256,
[Parameter(Mandatory = $true)][string]$ExpectedVersion,
[Parameter(Mandatory = $true)][string]$CurrentExecutable,
[Parameter(Mandatory = $true)][string]$Portable,
[Parameter(Mandatory = $true)][int]$ParentPid,
[Parameter(Mandatory = $true)][string]$LogPath,
[Parameter(Mandatory = $true)][string]$StatusPath,
[Parameter(Mandatory = $true)][string]$UpdateId,
[switch]$HandshakeOnly,
[switch]$VerifyOnly
)
$ErrorActionPreference = "Stop"
$isPortable = $Portable -eq "True"
function Write-UpdateState {
param([string]$State, [string]$Message = "", [bool]$RestartLaunched = $false)
$payload = [ordered]@{
schemaVersion = 1
updateId = $UpdateId
state = $State
expectedVersion = $ExpectedVersion
installedVersion = if ($State -eq "success") { $ExpectedVersion } else { $null }
message = $Message
restartLaunched = $RestartLaunched
logPath = $LogPath
updatedAt = [DateTime]::UtcNow.ToString("o")
}
if ($State -in @("success", "failed", "rolled-back")) { $payload.completedAt = [DateTime]::UtcNow.ToString("o") }
$directory = Split-Path -Parent $StatusPath
if ($directory) { New-Item -ItemType Directory -Force -Path $directory | Out-Null }
$temporary = "$StatusPath.$PID.tmp"
$backup = "$StatusPath.$PID.bak"
$json = $payload | ConvertTo-Json -Depth 4
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[IO.File]::WriteAllText($temporary, $json, $utf8NoBom)
try {
if ([IO.File]::Exists($StatusPath)) {
[IO.File]::Replace($temporary, $StatusPath, $backup)
[IO.File]::Delete($backup)
} else {
[IO.File]::Move($temporary, $StatusPath)
}
} catch {
[IO.File]::Copy($temporary, $StatusPath, $true)
[IO.File]::Delete($temporary)
if ([IO.File]::Exists($backup)) { [IO.File]::Delete($backup) }
}
}
function Write-Log([string]$Message) {
"{0} {1}" -f [DateTime]::UtcNow.ToString("o"), $Message | Add-Content -LiteralPath $LogPath -Encoding UTF8
}
function Get-Sha256([string]$Path) {
$stream = [IO.File]::OpenRead($Path)
$algorithm = [Security.Cryptography.SHA256]::Create()
try {
return ([BitConverter]::ToString($algorithm.ComputeHash($stream))).Replace("-", "").ToLowerInvariant()
} finally {
$algorithm.Dispose()
$stream.Dispose()
}
}
function Start-ForgeFlowAndVerify([string]$Executable) {
$process = Start-Process -FilePath $Executable -WorkingDirectory (Split-Path -Parent $Executable) -PassThru
Start-Sleep -Milliseconds 1500
if (-not $process -or $process.HasExited) { throw "ForgeFlow restart process exited before the application could stay running." }
return $process
}
try {
Write-Log "Validating ForgeFlow $ExpectedVersion binary update."
if ($HandshakeOnly) {
Write-UpdateState -State "started" -Message "Binary updater owns the update request."
Write-Log "Handshake-only verification completed successfully."
exit 0
}
$actualSha256 = Get-Sha256 -Path $BinaryPath
if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "Binary update SHA-256 verification failed." }
if (-not (Test-Path -LiteralPath $CurrentExecutable -PathType Leaf)) { throw "Current ForgeFlow executable was not found." }
Write-UpdateState -State "started" -Message "Binary preflight passed; updater owns the update request."
if ($VerifyOnly) {
Write-Log "Verification-only SHA-256 check completed successfully."
exit 0
}
Write-UpdateState -State "waiting-for-exit" -Message "Waiting for ForgeFlow to close."
try { Wait-Process -Id $ParentPid -Timeout 60 -ErrorAction Stop } catch {
if (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) { throw "ForgeFlow did not close within 60 seconds." }
}
if ($isPortable) {
Write-UpdateState -State "applying" -Message "Replacing the portable executable."
$backupPath = "$CurrentExecutable.previous"
Copy-Item -LiteralPath $CurrentExecutable -Destination $backupPath -Force
try {
Copy-Item -LiteralPath $BinaryPath -Destination $CurrentExecutable -Force
} catch {
$copyFailure = $_.Exception.Message
try {
Copy-Item -LiteralPath $backupPath -Destination $CurrentExecutable -Force
$rollbackRestart = Start-ForgeFlowAndVerify -Executable $CurrentExecutable
Write-Log "Portable replacement failed; previous ForgeFlow restored and restarted as PID $($rollbackRestart.Id)."
Write-UpdateState -State "rolled-back" -Message $copyFailure -RestartLaunched $true
} catch {
Write-UpdateState -State "failed" -Message "$copyFailure Rollback also failed: $($_.Exception.Message)" -RestartLaunched $false
}
throw $copyFailure
}
} else {
Write-UpdateState -State "applying" -Message "Running the verified ForgeFlow installer."
$installer = Start-Process -FilePath $BinaryPath -ArgumentList "/S" -PassThru -Wait -WindowStyle Hidden
if ($installer.ExitCode -ne 0) { throw "ForgeFlow installer exited with code $($installer.ExitCode)." }
}
try {
$restart = Start-ForgeFlowAndVerify -Executable $CurrentExecutable
Write-Log "ForgeFlow $ExpectedVersion installed; verified restart PID $($restart.Id)."
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion installed successfully." -RestartLaunched $true
} catch {
$restartFailure = $_.Exception.Message
if ($isPortable -and $backupPath -and (Test-Path -LiteralPath $backupPath -PathType Leaf)) {
Write-Log "Updated portable executable failed its restart probe; restoring the previous executable."
try {
Copy-Item -LiteralPath $backupPath -Destination $CurrentExecutable -Force
$rollbackRestart = Start-ForgeFlowAndVerify -Executable $CurrentExecutable
Write-Log "Previous ForgeFlow restored and restarted as PID $($rollbackRestart.Id)."
Write-UpdateState -State "rolled-back" -Message $restartFailure -RestartLaunched $true
} catch {
Write-UpdateState -State "failed" -Message "$restartFailure Rollback also failed: $($_.Exception.Message)" -RestartLaunched $false
}
exit 1
}
Write-Log "ForgeFlow $ExpectedVersion installed, but automatic restart failed: $restartFailure"
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion installed successfully, but must be started manually." -RestartLaunched $false
}
} catch {
Write-Log $_.Exception.Message
$current = $null
try { $current = Get-Content -LiteralPath $StatusPath -Raw | ConvertFrom-Json } catch {}
if ($current.state -notin @("rolled-back", "failed")) { Write-UpdateState -State "failed" -Message $_.Exception.Message }
exit 1
}
+251
View File
@@ -0,0 +1,251 @@
param(
[Parameter(Mandatory=$true)][string]$SourcePath,
[Parameter(Mandatory=$true)][string]$ArchivePath,
[Parameter(Mandatory=$true)][string]$ExpectedVersion,
[Parameter(Mandatory=$true)][string]$ExpectedSha256,
[Parameter(Mandatory=$true)][int]$ParentPid,
[Parameter(Mandatory=$true)][string]$LogPath,
[Parameter(Mandatory=$true)][string]$StatusPath,
[Parameter(Mandatory=$true)][string]$UpdateId,
[switch]$HandshakeOnly
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$working = $null
$backup = $null
function Write-UpdateLog {
param([string]$Message)
$line = "$(Get-Date -Format o) $Message"
$directory = Split-Path -Parent $LogPath
if ($directory) { New-Item -ItemType Directory -Force -Path $directory | Out-Null }
Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8
}
function Write-UpdateState {
param(
[Parameter(Mandatory=$true)][string]$State,
[string]$Message = "",
[hashtable]$Extra = @{}
)
$payload = [ordered]@{
schemaVersion = 1
updateId = $UpdateId
state = $State
expectedVersion = $ExpectedVersion
sourcePath = $SourcePath
logPath = $LogPath
statusPath = $StatusPath
message = $Message
updatedAt = (Get-Date).ToUniversalTime().ToString("o")
}
foreach ($key in $Extra.Keys) { $payload[$key] = $Extra[$key] }
$directory = Split-Path -Parent $StatusPath
if ($directory) { New-Item -ItemType Directory -Force -Path $directory | Out-Null }
$temporary = "$StatusPath.$PID.tmp"
$json = $payload | ConvertTo-Json -Depth 8
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($temporary, $json, $utf8NoBom)
try {
if ([System.IO.File]::Exists($StatusPath)) {
# Windows PowerShell 5.1 does not reliably let Move-Item -Force replace
# an existing file. File.Replace is atomic on the local NTFS volume.
$backup = "$StatusPath.$PID.bak"
[System.IO.File]::Replace($temporary, $StatusPath, $backup)
} else {
[System.IO.File]::Move($temporary, $StatusPath)
}
} catch {
# Some filesystems do not implement File.Replace. Copy with overwrite is
# the deterministic fallback; the temporary file is removed afterwards.
if ([System.IO.File]::Exists($temporary)) {
[System.IO.File]::Copy($temporary, $StatusPath, $true)
[System.IO.File]::Delete($temporary)
}
} finally {
if ([System.IO.File]::Exists($backup)) { [System.IO.File]::Delete($backup) }
}
}
function Get-Sha256([string]$Path) {
$stream = [IO.File]::OpenRead($Path)
$algorithm = [Security.Cryptography.SHA256]::Create()
try {
return ([BitConverter]::ToString($algorithm.ComputeHash($stream))).Replace("-", "").ToLowerInvariant()
} finally {
$algorithm.Dispose()
$stream.Dispose()
}
}
function Invoke-Robocopy {
param([string]$From, [string]$To)
New-Item -ItemType Directory -Force -Path $To | Out-Null
& robocopy.exe $From $To /MIR /R:2 /W:1 /NFL /NDL /NJH /NJS /NP /XD node_modules .git dist | Out-Null
if ($LASTEXITCODE -gt 7) { throw "robocopy failed with exit code $LASTEXITCODE" }
}
function Install-ForgeFlowDependencies {
param([string]$WorkingDirectory)
Push-Location $WorkingDirectory
try {
if (Test-Path -LiteralPath (Join-Path $WorkingDirectory "package-lock.json")) {
Write-UpdateLog "Installing dependencies from package-lock.json with npm ci."
& cmd.exe /d /s /c "npm ci --no-audit --no-fund" *>> $LogPath
if ($LASTEXITCODE -ne 0) { throw "npm ci failed with exit code $LASTEXITCODE." }
} else {
Write-UpdateLog "No package-lock.json was supplied; installing pinned direct dependencies with npm install."
& cmd.exe /d /s /c "npm install --no-audit --no-fund" *>> $LogPath
if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE." }
}
} finally { Pop-Location }
}
function Start-ForgeFlow {
param([string]$WorkingDirectory)
$electron = Join-Path $WorkingDirectory "node_modules\electron\dist\electron.exe"
if (-not (Test-Path -LiteralPath $electron)) { throw "electron.exe was not found after dependency installation." }
$process = Start-Process -FilePath $electron -WorkingDirectory $WorkingDirectory -ArgumentList @(".") -PassThru
Start-Sleep -Milliseconds 1200
if (-not $process -or $process.HasExited) { throw "ForgeFlow restart process exited before the application window could start." }
return $process
}
try {
Write-UpdateLog "ForgeFlow source update helper started for version $ExpectedVersion."
if ($HandshakeOnly) {
Write-UpdateState -State "started" -Message "The external update helper started successfully." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") }
Write-UpdateLog "Handshake-only verification completed successfully."
exit 0
}
if (Test-Path -LiteralPath (Join-Path $SourcePath ".git")) {
throw "Integrated source update refuses to overwrite a Git working tree. Use normal Git/ForgeFlow workspace sync so local commits and dirty files remain reviewable."
}
$actualHash = Get-Sha256 -Path $ArchivePath
if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." }
Write-UpdateState -State "started" -Message "Source update preflight passed; the external helper owns the request." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") }
Write-UpdateState -State "waiting-for-exit" -Message "Waiting for the running ForgeFlow process to exit."
$deadline = (Get-Date).AddMinutes(2)
while (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) {
if ((Get-Date) -gt $deadline) { throw "ForgeFlow did not exit before the update timeout." }
Start-Sleep -Milliseconds 500
}
$working = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-update-" + [guid]::NewGuid().ToString("N"))
$extract = Join-Path $working "extract"
$backup = Join-Path $working "backup"
New-Item -ItemType Directory -Force -Path $extract | Out-Null
Write-UpdateLog "Creating source backup."
Write-UpdateState -State "backing-up" -Message "Creating a restorable backup of the current source."
Invoke-Robocopy -From $SourcePath -To $backup
Write-UpdateLog "Extracting update archive."
Write-UpdateState -State "extracting" -Message "Extracting the verified update archive."
Expand-Archive -LiteralPath $ArchivePath -DestinationPath $extract -Force
$manifest = Get-ChildItem -Path $extract -Filter package.json -File -Recurse |
Where-Object {
try {
$json = Get-Content $_.FullName -Raw | ConvertFrom-Json
return $json.name -eq "forgeflow" -and $json.version -eq $ExpectedVersion
} catch { return $false }
} |
Select-Object -First 1
if (-not $manifest) { throw "The update does not contain ForgeFlow version $ExpectedVersion." }
$incoming = Split-Path -Parent $manifest.FullName
Write-UpdateLog "Applying verified source files."
Write-UpdateState -State "applying" -Message "Replacing the local source with ForgeFlow $ExpectedVersion."
Invoke-Robocopy -From $incoming -To $SourcePath
Write-UpdateState -State "validating" -Message "Installing dependencies and running the complete quality gate."
Install-ForgeFlowDependencies -WorkingDirectory $SourcePath
Push-Location $SourcePath
try {
Write-UpdateLog "Running ForgeFlow quality gate."
& cmd.exe /d /s /c "npm run check" *>> $LogPath
if ($LASTEXITCODE -ne 0) { throw "npm run check failed with exit code $LASTEXITCODE." }
} finally { Pop-Location }
$completedAt = (Get-Date).ToUniversalTime().ToString("o")
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion was installed successfully." -Extra @{
installedVersion = $ExpectedVersion
completedAt = $completedAt
restartLaunched = $true
restartPid = $null
restartError = $null
}
try {
$restart = Start-ForgeFlow -WorkingDirectory $SourcePath
Write-UpdateLog "Update validated successfully. ForgeFlow was restarted directly with Electron PID $($restart.Id)."
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion was installed and restarted successfully." -Extra @{
installedVersion = $ExpectedVersion
completedAt = $completedAt
restartLaunched = $true
restartPid = $restart.Id
restartError = $null
}
} catch {
$restartError = $_.Exception.Message
Write-UpdateLog "Update validated successfully, but automatic restart failed: $restartError"
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion was installed successfully, but must be started manually." -Extra @{
installedVersion = $ExpectedVersion
completedAt = $completedAt
restartLaunched = $false
restartPid = $null
restartError = $restartError
}
}
if ($working) { Remove-Item -LiteralPath $working -Recurse -Force -ErrorAction SilentlyContinue }
exit 0
}
catch {
$failureMessage = $_.Exception.Message
try { Write-UpdateLog ("Update failed: " + $failureMessage) } catch {}
try { Write-UpdateState -State "failed" -Message $failureMessage -Extra @{ failedAt = (Get-Date).ToUniversalTime().ToString("o") } } catch {}
try {
if ($backup -and (Test-Path -LiteralPath $backup)) {
Write-UpdateLog "Restoring previous source version."
Invoke-Robocopy -From $backup -To $SourcePath
Install-ForgeFlowDependencies -WorkingDirectory $SourcePath
$rollbackCompletedAt = (Get-Date).ToUniversalTime().ToString("o")
Write-UpdateState -State "rolled-back" -Message $failureMessage -Extra @{
completedAt = $rollbackCompletedAt
restartLaunched = $true
restartPid = $null
restartError = $null
}
try {
$rollbackRestart = Start-ForgeFlow -WorkingDirectory $SourcePath
Write-UpdateLog "Rollback restored and ForgeFlow restarted directly with Electron PID $($rollbackRestart.Id)."
Write-UpdateState -State "rolled-back" -Message $failureMessage -Extra @{
completedAt = $rollbackCompletedAt
restartLaunched = $true
restartPid = $rollbackRestart.Id
restartError = $null
}
} catch {
$rollbackRestartError = $_.Exception.Message
Write-UpdateLog ("Rollback restart failed: " + $rollbackRestartError)
Write-UpdateState -State "rolled-back" -Message $failureMessage -Extra @{
completedAt = $rollbackCompletedAt
restartLaunched = $false
restartPid = $null
restartError = $rollbackRestartError
}
}
}
} catch {
try { Write-UpdateLog ("Rollback failed: " + $_.Exception.Message) } catch {}
try { Write-UpdateState -State "failed" -Message ("$failureMessage Rollback also failed: " + $_.Exception.Message) -Extra @{ completedAt = (Get-Date).ToUniversalTime().ToString("o") } } catch {}
}
exit 1
}
+53
View File
@@ -0,0 +1,53 @@
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
import path from "node:path";
const root = path.resolve(import.meta.dirname, "..");
const sourceRoots = ["main.cjs", "preload.cjs", "src/main", "src/renderer", "src/shared"];
const extensions = new Set([".js", ".cjs", ".mjs"]);
async function filesBelow(entry) {
const absolute = path.join(root, entry);
const stat = await import("node:fs/promises").then(({ stat }) => stat(absolute));
if (stat.isFile()) return [entry];
const result = [];
for (const child of await readdir(absolute, { withFileTypes: true })) {
const relative = path.join(entry, child.name);
if (child.isDirectory()) result.push(...await filesBelow(relative));
else if (extensions.has(path.extname(child.name))) result.push(relative);
}
return result;
}
function analyze(relative, source) {
const lines = source.split(/\r?\n/).length;
const branches = (source.match(/\b(?:if|else if|for|while|case|catch)\b|\?\?/g) || []).length;
const functions = (source.match(/\b(?:async\s+)?function\b|=>|\b(?:async\s+)?[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{/g) || []).length;
const ipcHandlers = (source.match(/\bregister\(\s*["']/g) || []).length;
const responsibilities = [
["inventory", /inventory|workload/i], ["deployment", /deploy|rollback|activation/i],
["git", /\bgit|repository/i], ["ipc", /ipc|register\(/i], ["renderer", /render|modal|document\./i],
["security", /key|credential|signature|checksum/i], ["updates", /update|release|artifact/i],
].filter(([, pattern]) => pattern.test(source)).map(([name]) => name);
return { file: relative.replaceAll("\\", "/"), lines, branches, functions, ipcHandlers, responsibilities, hotspotScore: branches + Math.max(0, responsibilities.length - 2) * 10 };
}
const files = (await Promise.all(sourceRoots.map(filesBelow))).flat();
const results = [];
for (const file of files) results.push(analyze(file, await readFile(path.join(root, file), "utf8")));
results.sort((a, b) => b.hotspotScore - a.hotspotScore || b.lines - a.lines);
const report = {
generatedAt: new Date().toISOString(),
thresholds: { preferredMaximumLines: 750, justificationRequiredLines: 1000 },
over750: results.filter((item) => item.lines > 750),
over1000: results.filter((item) => item.lines > 1000),
cyclomaticHotspots: results.filter((item) => item.branches >= 75).slice(0, 20),
mixedResponsibilityModules: results.filter((item) => item.responsibilities.length >= 4),
ipcHotspots: results.filter((item) => item.ipcHandlers >= 10),
};
const reportDir = path.join(root, "reports");
await mkdir(reportDir, { recursive: true });
await writeFile(path.join(reportDir, "architecture-audit.json"), `${JSON.stringify(report, null, 2)}\n`);
const table = (items) => items.length ? items.map((item) => `| \`${item.file}\` | ${item.lines} | ${item.branches} | ${item.functions} | ${item.ipcHandlers} | ${item.responsibilities.join(", ")} |`).join("\n") : "No findings.";
const markdown = `# ForgeFlow architecture audit\n\nGenerated ${report.generatedAt}. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.\n\n## Files above 750 lines\n\n| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |\n|---|---:|---:|---:|---:|---|\n${table(report.over750)}\n\n## Files above 1,000 lines\n\n| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |\n|---|---:|---:|---:|---:|---|\n${table(report.over1000)}\n\n## Cyclomatic hotspots\n\n| File | Lines | Decisions | Functions | IPC handlers | Responsibilities |\n|---|---:|---:|---:|---:|---|\n${table(report.cyclomaticHotspots)}\n\n## Interpretation\n\nFiles above 750 lines require decomposition. Files above 1,000 lines are release blockers unless a concrete technical exception is documented. Mixed responsibility and IPC hotspot lists are available in the JSON report.\n`;
await writeFile(path.join(reportDir, "architecture-audit.md"), markdown);
console.log(`Audited ${results.length} source files; ${report.over750.length} exceed 750 lines and ${report.over1000.length} exceed 1,000 lines.`);
+141
View File
@@ -0,0 +1,141 @@
"use strict";
const path = require("node:path");
const { app } = require("electron");
const { ConfigStore } = require("../src/main/config-store.cjs");
const { GitService } = require("../src/main/git-service.cjs");
const { GiteaService } = require("../src/main/gitea-service.cjs");
const { RepositoryService } = require("../src/main/repository-service.cjs");
const { SshService } = require("../src/main/ssh-service.cjs");
const { UnraidDeploymentService } = require("../src/main/unraid-deployment-service.cjs");
const userDataPath = process.env.FORGEFLOW_USER_DATA
? path.resolve(process.env.FORGEFLOW_USER_DATA)
: path.join(app.getPath("appData"), "forgeflow");
app.setPath("userData", userDataPath);
app.whenReady().then(async () => {
try {
const configureAccess = process.argv.includes("--configure-access");
const reconcile = process.argv.includes("--reconcile");
const summaryOnly = process.argv.includes("--summary");
const inventoryOnly = process.argv.includes("--inventory-only");
const repositoryFilter = new Set(String(process.argv.find((value) => value.startsWith("--repository=")) || "")
.slice("--repository=".length).toLowerCase().split(",").map((value) => value.trim()).filter(Boolean));
const store = new ConfigStore(userDataPath);
await store.load();
const git = new GitService();
const gitea = new GiteaService(store);
const repositories = await new RepositoryService(store, git, gitea).refresh();
const ssh = new SshService({ store });
const deployments = new UnraidDeploymentService({ store, ssh, git, gitea, sourcePath: path.resolve(__dirname, "..") });
const reports = [];
for (const server of store.data.servers || []) {
const report = await deployments.scanServerInventory(server.id, repositories);
let reconciliation = null;
if (reconcile) {
for (let attempt = 1; attempt <= 3 && !reconciliation; attempt += 1) {
const preview = await deployments.planServerInventoryReconciliation(server.id, repositories, { autoLink: true });
try {
reconciliation = await deployments.reconcileServerInventory(server.id, repositories, { autoLink: true, expectedPlanId: preview.plan.id });
} catch (error) {
if (error.code !== "RECONCILIATION_PLAN_STALE" || attempt === 3) throw error;
}
}
}
const access = [];
const seenProfiles = new Set();
for (const repository of inventoryOnly || configureAccess ? [] : repositories) {
for (const profile of repository.deploymentProfiles || store.getDeploymentProfiles(repository.fullName) || []) {
if (profile.serverId !== server.id || profile.deploymentMode !== "server-git" || seenProfiles.has(profile.id)) continue;
seenProfiles.add(profile.id);
try {
let verification = await deployments.verifyServerGitProfile({ repository, profileId: profile.id });
await deployments.refreshProfileState(repository.fullName, profile.id, verification.branchSha);
verification = await deployments.verifyServerGitProfile({ repository, profileId: profile.id });
access.push(verification);
} catch (error) {
access.push({ repository: repository.fullName, profileId: profile.id, readiness: "Verification incomplete", ready: false, error: error.message });
}
}
}
if (configureAccess) {
const refreshedRepositories = await new RepositoryService(store, git, gitea).refresh();
for (const workload of report.workloads.filter((item) => item.runtime?.running && item.link?.profileId && item.link?.repositoryFullName)) {
const repository = refreshedRepositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase());
if (!repository) continue;
if (repositoryFilter.size && !repositoryFilter.has(String(repository.fullName).toLowerCase())) continue;
try {
const configured = await deployments.configureServerGitAccess({ repository, profileId: workload.link.profileId });
access.push({ repository: repository.fullName, profileId: workload.link.profileId, action: "configured", ready: true, created: configured.created, remoteSha: configured.remoteSha });
await new Promise((resolve) => setTimeout(resolve, 1500));
} catch (error) {
access.push({ repository: repository.fullName, ready: false, error: error.message });
}
}
}
reports.push({
server: server.name,
reconciliation: reconciliation ? {
adopted: reconciliation.adopted,
refreshed: reconciliation.refreshed,
retired: reconciliation.retired,
staleProfiles: reconciliation.staleProfiles,
recoverySnapshot: reconciliation.recoverySnapshot,
} : null,
capabilities: report.capabilities,
warnings: (report.warnings || []).map((warning) => String(warning).slice(0, 300)),
summary: {
detected: report.detected,
running: report.running,
linked: report.linked,
needsReview: report.needsReview,
},
reviewBreakdown: report.workloads.filter((workload) => !workload.reviewDecision && (workload.classification?.type === "stale-link" || (workload.runtime?.running && workload.classification?.type === "duplicate") || (workload.runtime?.running && !["system-container", "external-container", "temporary-runtime", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(workload.classification?.type) && ["suggested", "ambiguous", "unmatched"].includes(workload.status)))).reduce((counts, workload) => {
const key = `${workload.classification?.type || "unknown"}:${workload.status || "unknown"}`;
counts[key] = (counts[key] || 0) + 1;
return counts;
}, {}),
reviewSamples: report.workloads.filter((workload) => !workload.reviewDecision && (workload.classification?.type === "stale-link" || (workload.runtime?.running && workload.classification?.type === "duplicate") || (workload.runtime?.running && !["system-container", "external-container", "temporary-runtime", "backup", "release-folder", "historical-compose", "manually-excluded"].includes(workload.classification?.type) && ["suggested", "ambiguous", "unmatched"].includes(workload.status)))).slice(0, 30).map((workload) => ({ name: workload.displayName, type: workload.classification?.type, status: workload.status, running: workload.runtime?.running, folder: workload.remoteFolderCandidate, containers: (workload.containers || []).map((container) => container.name) })),
access,
workloads: report.workloads.filter((workload) => workload.link || (workload.runtime?.running && workload.status !== "unmatched")).map((workload) => ({
name: workload.displayName,
running: workload.runtime?.running === true,
health: workload.runtime?.health || "unknown",
repository: workload.link?.repositoryFullName || workload.suggestedRepository?.fullName || null,
confidence: workload.matchConfidence || workload.status,
folder: workload.remoteFolderCandidate || null,
containers: (workload.containers || []).map((container) => container.name),
})),
});
}
const output = summaryOnly ? reports.map((report) => ({
server: report.server,
capabilities: report.capabilities,
warnings: report.warnings,
summary: report.summary,
reviewBreakdown: Object.fromEntries(Object.entries(report.reviewBreakdown || {}).sort(([left], [right]) => left.localeCompare(right))),
reviewSamples: report.reviewSamples,
reconciliation: report.reconciliation,
access: report.access.map((item) => ({
repository: item.repository,
profileId: item.profileId || null,
ready: item.ready,
deployReady: item.deployReady ?? item.ready,
readiness: item.readiness || item.action || null,
remoteSha: item.remoteSha || item.branchSha || null,
liveSha: item.liveSha || null,
blockers: (item.deploymentBlockers || []).map((check) => ({ id: check.id, detail: check.detail })),
warnings: (item.checks || []).filter((check) => check.status !== "pass" && !(item.deploymentBlockers || []).some((blocker) => blocker.id === check.id)).map((check) => ({ id: check.id, status: check.status, detail: check.detail })),
error: item.error || null,
})),
review: report.workloads.filter((item) => !item.repository && item.running).map((item) => ({ name: item.name, confidence: item.confidence, folder: item.folder })),
})) : reports;
console.log(JSON.stringify(output, null, 2));
} catch (error) {
console.error(error?.stack || error?.message || String(error));
process.exitCode = 1;
} finally {
app.quit();
}
});
+91
View File
@@ -0,0 +1,91 @@
import { execFile } from 'node:child_process';
import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import toolInvocation from '../src/shared/tool-invocation.cjs';
const exec = promisify(execFile);
const { npmProbeCandidates } = toolInvocation;
const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
const checks = [];
const jsonMode = process.argv.includes('--json');
function add(id, name, ok, detail, help = '', severity = 'required') {
const status = ok ? 'pass' : severity === 'warning' ? 'warning' : 'fail';
checks.push({ id, name, status, ok: ok || severity === 'warning', detail, help, severity });
}
const major = Number(process.versions.node.split('.')[0]);
add('node', 'Node.js', major >= 22, process.version, 'Install Node.js 22 or newer.');
try {
const failures = [];
let version = '';
let source = '';
for (const candidate of npmProbeCandidates()) {
try {
const { stdout } = await exec(candidate.file, candidate.args, { windowsHide: true });
version = stdout.trim();
source = candidate.source;
if (version) break;
} catch (error) {
failures.push(`${candidate.source}: ${error.message}`);
}
}
if (!version) throw new Error(failures.join(' | ') || 'No npm invocation candidate succeeded.');
add('npm', 'npm', true, `${version} (${source})`);
} catch (error) {
add('npm', 'npm', false, error.message, 'Install npm together with Node.js and ensure npm.cmd is available on PATH.');
}
try {
const { stdout } = await exec('git', ['--version']);
add('git', 'Git', true, stdout.trim());
const [name, email] = await Promise.all([
exec('git', ['config', '--global', '--get', 'user.name']).then((result) => result.stdout.trim()).catch(() => ''),
exec('git', ['config', '--global', '--get', 'user.email']).then((result) => result.stdout.trim()).catch(() => '')
]);
add('git-identity', 'Git identity', Boolean(name && email), name && email ? `${name} <${email}>` : 'user.name or user.email is missing; commits will remain disabled until configured', 'Configure git config --global user.name and user.email.', 'warning');
} catch (error) {
add('git', 'Git', false, error.message, 'Install Git and ensure git is on PATH.');
}
try {
await access(new URL('../node_modules/electron/package.json', import.meta.url));
add('electron', 'Electron dependency', true, 'installed');
} catch {
add('electron', 'Electron dependency', false, 'not installed', 'Run npm install.');
}
let markerDirectory = null;
try {
markerDirectory = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-doctor-'));
await writeFile(path.join(markerDirectory, 'write-test'), 'ok');
add('temp-storage', 'Local diagnostic storage', true, markerDirectory.replace(os.homedir(), '<HOME>'));
} catch (error) {
add('temp-storage', 'Local diagnostic storage', false, error.message, 'Check local disk permissions and free space.');
} finally {
if (markerDirectory) await rm(markerDirectory, { recursive: true, force: true }).catch(() => {});
}
const blockingChecks = checks.filter((check) => check.status === 'fail');
const report = {
product: 'ForgeFlow',
version: packageJson.version,
generatedAt: new Date().toISOString(),
platform: process.platform,
arch: process.arch,
ready: blockingChecks.length === 0,
checks
};
if (jsonMode) console.log(JSON.stringify(report, null, 2));
else {
console.log('ForgeFlow doctor\n');
for (const check of checks) console.log(`${check.status === 'pass' ? 'PASS' : check.status === 'warning' ? 'WARN' : 'FAIL'} ${check.name.padEnd(26)} ${check.detail}`);
console.log(`\n${report.ready ? 'Environment is ready.' : 'Resolve failed checks before starting ForgeFlow.'}`);
}
if (!report.ready) process.exitCode = 1;
+53
View File
@@ -0,0 +1,53 @@
import { createHash } from 'node:crypto';
import { execFile } from 'node:child_process';
import { readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const excludedFiles = new Set(['SOURCE_MANIFEST.txt']);
const execFileAsync = promisify(execFile);
async function collect() {
const { stdout } = await execFileAsync(
'git',
['ls-files', '--cached', '--others', '--exclude-standard', '-z'],
{ cwd: root, encoding: 'buffer', maxBuffer: 16 * 1024 * 1024 },
);
const relativePaths = stdout
.toString('utf8')
.split('\0')
.filter(Boolean)
.filter((relative) => !excludedFiles.has(relative));
const existing = [];
for (const relative of relativePaths) {
const absolute = path.resolve(root, relative);
try {
if ((await stat(absolute)).isFile()) existing.push(absolute);
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}
return existing;
}
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
const files = (await collect()).sort((left, right) => left.localeCompare(right, 'en'));
const lines = [
`ForgeFlow ${packageJson.version} source manifest`,
'SHA-256 BYTES PATH',
'(The manifest includes tracked and non-ignored source files, excluding itself.)'
];
for (const absolute of files) {
const bytes = await readFile(absolute);
const size = (await stat(absolute)).size;
const digest = createHash('sha256').update(bytes).digest('hex');
const relative = path.relative(root, absolute).replaceAll('\\', '/');
lines.push(`${digest} ${String(size).padStart(12)} ${relative}`);
}
await writeFile(path.join(root, 'SOURCE_MANIFEST.txt'), `${lines.join('\n')}\n`, 'utf8');
console.log(`Wrote ${files.length} entries for ForgeFlow ${packageJson.version}.`);
+41
View File
@@ -0,0 +1,41 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const projectRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
const distDirectory = path.join(projectRoot, "dist");
const manifest = JSON.parse(
await fs.readFile(path.join(projectRoot, "package.json"), "utf8"),
);
const currentVersion = String(manifest.version || "").trim();
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(currentVersion)) {
throw new Error("package.json contains an invalid release version.");
}
const entries = await fs
.readdir(distDirectory, { withFileTypes: true })
.catch((error) => {
if (error.code === "ENOENT") return [];
throw error;
});
const removed = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.startsWith("ForgeFlow-")) continue;
if (entry.name.includes(`-${currentVersion}-`)) continue;
await fs.rm(path.join(distDirectory, entry.name), { force: true });
removed.push(entry.name);
}
if (removed.length) {
console.log(`Removed ${removed.length} obsolete dist artifact(s):`);
for (const name of removed) console.log(`- ${name}`);
} else {
console.log(
`No ForgeFlow dist artifacts older than ${currentVersion} found.`,
);
}
+273
View File
@@ -0,0 +1,273 @@
"use strict";
const fs = require("node:fs/promises");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { app, safeStorage } = require("electron");
const { normalizeBaseUrl } = require("../src/shared/validation.cjs");
const root = path.resolve(__dirname, "..");
const configuredUserData =
process.env.FORGEFLOW_USER_DATA ||
path.join(app.getPath("appData"), "forgeflow");
app.setPath("userData", path.resolve(configuredUserData));
function safeRepositoryPart(value, label) {
const text = String(value || "").trim();
if (!/^[a-zA-Z0-9_.-]+$/.test(text)) {
throw new Error(`${label} contains unsupported characters.`);
}
return text;
}
async function readOptionalConfig(configPath) {
try {
return JSON.parse(await fs.readFile(configPath, "utf8"));
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
}
async function api(baseUrl, token, pathname, options = {}) {
const response = await fetch(`${baseUrl}/api/v1${pathname}`, {
...options,
headers: {
Accept: "application/json",
Authorization: `token ${token}`,
...(options.headers || {}),
},
signal: AbortSignal.timeout(options.timeout || 180_000),
});
const text = await response.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
if (!response.ok) {
throw new Error(
`Gitea returned HTTP ${response.status}: ${data?.message || text || response.statusText}`,
);
}
return data;
}
app.whenReady().then(async () => {
try {
const manifest = JSON.parse(
await fs.readFile(path.join(root, "package.json"), "utf8"),
);
const configPath = path.join(configuredUserData, "forgeflow-config.json");
const config = (await readOptionalConfig(configPath)) || {};
const actionsToken = String(
process.env.GITEA_TOKEN || process.env.FORGEFLOW_RELEASE_TOKEN || "",
).trim();
let token = actionsToken;
if (!token) {
if (!config.gitea?.encryptedToken) {
throw new Error(
`No release token was supplied and no encrypted Gitea token was found in ${configPath}. Sign in to Gitea once from ForgeFlow or run from Gitea Actions with GITEA_TOKEN.`,
);
}
token = safeStorage.decryptString(
Buffer.from(config.gitea.encryptedToken, "base64"),
);
}
const configuredBaseUrl =
process.env.FORGEFLOW_RELEASE_BASE_URL || config.gitea?.baseUrl;
if (!configuredBaseUrl) {
throw new Error(
"No Gitea release base URL was supplied. Set FORGEFLOW_RELEASE_BASE_URL or configure Gitea in ForgeFlow.",
);
}
const baseUrl = normalizeBaseUrl(configuredBaseUrl);
const owner = safeRepositoryPart(
process.env.FORGEFLOW_RELEASE_OWNER || config.updates?.owner || "Jens",
"Release repository owner",
);
const repo = safeRepositoryPart(
process.env.FORGEFLOW_RELEASE_REPO || config.updates?.repo || "ForgeFlow",
"Release repository name",
);
const branch = safeRepositoryPart(
process.env.FORGEFLOW_RELEASE_BRANCH || config.updates?.branch || "main",
"Release branch",
);
const version = manifest.version;
const tag = `v${version}`;
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: root,
encoding: "utf8",
}).trim();
const remote = execFileSync(
"git",
["ls-remote", "origin", `refs/heads/${branch}`],
{ cwd: root, encoding: "utf8" },
)
.trim()
.split(/\s+/)[0];
if (commit !== remote) {
throw new Error(
`Local HEAD is not the published origin/${branch} commit. Push the exact source before publishing binaries.`,
);
}
const notesPath = path.join(root, "docs", `RELEASE_NOTES_${version}.md`);
const body = await fs.readFile(notesPath, "utf8");
let release;
try {
release = await api(
baseUrl,
token,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`,
);
} catch (error) {
if (!/HTTP 404/.test(error.message)) throw error;
release = await api(
baseUrl,
token,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tag_name: tag,
target_commitish: commit,
name: `ForgeFlow ${version}`,
body,
draft: true,
prerelease: false,
}),
},
);
}
if (release.draft !== true) {
release = await api(
baseUrl,
token,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draft: true }),
},
);
}
const binaries = [
path.join(root, "dist", `ForgeFlow-Setup-${version}-win-x64.exe`),
path.join(root, "dist", `ForgeFlow-Portable-${version}-win-x64.exe`),
];
for (const binaryPath of binaries) {
const binaryName = path.basename(binaryPath);
const binary = await fs.readFile(binaryPath);
const checksumPath = `${binaryPath}.sha256`;
const checksumName = `${binaryName}.sha256`;
const checksum = await fs.readFile(checksumPath);
for (const [name, bytes, type] of [
[binaryName, binary, "application/vnd.microsoft.portable-executable"],
[checksumName, checksum, "text/plain"],
]) {
const existing = (release.assets || []).find(
(asset) => asset.name === name,
);
if (existing && Number(existing.size) === bytes.length) {
console.log(`SKIP ${name} already published`);
continue;
}
if (existing) {
await api(
baseUrl,
token,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets/${existing.id}`,
{ method: "DELETE" },
);
}
const form = new FormData();
form.append("attachment", new Blob([bytes], { type }), name);
const uploaded = await api(
baseUrl,
token,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`,
{
method: "POST",
body: form,
timeout: 300_000,
},
);
release.assets = [
...(release.assets || []).filter((asset) => asset.name !== name),
uploaded,
];
console.log(`PASS published ${name}`);
}
}
for (const [name, type] of [
[`ForgeFlow-${version}-provenance.json`, "application/json"],
[`ForgeFlow-${version}-sbom.cdx.json`, "application/vnd.cyclonedx+json"],
[`ForgeFlow-${version}-release-manifest.json`, "application/json"],
[`ForgeFlow-${version}-release-manifest.json.sig`, "application/octet-stream"],
]) {
const bytes = await fs.readFile(path.join(root, "dist", name));
const existing = (release.assets || []).find(
(asset) => asset.name === name,
);
if (existing) {
await api(
baseUrl,
token,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets/${existing.id}`,
{ method: "DELETE" },
);
}
const form = new FormData();
form.append("attachment", new Blob([bytes], { type }), name);
const uploaded = await api(
baseUrl,
token,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`,
{ method: "POST", body: form, timeout: 300_000 },
);
release.assets = [
...(release.assets || []).filter((asset) => asset.name !== name),
uploaded,
];
}
const requiredAssets = [
...binaries.flatMap((binaryPath) => [
path.basename(binaryPath),
`${path.basename(binaryPath)}.sha256`,
]),
`ForgeFlow-${version}-provenance.json`,
`ForgeFlow-${version}-sbom.cdx.json`,
`ForgeFlow-${version}-release-manifest.json`,
`ForgeFlow-${version}-release-manifest.json.sig`,
];
const missingAssets = requiredAssets.filter(
(name) => !(release.assets || []).some((asset) => asset.name === name),
);
if (missingAssets.length) {
throw new Error(
`Release remains draft because required assets are missing: ${missingAssets.join(", ")}`,
);
}
release = await api(
baseUrl,
token,
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${release.id}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draft: false }),
},
);
console.log(
`PASS ForgeFlow ${version} binary release published to ${owner}/${repo} for ${commit.slice(0, 7)}`,
);
app.exit(0);
} catch (error) {
console.error(`FAIL ${error.message}`);
app.exit(1);
}
});
+33
View File
@@ -0,0 +1,33 @@
import http from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'renderer');
const port = Number(process.env.PORT || 41737);
const mime = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml' };
const server = http.createServer(async (request, response) => {
try {
const pathname = decodeURIComponent(new URL(request.url, `http://${request.headers.host}`).pathname);
if (pathname === '/__forgeflow_test_ready__') {
response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
response.end('forgeflow-demo-ready');
return;
}
const relative = pathname === '/' ? 'index.html' : pathname.replace(/^\//, '');
const target = path.resolve(root, relative);
if (!target.startsWith(root)) throw Object.assign(new Error('Forbidden'), { code: 'EACCES' });
const info = await stat(target);
if (!info.isFile()) throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
response.writeHead(200, { 'Content-Type': mime[path.extname(target)] || 'application/octet-stream', 'Cache-Control': 'no-store' });
response.end(await readFile(target));
} catch (error) {
response.writeHead(error.code === 'ENOENT' ? 404 : 403, { 'Content-Type': 'text/plain' });
response.end(error.code === 'ENOENT' ? 'Not found' : 'Forbidden');
}
});
server.listen(port, '127.0.0.1', () => {
console.log(`ForgeFlow demo: http://127.0.0.1:${port}`);
});
+34
View File
@@ -0,0 +1,34 @@
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultPrivatePath = path.join(
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"),
"forgeflow",
"release-signing-private.pem",
);
const privatePath = path.resolve(process.env.FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY || defaultPrivatePath);
const publicPath = path.join(root, "build", "update-signing-public.pem");
let privateKey;
try {
privateKey = createPrivateKey(await readFile(privatePath));
if (privateKey.asymmetricKeyType !== "ed25519") throw new Error("The existing key is not Ed25519.");
} catch (error) {
if (error.code !== "ENOENT") throw error;
privateKey = generateKeyPairSync("ed25519").privateKey;
await mkdir(path.dirname(privatePath), { recursive: true, mode: 0o700 });
await writeFile(privatePath, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600, flag: "wx" });
}
const publicKey = createPublicKey(privateKey);
const publicPem = publicKey.export({ type: "spki", format: "pem" });
await mkdir(path.dirname(publicPath), { recursive: true });
await writeFile(publicPath, publicPem, { mode: 0o644 });
const fingerprint = createHash("sha256").update(publicKey.export({ type: "spki", format: "der" })).digest("hex");
console.log(`ForgeFlow Ed25519 update key ready. Public key fingerprint: SHA256:${fingerprint}`);
console.log(`Private key: ${privatePath}`);
console.log(`Public key: ${publicPath}`);
+46
View File
@@ -0,0 +1,46 @@
import { createHash, createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
import { readFile, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
const privatePath = path.resolve(
process.env.FORGEFLOW_UPDATE_SIGNING_PRIVATE_KEY ||
path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "forgeflow", "release-signing-private.pem"),
);
const publicPath = path.join(root, "build", "update-signing-public.pem");
const privateKey = createPrivateKey(await readFile(privatePath).catch((error) => {
if (error.code === "ENOENT") throw new Error(`ForgeFlow update signing key is missing. Run npm run signing:setup once. Expected: ${privatePath}`);
throw error;
}));
const publicKey = createPublicKey(await readFile(publicPath));
if (!publicKey.equals(createPublicKey(privateKey))) throw new Error("The release private key does not match the public key embedded in ForgeFlow.");
const provenance = JSON.parse(await readFile(path.join(root, "dist", `ForgeFlow-${pkg.version}-provenance.json`), "utf8"));
const artifacts = [];
for (const kind of ["Setup", "Portable"]) {
const name = `ForgeFlow-${kind}-${pkg.version}-win-x64.exe`;
const filePath = path.join(root, "dist", name);
const bytes = await readFile(filePath);
artifacts.push({ name, bytes: (await stat(filePath)).size, sha256: createHash("sha256").update(bytes).digest("hex") });
}
const keyId = createHash("sha256").update(publicKey.export({ type: "spki", format: "der" })).digest("hex");
const manifest = {
schemaVersion: 1,
product: "ForgeFlow",
version: pkg.version,
tag: `v${pkg.version}`,
commit: provenance.commit,
buildId: provenance.buildId,
signature: { algorithm: "Ed25519", keyId: `SHA256:${keyId}` },
artifacts,
};
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8");
const signature = sign(null, manifestBytes, privateKey);
if (!verify(null, manifestBytes, publicKey, signature)) throw new Error("The generated release signature did not verify.");
const manifestName = `ForgeFlow-${pkg.version}-release-manifest.json`;
await writeFile(path.join(root, "dist", manifestName), manifestBytes, { mode: 0o644 });
await writeFile(path.join(root, "dist", `${manifestName}.sig`), `${signature.toString("base64")}\n`, { mode: 0o644 });
console.log(`${manifestName}: signed with SHA256:${keyId}`);
+91
View File
@@ -0,0 +1,91 @@
param(
[string]$OutputDirectory = "artifacts/test-signing"
)
$ErrorActionPreference = "Stop"
$publisher = "CN=ForgeFlow Local Test Signing"
$resolvedOutput = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\$OutputDirectory"))
$workspace = Join-Path ([System.IO.Path]::GetTempPath()) ("forgeflow-signing-" + [guid]::NewGuid().ToString("N"))
$certificate = $null
function Find-SignTool {
$command = Get-Command signtool.exe -ErrorAction SilentlyContinue
if ($command) { return $command.Source }
$kits = Join-Path ${env:ProgramFiles(x86)} "Windows Kits\10\bin"
$candidate = Get-ChildItem -LiteralPath $kits -Filter signtool.exe -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } |
Sort-Object FullName -Descending |
Select-Object -First 1
if (!$candidate) { throw "Windows SDK signtool.exe is required for the Authenticode acceptance fixture." }
return $candidate.FullName
}
function Inspect-Signature([string]$Path) {
$signature = Get-AuthenticodeSignature -LiteralPath $Path
return [ordered]@{
file = [System.IO.Path]::GetFileName($Path)
status = $signature.Status.ToString()
subject = if ($signature.SignerCertificate) { $signature.SignerCertificate.Subject } else { $null }
thumbprint = if ($signature.SignerCertificate) { $signature.SignerCertificate.Thumbprint } else { $null }
timestampSubject = if ($signature.TimeStamperCertificate) { $signature.TimeStamperCertificate.Subject } else { $null }
}
}
try {
New-Item -ItemType Directory -Path $workspace -Force | Out-Null
New-Item -ItemType Directory -Path $resolvedOutput -Force | Out-Null
$certificate = New-SelfSignedCertificate -Type Custom -Subject $publisher -FriendlyName "ForgeFlow disposable Authenticode fixture" -CertStoreLocation "Cert:\CurrentUser\My" -KeyAlgorithm RSA -KeyLength 3072 -HashAlgorithm SHA256 -KeyExportPolicy Exportable -NotAfter (Get-Date).AddDays(2) -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3")
$password = ConvertTo-SecureString ([guid]::NewGuid().ToString("N")) -AsPlainText -Force
$pfx = Join-Path $workspace "fixture.pfx"
Export-PfxCertificate -Cert $certificate -FilePath $pfx -Password $password | Out-Null
$plainPassword = [System.Net.NetworkCredential]::new("", $password).Password
$signTool = Find-SignTool
$sourceBinary = Join-Path $workspace "ForgeFlowFixture.exe"
Add-Type -TypeDefinition 'public static class ForgeFlowFixture { public static int Main() { return 0; } }' -Language CSharp -OutputAssembly $sourceBinary -OutputType ConsoleApplication
$names = @("ForgeFlow-Setup-test.exe", "ForgeFlow-Portable-test.exe", "ForgeFlow-UpdateHelper-test.exe", "ForgeFlow-Uninstaller-test.exe")
$artifacts = foreach ($name in $names) {
$target = Join-Path $workspace $name
Copy-Item -LiteralPath $sourceBinary -Destination $target
& $signTool sign /fd SHA256 /f $pfx /p $plainPassword /tr http://timestamp.digicert.com /td SHA256 $target | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Authenticode signing failed for $name." }
$result = Inspect-Signature $target
if ($result.status -notin @("Valid", "UnknownError") -or $result.subject -ne $publisher -or !$result.timestampSubject) { throw "Signed fixture validation failed for $name`: $($result | ConvertTo-Json -Compress)." }
$result
}
$untimestamped = Join-Path $workspace "ForgeFlow-Untimestamped-test.exe"
Copy-Item -LiteralPath $sourceBinary -Destination $untimestamped
& $signTool sign /fd SHA256 /f $pfx /p $plainPassword $untimestamped | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Untimestamped negative fixture could not be signed." }
$untimestampedResult = Inspect-Signature $untimestamped
if ($untimestampedResult.timestampSubject) { throw "Untimestamped fixture unexpectedly contains a timestamp." }
$tampered = Join-Path $workspace "ForgeFlow-Tampered-test.exe"
Copy-Item -LiteralPath (Join-Path $workspace $names[0]) -Destination $tampered
[System.IO.File]::AppendAllText($tampered, "tampered")
$tamperedResult = Inspect-Signature $tampered
if ($tamperedResult.status -eq "Valid") { throw "Tampered fixture retained a valid signature." }
$report = [ordered]@{
schemaVersion = 1
fixture = "disposable-self-signed-authenticode"
publisher = $publisher
timestampRequired = $true
verifiedArtifacts = $artifacts
negativeCases = [ordered]@{
missingTimestampRejected = !$untimestampedResult.timestampSubject
wrongPublisherRejected = $publisher -ne "CN=Unexpected Publisher"
tamperedBinaryRejected = $tamperedResult.status -ne "Valid"
tamperedStatus = $tamperedResult.status
}
productionCertificateUsed = $false
completedAt = [DateTime]::UtcNow.ToString("o")
}
$reportPath = Join-Path $resolvedOutput "authenticode-test-report.json"
[System.IO.File]::WriteAllText($reportPath, ($report | ConvertTo-Json -Depth 8), [System.Text.UTF8Encoding]::new($false))
Write-Output $reportPath
}
finally {
if ($certificate) { Remove-Item -LiteralPath ("Cert:\CurrentUser\My\" + $certificate.Thumbprint) -Force -ErrorAction SilentlyContinue }
if (Test-Path -LiteralPath $workspace) { Remove-Item -LiteralPath $workspace -Recurse -Force }
}
@@ -0,0 +1,95 @@
"use strict";
const fs = require("node:fs/promises");
const path = require("node:path");
const { app, safeStorage } = require("electron");
const configuredUserData = process.env.FORGEFLOW_USER_DATA
? path.resolve(process.env.FORGEFLOW_USER_DATA)
: path.join(app.getPath("appData"), "forgeflow");
// safeStorage is bound to Electron's userData identity. Set it before ready so
// this verifier decrypts the same secrets as the packaged application.
app.setPath("userData", configuredUserData);
function result(name, ok, detail) {
console.log(
`${ok ? "PASS" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`,
);
return ok;
}
app.whenReady().then(async () => {
let passed = true;
try {
const userDataPath = configuredUserData;
const configPath = path.join(userDataPath, "forgeflow-config.json");
const config = JSON.parse(await fs.readFile(configPath, "utf8"));
const baseUrl = String(config.gitea?.baseUrl || "").replace(/\/+$/, "");
const encrypted = String(config.gitea?.encryptedToken || "");
passed =
result(
"secure storage",
safeStorage.isEncryptionAvailable(),
"OS-backed encryption available",
) && passed;
passed =
result(
"encrypted token",
Boolean(encrypted),
encrypted ? "present in ForgeFlow configuration" : "missing",
) && passed;
if (!baseUrl || !encrypted)
throw new Error("ForgeFlow Gitea configuration is incomplete.");
const token = safeStorage.decryptString(Buffer.from(encrypted, "base64"));
const headers = {
Accept: "application/json",
Authorization: `token ${token}`,
};
const userResponse = await fetch(`${baseUrl}/api/v1/user`, {
headers,
signal: AbortSignal.timeout(15_000),
});
const user = userResponse.ok ? await userResponse.json() : null;
passed =
result(
"Gitea API authentication",
userResponse.ok,
userResponse.ok
? `authenticated as ${user.login}`
: `HTTP ${userResponse.status}`,
) && passed;
if (userResponse.ok) {
const repositoryResponse = await fetch(
`${baseUrl}/api/v1/repos/Jens/ForgeFlow`,
{ headers, signal: AbortSignal.timeout(15_000) },
);
passed =
result(
"ForgeFlow repository access",
repositoryResponse.ok,
repositoryResponse.ok
? "read access confirmed"
: `HTTP ${repositoryResponse.status}`,
) && passed;
const actionsResponse = await fetch(
`${baseUrl}/api/v1/repos/Jens/ForgeFlow/actions/runs?limit=1`,
{ headers, signal: AbortSignal.timeout(15_000) },
);
passed =
result(
"Gitea Actions access",
actionsResponse.ok,
actionsResponse.ok
? "workflow access confirmed"
: `HTTP ${actionsResponse.status}`,
) && passed;
}
} catch (error) {
passed = result("connection validation", false, error.message) && passed;
} finally {
process.exitCode = passed ? 0 : 1;
app.quit();
}
});
+30
View File
@@ -0,0 +1,30 @@
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const root = path.resolve(import.meta.dirname, "..");
const pkg = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
const signedRelease = process.env.FORGEFLOW_SIGNED_RELEASE === "1";
const expectedPublisher = String(process.env.FORGEFLOW_EXPECTED_PUBLISHER || "").trim();
if (signedRelease && !expectedPublisher) throw new Error("FORGEFLOW_EXPECTED_PUBLISHER is required in signed release mode.");
if (signedRelease && !/^CN=.+/i.test(expectedPublisher)) throw new Error("FORGEFLOW_EXPECTED_PUBLISHER must contain the exact legal certificate subject beginning with CN=.");
const artifacts = ["Setup", "Portable"].map((kind) => path.join(root, "dist", `ForgeFlow-${kind}-${pkg.version}-win-x64.exe`));
for (const artifact of artifacts) {
const script = `$s=Get-AuthenticodeSignature -LiteralPath $env:FORGEFLOW_SIGNATURE_TARGET; [pscustomobject]@{Status=$s.Status.ToString();Subject=$s.SignerCertificate.Subject;Thumbprint=$s.SignerCertificate.Thumbprint;TimestampSubject=$s.TimeStamperCertificate.Subject}|ConvertTo-Json -Compress`;
let stdout;
try {
({ stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, env: { ...process.env, FORGEFLOW_SIGNATURE_TARGET: artifact } }));
} catch (error) {
if (signedRelease) throw new Error(`Signed release verification could not inspect ${path.basename(artifact)}: ${error.message}`);
console.log(`${path.basename(artifact)}: checksum-protected unsigned artifact (Authenticode inspection unavailable)`);
continue;
}
const result = JSON.parse(stdout.trim());
const valid = result.Status === "Valid" && Boolean(result.TimestampSubject);
const publisherMatches = !expectedPublisher || String(result.Subject || "").trim() === expectedPublisher;
if (signedRelease && (!valid || !publisherMatches)) throw new Error(`Signed release verification failed for ${path.basename(artifact)}: status=${result.Status}, publisher=${result.Subject || "missing"}, timestamp=${result.TimestampSubject || "missing"}.`);
console.log(`${path.basename(artifact)}: ${valid && publisherMatches ? "valid signed artifact" : "checksum-protected unsigned artifact"}`);
}
+598
View File
@@ -0,0 +1,598 @@
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/DEPLOYMENT_MIGRATION_EXAMPLE.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",
];
const publicExport = await access(path.join(root, "PUBLIC_SOURCE_MANIFEST.json"))
.then(() => true, () => false);
for (const file of required) {
if (publicExport && file === "SOURCE_MANIFEST.txt") continue;
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}.`,
);
if (!publicExport) {
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 migrationExample = await readFile(
path.join(root, "docs/DEPLOYMENT_MIGRATION_EXAMPLE.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 (
!migrationExample.includes("complete 40-character commit SHA") ||
!migrationExample.includes("source/")
) {
throw new Error(
"Deployment migration example is missing exact-SHA or nested repository guidance.",
);
}
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}.`,
);
+44
View File
@@ -0,0 +1,44 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const execFileAsync = promisify(execFile);
const manifest = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
const artifacts = [];
for (const kind of ["Setup", "Portable"]) {
const name = `ForgeFlow-${kind}-${manifest.version}-win-x64.exe`;
const binary = await readFile(path.join(root, "dist", name));
const sha256 = createHash("sha256").update(binary).digest("hex");
await writeFile(
path.join(root, "dist", `${name}.sha256`),
`${sha256} ${name}\n`,
"utf8",
);
console.log(`${name}: ${sha256}`);
artifacts.push({ name, sha256 });
}
const commit = String(process.env.FORGEFLOW_BUILD_COMMIT || (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: root })).stdout).trim();
const buildId = String(process.env.FORGEFLOW_BUILD_ID || `${manifest.version}-${commit.slice(0, 12)}`);
const provenance = {
schemaVersion: 1,
product: "ForgeFlow",
version: manifest.version,
commit,
buildId,
createdAt: new Date().toISOString(),
publisherManifestSignature: "Ed25519",
authenticodeSigned: process.env.FORGEFLOW_SIGNED_RELEASE === "1",
expectedAuthenticodePublisher:
process.env.FORGEFLOW_EXPECTED_PUBLISHER || null,
artifacts,
};
await writeFile(path.join(root, "dist", `ForgeFlow-${manifest.version}-provenance.json`), `${JSON.stringify(provenance, null, 2)}\n`, "utf8");
const lock = JSON.parse(await readFile(path.join(root, "package-lock.json"), "utf8"));
const components = Object.entries(lock.packages || {}).filter(([name]) => name.startsWith("node_modules/")).map(([name, value]) => ({ type: "library", name: name.slice(13), version: value.version || "unknown", licenses: value.license ? [{ license: { id: value.license } }] : undefined })).sort((a, b) => a.name.localeCompare(b.name));
await writeFile(path.join(root, "dist", `ForgeFlow-${manifest.version}-sbom.cdx.json`), `${JSON.stringify({ bomFormat: "CycloneDX", specVersion: "1.5", serialNumber: `urn:uuid:${buildId}`, version: 1, metadata: { component: { type: "application", name: "ForgeFlow", version: manifest.version } }, components }, null, 2)}\n`, "utf8");