Prepare ForgeFlow for public release
Managed validation / full (pull_request) Successful in 44s
ChatGPT validation / quality (push) Failing after 2m28s

This commit is contained in:
NuklearRabbit committed 2026-08-31 20:10:07 +02:00
1 parent 57929ea973
commit 8cca1bfc01
29 files changed
+400 -343

No files matched your search

+23 -5
View File
@@ -4,7 +4,7 @@ const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const { safeStorage } = require('electron');
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
const { normalizeBaseUrl, assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
const DEFAULT_CONFIG = {
schemaVersion: 13,
@@ -266,7 +266,16 @@ class ConfigStore {
const basePath = String(source.basePath || existing?.basePath || '/mnt/user/appdata').trim().replace(/\/+$/, '');
if (!basePath.startsWith('/') || /[\r\n\0]/.test(basePath)) throw new Error('The server base path must be an absolute Unix path.');
const privateKeyPath = String(source.privateKeyPath || existing?.privateKeyPath || '').trim();
const hostFingerprint = String(source.hostFingerprint || existing?.hostFingerprint || '').trim();
const credentialIdentityChanged = Boolean(existing && [
['host', existing.host, host],
['port', existing.port, port],
['username', existing.username, username],
['authType', existing.authType, authType],
['privateKeyPath', existing.privateKeyPath, privateKeyPath]
].some(([, previous, next]) => String(previous || '') !== String(next || '')));
const hostFingerprint = credentialIdentityChanged
? ''
: String(source.hostFingerprint || existing?.hostFingerprint || '').trim();
const scanRoots = uniqueStrings(source.scanRoots || existing?.scanRoots || [basePath]).map((value) => value.replace(/\/+$/, '')).filter((value) => value.startsWith('/') && !/[\r\n\0]/.test(value));
const scanExcludes = uniqueStrings(source.scanExcludes || existing?.scanExcludes || ['backups', 'archives', 'releases', 'staging', 'testdata']).filter((value) => /^[a-zA-Z0-9._*-]+$/.test(value));
return {
@@ -281,8 +290,8 @@ class ConfigStore {
scanExcludes,
privateKeyPath,
hostFingerprint,
encryptedPassword: existing?.encryptedPassword || null,
encryptedPassphrase: existing?.encryptedPassphrase || null,
encryptedPassword: credentialIdentityChanged ? null : existing?.encryptedPassword || null,
encryptedPassphrase: credentialIdentityChanged ? null : existing?.encryptedPassphrase || null,
createdAt: existing?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString()
};
@@ -385,10 +394,19 @@ class ConfigStore {
}
async updateGitea({ baseUrl, token, user }) {
const nextBaseUrl = normalizeBaseUrl(baseUrl);
const currentBaseUrl = this.data.gitea.baseUrl
? normalizeBaseUrl(this.data.gitea.baseUrl)
: '';
if (!String(token || '').trim() && nextBaseUrl !== currentBaseUrl && this.getToken()) {
const error = new Error('Enter a new Gitea token when changing the server address.');
error.code = 'GITEA_TOKEN_ORIGIN_CHANGED';
throw error;
}
const tokenState = this.setToken(token, { preserveExisting: true });
this.data.gitea = {
...this.data.gitea,
baseUrl,
baseUrl: nextBaseUrl,
user: user || this.data.gitea.user,
encryptedToken: this.data.gitea.encryptedToken
};
+13 -4
View File
@@ -5,7 +5,7 @@ const path = require('node:path');
const os = require('node:os');
const crypto = require('node:crypto');
const { createZip } = require('../shared/zip-writer.cjs');
const { sanitizeForDiagnostics, redactSecrets } = require('./log-redaction.cjs');
const { sanitizeForDiagnostics } = require('./log-redaction.cjs');
const LEVELS = { debug: 10, info: 20, warning: 30, error: 40 };
@@ -202,7 +202,7 @@ class DiagnosticsService {
return this.getStatus();
}
async collectLogs(maxBytes = 20 * 1024 * 1024) {
async collectLogs(maxBytes = 20 * 1024 * 1024, { strictIdentifiers = false } = {}) {
await this.flush();
const output = [];
let used = 0;
@@ -211,7 +211,16 @@ class DiagnosticsService {
const remaining = maxBytes - used;
const content = await fs.readFile(file.path);
const slice = content.length > remaining ? content.subarray(content.length - remaining) : content;
output.push({ name: `logs/${file.name}`, data: Buffer.from(redactSecrets(slice.toString('utf8'), this.secretProvider?.() || []), 'utf8') });
output.push({
name: `logs/${file.name}`,
data: Buffer.from(
sanitizeForDiagnostics(slice.toString('utf8'), {
secrets: this.secretProvider?.() || [],
strictIdentifiers,
}),
'utf8',
),
});
used += slice.length;
}
return output;
@@ -345,7 +354,7 @@ class DiagnosticsService {
{ name: 'operations-sanitized.json', data: safeJson(sanitizedOperations) },
{ name: 'preflight.json', data: safeJson(sanitize(preflight || {})) },
{ name: 'context.json', data: safeJson(sanitize(extra || {})) },
...(await this.collectLogs())
...(await this.collectLogs(20 * 1024 * 1024, { strictIdentifiers: strict }))
];
const safetyAudit = auditBundleEntries(entries, this.secretProvider?.() || []);
+19 -6
View File
@@ -3,13 +3,26 @@
const { spawn } = require('node:child_process');
const path = require('node:path');
function normalizeTool(tool, defaults) {
const TOOL_PROFILES = Object.freeze({
editor: Object.freeze({
code: ['--reuse-window', '--goto', '{file}:{line}'],
'code.exe': ['--reuse-window', '--goto', '{file}:{line}'],
codium: ['--reuse-window', '--goto', '{file}:{line}'],
'codium.exe': ['--reuse-window', '--goto', '{file}:{line}'],
}),
terminal: Object.freeze({
wt: ['-d', '{path}'],
'wt.exe': ['-d', '{path}'],
}),
});
function normalizeTool(tool, defaults, kind) {
const source = tool && typeof tool === 'object' ? tool : {};
const executable = String(source.executable || defaults.executable).trim();
if (!executable || /[\r\n\0]/.test(executable)) throw new Error('Tool executable is invalid.');
const args = (Array.isArray(source.args) ? source.args : defaults.args).map((item) => String(item)).slice(0, 20);
if (args.some((item) => /[\r\n\0]/.test(item))) throw new Error('Tool argument is invalid.');
return { executable, args };
const profile = TOOL_PROFILES[kind]?.[executable.toLowerCase()];
if (!profile) throw new Error(`Unsupported ${kind || 'external'} tool. Select a built-in trusted tool profile.`);
return { executable, args: [...profile] };
}
function expandTool(tool, context) {
@@ -27,7 +40,7 @@ class ExternalToolsService {
const defaults = kind === 'terminal'
? { executable: 'wt.exe', args: ['-d', '{path}'] }
: { executable: 'code', args: ['--reuse-window', '--goto', '{file}:{line}'] };
const configured = normalizeTool(this.store.data.preferences?.[kind], defaults);
const configured = normalizeTool(this.store.data.preferences?.[kind], defaults, kind);
const invocation = expandTool(configured, { path: root, file: candidate, line });
const child = spawn(invocation.executable, invocation.args, { cwd: root, detached: true, stdio: 'ignore', windowsHide: false, shell: false });
child.unref();
@@ -35,4 +48,4 @@ class ExternalToolsService {
}
}
module.exports = { ExternalToolsService, normalizeTool, expandTool };
module.exports = { ExternalToolsService, normalizeTool, expandTool, TOOL_PROFILES };
+18 -2
View File
@@ -20,6 +20,7 @@ const {
readEncryptedBackup,
} = require("./configuration-backup.cjs");
const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs");
const { normalizeBaseUrl } = require("../shared/validation.cjs");
function registerIpc({
store,
git,
@@ -253,8 +254,23 @@ function registerIpc({
});
register("settings:update-gitea", async ({ baseUrl, token }) => {
const effectiveToken = String(token || "").trim() || store.getToken();
const validation = await gitea.validateConnection(baseUrl, effectiveToken);
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
const currentBaseUrl = store.data.gitea.baseUrl
? normalizeBaseUrl(store.data.gitea.baseUrl)
: "";
const submittedToken = String(token || "").trim();
if (!submittedToken && normalizedBaseUrl !== currentBaseUrl) {
const error = new Error(
"Enter a new Gitea token when changing the server address. Stored tokens are bound to their original origin.",
);
error.code = "GITEA_TOKEN_ORIGIN_CHANGED";
throw error;
}
const effectiveToken = submittedToken || store.getToken();
const validation = await gitea.validateConnection(
normalizedBaseUrl,
effectiveToken,
);
const tokenState = await store.updateGitea({
baseUrl: validation.baseUrl,
token,
-1
View File
@@ -96,7 +96,6 @@ function registerDeploymentIpc({
});
},
);
register("deployment:health", ({ url }) => deployments.checkHealth(url));
register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) => {
const current = await resolveRepository(repository);
const result = await unraid.linkServerWorkload({
+10 -2
View File
@@ -49,6 +49,13 @@ function stableAlias(value, prefix = 'item') {
return `${prefix}-${hash}`;
}
function redactPrivateInfrastructure(value) {
return String(value ?? '')
.replace(/\b(?:10(?:\.\d{1,3}){3}|127(?:\.\d{1,3}){3}|169\.254(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}|192\.168(?:\.\d{1,3}){2})\b/g, '<PRIVATE_ADDRESS>')
.replace(/\b(?:https?|ssh):\/\/[^\s"'<>]+/gi, '<PRIVATE_URL>')
.replace(/\/(?:mnt|srv|opt|var\/lib)\/[^\s"'<>]*/g, '<SERVER_PATH>');
}
function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) {
const {
secrets = [],
@@ -63,6 +70,7 @@ function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) {
if (typeof value === 'string') {
let output = redactSecrets(value, secrets);
if (pathMode === 'alias') output = pathAlias(output, { homeDir, cwd });
if (strictIdentifiers) output = redactPrivateInfrastructure(output);
return output;
}
if (value instanceof Error) {
@@ -80,7 +88,7 @@ function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) {
output[key] = '[REDACTED]';
continue;
}
if (strictIdentifiers && ['fullName', 'repository', 'owner', 'user', 'login', 'email'].includes(key)) {
if (strictIdentifiers && ['full_name', 'repository', 'owner', 'user', 'login', 'email', 'host', 'hostname', 'username', 'base_path', 'private_key_path', 'local_path', 'remote_folder', 'remote_url', 'clone_url', 'status_url', 'healthcheck_url', 'web_ui_url', 'workspace_roots', 'scan_roots'].includes(normalizedKey.toLowerCase())) {
output[key] = stableAlias(typeof item === 'object' ? JSON.stringify(item) : item, key.toLowerCase());
continue;
}
@@ -90,4 +98,4 @@ function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) {
return output;
}
module.exports = { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias, SENSITIVE_KEY };
module.exports = { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias, redactPrivateInfrastructure, SENSITIVE_KEY };
+18
View File
@@ -97,6 +97,12 @@ function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function requireSignedSourceUpdate(message) {
const error = new Error(message);
error.code = "SIGNED_SOURCE_UPDATE_REQUIRED";
throw error;
}
function resolveWindowsPowerShellPath(environment = process.env) {
const windowsRoot = environment.SystemRoot || environment.WINDIR;
if (windowsRoot) {
@@ -325,6 +331,11 @@ class UpdateService {
return this.downloadPackaged(update);
}
requireSignedSourceUpdate(
"Integrated source updates are disabled because source archives do not yet carry an independently signed publisher manifest. Update a source checkout with Git after reviewing the exact commit.",
);
/* c8 ignore start -- retained for a future signed source-archive implementation */
await fs.mkdir(this.updateDirectory, { recursive: true });
const archiveUrl = `${this.store.data.gitea.baseUrl.replace(/\/+$/, "")}/${encodeURIComponent(update.owner)}/${encodeURIComponent(update.repo)}/archive/${update.remoteSha}.zip`;
const archive = await this.gitea.downloadAuthenticated(archiveUrl);
@@ -354,6 +365,7 @@ class UpdateService {
sha256,
});
return { ...metadata, downloaded: true };
/* c8 ignore stop */
}
async downloadPackaged(update) {
@@ -528,6 +540,10 @@ class UpdateService {
"The integrated updater currently supports Windows only.",
);
if (update.kind === "binary") return this.applyPackaged(update);
requireSignedSourceUpdate(
"This source archive cannot be applied because it has no independently signed publisher manifest.",
);
/* c8 ignore start -- legacy helper retained only for migration compatibility */
const stat = await fs.stat(update.archivePath).catch(() => null);
if (!stat?.isFile())
throw new Error("The staged update archive is no longer available.");
@@ -662,6 +678,7 @@ class UpdateService {
logPath,
statusPath,
};
/* c8 ignore stop */
}
async applyPackaged(update) {
@@ -850,4 +867,5 @@ module.exports = {
waitForUpdaterStarted,
readJsonFile,
readLogTail,
requireSignedSourceUpdate,
};
+2 -2
View File
@@ -761,8 +761,8 @@ function renderSettings() {
const update = ui.updateStatus;
const servers = state.servers || [];
return `<div class="settings-layout"><aside class="settings-nav"><button class="nav-button active">${icon("settings")}<span>General</span></button><button class="nav-button" data-action="check-updates">${icon("update")}<span>Updates</span></button><button class="nav-button" data-action="open-add-server">${icon("server")}<span>Servers</span></button><button class="nav-button" data-action="reset-app">${icon("trash")}<span>Reset setup</span></button></aside><div class="settings-content"><div class="page-header"><div><div class="eyebrow">Application</div><h1>Settings</h1><p>Connections, project discovery, secure SSH servers and application updates.</p></div></div>
<section class="settings-group"><h2>Gitea connection</h2><div class="form-grid"><div class="field full"><label for="settings-gitea-url">Instance URL</label><input id="settings-gitea-url" class="input" value="${attr(state.gitea.baseUrl)}" placeholder="https://gitea.example.com" /></div><div class="field full"><label for="settings-gitea-token">New access token</label><input id="settings-gitea-token" class="input" type="password" placeholder="Leave empty to keep the existing token" /></div></div><div class="connection-card" style="margin-top:10px"><div><strong>${state.gitea.hasToken ? `Connected as ${escapeHtml(state.gitea.user?.login || "user")}` : "Not connected"}</strong><div class="queue-sub">${escapeHtml(state.gitea.baseUrl || "No Gitea instance configured")}</div></div><button class="button primary" data-action="save-gitea-settings">Validate & save</button></div></section>
<section class="settings-group"><div class="section-heading"><div><h2>ForgeFlow updates</h2><span class="meta">Secure source update from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow")}</span></div><button class="button" data-action="check-updates" ${ui.updateChecking ? "disabled" : ""}>${icon("update")}${ui.updateChecking ? "Checking…" : "Check now"}</button></div><div class="form-grid"><div class="field"><label>Repository owner</label><input id="update-owner" class="input" value="${attr(state.updates?.owner || "Jens")}"/></div><div class="field"><label>Repository name</label><input id="update-repo" class="input" value="${attr(state.updates?.repo || "ForgeFlow")}"/></div><div class="field"><label>Release branch</label><input id="update-branch" class="input" value="${attr(state.updates?.branch || "main")}"/></div><div class="field"><label>Automatic startup check</label><select id="update-auto-check" class="select"><option value="true" ${state.updates?.autoCheck !== false ? "selected" : ""}>Enabled</option><option value="false" ${state.updates?.autoCheck === false ? "selected" : ""}>Disabled</option></select></div></div><div class="update-card ${update?.available ? "available" : ""}"><div>${icon(update?.available ? "download" : "check")}<span><strong>${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}</strong><small>${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}</small></span></div><div class="stack horizontal compact">${update?.available && !update.downloaded ? `<button class="button primary" data-action="download-update">${icon("download")}Download update</button>` : ""}${update?.downloaded ? `<button class="button success" data-action="apply-update">${icon("update")}Apply & restart</button>` : ""}<button class="button" data-action="save-update-settings">Save update settings</button></div></div><div class="notice" style="margin-top:10px">${icon("shield")}The updater downloads an authenticated ZIP for the exact remote commit, verifies its SHA-256 checksum, runs the complete quality gate and restores the previous source version if validation fails.</div></section>
<section class="settings-group"><h2>Gitea connection</h2><div class="form-grid"><div class="field full"><label for="settings-gitea-url">Instance URL</label><input id="settings-gitea-url" class="input" value="${attr(state.gitea.baseUrl)}" placeholder="https://gitea.example.com" /></div><div class="field full"><label for="settings-gitea-token">New access token</label><input id="settings-gitea-token" class="input" type="password" placeholder="Leave empty only when keeping the same server" /></div></div><div class="connection-card" style="margin-top:10px"><div><strong>${state.gitea.hasToken ? `Connected as ${escapeHtml(state.gitea.user?.login || "user")}` : "Not connected"}</strong><div class="queue-sub">${escapeHtml(state.gitea.baseUrl || "No Gitea instance configured")}</div></div><button class="button primary" data-action="save-gitea-settings">Validate & save</button></div></section>
<section class="settings-group"><div class="section-heading"><div><h2>ForgeFlow updates</h2><span class="meta">Signed packaged updates from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow")}</span></div><button class="button" data-action="check-updates" ${ui.updateChecking ? "disabled" : ""}>${icon("update")}${ui.updateChecking ? "Checking…" : "Check now"}</button></div><div class="form-grid"><div class="field"><label>Repository owner</label><input id="update-owner" class="input" value="${attr(state.updates?.owner || "Jens")}"/></div><div class="field"><label>Repository name</label><input id="update-repo" class="input" value="${attr(state.updates?.repo || "ForgeFlow")}"/></div><div class="field"><label>Release branch</label><input id="update-branch" class="input" value="${attr(state.updates?.branch || "main")}"/></div><div class="field"><label>Automatic startup check</label><select id="update-auto-check" class="select"><option value="true" ${state.updates?.autoCheck !== false ? "selected" : ""}>Enabled</option><option value="false" ${state.updates?.autoCheck === false ? "selected" : ""}>Disabled</option></select></div></div><div class="update-card ${update?.available ? "available" : ""}"><div>${icon(update?.available ? "download" : "check")}<span><strong>${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available${update.packaged ? "" : " in the source repository"}` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}</strong><small>${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}</small></span></div><div class="stack horizontal compact">${update?.available && update.packaged && !update.downloaded ? `<button class="button primary" data-action="download-update">${icon("download")}Download signed update</button>` : ""}${update?.downloaded ? `<button class="button success" data-action="apply-update">${icon("update")}Apply & restart</button>` : ""}<button class="button" data-action="save-update-settings">Save update settings</button></div></div><div class="notice" style="margin-top:10px">${icon("shield")}${update && !update.packaged ? "Source checkouts must be updated with Git after reviewing the exact commit. Integrated source replacement remains disabled until source archives are publisher-signed." : "Packaged updates require an Ed25519 publisher signature that binds the exact commit, artifact name, size and SHA-256 digest."}</div></section>
<section class="settings-group"><div class="section-heading"><div><h2>SSH / Unraid servers</h2><span class="meta">Credentials are encrypted locally; a new host fingerprint is shown before authentication.</span></div><button class="button primary" data-action="open-add-server">${icon("plus")}Add server</button></div>${servers.length ? `<div class="server-list">${servers.map((server) => `<article class="server-card"><div class="server-card-main">${icon("server")}<div><strong>${escapeHtml(server.name)}</strong><span>${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}</span><small>${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : "Host identity not trusted yet"}</small></div></div><div class="stack horizontal compact"><button class="button" data-action="test-server" data-server-id="${attr(server.id)}">${server.hostFingerprint ? "Test connection" : "Preview & trust fingerprint"}</button><button class="button" data-action="edit-server" data-server-id="${attr(server.id)}">Edit</button><button class="icon-button danger" data-action="delete-server" data-server-id="${attr(server.id)}" title="Delete server">${icon("trash")}</button></div></article>`).join("")}</div>` : '<div class="empty-state compact"><p>No SSH server configured. Add your Unraid server before creating an SSH deployment profile.</p></div>'}</section>
<section class="settings-group"><div class="section-heading"><div><h2>Git remote maintenance</h2><span class="meta">Standardize linked repositories to the current Gitea SSH URLs.</span></div><button class="button" data-action="normalize-origins">${icon("link")}Normalize all origins</button></div><p>This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.</p></section>
<section class="settings-group"><h2>Project roots</h2><p>The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.</p><div class="stack">${state.workspaceRoots.map((root, index) => `<div class="root-row">${index === 0 ? '<span class="status-pill success">Default</span>' : ""}<input class="input" data-root-index="${index}" value="${attr(root)}" aria-label="Project root ${index + 1}"/><button class="icon-button" data-action="remove-root" data-index="${index}" title="Remove">${icon("trash")}</button></div>`).join("")}<button class="button" data-action="add-root">${icon("plus")}Add project root</button><button class="button primary" data-action="save-roots">Save folders & rescan</button></div></section>