Publish curated ForgeFlow source from 2ed1787c0b52
This commit is contained in:
commit
f60b269686
254 files changed
+46204
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.`);
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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}.`);
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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"}`);
|
||||
}
|
||||
@@ -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}.`,
|
||||
);
|
||||
@@ -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");
|
||||
Reference in new issue
Block a user