Release ForgeFlow 0.8.1
Add advanced Git and deployment workflows, secure backups and auditing, live Gitea integration, desktop notifications, connection validation, and the premium responsive UX refresh.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
+8
-5
@@ -11,8 +11,9 @@ const packageJson = JSON.parse(await readFile(new URL('../package.json', import.
|
||||
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 });
|
||||
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]);
|
||||
@@ -45,7 +46,7 @@ try {
|
||||
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.');
|
||||
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.');
|
||||
}
|
||||
@@ -68,20 +69,22 @@ try {
|
||||
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: checks.every((check) => check.ok),
|
||||
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.ok ? 'PASS' : 'FAIL'} ${check.name.padEnd(26)} ${check.detail}`);
|
||||
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.'}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const excludedDirectories = new Set(['.git', 'dist', 'node_modules']);
|
||||
const excludedFiles = new Set(['SOURCE_MANIFEST.txt']);
|
||||
|
||||
async function collect(directory, output = []) {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && excludedDirectories.has(entry.name)) continue;
|
||||
const absolute = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) await collect(absolute, output);
|
||||
else if (!excludedFiles.has(entry.name)) output.push(absolute);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
||||
const files = (await collect(root)).sort((left, right) => left.localeCompare(right, 'en'));
|
||||
const lines = [
|
||||
`ForgeFlow ${packageJson.version} source manifest`,
|
||||
'SHA-256 BYTES PATH',
|
||||
'(The manifest excludes itself, dependencies and generated release artifacts.)'
|
||||
];
|
||||
|
||||
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,94 @@
|
||||
"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;
|
||||
if (configuredUserData)
|
||||
app.setPath("userData", path.resolve(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
|
||||
? path.resolve(configuredUserData)
|
||||
: path.join(app.getPath("appData"), "forgeflow");
|
||||
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();
|
||||
}
|
||||
});
|
||||
+335
-86
@@ -1,54 +1,140 @@
|
||||
import { access, readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import shellVerification from '../src/shared/shell-verification.cjs';
|
||||
import { access, readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import shellVerification from "../src/shared/shell-verification.cjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const required = [
|
||||
'package.json', 'main.cjs', 'preload.cjs',
|
||||
'src/renderer/index.html', 'src/renderer/styles.css', 'src/renderer/app.js', 'src/renderer/mock-bridge.js',
|
||||
'src/renderer/assets/itworx-mark.png', 'src/renderer/assets/itworx-wordmark.png', 'src/renderer/assets/itworx-wordmark-light.png', 'src/renderer/assets/itworx-wordmark-dark.png',
|
||||
'src/main/config-store.cjs', 'src/main/git-service.cjs', 'src/main/gitea-service.cjs',
|
||||
'src/main/repository-service.cjs', 'src/main/repository-monitor.cjs', 'src/main/deployment-service.cjs',
|
||||
'src/main/unraid-deployment-service.cjs', 'src/main/ssh-service.cjs', 'src/main/update-service.cjs',
|
||||
'src/main/diagnostics-service.cjs', 'src/main/preflight-service.cjs', 'src/main/log-redaction.cjs', 'src/main/ipc.cjs',
|
||||
'src/shared/clone-target.cjs', 'src/shared/semver.cjs', 'src/shared/zip-writer.cjs',
|
||||
'src/shared/tool-invocation.cjs', 'src/shared/shell-verification.cjs', 'START_HERE.md', 'README.md', 'SOURCE_MANIFEST.txt',
|
||||
'setup-windows.ps1', 'START-FORGEFLOW-OVERLAY.ps1', 'update-windows.ps1', 'build-windows.ps1', 'UPDATE_FROM_0.3.2.md', 'scripts/apply-source-update.ps1',
|
||||
'docs/ARCHITECTURE.md', 'docs/SECURITY.md', 'docs/ROADMAP.md', 'docs/SETUP_GUIDE.md',
|
||||
'docs/UPDATING.md', 'docs/DIAGNOSTICS.md', 'docs/DEPLOYMENT_SETUP.md', 'docs/SSH_UNRAID_DEPLOYMENT.md',
|
||||
'docs/LUMAOPS_SERVER_AUDIT.md', 'docs/STATUS_ENDPOINT.md', 'docs/TEST_MATRIX.md', 'docs/RELEASE_NOTES_0.4.0.md', 'docs/RELEASE_NOTES_0.4.1.md', 'docs/RELEASE_NOTES_0.4.2.md', 'docs/RELEASE_NOTES_0.4.3.md',
|
||||
'docs/RELEASE_AUDIT_0.6.0.md', 'docs/RELEASE_NOTES_0.6.1.md', 'docs/RELEASE_NOTES_0.5.0.md', 'docs/RELEASE_NOTES_0.5.1.md', 'docs/RELEASE_NOTES_0.5.2.md', 'docs/RELEASE_NOTES_0.5.3.md', 'docs/RELEASE_NOTES_0.5.4.md', 'docs/RELEASE_NOTES_0.6.0.md',
|
||||
'Publish-ForgeFlow-Release.ps1', 'docs/RELEASE_NOTES_0.4.4.md', 'docs/RELEASE_NOTES_0.4.5.md',
|
||||
'examples/gitea-actions/deploy.yml', 'examples/gitea-actions/rollback.yml',
|
||||
'examples/server/forgeflow-deploy', 'examples/server/forgeflow-targets.conf',
|
||||
'examples/server/forgeflow-runner.sudoers', 'examples/server/status-example.json',
|
||||
'build/icon.png', 'build/icon.ico'
|
||||
"package.json",
|
||||
"main.cjs",
|
||||
"preload.cjs",
|
||||
"src/renderer/index.html",
|
||||
"src/renderer/styles.css",
|
||||
"src/renderer/app.js",
|
||||
"src/renderer/mock-bridge.js",
|
||||
"src/renderer/assets/itworx-mark.png",
|
||||
"src/renderer/assets/itworx-wordmark.png",
|
||||
"src/renderer/assets/itworx-wordmark-light.png",
|
||||
"src/renderer/assets/itworx-wordmark-dark.png",
|
||||
"src/main/config-store.cjs",
|
||||
"src/main/git-service.cjs",
|
||||
"src/main/gitea-service.cjs",
|
||||
"src/main/audit-service.cjs",
|
||||
"src/main/configuration-backup.cjs",
|
||||
"src/main/external-tools-service.cjs",
|
||||
"src/main/repository-service.cjs",
|
||||
"src/main/repository-monitor.cjs",
|
||||
"src/main/deployment-service.cjs",
|
||||
"src/main/unraid-deployment-service.cjs",
|
||||
"src/main/ssh-service.cjs",
|
||||
"src/main/update-service.cjs",
|
||||
"src/main/diagnostics-service.cjs",
|
||||
"src/main/preflight-service.cjs",
|
||||
"src/main/log-redaction.cjs",
|
||||
"src/main/ipc.cjs",
|
||||
"src/shared/clone-target.cjs",
|
||||
"src/shared/semver.cjs",
|
||||
"src/shared/zip-writer.cjs",
|
||||
"src/shared/tool-invocation.cjs",
|
||||
"src/shared/shell-verification.cjs",
|
||||
"START_HERE.md",
|
||||
"README.md",
|
||||
"SOURCE_MANIFEST.txt",
|
||||
"src/shared/deployment-policy.cjs",
|
||||
"scripts/acceptance.mjs",
|
||||
"scripts/validate-installed-connections.cjs",
|
||||
"scripts/generate-source-manifest.mjs",
|
||||
"setup-windows.ps1",
|
||||
"START-FORGEFLOW-OVERLAY.ps1",
|
||||
"update-windows.ps1",
|
||||
"build-windows.ps1",
|
||||
"UPDATE_FROM_0.3.2.md",
|
||||
"scripts/apply-source-update.ps1",
|
||||
"docs/ARCHITECTURE.md",
|
||||
"docs/SECURITY.md",
|
||||
"docs/ROADMAP.md",
|
||||
"docs/SETUP_GUIDE.md",
|
||||
"docs/ACCEPTANCE.md",
|
||||
"docs/RELEASE_NOTES_0.8.0.md",
|
||||
"docs/RELEASE_NOTES_0.8.1.md",
|
||||
"docs/UPDATING.md",
|
||||
"docs/DIAGNOSTICS.md",
|
||||
"docs/DEPLOYMENT_SETUP.md",
|
||||
"docs/SSH_UNRAID_DEPLOYMENT.md",
|
||||
"docs/LUMAOPS_SERVER_AUDIT.md",
|
||||
"docs/STATUS_ENDPOINT.md",
|
||||
"docs/TEST_MATRIX.md",
|
||||
"docs/RELEASE_NOTES_0.4.0.md",
|
||||
"docs/RELEASE_NOTES_0.4.1.md",
|
||||
"docs/RELEASE_NOTES_0.4.2.md",
|
||||
"docs/RELEASE_NOTES_0.4.3.md",
|
||||
"docs/RELEASE_AUDIT_0.6.0.md",
|
||||
"docs/RELEASE_NOTES_0.6.1.md",
|
||||
"docs/RELEASE_NOTES_0.7.0.md",
|
||||
"docs/RELEASE_NOTES_0.5.0.md",
|
||||
"docs/RELEASE_NOTES_0.5.1.md",
|
||||
"docs/RELEASE_NOTES_0.5.2.md",
|
||||
"docs/RELEASE_NOTES_0.5.3.md",
|
||||
"docs/RELEASE_NOTES_0.5.4.md",
|
||||
"docs/RELEASE_NOTES_0.6.0.md",
|
||||
"Publish-ForgeFlow-Release.ps1",
|
||||
"docs/RELEASE_NOTES_0.4.4.md",
|
||||
"docs/RELEASE_NOTES_0.4.5.md",
|
||||
"examples/gitea-actions/deploy.yml",
|
||||
"examples/gitea-actions/rollback.yml",
|
||||
"examples/server/forgeflow-deploy",
|
||||
"examples/server/forgeflow-targets.conf",
|
||||
"examples/server/forgeflow-runner.sudoers",
|
||||
"examples/server/status-example.json",
|
||||
"build/icon.png",
|
||||
"build/icon.ico",
|
||||
];
|
||||
|
||||
for (const file of required) await access(path.join(root, file));
|
||||
|
||||
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
||||
if (packageJson.version !== '0.6.1') throw new Error(`Expected package version 0.6.1, got ${packageJson.version}.`);
|
||||
const sourceManifest = await readFile(path.join(root, 'SOURCE_MANIFEST.txt'), 'utf8');
|
||||
if (!sourceManifest.startsWith(`ForgeFlow ${packageJson.version} source manifest\n`)) throw new Error('SOURCE_MANIFEST.txt does not match the package version.');
|
||||
for (const group of ['dependencies', 'devDependencies']) {
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(root, "package.json"), "utf8"),
|
||||
);
|
||||
if (packageJson.version !== "0.8.1")
|
||||
throw new Error(
|
||||
`Expected package version 0.8.1, got ${packageJson.version}.`,
|
||||
);
|
||||
const sourceManifest = await readFile(
|
||||
path.join(root, "SOURCE_MANIFEST.txt"),
|
||||
"utf8",
|
||||
);
|
||||
if (
|
||||
!sourceManifest
|
||||
.replace(/\r\n/g, "\n")
|
||||
.startsWith(`ForgeFlow ${packageJson.version} source manifest\n`)
|
||||
)
|
||||
throw new Error("SOURCE_MANIFEST.txt does not match the package version.");
|
||||
for (const group of ["dependencies", "devDependencies"]) {
|
||||
for (const [name, version] of Object.entries(packageJson[group] || {})) {
|
||||
if (/^[~^*]/.test(version)) throw new Error(`${group} dependency ${name} must be pinned exactly, got ${version}.`);
|
||||
if (/^[~^*]/.test(version))
|
||||
throw new Error(
|
||||
`${group} dependency ${name} must be pinned exactly, got ${version}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (packageJson.dependencies?.ssh2 !== '1.17.0') throw new Error('ssh2 must remain pinned to 1.17.0.');
|
||||
for (const script of ['start', 'demo', 'test', 'verify', 'check']) {
|
||||
if (!packageJson.scripts?.[script]) throw new Error(`Required npm script is missing: ${script}`);
|
||||
if (packageJson.dependencies?.ssh2 !== "1.17.0")
|
||||
throw new Error("ssh2 must remain pinned to 1.17.0.");
|
||||
for (const script of ["start", "demo", "test", "verify", "check"]) {
|
||||
if (!packageJson.scripts?.[script])
|
||||
throw new Error(`Required npm script is missing: ${script}`);
|
||||
}
|
||||
if (!packageJson.build?.win?.icon || !packageJson.build?.linux?.icon || !packageJson.build?.mac?.icon) {
|
||||
throw new Error('Package icon configuration is incomplete.');
|
||||
if (
|
||||
!packageJson.build?.win?.icon ||
|
||||
!packageJson.build?.linux?.icon ||
|
||||
!packageJson.build?.mac?.icon
|
||||
) {
|
||||
throw new Error("Package icon configuration is incomplete.");
|
||||
}
|
||||
|
||||
async function collect(directory, extensions, output = []) {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (['node_modules', 'dist'].includes(entry.name)) continue;
|
||||
if (["node_modules", "dist"].includes(entry.name)) continue;
|
||||
const absolute = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) await collect(absolute, extensions, output);
|
||||
else if (extensions.has(path.extname(entry.name))) output.push(absolute);
|
||||
@@ -56,13 +142,21 @@ async function collect(directory, extensions, output = []) {
|
||||
return output;
|
||||
}
|
||||
|
||||
const javascriptFiles = await collect(root, new Set(['.js', '.cjs', '.mjs']));
|
||||
const javascriptFiles = await collect(root, new Set([".js", ".cjs", ".mjs"]));
|
||||
for (const file of javascriptFiles) {
|
||||
const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' });
|
||||
if (result.status !== 0) throw new Error(`${path.relative(root, file)} failed syntax validation:\n${result.stderr}`);
|
||||
const result = spawnSync(process.execPath, ["--check", file], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (result.status !== 0)
|
||||
throw new Error(
|
||||
`${path.relative(root, file)} failed syntax validation:\n${result.stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
const deploymentScript = await readFile(path.join(root, 'examples/server/forgeflow-deploy'), 'utf8');
|
||||
const deploymentScript = await readFile(
|
||||
path.join(root, "examples/server/forgeflow-deploy"),
|
||||
"utf8",
|
||||
);
|
||||
shellVerification.validateShellScriptStructure(deploymentScript);
|
||||
|
||||
// The server deployment script targets Linux/Unraid. On Windows, different tools may
|
||||
@@ -71,66 +165,221 @@ shellVerification.validateShellScriptStructure(deploymentScript);
|
||||
// a desktop update therefore never depend on a Windows Bash shim. Portable structural
|
||||
// validation always runs; GNU Bash syntax validation additionally runs on non-Windows.
|
||||
if (shellVerification.shouldRunExternalBash(process.platform)) {
|
||||
const bashCheck = shellVerification.bashSyntaxCheckFromTextInvocation(deploymentScript);
|
||||
const bashCheck =
|
||||
shellVerification.bashSyntaxCheckFromTextInvocation(deploymentScript);
|
||||
const shell = spawnSync(bashCheck.command, bashCheck.args, bashCheck.options);
|
||||
if (shell.error) throw new Error(`Unable to start Bash for server deployment syntax validation: ${shell.error.message}`);
|
||||
if (shell.status !== 0) throw new Error(`Server deployment example failed bash syntax validation:
|
||||
${shell.stderr || shell.stdout || 'Bash returned a non-zero status.'}`);
|
||||
if (shell.error)
|
||||
throw new Error(
|
||||
`Unable to start Bash for server deployment syntax validation: ${shell.error.message}`,
|
||||
);
|
||||
if (shell.status !== 0)
|
||||
throw new Error(`Server deployment example failed bash syntax validation:
|
||||
${shell.stderr || shell.stdout || "Bash returned a non-zero status."}`);
|
||||
} else {
|
||||
console.log('Windows: external Bash syntax validation skipped; portable server-script validation passed.');
|
||||
console.log(
|
||||
"Windows: external Bash syntax validation skipped; portable server-script validation passed.",
|
||||
);
|
||||
}
|
||||
|
||||
JSON.parse(await readFile(path.join(root, 'examples/server/status-example.json'), 'utf8'));
|
||||
const setupGuide = await readFile(path.join(root, 'docs/SETUP_GUIDE.md'), 'utf8');
|
||||
const sshGuide = await readFile(path.join(root, 'docs/SSH_UNRAID_DEPLOYMENT.md'), 'utf8');
|
||||
const audit = await readFile(path.join(root, 'docs/LUMAOPS_SERVER_AUDIT.md'), 'utf8');
|
||||
const releaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.6.0.md'), 'utf8');
|
||||
const updaterReleaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.6.1.md'), 'utf8');
|
||||
if (!setupGuide.includes('Gitea access token') || !setupGuide.includes('diagnostic bundle')) {
|
||||
throw new Error('Setup guide is missing required connection or diagnostics instructions.');
|
||||
JSON.parse(
|
||||
await readFile(
|
||||
path.join(root, "examples/server/status-example.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const setupGuide = await readFile(
|
||||
path.join(root, "docs/SETUP_GUIDE.md"),
|
||||
"utf8",
|
||||
);
|
||||
const sshGuide = await readFile(
|
||||
path.join(root, "docs/SSH_UNRAID_DEPLOYMENT.md"),
|
||||
"utf8",
|
||||
);
|
||||
const audit = await readFile(
|
||||
path.join(root, "docs/LUMAOPS_SERVER_AUDIT.md"),
|
||||
"utf8",
|
||||
);
|
||||
const releaseNotes = await readFile(
|
||||
path.join(root, "docs/RELEASE_NOTES_0.6.0.md"),
|
||||
"utf8",
|
||||
);
|
||||
const updaterReleaseNotes = await readFile(
|
||||
path.join(root, "docs/RELEASE_NOTES_0.6.1.md"),
|
||||
"utf8",
|
||||
);
|
||||
if (
|
||||
!setupGuide.includes("Gitea access token") ||
|
||||
!setupGuide.includes("diagnostic bundle")
|
||||
) {
|
||||
throw new Error(
|
||||
"Setup guide is missing required connection or diagnostics instructions.",
|
||||
);
|
||||
}
|
||||
if (!sshGuide.includes('/mnt/user/appdata') || !sshGuide.includes('host-key fingerprint')) {
|
||||
throw new Error('SSH / Unraid guide is missing its base path or host identity policy.');
|
||||
if (
|
||||
!sshGuide.includes("/mnt/user/appdata") ||
|
||||
!sshGuide.includes("host-key fingerprint")
|
||||
) {
|
||||
throw new Error(
|
||||
"SSH / Unraid guide is missing its base path or host identity policy.",
|
||||
);
|
||||
}
|
||||
if (!audit.includes('d42d4a7f08240c478d07466e3fabec654dc71367') || !audit.includes('source/')) {
|
||||
throw new Error('LumaOps audit is missing the exact matching SHA or nested repository finding.');
|
||||
if (
|
||||
!audit.includes("d42d4a7f08240c478d07466e3fabec654dc71367") ||
|
||||
!audit.includes("source/")
|
||||
) {
|
||||
throw new Error(
|
||||
"LumaOps audit is missing the exact matching SHA or nested repository finding.",
|
||||
);
|
||||
}
|
||||
for (const phrase of ['DockerMan', 'HEAD.lock', 'deployment reconciliation', 'Portfolio', 'safety branch', 'high-contrast ITWorx']) {
|
||||
if (!releaseNotes.includes(phrase)) throw new Error(`Release notes are missing: ${phrase}`);
|
||||
for (const phrase of [
|
||||
"DockerMan",
|
||||
"HEAD.lock",
|
||||
"deployment reconciliation",
|
||||
"Portfolio",
|
||||
"safety branch",
|
||||
"high-contrast ITWorx",
|
||||
]) {
|
||||
if (!releaseNotes.includes(phrase))
|
||||
throw new Error(`Release notes are missing: ${phrase}`);
|
||||
}
|
||||
for (const phrase of ['Windows PowerShell 5.1', 'File.Replace', 'handshake-only', 'updateId']) {
|
||||
if (!updaterReleaseNotes.includes(phrase)) throw new Error(`Updater release notes are missing: ${phrase}`);
|
||||
for (const phrase of [
|
||||
"Windows PowerShell 5.1",
|
||||
"File.Replace",
|
||||
"handshake-only",
|
||||
"updateId",
|
||||
]) {
|
||||
if (!updaterReleaseNotes.includes(phrase))
|
||||
throw new Error(`Updater release notes are missing: ${phrase}`);
|
||||
}
|
||||
const updateHelperPath = path.join(root, 'scripts/apply-source-update.ps1');
|
||||
const setupScript = await readFile(
|
||||
path.join(root, "setup-windows.ps1"),
|
||||
"utf8",
|
||||
);
|
||||
const sourceUpdateScript = await readFile(
|
||||
path.join(root, "update-windows.ps1"),
|
||||
"utf8",
|
||||
);
|
||||
for (const [name, script] of [
|
||||
["setup-windows.ps1", setupScript],
|
||||
["update-windows.ps1", sourceUpdateScript],
|
||||
]) {
|
||||
if (
|
||||
!script.includes("$version = [string]$package.version") ||
|
||||
!script.includes("npm ci --no-audit --no-fund")
|
||||
)
|
||||
throw new Error(
|
||||
`${name} must use the package version dynamically and install from package-lock.json.`,
|
||||
);
|
||||
if (/v0\.4\.2|version -ne "0\.4\.2"/.test(script))
|
||||
throw new Error(
|
||||
`${name} still contains a stale hard-coded release version.`,
|
||||
);
|
||||
}
|
||||
|
||||
const updateHelperPath = path.join(root, "scripts/apply-source-update.ps1");
|
||||
const updateHelperBytes = await readFile(updateHelperPath);
|
||||
if (updateHelperBytes[0] === 0xef && updateHelperBytes[1] === 0xbb && updateHelperBytes[2] === 0xbf) throw new Error('PowerShell update helper must not contain a UTF-8 BOM.');
|
||||
const updateHelper = updateHelperBytes.toString('utf8');
|
||||
if (!updateHelper.trimStart().startsWith('param(') || updateHelper.trimStart().startsWith('\\')) throw new Error('PowerShell update helper must start directly with param(.');
|
||||
if (
|
||||
updateHelperBytes[0] === 0xef &&
|
||||
updateHelperBytes[1] === 0xbb &&
|
||||
updateHelperBytes[2] === 0xbf
|
||||
)
|
||||
throw new Error("PowerShell update helper must not contain a UTF-8 BOM.");
|
||||
const updateHelper = updateHelperBytes.toString("utf8");
|
||||
if (
|
||||
!updateHelper.trimStart().startsWith("param(") ||
|
||||
updateHelper.trimStart().startsWith("\\")
|
||||
)
|
||||
throw new Error("PowerShell update helper must start directly with param(.");
|
||||
|
||||
const renderer = await readFile(path.join(root, 'src/renderer/app.js'), 'utf8');
|
||||
const styles = await readFile(path.join(root, 'src/renderer/styles.css'), 'utf8');
|
||||
const preload = await readFile(path.join(root, 'preload.cjs'), 'utf8');
|
||||
const ipc = await readFile(path.join(root, 'src/main/ipc.cjs'), 'utf8');
|
||||
for (const phrase of ['Commit selected & push to Gitea', 'checkForUpdates', 'saveServer', 'profile-provider', 'profile-icon-mode', 'itworx-mark.png', 'Repair DockerMan integration', 'Repository troubleshooting', 'repair-repository-sync']) {
|
||||
if (!renderer.includes(phrase) && !preload.includes(phrase)) throw new Error(`Frontend integration is missing: ${phrase}`);
|
||||
const renderer = await readFile(path.join(root, "src/renderer/app.js"), "utf8");
|
||||
const styles = await readFile(
|
||||
path.join(root, "src/renderer/styles.css"),
|
||||
"utf8",
|
||||
);
|
||||
const preload = await readFile(path.join(root, "preload.cjs"), "utf8");
|
||||
const ipc = await readFile(path.join(root, "src/main/ipc.cjs"), "utf8");
|
||||
for (const phrase of [
|
||||
'data-action="commit-push"',
|
||||
"checkForUpdates",
|
||||
"saveServer",
|
||||
"profile-provider",
|
||||
"profile-icon-mode",
|
||||
"itworx-mark.png",
|
||||
"Repair DockerMan integration",
|
||||
"Repository troubleshooting",
|
||||
"repair-repository-sync",
|
||||
]) {
|
||||
if (!renderer.includes(phrase) && !preload.includes(phrase))
|
||||
throw new Error(`Frontend integration is missing: ${phrase}`);
|
||||
}
|
||||
if (!styles.includes('.file-list { flex: 1 1 auto;') || !styles.includes('.main-canvas.repository-canvas')) {
|
||||
throw new Error('Changed-file scrolling constraints are missing.');
|
||||
if (
|
||||
!/\.file-list\s*\{[^}]*flex:\s*1 1 auto;/s.test(styles) ||
|
||||
!styles.includes(".main-canvas.repository-canvas")
|
||||
) {
|
||||
throw new Error("Changed-file scrolling constraints are missing.");
|
||||
}
|
||||
for (const channel of ['updates:check', 'updates:download', 'updates:apply', 'server:save', 'server:test', 'server:inspect-project', 'repository:repair-git-locks', 'repository:repair-sync', 'deployment:apply-dockerman-metadata', 'deployment:reconcile']) {
|
||||
if (!ipc.includes(channel)) throw new Error(`IPC registration is missing: ${channel}`);
|
||||
for (const channel of [
|
||||
"server:discover-existing",
|
||||
"troubleshooter:scan",
|
||||
"troubleshooter:repair",
|
||||
"troubleshooter:auto-repair",
|
||||
"updates:check",
|
||||
"updates:download",
|
||||
"updates:apply",
|
||||
"server:save",
|
||||
"server:test",
|
||||
"server:inspect-project",
|
||||
"repository:repair-git-locks",
|
||||
"repository:repair-sync",
|
||||
"deployment:apply-dockerman-metadata",
|
||||
"deployment:reconcile",
|
||||
]) {
|
||||
if (!ipc.includes(channel))
|
||||
throw new Error(`IPC registration is missing: ${channel}`);
|
||||
}
|
||||
const gitSource = await readFile(path.join(root, 'src/main/git-service.cjs'), 'utf8');
|
||||
const unraidSource = await readFile(path.join(root, 'src/main/unraid-deployment-service.cjs'), 'utf8');
|
||||
const publisher = await readFile(path.join(root, 'Publish-ForgeFlow-Release.ps1'), 'utf8');
|
||||
for (const phrase of ['HEAD.lock', 'backup-reset', 'repairSync', "segments.includes('objects')"]) {
|
||||
if (!gitSource.includes(phrase)) throw new Error(`Git recovery implementation is missing: ${phrase}`);
|
||||
const gitSource = await readFile(
|
||||
path.join(root, "src/main/git-service.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
const unraidSource = await readFile(
|
||||
path.join(root, "src/main/unraid-deployment-service.cjs"),
|
||||
"utf8",
|
||||
);
|
||||
const publisher = await readFile(
|
||||
path.join(root, "Publish-ForgeFlow-Release.ps1"),
|
||||
"utf8",
|
||||
);
|
||||
for (const phrase of [
|
||||
"HEAD.lock",
|
||||
"backup-reset",
|
||||
"repairSync",
|
||||
"segments.includes('objects')",
|
||||
]) {
|
||||
if (!gitSource.includes(phrase))
|
||||
throw new Error(`Git recovery implementation is missing: ${phrase}`);
|
||||
}
|
||||
for (const phrase of ['net.unraid.docker.managed', "'dockerman'", 'iconCacheRefresh', '[PORT:', 'Superseded by live commit']) {
|
||||
if (!unraidSource.includes(phrase)) throw new Error(`Unraid recovery implementation is missing: ${phrase}`);
|
||||
for (const phrase of [
|
||||
"discoverExisting",
|
||||
"deriveDetectedProfile",
|
||||
"docker inspect",
|
||||
"net.unraid.docker.managed",
|
||||
"'dockerman'",
|
||||
"iconCacheRefresh",
|
||||
"[PORT:",
|
||||
"Superseded by live commit",
|
||||
]) {
|
||||
if (!unraidSource.includes(phrase))
|
||||
throw new Error(`Unraid recovery implementation is missing: ${phrase}`);
|
||||
}
|
||||
for (const phrase of ['git ls-remote origin', 'apply-source-update.ps1', 'without changing its version']) {
|
||||
if (!publisher.includes(phrase)) throw new Error(`Publishing workflow is missing: ${phrase}`);
|
||||
for (const phrase of [
|
||||
"git ls-remote origin",
|
||||
"apply-source-update.ps1",
|
||||
"without changing its version",
|
||||
]) {
|
||||
if (!publisher.includes(phrase))
|
||||
throw new Error(`Publishing workflow is missing: ${phrase}`);
|
||||
}
|
||||
|
||||
console.log(`Verified ${required.length} required project files and ${javascriptFiles.length} JavaScript files for ForgeFlow ${packageJson.version}.`);
|
||||
console.log(
|
||||
`Verified ${required.length} required project files and ${javascriptFiles.length} JavaScript files for ForgeFlow ${packageJson.version}.`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user