Update
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
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
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Write-UpdateLog {
|
||||
param([string]$Message)
|
||||
$line = "$(Get-Date -Format o) $Message"
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LogPath) | Out-Null
|
||||
Add-Content -Path $LogPath -Value $line -Encoding UTF8
|
||||
}
|
||||
|
||||
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" }
|
||||
}
|
||||
|
||||
try {
|
||||
Write-UpdateLog "ForgeFlow source update started for version $ExpectedVersion."
|
||||
$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
|
||||
}
|
||||
|
||||
$actualHash = (Get-FileHash -Path $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." }
|
||||
|
||||
$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."
|
||||
Invoke-Robocopy -From $SourcePath -To $backup
|
||||
|
||||
Write-UpdateLog "Extracting 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."
|
||||
Invoke-Robocopy -From $incoming -To $SourcePath
|
||||
|
||||
Push-Location $SourcePath
|
||||
try {
|
||||
Write-UpdateLog "Installing exact dependencies."
|
||||
& cmd.exe /d /s /c "npm install --no-audit --no-fund" *>> $LogPath
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE." }
|
||||
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 }
|
||||
|
||||
Write-UpdateLog "Update validated successfully. Restarting ForgeFlow."
|
||||
Start-Process -FilePath "cmd.exe" -WorkingDirectory $SourcePath -ArgumentList "/d", "/s", "/c", "npm start"
|
||||
Remove-Item -LiteralPath $working -Recurse -Force -ErrorAction SilentlyContinue
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-UpdateLog ("Update failed: " + $_.Exception.Message)
|
||||
try {
|
||||
if ($backup -and (Test-Path $backup)) {
|
||||
Write-UpdateLog "Restoring previous source version."
|
||||
Invoke-Robocopy -From $backup -To $SourcePath
|
||||
Push-Location $SourcePath
|
||||
try {
|
||||
& cmd.exe /d /s /c "npm install --no-audit --no-fund" *>> $LogPath
|
||||
} finally { Pop-Location }
|
||||
Start-Process -FilePath "cmd.exe" -WorkingDirectory $SourcePath -ArgumentList "/d", "/s", "/c", "npm start"
|
||||
}
|
||||
} catch {
|
||||
Write-UpdateLog ("Rollback failed: " + $_.Exception.Message)
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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 = '') {
|
||||
checks.push({ id, name, status: ok ? 'pass' : 'fail', ok, detail, help });
|
||||
}
|
||||
|
||||
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', 'Configure git config --global user.name and user.email.');
|
||||
} 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 report = {
|
||||
product: 'ForgeFlow',
|
||||
version: packageJson.version,
|
||||
generatedAt: new Date().toISOString(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
ready: checks.every((check) => check.ok),
|
||||
checks
|
||||
};
|
||||
|
||||
if (jsonMode) console.log(JSON.stringify(report, null, 2));
|
||||
else {
|
||||
console.log('ForgeFlow doctor\n');
|
||||
for (const check of checks) console.log(`${check.ok ? 'PASS' : '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,28 @@
|
||||
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 || 4173);
|
||||
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);
|
||||
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,97 @@
|
||||
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/main/config-store.cjs', 'src/main/git-service.cjs', 'src/main/gitea-service.cjs',
|
||||
'src/main/repository-service.cjs', 'src/main/repository-monitor.cjs', 'src/main/deployment-service.cjs',
|
||||
'src/main/unraid-deployment-service.cjs', 'src/main/ssh-service.cjs', 'src/main/update-service.cjs',
|
||||
'src/main/diagnostics-service.cjs', 'src/main/preflight-service.cjs', 'src/main/log-redaction.cjs', 'src/main/ipc.cjs',
|
||||
'src/shared/clone-target.cjs', 'src/shared/semver.cjs', 'src/shared/zip-writer.cjs',
|
||||
'src/shared/tool-invocation.cjs', 'src/shared/shell-verification.cjs', 'START_HERE.md', 'README.md',
|
||||
'setup-windows.ps1', 'update-windows.ps1', 'build-windows.ps1', 'UPDATE_FROM_0.3.2.md', 'scripts/apply-source-update.ps1',
|
||||
'docs/ARCHITECTURE.md', 'docs/SECURITY.md', 'docs/ROADMAP.md', 'docs/SETUP_GUIDE.md',
|
||||
'docs/UPDATING.md', 'docs/DIAGNOSTICS.md', 'docs/DEPLOYMENT_SETUP.md', 'docs/SSH_UNRAID_DEPLOYMENT.md',
|
||||
'docs/LUMAOPS_SERVER_AUDIT.md', 'docs/STATUS_ENDPOINT.md', 'docs/TEST_MATRIX.md', 'docs/RELEASE_NOTES_0.4.0.md', 'docs/RELEASE_NOTES_0.4.1.md', 'docs/RELEASE_NOTES_0.4.2.md', 'docs/RELEASE_NOTES_0.4.3.md',
|
||||
'examples/gitea-actions/deploy.yml', 'examples/gitea-actions/rollback.yml',
|
||||
'examples/server/forgeflow-deploy', 'examples/server/forgeflow-targets.conf',
|
||||
'examples/server/forgeflow-runner.sudoers', 'examples/server/status-example.json',
|
||||
'build/icon.png', 'build/icon.ico'
|
||||
];
|
||||
|
||||
for (const file of required) await access(path.join(root, file));
|
||||
|
||||
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
||||
if (packageJson.version !== '0.4.3') throw new Error(`Expected package version 0.4.3, got ${packageJson.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 bashCheck = shellVerification.bashSyntaxCheckInvocation(root);
|
||||
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:\n${shell.stderr}`);
|
||||
|
||||
JSON.parse(await readFile(path.join(root, 'examples/server/status-example.json'), 'utf8'));
|
||||
const setupGuide = await readFile(path.join(root, 'docs/SETUP_GUIDE.md'), 'utf8');
|
||||
const sshGuide = await readFile(path.join(root, 'docs/SSH_UNRAID_DEPLOYMENT.md'), 'utf8');
|
||||
const audit = await readFile(path.join(root, 'docs/LUMAOPS_SERVER_AUDIT.md'), 'utf8');
|
||||
const releaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.4.3.md'), 'utf8');
|
||||
if (!setupGuide.includes('Gitea access token') || !setupGuide.includes('diagnostic bundle')) {
|
||||
throw new Error('Setup guide is missing required connection or diagnostics instructions.');
|
||||
}
|
||||
if (!sshGuide.includes('/mnt/user/appdata') || !sshGuide.includes('host-key fingerprint')) {
|
||||
throw new Error('SSH / Unraid guide is missing its base path or host identity policy.');
|
||||
}
|
||||
if (!audit.includes('d42d4a7f08240c478d07466e3fabec654dc71367') || !audit.includes('source/')) {
|
||||
throw new Error('LumaOps audit is missing the exact matching SHA or nested repository finding.');
|
||||
}
|
||||
for (const phrase of ['platform-independent', 'SSH inspection', 'Windows', 'Regression coverage']) {
|
||||
if (!releaseNotes.includes(phrase)) throw new Error(`Release notes are missing: ${phrase}`);
|
||||
}
|
||||
const renderer = await readFile(path.join(root, 'src/renderer/app.js'), 'utf8');
|
||||
const styles = await readFile(path.join(root, 'src/renderer/styles.css'), 'utf8');
|
||||
const preload = await readFile(path.join(root, 'preload.cjs'), 'utf8');
|
||||
const ipc = await readFile(path.join(root, 'src/main/ipc.cjs'), 'utf8');
|
||||
for (const phrase of ['Commit selected & push to Gitea', 'checkForUpdates', 'saveServer', 'profile-provider', 'itworx-mark.png']) {
|
||||
if (!renderer.includes(phrase) && !preload.includes(phrase)) throw new Error(`Frontend integration is missing: ${phrase}`);
|
||||
}
|
||||
if (!styles.includes('.file-list { flex: 1 1 auto;') || !styles.includes('.main-canvas.repository-canvas')) {
|
||||
throw new Error('Changed-file scrolling constraints are missing.');
|
||||
}
|
||||
for (const channel of ['updates:check', 'updates:download', 'updates:apply', 'server:save', 'server:test', 'server:inspect-project']) {
|
||||
if (!ipc.includes(channel)) throw new Error(`IPC registration is missing: ${channel}`);
|
||||
}
|
||||
|
||||
console.log(`Verified ${required.length} required project files and ${javascriptFiles.length} JavaScript files for ForgeFlow ${packageJson.version}.`);
|
||||
Reference in New Issue
Block a user