feat: add safe Gitea sync and signed updates
This commit is contained in:
@@ -615,7 +615,10 @@ class ConfigStore {
|
||||
const next = { ...this.data.preferences, ...(preferences || {}) };
|
||||
next.repositoryPollSeconds = Math.min(Math.max(Number(next.repositoryPollSeconds) || 4, 2), 60);
|
||||
next.operationPollSeconds = Math.min(Math.max(Number(next.operationPollSeconds) || 5, 3), 120);
|
||||
next.fetchIntervalMinutes = Math.min(Math.max(Number(next.fetchIntervalMinutes) || 10, 0), 240);
|
||||
const fetchIntervalMinutes = Number(next.fetchIntervalMinutes);
|
||||
next.fetchIntervalMinutes = Number.isFinite(fetchIntervalMinutes)
|
||||
? Math.min(Math.max(fetchIntervalMinutes, 0), 240)
|
||||
: 10;
|
||||
next.autoRefresh = next.autoRefresh !== false;
|
||||
next.preferredCloneProtocol = ['https', 'ssh'].includes(next.preferredCloneProtocol) ? next.preferredCloneProtocol : 'https';
|
||||
next.diagnosticsEnabled = next.diagnosticsEnabled !== false;
|
||||
|
||||
@@ -291,9 +291,13 @@ class DiagnosticsService {
|
||||
dispatchedAt: operation.dispatchedAt,
|
||||
stages: operation.stages,
|
||||
jobs: operation.jobs,
|
||||
logs: operation.logs,
|
||||
failure: operation.failure,
|
||||
pollError: operation.pollError,
|
||||
remoteOutput: operation.logs || operation.failure || operation.pollError ? {
|
||||
included: false,
|
||||
reason: 'Remote build and command output is intentionally omitted because it may contain application secrets unknown to ForgeFlow.',
|
||||
logCharacters: String(operation.logs || '').length,
|
||||
failureRecorded: Boolean(operation.failure),
|
||||
pollErrorRecorded: Boolean(operation.pollError)
|
||||
} : null,
|
||||
applicationState: operation.applicationState,
|
||||
run: operation.run ? {
|
||||
id: operation.run.id,
|
||||
|
||||
+195
-1
@@ -2,11 +2,13 @@
|
||||
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs/promises');
|
||||
const crypto = require('node:crypto');
|
||||
const { run } = require('./process-runner.cjs');
|
||||
const { parsePorcelainV2 } = require('../shared/git-status.cjs');
|
||||
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
|
||||
|
||||
const COMMON_GIT_LOCK_FILES = ['HEAD.lock', 'index.lock'];
|
||||
const MAX_UNTRACKED_DIFF_BYTES = 16 * 1024 * 1024;
|
||||
const {
|
||||
assertSafeRepositoryPath,
|
||||
assertRepositoryRelativePath,
|
||||
@@ -28,6 +30,34 @@ function parseUnifiedDiff(diffText) {
|
||||
return { header, hunks };
|
||||
}
|
||||
|
||||
function parseNameStatus(output) {
|
||||
const entries = String(output || '').split('\0');
|
||||
const changes = [];
|
||||
for (let index = 0; index < entries.length;) {
|
||||
const rawStatus = entries[index++];
|
||||
if (!rawStatus) continue;
|
||||
const code = rawStatus[0];
|
||||
if (code === 'R' || code === 'C') {
|
||||
const originalPath = entries[index++] || '';
|
||||
const filePath = entries[index++] || '';
|
||||
if (filePath) changes.push({ code, status: code === 'R' ? 'renamed' : 'copied', path: filePath, originalPath });
|
||||
continue;
|
||||
}
|
||||
const filePath = entries[index++] || '';
|
||||
if (!filePath) continue;
|
||||
const labels = { A: 'added', D: 'deleted', M: 'modified', T: 'type-changed', U: 'conflict' };
|
||||
changes.push({ code, status: labels[code] || 'changed', path: filePath, originalPath: null });
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function parseCompactLog(output) {
|
||||
return String(output || '').split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
|
||||
const [sha, shortSha, date, subject] = record.split('\x1f');
|
||||
return { sha, shortSha, date, subject };
|
||||
});
|
||||
}
|
||||
|
||||
class GitService {
|
||||
constructor() {
|
||||
// `git remote get-url` is only re-run when the repository configuration file
|
||||
@@ -316,6 +346,151 @@ class GitService {
|
||||
return { strategy: requested, backupBranch: null, status, lockReport: await this.listGitLocks(root) };
|
||||
}
|
||||
|
||||
async previewWorkspaceSync(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const { status } = await this.fetch(root);
|
||||
const branch = status.branch?.head;
|
||||
const upstream = status.branch?.upstream;
|
||||
if (!status.head || !branch || branch === '(detached)') {
|
||||
const error = new Error('Workspace synchronization requires a named branch with at least one commit.');
|
||||
error.code = 'WORKSPACE_SYNC_BRANCH_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
if (!upstream) {
|
||||
const error = new Error('The current branch has no Gitea upstream. Publish it or switch to a tracked branch first.');
|
||||
error.code = 'WORKSPACE_SYNC_UPSTREAM_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const targetSha = (await run('git', ['rev-parse', '--verify', upstream], { cwd: root, timeout: 30_000 })).stdout.trim();
|
||||
const changes = parseNameStatus((await run('git', [
|
||||
'diff', '--name-status', '-z', '--find-renames', 'HEAD', upstream, '--'
|
||||
], { cwd: root, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 })).stdout);
|
||||
const logFormat = '%H%x1f%h%x1f%aI%x1f%s%x1e';
|
||||
const [incomingResult, localResult, interruptedOperation] = await Promise.all([
|
||||
run('git', ['log', `--format=${logFormat}`, `HEAD..${upstream}`, '-20'], { cwd: root, timeout: 30_000 }),
|
||||
run('git', ['log', `--format=${logFormat}`, `${upstream}..HEAD`, '-20'], { cwd: root, timeout: 30_000 }),
|
||||
this.detectInterruptedOperation(root)
|
||||
]);
|
||||
const blockers = [];
|
||||
if (interruptedOperation) blockers.push(`Finish or abort the active Git ${interruptedOperation} before synchronizing.`);
|
||||
if (status.counts.conflicts) blockers.push(`Resolve ${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? '' : 's'} before synchronizing.`);
|
||||
const summary = {
|
||||
resultingTrackedChanges: changes.length,
|
||||
added: changes.filter((item) => item.code === 'A').length,
|
||||
modified: changes.filter((item) => ['M', 'T'].includes(item.code)).length,
|
||||
deleted: changes.filter((item) => item.code === 'D').length,
|
||||
renamed: changes.filter((item) => item.code === 'R').length,
|
||||
localFilesToStash: status.counts.changed,
|
||||
untrackedFilesToStash: status.counts.untracked,
|
||||
localCommitsToProtect: status.branch.ahead,
|
||||
incomingCommits: status.branch.behind
|
||||
};
|
||||
const planId = crypto.createHash('sha256').update(JSON.stringify({
|
||||
head: status.head,
|
||||
targetSha,
|
||||
branch,
|
||||
upstream,
|
||||
fingerprint: this.statusFingerprint(status)
|
||||
})).digest('hex');
|
||||
return {
|
||||
id: planId,
|
||||
repositoryRoot: root,
|
||||
branch,
|
||||
upstream,
|
||||
currentSha: status.head,
|
||||
targetSha,
|
||||
needsSync: status.head !== targetSha || !status.clean,
|
||||
cleanBeforeSync: status.clean,
|
||||
blockers,
|
||||
summary,
|
||||
changes: changes.slice(0, 250),
|
||||
changesTruncated: changes.length > 250,
|
||||
localFiles: status.files.slice(0, 250),
|
||||
localFilesTruncated: status.files.length > 250,
|
||||
incomingCommits: parseCompactLog(incomingResult.stdout),
|
||||
localCommits: parseCompactLog(localResult.stdout),
|
||||
recovery: {
|
||||
safetyBranch: status.branch.ahead > 0,
|
||||
stash: status.counts.changed > 0,
|
||||
untrackedCleanup: status.counts.untracked > 0,
|
||||
ignoredFilesPreserved: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async synchronizeWorkspace(repoPath, expectedPlanId) {
|
||||
const expected = String(expectedPlanId || '').trim();
|
||||
if (!/^[0-9a-f]{64}$/i.test(expected)) {
|
||||
const error = new Error('Apply workspace synchronization only from a reviewed preview.');
|
||||
error.code = 'WORKSPACE_SYNC_PLAN_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
const plan = await this.previewWorkspaceSync(repoPath);
|
||||
if (plan.id !== expected) {
|
||||
const error = new Error('The local workspace or Gitea branch changed after the preview. Review a fresh synchronization plan.');
|
||||
error.code = 'WORKSPACE_SYNC_PLAN_STALE';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
if (plan.blockers.length) {
|
||||
const error = new Error(plan.blockers.join(' '));
|
||||
error.code = 'WORKSPACE_SYNC_BLOCKED';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
if (!plan.needsSync) {
|
||||
return { applied: false, unchanged: true, plan, status: await this.status(plan.repositoryRoot), backupBranch: null, stash: null, cleaned: [] };
|
||||
}
|
||||
|
||||
const root = plan.repositoryRoot;
|
||||
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
|
||||
let backupBranch = null;
|
||||
let stash = null;
|
||||
if (plan.summary.localCommitsToProtect > 0) {
|
||||
const safeBranch = plan.branch.replace(/[^A-Za-z0-9._-]/g, '-');
|
||||
backupBranch = `forgeflow/recovery-${safeBranch}-${stamp}-${plan.currentSha.slice(0, 7)}`;
|
||||
await run('git', ['check-ref-format', '--branch', backupBranch], { cwd: root, timeout: 30_000 });
|
||||
await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 });
|
||||
}
|
||||
if (plan.summary.localFilesToStash > 0) {
|
||||
const label = `ForgeFlow workspace sync ${plan.branch} ${stamp}`;
|
||||
await run('git', ['stash', 'push', '--include-untracked', '-m', label], { cwd: root, timeout: 120_000 });
|
||||
stash = (await this.stashList(root))[0] || null;
|
||||
}
|
||||
|
||||
const protectedStatus = await this.status(root);
|
||||
if (!protectedStatus.clean || protectedStatus.head !== plan.currentSha) {
|
||||
const error = new Error('The workspace changed while ForgeFlow was protecting local work. Nothing was reset; review a fresh synchronization plan.');
|
||||
error.code = 'WORKSPACE_SYNC_CONCURRENT_CHANGE';
|
||||
error.recoverable = true;
|
||||
error.backupBranch = backupBranch;
|
||||
error.stash = stash;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await run('git', ['reset', '--hard', plan.targetSha], { cwd: root, timeout: 2 * 60_000 });
|
||||
const status = await this.status(root);
|
||||
if (status.head !== plan.targetSha || !status.clean) {
|
||||
const error = new Error('Git did not verify an exact clean match with the reviewed Gitea commit. Local recovery references were preserved.');
|
||||
error.code = 'WORKSPACE_SYNC_VERIFICATION_FAILED';
|
||||
error.recoverable = true;
|
||||
error.backupBranch = backupBranch;
|
||||
error.stash = stash;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
applied: true,
|
||||
unchanged: false,
|
||||
plan,
|
||||
status,
|
||||
backupBranch,
|
||||
stash,
|
||||
cleaned: plan.localFiles.filter((file) => file.untracked).map((file) => file.path),
|
||||
ignoredFilesPreserved: true
|
||||
};
|
||||
}
|
||||
|
||||
async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const safeRemote = assertCloneRemote(remoteUrl);
|
||||
@@ -336,7 +511,26 @@ class GitService {
|
||||
if (!result.stdout && safeFile && !staged) {
|
||||
const candidate = path.resolve(root, safeFile);
|
||||
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) throw new Error('File path escapes repository root.');
|
||||
const content = await fs.readFile(candidate, 'utf8').catch(() => '');
|
||||
const [realRoot, realCandidate, candidateStat] = await Promise.all([
|
||||
fs.realpath(root).catch(() => root),
|
||||
fs.realpath(candidate).catch(() => candidate),
|
||||
fs.stat(candidate).catch(() => null)
|
||||
]);
|
||||
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
|
||||
const normalizedRoot = normalize(realRoot);
|
||||
const normalizedCandidate = normalize(realCandidate);
|
||||
if (normalizedCandidate !== normalizedRoot && !normalizedCandidate.startsWith(`${normalizedRoot}${path.sep}`)) {
|
||||
const error = new Error('ForgeFlow refuses to read a diff target that resolves outside the repository.');
|
||||
error.code = 'DIFF_TARGET_OUTSIDE_REPOSITORY';
|
||||
throw error;
|
||||
}
|
||||
if (candidateStat?.size > MAX_UNTRACKED_DIFF_BYTES) {
|
||||
const error = new Error('The untracked file is too large to render safely as a diff.');
|
||||
error.code = 'DIFF_FILE_TOO_LARGE';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
const content = candidateStat?.isFile() ? await fs.readFile(candidate, 'utf8').catch(() => '') : '';
|
||||
if (content) return `diff --git a/${safeFile} b/${safeFile}\nnew file mode 100644\n--- /dev/null\n+++ b/${safeFile}\n${content.split('\n').map((line) => `+${line}`).join('\n')}`;
|
||||
}
|
||||
return result.stdout;
|
||||
|
||||
+14
-2
@@ -415,13 +415,25 @@ function registerIpc({
|
||||
await diagnostics.info("server.deleted", { serverId });
|
||||
return store.getPublicState();
|
||||
});
|
||||
register("server:test", async ({ serverId }) => {
|
||||
register("server:test", async ({ serverId, expectedFingerprint = "" }) => {
|
||||
const server = store.getServer(serverId);
|
||||
if (!server) throw new Error("The configured server no longer exists.");
|
||||
const expected = String(expectedFingerprint || "").trim();
|
||||
if (!server.hostFingerprint && !expected) {
|
||||
const probe = await ssh.probeHostFingerprint(serverId);
|
||||
return { ...probe, connected: false, needsTrust: true, state: store.getPublicState() };
|
||||
}
|
||||
if (!server.hostFingerprint && !/^SHA256:[A-Za-z0-9+/]{40,44}$/.test(expected))
|
||||
throw new Error("Confirm the exact SSH host fingerprint returned by ForgeFlow.");
|
||||
const result = await ssh.test(serverId, {
|
||||
trustOnFirstUse: !server.hostFingerprint,
|
||||
expectedFingerprint: server.hostFingerprint ? null : expected,
|
||||
});
|
||||
if (!server.hostFingerprint) {
|
||||
if (result.fingerprint !== expected) {
|
||||
const error = new Error("The SSH host identity changed between preview and confirmation.");
|
||||
error.code = "SSH_HOST_KEY_MISMATCH";
|
||||
throw error;
|
||||
}
|
||||
await store.saveServer(
|
||||
{ ...server, hostFingerprint: result.fingerprint },
|
||||
{},
|
||||
|
||||
@@ -265,6 +265,44 @@ function registerRepositoryIpc({
|
||||
git.repairSync(safePath, strategy),
|
||||
);
|
||||
});
|
||||
register("repository:workspace-sync-preview", async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const plan = await withRepositoryMutation(safePath, () =>
|
||||
git.previewWorkspaceSync(safePath),
|
||||
);
|
||||
await diagnostics.info("repository.workspace-sync.previewed", {
|
||||
localPath: safePath,
|
||||
branch: plan.branch,
|
||||
upstream: plan.upstream,
|
||||
currentSha: plan.currentSha,
|
||||
targetSha: plan.targetSha,
|
||||
planId: plan.id,
|
||||
summary: plan.summary,
|
||||
blockers: plan.blockers,
|
||||
});
|
||||
return plan;
|
||||
});
|
||||
register(
|
||||
"repository:workspace-sync-apply",
|
||||
async ({ localPath, expectedPlanId }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const result = await withRepositoryMutation(safePath, () =>
|
||||
git.synchronizeWorkspace(safePath, expectedPlanId),
|
||||
);
|
||||
await audit.append("repository.workspace-synchronized", {
|
||||
localPath: safePath,
|
||||
branch: result.plan.branch,
|
||||
upstream: result.plan.upstream,
|
||||
previousSha: result.plan.currentSha,
|
||||
targetSha: result.plan.targetSha,
|
||||
backupBranch: result.backupBranch,
|
||||
stashSha: result.stash?.sha || null,
|
||||
ignoredFilesPreserved: true,
|
||||
applied: result.applied,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
);
|
||||
register("repository:set-origin", async ({ localPath, remoteUrl }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
return withRepositoryMutation(safePath, () =>
|
||||
|
||||
@@ -26,7 +26,9 @@ class RepositoryMonitor {
|
||||
this.watchers = new Map();
|
||||
this.changed = new Set();
|
||||
this.lastCheckedAt = new Map();
|
||||
this.lastFetchedAt = new Map();
|
||||
this.watchTimer = null;
|
||||
this.fetchRunning = false;
|
||||
}
|
||||
|
||||
setPaths(paths) {
|
||||
@@ -46,6 +48,13 @@ class RepositoryMonitor {
|
||||
for (const existing of [...this.lastCheckedAt.keys()]) {
|
||||
if (!watched.has(existing)) this.lastCheckedAt.delete(existing);
|
||||
}
|
||||
for (const existing of [...this.lastFetchedAt.keys()]) {
|
||||
if (!watched.has(existing)) this.lastFetchedAt.delete(existing);
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const localPath of this.paths) {
|
||||
if (!this.lastFetchedAt.has(localPath)) this.lastFetchedAt.set(localPath, now);
|
||||
}
|
||||
this.syncWatchers();
|
||||
}
|
||||
|
||||
@@ -105,6 +114,60 @@ class RepositoryMonitor {
|
||||
return sinceLastCheck >= SAFETY_CHECK_INTERVAL_MS;
|
||||
}
|
||||
|
||||
fetchIntervalMs() {
|
||||
const minutes = Number(this.store.data.preferences.fetchIntervalMinutes);
|
||||
return Number.isFinite(minutes) && minutes > 0 ? Math.min(minutes, 240) * 60_000 : 0;
|
||||
}
|
||||
|
||||
shouldFetch(localPath, now) {
|
||||
const interval = this.fetchIntervalMs();
|
||||
return interval > 0
|
||||
&& !this.paused.has(localPath)
|
||||
&& now - (this.lastFetchedAt.get(localPath) || now) >= interval;
|
||||
}
|
||||
|
||||
async recordStatus(localPath, status, reason) {
|
||||
const next = this.git.statusFingerprint(status);
|
||||
const previous = this.fingerprints.get(localPath);
|
||||
this.fingerprints.set(localPath, next);
|
||||
if (previous && previous !== next) {
|
||||
await this.diagnostics?.debug('repository-monitor.changed', { localPath, head: status.head, branch: status.branch?.head, counts: status.counts, reason });
|
||||
this.onChange?.({ localPath, status, reason });
|
||||
}
|
||||
}
|
||||
|
||||
async fetchRemoteUpdates(now = Date.now()) {
|
||||
if (this.fetchRunning) return;
|
||||
const queue = this.paths.filter((localPath) => this.shouldFetch(localPath, now));
|
||||
if (!queue.length) return;
|
||||
this.fetchRunning = true;
|
||||
try {
|
||||
const workers = Array.from({ length: Math.min(2, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
const localPath = queue.shift();
|
||||
// Mark the attempt before awaiting the network. A failing remote should
|
||||
// not be retried every local poll interval.
|
||||
this.lastFetchedAt.set(localPath, Date.now());
|
||||
try {
|
||||
const result = await this.git.fetch(localPath);
|
||||
await this.recordStatus(localPath, result.status, 'remote-state-changed');
|
||||
await this.diagnostics?.debug('repository-monitor.fetch.completed', {
|
||||
localPath,
|
||||
branch: result.status?.branch?.head,
|
||||
ahead: result.status?.branch?.ahead,
|
||||
behind: result.status?.branch?.behind,
|
||||
});
|
||||
} catch (error) {
|
||||
await this.diagnostics?.warning('repository-monitor.fetch.failed', { localPath, message: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
} finally {
|
||||
this.fetchRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
pause(localPath) { if (localPath) this.paused.add(localPath); }
|
||||
resume(localPath) { if (localPath) this.paused.delete(localPath); }
|
||||
|
||||
@@ -128,6 +191,7 @@ class RepositoryMonitor {
|
||||
}
|
||||
|
||||
async tick() {
|
||||
void this.fetchRemoteUpdates().catch((error) => this.diagnostics?.warning('repository-monitor.fetch-cycle.failed', error));
|
||||
if (this.running || !this.paths.length) return;
|
||||
this.running = true;
|
||||
try {
|
||||
@@ -140,13 +204,7 @@ class RepositoryMonitor {
|
||||
this.lastCheckedAt.set(localPath, Date.now());
|
||||
try {
|
||||
const status = await this.git.status(localPath);
|
||||
const next = this.git.statusFingerprint(status);
|
||||
const previous = this.fingerprints.get(localPath);
|
||||
this.fingerprints.set(localPath, next);
|
||||
if (previous && previous !== next) {
|
||||
await this.diagnostics?.debug('repository-monitor.changed', { localPath, head: status.head, branch: status.branch?.head, counts: status.counts });
|
||||
this.onChange?.({ localPath, status, reason: 'working-tree-changed' });
|
||||
}
|
||||
await this.recordStatus(localPath, status, 'working-tree-changed');
|
||||
} catch (error) {
|
||||
const next = `error:${error.message}`;
|
||||
const previous = this.fingerprints.get(localPath);
|
||||
|
||||
@@ -53,9 +53,10 @@ function parseCapabilityOutput(output) {
|
||||
}
|
||||
|
||||
class SshService {
|
||||
constructor({ store, diagnostics, idleConnectionMs = 60_000 }) {
|
||||
constructor({ store, diagnostics, idleConnectionMs = 60_000, clientFactory = loadSshClient }) {
|
||||
this.store = store;
|
||||
this.diagnostics = diagnostics;
|
||||
this.clientFactory = clientFactory;
|
||||
// Every command used to pay for a TCP handshake, a key exchange and an
|
||||
// authentication round trip. Sessions are kept per server for a short while
|
||||
// so a sequence of commands shares one connection.
|
||||
@@ -104,7 +105,7 @@ class SshService {
|
||||
return { valid: true, method: 'privateKey', encrypted: Boolean(passphrase), privateKeyPath };
|
||||
}
|
||||
|
||||
async connectionOptions(server, { trustOnFirstUse = false } = {}) {
|
||||
async connectionOptions(server, { trustOnFirstUse = false, expectedFingerprint = null } = {}) {
|
||||
const credentials = this.store.getServerCredentials(server.id);
|
||||
let observedFingerprint = null;
|
||||
const options = {
|
||||
@@ -116,7 +117,8 @@ class SshService {
|
||||
keepaliveCountMax: 3,
|
||||
hostVerifier: (key) => {
|
||||
observedFingerprint = fingerprintKey(key);
|
||||
return trustOnFirstUse || Boolean(server.hostFingerprint && observedFingerprint === server.hostFingerprint);
|
||||
const trustedFingerprint = String(server.hostFingerprint || expectedFingerprint || '').trim();
|
||||
return trustOnFirstUse || Boolean(trustedFingerprint && observedFingerprint === trustedFingerprint);
|
||||
},
|
||||
};
|
||||
if (server.authType === 'password') options.password = credentials.password;
|
||||
@@ -137,7 +139,7 @@ class SshService {
|
||||
if (!server) throw new Error('The configured SSH server no longer exists.');
|
||||
// A trust-on-first-use connection is established without checking the
|
||||
// fingerprint, so it must never serve a later verified call.
|
||||
if (options.trustOnFirstUse) return this.withDedicatedClient(server, action, options);
|
||||
if (options.trustOnFirstUse || options.expectedFingerprint) return this.withDedicatedClient(server, action, options);
|
||||
return this.withPooledClient(server, action, options);
|
||||
}
|
||||
|
||||
@@ -243,7 +245,7 @@ class SshService {
|
||||
|
||||
async withDedicatedClient(server, action, options = {}, { keepOpen = false } = {}) {
|
||||
const serverId = server.id;
|
||||
const Client = loadSshClient();
|
||||
const Client = this.clientFactory();
|
||||
const connection = await this.connectionOptions(server, options);
|
||||
const client = new Client();
|
||||
const started = Date.now();
|
||||
@@ -414,7 +416,51 @@ class SshService {
|
||||
}));
|
||||
}
|
||||
|
||||
async test(serverId, { trustOnFirstUse = true } = {}) {
|
||||
async probeHostFingerprint(serverId) {
|
||||
const server = this.store.getServer(serverId);
|
||||
if (!server) throw new Error('The configured SSH server no longer exists.');
|
||||
const Client = this.clientFactory();
|
||||
const client = new Client();
|
||||
let observedFingerprint = null;
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
try { client.end(); } catch { /* handshake already closed */ }
|
||||
callback(value);
|
||||
};
|
||||
const completeProbe = (error = null) => {
|
||||
if (observedFingerprint) {
|
||||
finish(resolve, {
|
||||
fingerprint: observedFingerprint,
|
||||
server: { id: server.id, name: server.name, host: server.host, port: server.port || 22 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const wrapped = new Error(`Could not read the SSH host fingerprint: ${error?.message || 'the server closed the handshake'}`);
|
||||
wrapped.code = error?.code || 'SSH_HOST_KEY_PROBE_FAILED';
|
||||
finish(reject, wrapped);
|
||||
};
|
||||
const timer = setTimeout(() => completeProbe(new Error('The SSH host-key probe timed out.')), 25_000);
|
||||
client.on('error', completeProbe);
|
||||
client.on('close', () => completeProbe());
|
||||
client.on('end', () => completeProbe());
|
||||
client.connect({
|
||||
host: server.host,
|
||||
port: server.port || 22,
|
||||
username: server.username,
|
||||
readyTimeout: 20_000,
|
||||
hostVerifier: (key) => {
|
||||
observedFingerprint = fingerprintKey(key);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async test(serverId, { trustOnFirstUse = false, expectedFingerprint = null } = {}) {
|
||||
return this.withClient(serverId, async (client, server, fingerprint) => {
|
||||
const script = `
|
||||
platform=$(uname -srm 2>/dev/null || true)
|
||||
@@ -449,7 +495,7 @@ printf 'baseWritable=%s\\n' "$base_writable"
|
||||
capabilities,
|
||||
output: [capabilities.platform, capabilities.composeVersion].filter(Boolean).join('\n'),
|
||||
};
|
||||
}, { trustOnFirstUse });
|
||||
}, { trustOnFirstUse, expectedFingerprint });
|
||||
}
|
||||
|
||||
async exec(serverId, command, options = {}) {
|
||||
|
||||
@@ -431,7 +431,7 @@ function createUnraidInventoryMethods({
|
||||
const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink: true });
|
||||
if (plan.additions.length) await this.store.createRecoverySnapshot?.(`automatic-server-links-${serverId}`);
|
||||
const linkedRepositories = new Set(workloads
|
||||
.filter((workload) => workload.link?.repositoryFullName)
|
||||
.filter((workload) => workload.classification?.type !== "stale-link" && workload.link?.repositoryFullName)
|
||||
.map((workload) => String(workload.link.repositoryFullName).toLowerCase()));
|
||||
for (const addition of plan.additions) {
|
||||
const workload = workloads.find((item) => item.workloadId === addition.workloadId);
|
||||
@@ -466,7 +466,9 @@ function createUnraidInventoryMethods({
|
||||
reconciliationPlan(server, workloads, repositories, { autoLink = true } = {}) {
|
||||
const profiles = this.allSshProfiles().filter((profile) => profile.serverId === server.id);
|
||||
const activeWorkloadIds = new Set(workloads.filter((item) => item.classification?.type !== "stale-link").map((item) => item.workloadId));
|
||||
const linkedRepositories = new Set(workloads.filter((item) => item.link?.repositoryFullName).map((item) => String(item.link.repositoryFullName).toLowerCase()));
|
||||
const linkedRepositories = new Set(workloads
|
||||
.filter((item) => item.classification?.type !== "stale-link" && item.link?.repositoryFullName)
|
||||
.map((item) => String(item.link.repositoryFullName).toLowerCase()));
|
||||
const additions = [];
|
||||
const updates = [];
|
||||
const conflicts = [];
|
||||
|
||||
+151
-19
@@ -14,6 +14,85 @@ function safeRepositoryPart(value, label) {
|
||||
return text;
|
||||
}
|
||||
|
||||
function verifyReleaseManifest({
|
||||
manifestBytes,
|
||||
signatureBytes,
|
||||
publicKey,
|
||||
update,
|
||||
assetName,
|
||||
}) {
|
||||
if (
|
||||
!Buffer.isBuffer(manifestBytes) ||
|
||||
manifestBytes.length < 100 ||
|
||||
manifestBytes.length > 1_000_000
|
||||
) {
|
||||
throw new Error("The signed release manifest has an invalid size.");
|
||||
}
|
||||
const signatureText = Buffer.from(signatureBytes || "")
|
||||
.toString("utf8")
|
||||
.trim();
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(signatureText)) {
|
||||
throw new Error("The release manifest signature is invalid.");
|
||||
}
|
||||
const signature = Buffer.from(signatureText, "base64");
|
||||
if (signature.length !== 64) {
|
||||
throw new Error("The release manifest signature is invalid.");
|
||||
}
|
||||
let verified = false;
|
||||
try {
|
||||
verified = crypto.verify(null, manifestBytes, publicKey, signature);
|
||||
} catch {
|
||||
verified = false;
|
||||
}
|
||||
if (!verified) {
|
||||
const error = new Error(
|
||||
"The release manifest was not signed by the trusted ForgeFlow publisher key.",
|
||||
);
|
||||
error.code = "RELEASE_SIGNATURE_INVALID";
|
||||
throw error;
|
||||
}
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(manifestBytes.toString("utf8"));
|
||||
} catch {
|
||||
throw new Error("The signed release manifest is not valid JSON.");
|
||||
}
|
||||
const expectedVersion = String(update.remoteVersion || "").trim();
|
||||
const expectedCommit = String(update.remoteSha || "").toLowerCase();
|
||||
if (
|
||||
manifest.schemaVersion !== 1 ||
|
||||
manifest.product !== "ForgeFlow" ||
|
||||
manifest.version !== expectedVersion ||
|
||||
manifest.tag !== `v${expectedVersion}` ||
|
||||
manifest.signature?.algorithm !== "Ed25519" ||
|
||||
(expectedCommit &&
|
||||
String(manifest.commit || "").toLowerCase() !== expectedCommit)
|
||||
) {
|
||||
const error = new Error(
|
||||
"The signed release manifest does not match the requested ForgeFlow update.",
|
||||
);
|
||||
error.code = "RELEASE_MANIFEST_MISMATCH";
|
||||
throw error;
|
||||
}
|
||||
const artifact = Array.isArray(manifest.artifacts)
|
||||
? manifest.artifacts.find((item) => item?.name === assetName)
|
||||
: null;
|
||||
if (
|
||||
!artifact ||
|
||||
!Number.isSafeInteger(artifact.bytes) ||
|
||||
artifact.bytes < 1_000_000 ||
|
||||
!/^[a-f0-9]{64}$/.test(String(artifact.sha256 || ""))
|
||||
) {
|
||||
const error = new Error(
|
||||
`The signed release manifest has no valid entry for ${assetName}.`,
|
||||
);
|
||||
error.code = "RELEASE_MANIFEST_INCOMPLETE";
|
||||
throw error;
|
||||
}
|
||||
return { manifest, artifact };
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -156,6 +235,7 @@ class UpdateService {
|
||||
powershellPath = null,
|
||||
handshakeTimeoutMs = 12000,
|
||||
handshakePollMs = 100,
|
||||
updatePublicKey = null,
|
||||
}) {
|
||||
this.store = store;
|
||||
this.gitea = gitea;
|
||||
@@ -168,6 +248,7 @@ class UpdateService {
|
||||
this.powershellPath = powershellPath;
|
||||
this.handshakeTimeoutMs = handshakeTimeoutMs;
|
||||
this.handshakePollMs = handshakePollMs;
|
||||
this.updatePublicKey = updatePublicKey;
|
||||
this.staged = null;
|
||||
}
|
||||
|
||||
@@ -291,7 +372,7 @@ class UpdateService {
|
||||
));
|
||||
if (!release || release.draft || release.prerelease) {
|
||||
const error = new Error(
|
||||
`ForgeFlow ${update.remoteVersion} has no published binary release yet. The source branch was updated, but the matching Windows installer/portable assets were not published. Run Publish-Missing-Binary-Release.ps1 from the release source or publish the four required assets in Gitea.`,
|
||||
`ForgeFlow ${update.remoteVersion} has no published binary release yet. The source branch was updated, but the matching signed Windows release was not published. Run Publish-Missing-Binary-Release.ps1 from the release source.`,
|
||||
);
|
||||
error.code = "BINARY_RELEASE_NOT_FOUND";
|
||||
throw error;
|
||||
@@ -300,33 +381,70 @@ class UpdateService {
|
||||
const portable = Boolean(this.appInfo.portableExecutablePath);
|
||||
const assetName = `ForgeFlow-${portable ? "Portable" : "Setup"}-${update.remoteVersion}-win-x64.exe`;
|
||||
const checksumName = `${assetName}.sha256`;
|
||||
const manifestName = `ForgeFlow-${update.remoteVersion}-release-manifest.json`;
|
||||
const signatureName = `${manifestName}.sig`;
|
||||
const assets = Array.isArray(release.assets) ? release.assets : [];
|
||||
const asset = assets.find((item) => item.name === assetName);
|
||||
const checksumAsset = assets.find((item) => item.name === checksumName);
|
||||
if (!asset?.id || !checksumAsset?.id) {
|
||||
const manifestAsset = assets.find((item) => item.name === manifestName);
|
||||
const signatureAsset = assets.find((item) => item.name === signatureName);
|
||||
if (
|
||||
!asset?.id ||
|
||||
!checksumAsset?.id ||
|
||||
!manifestAsset?.id ||
|
||||
!signatureAsset?.id
|
||||
) {
|
||||
const error = new Error(
|
||||
`Release v${update.remoteVersion} is missing ${assetName} or its SHA-256 file.`,
|
||||
`Release v${update.remoteVersion} is incomplete: the executable, SHA-256 file, signed manifest and signature are all required.`,
|
||||
);
|
||||
error.code = "BINARY_RELEASE_INCOMPLETE";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [binary, checksumBytes] = await Promise.all([
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
asset.id,
|
||||
{ downloadUrl: asset.browser_download_url },
|
||||
),
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
checksumAsset.id,
|
||||
{ downloadUrl: checksumAsset.browser_download_url },
|
||||
),
|
||||
]);
|
||||
const [binary, checksumBytes, manifestBytes, signatureBytes] =
|
||||
await Promise.all([
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
asset.id,
|
||||
{ downloadUrl: asset.browser_download_url },
|
||||
),
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
checksumAsset.id,
|
||||
{ downloadUrl: checksumAsset.browser_download_url },
|
||||
),
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
manifestAsset.id,
|
||||
{ downloadUrl: manifestAsset.browser_download_url },
|
||||
),
|
||||
this.gitea.downloadReleaseAsset(
|
||||
update.owner,
|
||||
update.repo,
|
||||
release.id,
|
||||
signatureAsset.id,
|
||||
{ downloadUrl: signatureAsset.browser_download_url },
|
||||
),
|
||||
]);
|
||||
|
||||
const publicKey =
|
||||
this.updatePublicKey ||
|
||||
(await fs.readFile(
|
||||
path.join(this.sourcePath, "build", "update-signing-public.pem"),
|
||||
));
|
||||
const { manifest, artifact } = verifyReleaseManifest({
|
||||
manifestBytes,
|
||||
signatureBytes,
|
||||
publicKey,
|
||||
update,
|
||||
assetName,
|
||||
});
|
||||
if (binary.length < 1_000_000 || binary[0] !== 0x4d || binary[1] !== 0x5a) {
|
||||
const preview = binary.subarray(0, 200).toString("utf8").trim();
|
||||
const looksLikeMetadata =
|
||||
@@ -348,6 +466,16 @@ class UpdateService {
|
||||
?.toLowerCase();
|
||||
if (!/^[a-f0-9]{64}$/.test(expectedSha256 || ""))
|
||||
throw new Error("The release SHA-256 file is invalid.");
|
||||
if (expectedSha256 !== artifact.sha256) {
|
||||
throw new Error(
|
||||
"The release checksum does not match the signed publisher manifest.",
|
||||
);
|
||||
}
|
||||
if (binary.length !== artifact.bytes) {
|
||||
throw new Error(
|
||||
"The downloaded Windows update size does not match the signed publisher manifest.",
|
||||
);
|
||||
}
|
||||
const sha256 = crypto.createHash("sha256").update(binary).digest("hex");
|
||||
if (sha256 !== expectedSha256)
|
||||
throw new Error(
|
||||
@@ -368,6 +496,8 @@ class UpdateService {
|
||||
? this.appInfo.portableExecutablePath
|
||||
: this.appInfo.executablePath,
|
||||
releaseTag: release.tag_name,
|
||||
publisherKeyId: manifest.signature.keyId,
|
||||
releaseManifest: manifestName,
|
||||
downloadedAt: new Date().toISOString(),
|
||||
downloaded: true,
|
||||
};
|
||||
@@ -383,6 +513,7 @@ class UpdateService {
|
||||
bytes: binary.length,
|
||||
sha256,
|
||||
portable,
|
||||
publisherKeyId: manifest.signature.keyId,
|
||||
});
|
||||
return metadata;
|
||||
}
|
||||
@@ -713,6 +844,7 @@ class UpdateService {
|
||||
module.exports = {
|
||||
UpdateService,
|
||||
safeRepositoryPart,
|
||||
verifyReleaseManifest,
|
||||
resolveWindowsPowerShellPath,
|
||||
windowsUpdaterSpawnOptions,
|
||||
waitForUpdaterStarted,
|
||||
|
||||
@@ -23,6 +23,7 @@ async function handleDeploymentProfileActions(event, target, action, repository)
|
||||
};
|
||||
render();
|
||||
} else if (action === "close-modal") {
|
||||
if (ui.modal?.type === "workspace-sync") ui.workspaceSyncPlan = null;
|
||||
ui.modal = null;
|
||||
render();
|
||||
} else if (action === "select-profile-icon") {
|
||||
|
||||
@@ -121,6 +121,71 @@ Force repair after you have closed all Git tools for this repository?`)
|
||||
}
|
||||
setLoading(false);
|
||||
render();
|
||||
} else if (action === "preview-workspace-sync") {
|
||||
if (!repository?.localPath) return;
|
||||
setLoading(true, "Fetching Gitea and building a safe synchronization plan…");
|
||||
try {
|
||||
ui.workspaceSyncPlan = await window.forgeflow.previewWorkspaceSync(
|
||||
repository.localPath,
|
||||
);
|
||||
ui.modal = { type: "workspace-sync" };
|
||||
showToast(
|
||||
ui.workspaceSyncPlan.needsSync
|
||||
? "Workspace sync preview ready"
|
||||
: "Workspace already synchronized",
|
||||
ui.workspaceSyncPlan.needsSync
|
||||
? `${ui.workspaceSyncPlan.summary.resultingTrackedChanges} tracked change(s) and ${ui.workspaceSyncPlan.summary.localFilesToStash} local file(s) reviewed.`
|
||||
: `Local ${ui.workspaceSyncPlan.branch} already matches ${ui.workspaceSyncPlan.upstream}.`,
|
||||
ui.workspaceSyncPlan.blockers?.length ? "error" : "success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Could not preview Gitea sync", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
render();
|
||||
} else if (action === "confirm-workspace-sync") {
|
||||
if (!repository?.localPath || !ui.workspaceSyncPlan) return;
|
||||
const expectedPlanId = target.dataset.planId;
|
||||
setLoading(true, "Protecting local work and synchronizing exact Gitea state…");
|
||||
try {
|
||||
const result = await window.forgeflow.applyWorkspaceSync(
|
||||
repository.localPath,
|
||||
expectedPlanId,
|
||||
);
|
||||
ui.modal = null;
|
||||
ui.workspaceSyncPlan = null;
|
||||
await refreshRepositories(false);
|
||||
[ui.branches, ui.stashes] = await Promise.all([
|
||||
window.forgeflow.branches(repository.localPath),
|
||||
window.forgeflow.stashList(repository.localPath),
|
||||
]);
|
||||
const recovery = [
|
||||
result.backupBranch ? `recovery branch ${result.backupBranch}` : null,
|
||||
result.stash ? `stash ${result.stash.ref}` : null,
|
||||
].filter(Boolean).join(" and ");
|
||||
showToast(
|
||||
"Workspace synchronized with Gitea",
|
||||
recovery
|
||||
? `Local work is preserved in ${recovery}. Ignored runtime files were retained.`
|
||||
: `Tracked files now match ${result.plan.upstream}; ignored runtime files were retained.`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.code === "WORKSPACE_SYNC_PLAN_STALE") {
|
||||
try {
|
||||
ui.workspaceSyncPlan = await window.forgeflow.previewWorkspaceSync(
|
||||
repository.localPath,
|
||||
);
|
||||
ui.modal = { type: "workspace-sync" };
|
||||
} catch {
|
||||
ui.modal = null;
|
||||
ui.workspaceSyncPlan = null;
|
||||
}
|
||||
}
|
||||
showToast("Workspace synchronization stopped", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
render();
|
||||
} else if (action === "repair-repository-sync") {
|
||||
if (!repository?.localPath) return;
|
||||
const strategy = target.dataset.strategy;
|
||||
|
||||
@@ -242,7 +242,18 @@ async function handleSetupAndSettingsActions(event, target, action, repository)
|
||||
"Checking SSH identity, Docker, Compose and optional Git capabilities…",
|
||||
);
|
||||
try {
|
||||
const result = await window.forgeflow.testServer(target.dataset.serverId);
|
||||
let result = await window.forgeflow.testServer(target.dataset.serverId);
|
||||
if (result.needsTrust) {
|
||||
const approved = confirm(
|
||||
`Verify this fingerprint on the SSH server before trusting it:\n\n${result.fingerprint}\n\nServer: ${result.server.host}:${result.server.port}\n\nTrust this exact host identity and continue with authentication?`,
|
||||
);
|
||||
if (!approved) {
|
||||
showToast("SSH trust cancelled", "No credentials were sent and the host identity was not saved.", "info");
|
||||
setLoading(false);
|
||||
return true;
|
||||
}
|
||||
result = await window.forgeflow.testServer(target.dataset.serverId, result.fingerprint);
|
||||
}
|
||||
ui.boot.state = result.state;
|
||||
const capabilities = result.capabilities || {};
|
||||
const deploymentReady =
|
||||
@@ -329,6 +340,9 @@ async function handleSetupAndSettingsActions(event, target, action, repository)
|
||||
operationPollSeconds: Number(
|
||||
document.querySelector("#pref-operation-poll").value,
|
||||
),
|
||||
fetchIntervalMinutes: Number(
|
||||
document.querySelector("#pref-fetch-interval").value,
|
||||
),
|
||||
preferredCloneProtocol: document.querySelector("#pref-clone-protocol")
|
||||
.value,
|
||||
};
|
||||
|
||||
@@ -188,6 +188,7 @@ const ui = {
|
||||
servers: [],
|
||||
serverInspection: null,
|
||||
gitRecovery: null,
|
||||
workspaceSyncPlan: null,
|
||||
gitValidation: null,
|
||||
diffHunks: null,
|
||||
conflictState: null,
|
||||
@@ -607,6 +608,7 @@ function selectRepository(id, shouldRender = true) {
|
||||
ui.branches = [];
|
||||
ui.stashes = [];
|
||||
ui.gitRecovery = null;
|
||||
ui.workspaceSyncPlan = null;
|
||||
ui.gitValidation = null;
|
||||
ui.branchProtection = null;
|
||||
const repository = selectedRepository();
|
||||
|
||||
@@ -44,6 +44,20 @@ function renderModal() {
|
||||
];
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true" aria-labelledby="reconciliation-title"><header class="modal-header"><h2 id="reconciliation-title">Review server reconciliation</h2><button class="icon-button" data-action="close-modal" aria-label="Close reconciliation preview">${icon("close")}</button></header><div class="modal-body"><div class="notice success">${icon("shield")}This reviewed plan may update ForgeFlow configuration only. It never starts, stops or recreates containers, and stale profiles are never removed automatically.</div><div class="summary-grid" style="margin-top:12px"><div class="summary-card"><span>New links</span><strong>${Number(summary.additions || 0)}</strong></div><div class="summary-card"><span>Refreshes</span><strong>${Number(summary.updates || 0)}</strong></div><div class="summary-card"><span>Stale reviews</span><strong>${Number(summary.stale || 0)}</strong></div><div class="summary-card"><span>Conflicts</span><strong>${Number(summary.conflicts || 0)}</strong></div></div><div class="tool-list" style="margin-top:14px">${rows.length ? rows.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.title)}</strong><span>${escapeHtml(item.detail)}</span></div>${item.tone ? `<span class="status-pill ${item.tone}">${escapeHtml(item.tone === "success" ? "Planned" : item.tone === "warning" ? "Review" : "Blocked")}</span>` : ""}</div>`).join("") : '<div class="empty-state compact"><p>No configuration changes are proposed.</p></div>'}</div><div class="notice" style="margin-top:12px">${icon("archive")}A private recovery snapshot is written before the plan is applied. Plan ID: <span class="mono">${escapeHtml(String(plan.id || "").slice(0, 12))}</span></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="apply-server-reconciliation" data-server-id="${attr(plan.serverId || "")}" data-plan-id="${attr(plan.id || "")}" ${summary.conflicts ? "disabled title=\"Resolve ambiguous workloads manually before applying reconciliation\"" : ""}>Apply reviewed plan</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "workspace-sync") {
|
||||
const plan = ui.workspaceSyncPlan;
|
||||
if (!plan) return "";
|
||||
const summary = plan.summary || {};
|
||||
const blocked = Boolean(plan.blockers?.length);
|
||||
const changeRows = (plan.changes || []).map((change) => `<div class="tool-row"><div><strong>${escapeHtml(change.path)}</strong><span>${change.originalPath ? `${escapeHtml(change.originalPath)} → ` : ""}${escapeHtml(change.status)}</span></div><span class="status-pill ${change.code === "D" ? "danger" : change.code === "A" ? "success" : "warning"}">${escapeHtml(change.code)}</span></div>`).join("");
|
||||
const recoveryRows = [
|
||||
plan.recovery?.safetyBranch ? "Local commits → recovery branch" : "No local commits require a recovery branch",
|
||||
plan.recovery?.stash ? "Modified and untracked files → named Git stash" : "No working-tree files require a stash",
|
||||
plan.recovery?.untrackedCleanup ? "Untracked files are removed after they are stashed" : "No untracked cleanup required",
|
||||
"Ignored runtime files remain in place",
|
||||
];
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true" aria-labelledby="workspace-sync-title"><header class="modal-header"><h2 id="workspace-sync-title">Review Gitea workspace sync</h2><button class="icon-button" data-action="close-modal" aria-label="Close workspace sync preview">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero ${blocked ? "danger" : plan.needsSync ? "" : "success"}">${icon(blocked ? "error" : "shield")}<div><strong>${blocked ? "Synchronization is blocked" : plan.needsSync ? `${escapeHtml(plan.branch)} will match ${escapeHtml(plan.upstream)}` : "Workspace already matches Gitea"}</strong><span>${shortSha(plan.currentSha)} → ${shortSha(plan.targetSha)} · reviewed plan ${escapeHtml(plan.id.slice(0, 12))}</span></div></div>${blocked ? `<div class="notice danger" style="margin-top:12px">${icon("error")}<div><strong>Resolve before applying</strong><p>${escapeHtml(plan.blockers.join(" "))}</p></div></div>` : ""}<div class="summary-grid" style="margin-top:12px"><div class="summary-card"><span>Incoming commits</span><strong>${Number(summary.incomingCommits || 0)}</strong></div><div class="summary-card"><span>Tracked file changes</span><strong>${Number(summary.resultingTrackedChanges || 0)}</strong></div><div class="summary-card"><span>Files removed by sync</span><strong>${Number(summary.deleted || 0)}</strong></div><div class="summary-card"><span>Local files protected</span><strong>${Number(summary.localFilesToStash || 0)}</strong></div><div class="summary-card"><span>Local commits protected</span><strong>${Number(summary.localCommitsToProtect || 0)}</strong></div></div><section class="settings-group" style="margin-top:14px"><h3>Recovery contract</h3><ul>${recoveryRows.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul><div class="notice success">${icon("archive")}ForgeFlow never reapplies saved local work automatically. You can review the recovery branch or stash later, file by file.</div></section><section class="settings-group"><div class="section-heading"><div><h3>Resulting tracked changes</h3><span class="meta">${summary.added || 0} added · ${summary.modified || 0} modified · ${summary.deleted || 0} deleted · ${summary.renamed || 0} renamed</span></div></div><div class="tool-list">${changeRows || '<div class="empty-state compact"><p>No tracked file changes between local HEAD and Gitea.</p></div>'}</div>${plan.changesTruncated ? '<p class="meta">Only the first 250 paths are shown. Counts include the complete plan.</p>' : ""}</section></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="confirm-workspace-sync" data-plan-id="${attr(plan.id)}" ${blocked || !plan.needsSync ? "disabled" : ""}>Protect local work & synchronize</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "workload-link") {
|
||||
const serverResult = (ui.serverDiscovery || []).find(
|
||||
(item) => item.serverId === ui.modal.serverId,
|
||||
|
||||
@@ -5,7 +5,7 @@ function createMockRepositoryBridge(context) {
|
||||
await wait(80);
|
||||
snapshot();
|
||||
return {
|
||||
appVersion: "0.10.12-demo",
|
||||
appVersion: "0.10.13-demo",
|
||||
platform: "win32",
|
||||
state: clone(state),
|
||||
git: { available: true, version: "git version 2.47.3" },
|
||||
@@ -427,6 +427,71 @@ function createMockRepositoryBridge(context) {
|
||||
emitRepositories();
|
||||
return { output: "Fast-forwarded.", status: clone(repo.localStatus) };
|
||||
},
|
||||
async previewWorkspaceSync(localPath) {
|
||||
await wait(260);
|
||||
const repo = findRepo(localPath);
|
||||
const status = repo.localStatus;
|
||||
const targetSha = status.branch.behind ? "f".repeat(40) : status.head;
|
||||
return {
|
||||
id: `demo-${String(status.head).slice(0, 7)}-${status.branch.ahead}-${status.branch.behind}`.padEnd(64, "0").slice(0, 64),
|
||||
branch: status.branch.head,
|
||||
upstream: status.branch.upstream || `origin/${status.branch.head}`,
|
||||
currentSha: status.head,
|
||||
targetSha,
|
||||
needsSync: !status.clean || status.head !== targetSha || status.branch.ahead > 0,
|
||||
blockers: [],
|
||||
summary: {
|
||||
resultingTrackedChanges: status.branch.behind ? 3 : 0,
|
||||
added: status.branch.behind ? 1 : 0,
|
||||
modified: status.branch.behind ? 1 : 0,
|
||||
deleted: status.branch.behind ? 1 : 0,
|
||||
renamed: 0,
|
||||
localFilesToStash: status.counts.changed,
|
||||
untrackedFilesToStash: status.counts.untracked,
|
||||
localCommitsToProtect: status.branch.ahead,
|
||||
incomingCommits: status.branch.behind,
|
||||
},
|
||||
changes: status.branch.behind
|
||||
? [
|
||||
{ code: "A", status: "added", path: "src/remote-feature.js" },
|
||||
{ code: "M", status: "modified", path: "README.md" },
|
||||
{ code: "D", status: "deleted", path: "docs/obsolete.md" },
|
||||
]
|
||||
: [],
|
||||
localFiles: clone(status.files),
|
||||
incomingCommits: [],
|
||||
localCommits: [],
|
||||
recovery: {
|
||||
safetyBranch: status.branch.ahead > 0,
|
||||
stash: status.counts.changed > 0,
|
||||
untrackedCleanup: status.counts.untracked > 0,
|
||||
ignoredFilesPreserved: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
async applyWorkspaceSync(localPath, expectedPlanId) {
|
||||
const plan = await this.previewWorkspaceSync(localPath);
|
||||
if (plan.id !== expectedPlanId) throw new Error("The workspace sync preview is stale.");
|
||||
const repo = findRepo(localPath);
|
||||
const hadChanges = repo.localStatus.counts.changed > 0;
|
||||
repo.localStatus.head = plan.targetSha;
|
||||
repo.localStatus.shortHead = plan.targetSha.slice(0, 7);
|
||||
repo.localStatus.files = [];
|
||||
repo.localStatus.branch.ahead = 0;
|
||||
repo.localStatus.branch.behind = 0;
|
||||
recompute(repo);
|
||||
emitRepositories();
|
||||
return {
|
||||
applied: plan.needsSync,
|
||||
unchanged: !plan.needsSync,
|
||||
plan,
|
||||
status: clone(repo.localStatus),
|
||||
backupBranch: plan.summary.localCommitsToProtect ? `forgeflow/recovery-${plan.branch}-demo` : null,
|
||||
stash: hadChanges ? { ref: "stash@{0}", shortSha: "demo123", subject: "ForgeFlow workspace sync" } : null,
|
||||
ignoredFilesPreserved: true,
|
||||
cleaned: [],
|
||||
};
|
||||
},
|
||||
async history() {
|
||||
await wait(100);
|
||||
return clone(commitHistory);
|
||||
@@ -556,6 +621,85 @@ function createMockRepositoryBridge(context) {
|
||||
stashes: clone(list),
|
||||
};
|
||||
},
|
||||
async gitRecoveryStatus(localPath) {
|
||||
const repo = findRepo(localPath);
|
||||
const status = clone(repo.localStatus);
|
||||
const upstream = status.branch?.upstream;
|
||||
const recommendations = [
|
||||
{
|
||||
id: "fetch",
|
||||
label: "Fetch and recalculate remote state",
|
||||
action: "fetch",
|
||||
safe: true,
|
||||
},
|
||||
];
|
||||
if (
|
||||
status.clean &&
|
||||
status.branch.behind > 0 &&
|
||||
status.branch.ahead === 0 &&
|
||||
upstream
|
||||
) {
|
||||
recommendations.push({
|
||||
id: "pull",
|
||||
label: `Fast-forward from ${upstream}`,
|
||||
action: "fast-forward",
|
||||
safe: true,
|
||||
});
|
||||
}
|
||||
if (
|
||||
status.branch.ahead > 0 &&
|
||||
status.branch.behind === 0 &&
|
||||
upstream
|
||||
) {
|
||||
recommendations.push({
|
||||
id: "push",
|
||||
label: `Push ${status.branch.ahead} local commit(s)`,
|
||||
action: "push",
|
||||
safe: true,
|
||||
});
|
||||
}
|
||||
return {
|
||||
status,
|
||||
lockReport: {
|
||||
root: localPath,
|
||||
gitDir: `${localPath}\\.git`,
|
||||
locks: [],
|
||||
processes: { available: true, active: [] },
|
||||
},
|
||||
recommendations,
|
||||
};
|
||||
},
|
||||
async reconcileRepository(localPath) {
|
||||
await wait(160);
|
||||
return this.gitRecoveryStatus(localPath);
|
||||
},
|
||||
async repairGitLocks(localPath) {
|
||||
return {
|
||||
...(await this.gitRecoveryStatus(localPath)).lockReport,
|
||||
removed: [],
|
||||
skipped: [],
|
||||
repaired: false,
|
||||
};
|
||||
},
|
||||
async repairRepositorySync(localPath, strategy) {
|
||||
const repo = findRepo(localPath);
|
||||
if (strategy === "fast-forward") {
|
||||
repo.localStatus.branch.behind = 0;
|
||||
repo.localStatus.head = "f".repeat(40);
|
||||
} else if (strategy === "push") {
|
||||
repo.localStatus.branch.ahead = 0;
|
||||
} else if (strategy !== "fetch") {
|
||||
throw new Error("Unsupported demo synchronization strategy.");
|
||||
}
|
||||
recompute(repo);
|
||||
emitRepositories();
|
||||
return {
|
||||
strategy,
|
||||
backupBranch: null,
|
||||
status: clone(repo.localStatus),
|
||||
lockReport: (await this.gitRecoveryStatus(localPath)).lockReport,
|
||||
};
|
||||
},
|
||||
async indexLockInfo() {
|
||||
return { exists: false, ageMs: 0 };
|
||||
},
|
||||
|
||||
@@ -648,6 +648,24 @@ select:focus-visible {
|
||||
right: 14px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.modal .summary-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
|
||||
}
|
||||
.modal .summary-card {
|
||||
min-height: 108px;
|
||||
}
|
||||
.modal .summary-card > span {
|
||||
display: block;
|
||||
max-width: 12ch;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.modal .summary-card > strong {
|
||||
display: block;
|
||||
margin-top: 15px;
|
||||
font: 700 27px/1 var(--font-sans);
|
||||
color: var(--text);
|
||||
}
|
||||
.summary-card.warning .summary-value,
|
||||
.summary-card.warning .icon {
|
||||
color: var(--warning);
|
||||
@@ -2653,6 +2671,7 @@ kbd {
|
||||
.git-tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-rows: max-content;
|
||||
gap: 15px;
|
||||
align-items: start;
|
||||
}
|
||||
@@ -3318,6 +3337,33 @@ html[data-theme="light"] .setup-brand-logo-light {
|
||||
.git-tools-grid .troubleshooting-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.git-tools-grid .workspace-sync-panel {
|
||||
grid-column: 1 / -1;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 88% 10%, color-mix(in srgb, var(--accent) 15%, transparent), transparent 34%),
|
||||
var(--surface-1);
|
||||
}
|
||||
.workspace-sync-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
.workspace-sync-layout h3 {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.workspace-sync-layout p {
|
||||
margin: 0 0 13px;
|
||||
color: var(--text-muted);
|
||||
max-width: 820px;
|
||||
}
|
||||
.workspace-sync-actions {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 10px;
|
||||
min-width: 220px;
|
||||
}
|
||||
.troubleshooting-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3351,6 +3397,13 @@ html[data-theme="light"] .setup-brand-logo-light {
|
||||
margin-left: auto;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.workspace-sync-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.workspace-sync-actions {
|
||||
justify-items: stretch;
|
||||
min-width: 0;
|
||||
}
|
||||
.repo-quick-actions {
|
||||
margin-inline: 12px;
|
||||
}
|
||||
|
||||
+26
-5
@@ -183,7 +183,7 @@ function renderOverview() {
|
||||
${readinessRow("Git executable", ui.boot.git.available, ui.boot.git.version || ui.boot.git.error)}
|
||||
${readinessRow("Gitea connection", ui.boot.state.gitea.hasToken, ui.boot.state.gitea.baseUrl || "Not configured")}
|
||||
${readinessRow("Workspace folders", ui.boot.state.workspaceRoots.length > 0, `${ui.boot.state.workspaceRoots.length} configured`)}
|
||||
${readinessRow("Automatic awareness", ui.boot.state.preferences?.autoRefresh !== false, ui.boot.state.preferences?.autoRefresh === false ? "Manual refresh only" : `Every ${ui.boot.state.preferences?.repositoryPollSeconds || 4}s`)}
|
||||
${readinessRow("Automatic awareness", ui.boot.state.preferences?.autoRefresh !== false, ui.boot.state.preferences?.autoRefresh === false ? "Manual refresh only" : `Local every ${ui.boot.state.preferences?.repositoryPollSeconds || 4}s · Gitea every ${ui.boot.state.preferences?.fetchIntervalMinutes || "manual"}${ui.boot.state.preferences?.fetchIntervalMinutes ? " min" : ""}`)}
|
||||
</div></div>
|
||||
</section>
|
||||
</div>`;
|
||||
@@ -361,7 +361,28 @@ function renderGitTools(repository) {
|
||||
const locks = recovery?.lockReport?.locks || [];
|
||||
const activeProcesses = recovery?.lockReport?.processes?.active || [];
|
||||
const recommendations = recovery?.recommendations || [];
|
||||
return `<div class="tab-page git-tools-grid"><section class="panel"><div class="panel-header"><h2>Branches</h2><button class="button ghost" data-action="load-git-tools">${icon("refresh")}Refresh</button></div><div class="panel-body"><div class="inline-form"><input id="new-branch-name" class="input" placeholder="feature/name"/><button class="button" data-action="create-branch">${icon("plus")}Create & switch</button></div><div class="tool-list">${ui.branches.length ? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : " · unpublished"}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join("") : '<div class="empty-state compact"><p>Load branch information.</p></div>'}</div></div></section><section class="panel"><div class="panel-header"><h2>Stashes</h2><button class="button" data-action="stash-changes" ${repository.localStatus?.clean ? "disabled" : ""}>${icon("archive")}Stash changes</button></div><div class="panel-body"><div class="tool-list">${ui.stashes.length ? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div><button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button></div>`).join("") : '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>'}</div></div></section><section class="panel troubleshooting-panel"><div class="panel-header"><div><h2>Repository troubleshooting</h2><span class="meta">Safe, repository-specific recovery actions</span></div><button class="button primary" data-action="scan-git-recovery">${icon("pulse")}Scan</button></div><div class="panel-body">${recovery ? `<div class="troubleshooting-summary"><span class="status-pill ${locks.length ? "warning" : "success"}">${locks.length ? `${locks.length} lock${locks.length === 1 ? "" : "s"}` : "No Git locks"}</span><span>${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : "No matching active Git process detected"}</span></div>${locks.length ? `<div class="tool-list">${locks.map((lock) => `<div class="tool-row"><div><strong>${escapeHtml(lock.name)}</strong><span>${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}</span></div></div>`).join("")}</div>` : ""}${recommendations.length ? `<div class="tool-list recovery-actions">${recommendations.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.label)}</strong><span>${item.safe ? "Safe automated action" : item.action ? "Creates a safety branch before changing history" : "Review required"}</span></div>${item.action ? `<button class="button ${item.safe ? "" : "danger"}" data-action="repair-repository-sync" data-strategy="${attr(item.action)}">Run</button>` : ""}</div>`).join("")}</div>` : ""}` : '<div class="empty-state compact"><p>Scan before repairing. ForgeFlow checks every .lock file in the actual Git directory, not only index.lock.</p></div>'}<div class="card-actions"><button class="button" data-action="repair-git-locks">${icon("wrench")}Repair proven stale locks</button><button class="button" data-action="reconcile-repository">${icon("refresh")}Refresh Git state</button>${repository.sshUrl && repository.localStatus?.remoteUrl !== repository.sshUrl ? `<button class="button" data-action="repair-origin">${icon("link")}Repair origin</button>` : ""}</div><div class="notice warning">Lock repair refuses to run while a matching Git process is active. A force option is shown only when process detection itself is unavailable.</div></div></section></div>`;
|
||||
const status = repository.localStatus || {};
|
||||
const branchRows = ui.branches.length
|
||||
? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : " · unpublished"}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join("")
|
||||
: '<div class="empty-state compact"><p>Load branch information.</p></div>';
|
||||
const stashRows = ui.stashes.length
|
||||
? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div><button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button></div>`).join("")
|
||||
: '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>';
|
||||
const recoveryBody = recovery
|
||||
? `<div class="troubleshooting-summary"><span class="status-pill ${locks.length ? "warning" : "success"}">${locks.length ? `${locks.length} lock${locks.length === 1 ? "" : "s"}` : "No Git locks"}</span><span>${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : "No matching active Git process detected"}</span></div>${locks.length ? `<div class="tool-list">${locks.map((lock) => `<div class="tool-row"><div><strong>${escapeHtml(lock.name)}</strong><span>${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}</span></div></div>`).join("")}</div>` : ""}${recommendations.length ? `<div class="tool-list recovery-actions">${recommendations.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.label)}</strong><span>${item.safe ? "Safe automated action" : item.action ? "Creates a safety branch before changing history" : "Review required"}</span></div>${item.action ? `<button class="button ${item.safe ? "" : "danger"}" data-action="repair-repository-sync" data-strategy="${attr(item.action)}">Run</button>` : ""}</div>`).join("")}</div>` : ""}`
|
||||
: '<div class="empty-state compact"><p>Scan before repairing. ForgeFlow checks every .lock file in the actual Git directory, not only index.lock.</p></div>';
|
||||
const syncState = status.counts?.changed
|
||||
? `${status.counts.changed} local file${status.counts.changed === 1 ? "" : "s"} need protection`
|
||||
: status.branch?.ahead || status.branch?.behind
|
||||
? `${status.branch.ahead || 0} ahead · ${status.branch.behind || 0} behind`
|
||||
: "Preview against Gitea before changing files";
|
||||
|
||||
return `<div class="tab-page git-tools-grid">
|
||||
<section class="panel"><div class="panel-header"><h2>Branches</h2><button class="button ghost" data-action="load-git-tools">${icon("refresh")}Refresh</button></div><div class="panel-body"><div class="inline-form"><input id="new-branch-name" class="input" placeholder="feature/name"/><button class="button" data-action="create-branch">${icon("plus")}Create & switch</button></div><div class="tool-list">${branchRows}</div></div></section>
|
||||
<section class="panel"><div class="panel-header"><h2>Stashes</h2><button class="button" data-action="stash-changes" ${status.clean ? "disabled" : ""}>${icon("archive")}Stash changes</button></div><div class="panel-body"><div class="tool-list">${stashRows}</div></div></section>
|
||||
<section class="panel workspace-sync-panel"><div class="panel-header"><div><h2>Gitea workspace sync</h2><span class="meta">Make tracked files match the current upstream branch exactly</span></div><span class="status-pill ${status.branch?.behind || status.branch?.ahead || status.counts?.changed ? "warning" : "success"}">${escapeHtml(syncState)}</span></div><div class="panel-body"><div class="workspace-sync-layout"><div><h3>Safe mirror, never silent overwrite</h3><p>ForgeFlow fetches Gitea, previews additions, changes and deletions, then protects local Codex work before resetting. Local commits go to a recovery branch; modified and untracked files go to a stash.</p><div class="notice">${icon("shield")}Ignored runtime data such as <span class="mono">.env</span>, dependency folders and local databases is preserved. Background awareness only fetches; it never applies this sync automatically.</div></div><div class="workspace-sync-actions"><span class="meta">${escapeHtml(status.branch?.head || "No branch")} → ${escapeHtml(status.branch?.upstream || "No upstream")}</span><button class="button primary" data-action="preview-workspace-sync">${icon("refresh")}Preview Gitea sync</button></div></div></div></section>
|
||||
<section class="panel troubleshooting-panel"><div class="panel-header"><div><h2>Repository troubleshooting</h2><span class="meta">Safe, repository-specific recovery actions</span></div><button class="button primary" data-action="scan-git-recovery">${icon("pulse")}Scan</button></div><div class="panel-body">${recoveryBody}<div class="card-actions"><button class="button" data-action="repair-git-locks">${icon("wrench")}Repair proven stale locks</button><button class="button" data-action="reconcile-repository">${icon("refresh")}Refresh Git state</button>${repository.sshUrl && status.remoteUrl !== repository.sshUrl ? `<button class="button" data-action="repair-origin">${icon("link")}Repair origin</button>` : ""}</div><div class="notice warning">Lock repair refuses to run while a matching Git process is active. A force option is shown only when process detection itself is unavailable.</div></div></section>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderRepositorySettings(repository) {
|
||||
@@ -573,7 +594,7 @@ function renderServerInventory() {
|
||||
const repository = ui.repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase());
|
||||
return !repository?.deploymentProfiles?.some((profile) => profile.id === workload.link?.profileId);
|
||||
}).length;
|
||||
return `<section class="panel server-inventory-panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.running || 0} running · ${resolvedLinks} visible repository links${unresolvedLinks ? ` · ${unresolvedLinks} unresolved` : ""} · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}</span></div><div class="stack horizontal compact"><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span>${server.error ? "" : `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">${icon("shield")}Review reconciliation</button>`}</div></div><div class="panel-body">${errorBlock}${warnings}<div class="tool-list">${workloads}</div></div></section>`;
|
||||
return `<section class="panel server-inventory-panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.running || 0} running · ${resolvedLinks} visible repository link${resolvedLinks === 1 ? "" : "s"}${unresolvedLinks ? ` · ${unresolvedLinks} unresolved` : ""} · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}</span></div><div class="stack horizontal compact"><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span>${server.error ? "" : `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">${icon("shield")}Review reconciliation</button>`}</div></div><div class="panel-body">${errorBlock}${warnings}<div class="tool-list">${workloads}</div></div></section>`;
|
||||
}).join("");
|
||||
const empty = configuredServers.length
|
||||
? `<div class="empty-state panel"><h3>Server inventory has not completed</h3><p>ForgeFlow will query Docker directly. A failed connection is shown explicitly instead of being reported as zero deployments.</p><button class="button primary" data-action="scan-server-inventory">Scan servers now</button></div>`
|
||||
@@ -602,10 +623,10 @@ function renderSettings() {
|
||||
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"><div class="section-heading"><div><h2>SSH / Unraid servers</h2><span class="meta">Credentials are entered locally and encrypted with the Windows credential protection used by Electron.</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)}">Test & trust</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>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>
|
||||
<section class="settings-group"><h2>Background awareness</h2><div class="form-grid"><div class="field"><label>Automatic repository refresh</label><select id="pref-auto-refresh" class="select"><option value="true" ${prefs.autoRefresh !== false ? "selected" : ""}>Enabled</option><option value="false" ${prefs.autoRefresh === false ? "selected" : ""}>Disabled</option></select></div><div class="field"><label>Local poll interval</label><input id="pref-repo-poll" class="input" type="number" min="2" max="60" value="${attr(prefs.repositoryPollSeconds || 4)}"/></div><div class="field"><label>Actions poll interval</label><input id="pref-operation-poll" class="input" type="number" min="3" max="120" value="${attr(prefs.operationPollSeconds || 5)}"/></div><div class="field"><label>Preferred clone protocol</label><select id="pref-clone-protocol" class="select"><option value="https" ${prefs.preferredCloneProtocol !== "ssh" ? "selected" : ""}>HTTPS</option><option value="ssh" ${prefs.preferredCloneProtocol === "ssh" ? "selected" : ""}>SSH</option></select></div></div><button class="button primary" style="margin-top:12px" data-action="save-preferences">Save awareness settings</button></section>
|
||||
<section class="settings-group"><h2>Background awareness</h2><div class="form-grid"><div class="field"><label>Automatic repository refresh</label><select id="pref-auto-refresh" class="select"><option value="true" ${prefs.autoRefresh !== false ? "selected" : ""}>Enabled</option><option value="false" ${prefs.autoRefresh === false ? "selected" : ""}>Disabled</option></select></div><div class="field"><label>Local poll interval</label><input id="pref-repo-poll" class="input" type="number" min="2" max="60" value="${attr(prefs.repositoryPollSeconds || 4)}"/></div><div class="field"><label>Gitea fetch interval (minutes)</label><input id="pref-fetch-interval" class="input" type="number" min="0" max="240" value="${attr(Number.isFinite(Number(prefs.fetchIntervalMinutes)) ? prefs.fetchIntervalMinutes : 10)}"/><small>Read-only remote awareness. Use 0 to disable; fetching never changes project files.</small></div><div class="field"><label>Actions poll interval</label><input id="pref-operation-poll" class="input" type="number" min="3" max="120" value="${attr(prefs.operationPollSeconds || 5)}"/></div><div class="field"><label>Preferred clone protocol</label><select id="pref-clone-protocol" class="select"><option value="https" ${prefs.preferredCloneProtocol !== "ssh" ? "selected" : ""}>HTTPS</option><option value="ssh" ${prefs.preferredCloneProtocol === "ssh" ? "selected" : ""}>SSH</option></select></div></div><div class="notice" style="margin-top:12px">${icon("shield")}Remote awareness only fetches branch metadata. ForgeFlow never resets, cleans or overwrites a workspace in the background.</div><button class="button primary" style="margin-top:12px" data-action="save-preferences">Save awareness settings</button></section>
|
||||
<section class="settings-group"><h2>Desktop integration</h2><div class="form-grid"><div class="field"><label>Editor executable</label><input id="pref-editor-executable" class="input" value="${attr(prefs.editor?.executable || "code")}"/></div><div class="field"><label>Editor arguments</label><input id="pref-editor-args" class="input" value="${attr((prefs.editor?.args || ["--reuse-window", "--goto", "{file}:{line}"]).join(" | "))}"/><small>Separate arguments with |. Placeholders: {path}, {file}, {line}</small></div><div class="field"><label>Terminal executable</label><input id="pref-terminal-executable" class="input" value="${attr(prefs.terminal?.executable || "wt.exe")}"/></div><div class="field"><label>Terminal arguments</label><input id="pref-terminal-args" class="input" value="${attr((prefs.terminal?.args || ["-d", "{path}"]).join(" | "))}"/></div><label class="check-field"><input id="pref-notifications" type="checkbox" ${prefs.notificationsEnabled !== false ? "checked" : ""}/><span>Native deployment notifications</span></label><label class="check-field"><input id="pref-tray" type="checkbox" ${prefs.trayEnabled !== false ? "checked" : ""}/><span>Show system tray icon</span></label><label class="check-field"><input id="pref-close-tray" type="checkbox" ${prefs.closeToTray === true ? "checked" : ""}/><span>Hide to tray when closing</span></label><label class="check-field"><input id="pref-login" type="checkbox" ${prefs.startAtLogin === true ? "checked" : ""}/><span>Start ForgeFlow at login</span></label></div><button class="button primary" data-action="save-desktop-preferences">Save desktop integration</button></section>
|
||||
<section class="settings-group"><h2>Encrypted configuration backup</h2><p>Repository mappings, servers, deployment profiles and preferences are encrypted. Tokens, passwords, passphrases and operation history are never exported.</p><div class="inline-form"><input id="backup-passphrase" class="input" type="password" minlength="12" placeholder="Passphrase of at least 12 characters"/><button class="button" data-action="export-config-backup">Export</button><button class="button" data-action="import-config-backup">Import</button></div></section>
|
||||
<section class="settings-group"><h2>Appearance</h2><div class="field"><label for="appearance-select">Color theme</label><select id="appearance-select" class="select"><option value="dark" ${state.appearance === "dark" ? "selected" : ""}>Dark</option><option value="light" ${state.appearance === "light" ? "selected" : ""}>Light</option><option value="system" ${state.appearance === "system" ? "selected" : ""}>Follow system</option></select></div></section>
|
||||
|
||||
@@ -8,6 +8,10 @@ function normalizeBaseUrl(value) {
|
||||
const url = new URL(raw);
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported.');
|
||||
if (url.username || url.password) throw new Error('Do not include credentials in the Gitea URL.');
|
||||
const loopback = new Set(['localhost', '127.0.0.1', '[::1]']);
|
||||
if (url.protocol !== 'https:' && !loopback.has(url.hostname.toLowerCase())) {
|
||||
throw new Error('Gitea must use HTTPS so access tokens are never sent over plaintext HTTP. Loopback HTTP is allowed for local development only.');
|
||||
}
|
||||
url.hash = '';
|
||||
url.search = '';
|
||||
return url.toString().replace(/\/$/, '');
|
||||
|
||||
Reference in New Issue
Block a user