Prepare ForgeFlow for public release
This commit is contained in:
1 parent
57929ea973
commit
8cca1bfc01
29 files changed
+400
-343
No files matched your search
@@ -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
|
||||
};
|
||||
|
||||
@@ -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?.() || []);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user