Release ForgeFlow 0.5.2

This commit is contained in:
NuklearRabbit
2026-07-25 01:07:54 +02:00
parent 602309b203
commit cf1f67a823
23 changed files with 550 additions and 72 deletions
+49 -3
View File
@@ -57,6 +57,50 @@ class GitService {
return result.stdout.trim();
}
pathspecInput(paths) {
const selected = assertRepositoryRelativePaths(paths);
return selected.length ? `${selected.join('\0')}\0` : '';
}
async runWithPathspec(root, args, paths, options = {}) {
const selected = assertRepositoryRelativePaths(paths);
if (!selected.length) return run('git', args, { cwd: root, ...options });
return run('git', [...args, '--pathspec-from-file=-', '--pathspec-file-nul'], {
cwd: root,
input: this.pathspecInput(selected),
...options
});
}
async getIndexLockInfo(repoPath) {
const root = await this.ensureRepository(repoPath);
const lockPath = path.join(root, '.git', 'index.lock');
const stat = await fs.stat(lockPath).catch(() => null);
return stat ? { exists: true, lockPath, ageMs: Math.max(0, Date.now() - stat.mtimeMs) } : { exists: false, lockPath, ageMs: 0 };
}
async removeStaleIndexLock(repoPath, minimumAgeMs = 30_000) {
const info = await this.getIndexLockInfo(repoPath);
if (!info.exists) return { removed: false, reason: 'missing', ...info };
if (info.ageMs < minimumAgeMs) {
const error = new Error('The Git index lock is recent. Close other Git tools and try again before removing it.');
error.code = 'INDEX_LOCK_RECENT';
throw error;
}
await fs.rm(info.lockPath, { force: true });
return { removed: true, ...info };
}
async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') {
const root = await this.ensureRepository(repoPath);
const safeRemote = assertCloneRemote(remoteUrl);
const name = String(remote || 'origin').trim();
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error('Invalid Git remote name.');
await run('git', ['remote', 'set-url', name, safeRemote], { cwd: root, timeout: 30_000 });
return this.status(root);
}
async diff(repoPath, filePath, staged = false) {
const root = await this.ensureRepository(repoPath);
const safeFile = filePath ? assertRepositoryRelativePath(filePath) : '';
@@ -109,7 +153,7 @@ class GitService {
// renames are already ready for commit and must therefore be left alone.
const selected = await this.expandSelectedPaths(root, requested, { unstagedOnly: true });
if (selected.length) {
await run('git', ['add', '-A', '--', ...selected], { cwd: root, timeout: 60_000 });
await this.runWithPathspec(root, ['add', '-A'], selected, { timeout: 120_000 });
}
return this.status(root);
}
@@ -119,9 +163,11 @@ class GitService {
const selected = await this.expandSelectedPaths(root, files);
const hasHead = await run('git', ['rev-parse', '--verify', 'HEAD'], { cwd: root, allowExitCodes: [128] });
if (hasHead.exitCode === 0) {
await run('git', selected.length ? ['restore', '--staged', '--', ...selected] : ['restore', '--staged', '.'], { cwd: root });
if (selected.length) await this.runWithPathspec(root, ['restore', '--staged'], selected, { timeout: 120_000 });
else await run('git', ['restore', '--staged', '.'], { cwd: root });
} else {
await run('git', selected.length ? ['rm', '--cached', '--ignore-unmatch', '--', ...selected] : ['rm', '--cached', '-r', '.'], { cwd: root, allowExitCodes: [1] });
if (selected.length) await this.runWithPathspec(root, ['rm', '--cached', '--ignore-unmatch'], selected, { timeout: 120_000, allowExitCodes: [1] });
else await run('git', ['rm', '--cached', '-r', '.'], { cwd: root, allowExitCodes: [1] });
}
return this.status(root);
}
+39 -11
View File
@@ -55,11 +55,20 @@ function register(channel, handler) {
function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, monitor }) {
diagnosticsService = diagnostics;
const repositoryMutations = new Map();
const withRepositoryPause = async (localPath, action) => {
monitor?.pause(localPath);
try { return await action(); }
finally { monitor?.resume(localPath); }
};
const withRepositoryMutation = async (localPath, action) => {
const key = path.resolve(localPath);
const previous = repositoryMutations.get(key) || Promise.resolve();
const current = previous.catch(() => {}).then(() => withRepositoryPause(key, action));
repositoryMutations.set(key, current);
try { return await current; }
finally { if (repositoryMutations.get(key) === current) repositoryMutations.delete(key); }
};
const canonicalPath = async (value) => {
const resolved = path.resolve(String(value || ''));
@@ -262,20 +271,39 @@ function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh
register('repository:status', async ({ localPath }) => git.status(await assertKnownRepositoryPath(localPath)));
register('repository:diff', async ({ localPath, filePath, staged }) => git.diff(await assertKnownRepositoryPath(localPath), filePath, staged));
register('repository:stage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.stage(safePath, files)); });
register('repository:unstage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.unstage(safePath, files)); });
register('repository:commit', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.commit(safePath, message, files)); });
register('repository:commit-push', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.commitAndPush(safePath, message, files)); });
register('repository:push', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.push(safePath)); });
register('repository:fetch', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.fetch(safePath)); });
register('repository:pull', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.pullFastForward(safePath)); });
register('repository:stage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stage(safePath, files)); });
register('repository:unstage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.unstage(safePath, files)); });
register('repository:commit', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commit(safePath, message, files)); });
register('repository:commit-push', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.commitAndPush(safePath, message, files)); });
register('repository:push', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.push(safePath)); });
register('repository:fetch', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.fetch(safePath)); });
register('repository:pull', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.pullFastForward(safePath)); });
register('repository:history', async ({ localPath, limit }) => git.history(await assertKnownRepositoryPath(localPath), limit));
register('repository:branches', async ({ localPath }) => git.branches(await assertKnownRepositoryPath(localPath)));
register('repository:checkout-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.checkoutBranch(safePath, branch)); });
register('repository:create-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.createBranch(safePath, branch)); });
register('repository:stash', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.stash(safePath, message)); });
register('repository:checkout-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.checkoutBranch(safePath, branch)); });
register('repository:create-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.createBranch(safePath, branch)); });
register('repository:stash', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.stash(safePath, message)); });
register('repository:stash-list', async ({ localPath }) => git.stashList(await assertKnownRepositoryPath(localPath)));
register('repository:stash-pop', async ({ localPath, ref }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.popStash(safePath, ref)); });
register('repository:stash-pop', async ({ localPath, ref }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.popStash(safePath, ref)); });
register('repository:index-lock', async ({ localPath }) => git.getIndexLockInfo(await assertKnownRepositoryPath(localPath)));
register('repository:repair-index-lock', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.removeStaleIndexLock(safePath)); });
register('repository:set-origin', async ({ localPath, remoteUrl }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryMutation(safePath, () => git.setRemoteUrl(safePath, remoteUrl)); });
register('repositories:normalize-origins', async () => {
const current = await repositories.refresh();
const changes = [];
for (const repository of current) {
if (!repository.localPath || !repository.sshUrl) continue;
const actual = await git.getRemoteUrl(repository.localPath).catch(() => '');
if (actual === repository.sshUrl) continue;
await withRepositoryMutation(repository.localPath, () => git.setRemoteUrl(repository.localPath, repository.sshUrl));
changes.push({ fullName: repository.fullName, previous: actual, next: repository.sshUrl });
}
const refreshed = await repositories.refresh();
monitor?.setPaths(repositories.getWatchPaths());
await diagnostics.info('repositories.origins.normalized', { count: changes.length, changes });
return { changes, repositories: refreshed };
});
register('repository:clone', async ({ fullName, mode = 'default' }) => {
if (!['default', 'custom'].includes(mode)) throw new Error('Unsupported clone location mode.');
+15 -2
View File
@@ -8,11 +8,12 @@ function run(command, args = [], options = {}) {
timeout = 60_000,
maxBuffer = 8 * 1024 * 1024,
env,
input = null,
allowExitCodes = []
} = options;
return new Promise((resolve, reject) => {
execFile(command, args, {
const child = execFile(command, args, {
cwd,
timeout,
maxBuffer,
@@ -21,8 +22,16 @@ function run(command, args = [], options = {}) {
env: { ...process.env, ...(env || {}) }
}, (error, stdout, stderr) => {
if (error && !allowExitCodes.includes(error.code)) {
const wrapped = new Error((stderr || stdout || error.message).trim());
const message = (stderr || stdout || error.message).trim();
const wrapped = new Error(message);
wrapped.code = error.code;
if (/\.git[\\/]index\.lock[\s\S]*File exists/i.test(message) || /Unable to create .*index\.lock/i.test(message)) {
wrapped.code = 'GIT_INDEX_LOCKED';
wrapped.recoverable = true;
} else if (error.code === 'ENAMETOOLONG') {
wrapped.code = 'GIT_ARGUMENT_LIST_TOO_LONG';
wrapped.recoverable = true;
}
wrapped.stdout = stdout;
wrapped.stderr = stderr;
wrapped.command = `${command} ${args.join(' ')}`;
@@ -31,6 +40,10 @@ function run(command, args = [], options = {}) {
}
resolve({ stdout: stdout || '', stderr: stderr || '', exitCode: error?.code || 0 });
});
if (input !== null && input !== undefined) {
child.stdin.on('error', () => {});
child.stdin.end(input);
}
});
}
+38 -7
View File
@@ -453,7 +453,10 @@ function renderGitTools(repository) {
function renderRepositorySettings(repository) {
const automaticTarget = displayCloneTarget(repository);
return `<div class="tab-page"><section class="settings-group"><h2>Repository identity</h2><div class="form-grid"><div class="field full"><label>Gitea repository</label><input class="input" value="${attr(repository.fullName)}" readonly/></div><div class="field full"><label>Local working tree</label><input class="input mono" value="${attr(repository.localPath || automaticTarget || 'Not linked')}" readonly/></div></div><div class="card-actions"><button class="button" data-action="${repository.localPath ? 'open-path' : 'link-repo'}">${icon('folder')}${repository.localPath ? 'Open project folder' : 'Link local folder'}</button>${repository.localPath ? `<button class="button danger" data-action="unlink-repo">${icon('link')}Remove link</button>` : `<button class="button primary" data-action="clone-repo">${icon('cloud')}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button>`}</div></section><section class="settings-group"><h2>Repository behavior</h2><div class="notice">${icon('shield')}ForgeFlow automatically creates a repository-named subfolder and never overwrites a non-empty conflicting folder. Existing matching clones are linked instead.</div></section></div>`;
const currentOrigin = repository.localStatus?.remoteUrl || 'Unavailable';
const desiredOrigin = repository.sshUrl || repository.preferredCloneUrl || '';
const originNeedsRepair = Boolean(repository.localPath && desiredOrigin && currentOrigin !== desiredOrigin);
return `<div class="tab-page"><section class="settings-group"><h2>Repository identity</h2><div class="form-grid"><div class="field full"><label>Gitea repository</label><input class="input" value="${attr(repository.fullName)}" readonly/></div><div class="field full"><label>Local working tree</label><input class="input mono" value="${attr(repository.localPath || automaticTarget || 'Not linked')}" readonly/></div><div class="field full"><label>Current origin</label><input class="input mono" value="${attr(currentOrigin)}" readonly/></div>${desiredOrigin ? `<div class="field full"><label>Current Gitea SSH origin</label><input class="input mono" value="${attr(desiredOrigin)}" readonly/></div>` : ''}</div><div class="card-actions"><button class="button" data-action="${repository.localPath ? 'open-path' : 'link-repo'}">${icon('folder')}${repository.localPath ? 'Open project folder' : 'Link local folder'}</button>${originNeedsRepair ? `<button class="button primary" data-action="repair-origin">${icon('link')}Use current Gitea origin</button>` : ''}${repository.localPath ? `<button class="button" data-action="repair-index-lock">${icon('key')}Repair stale Git lock</button><button class="button danger" data-action="unlink-repo">${icon('link')}Remove link</button>` : `<button class="button primary" data-action="clone-repo">${icon('cloud')}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button>`}</div></section><section class="settings-group"><h2>Repository behavior</h2><div class="notice">${icon('shield')}Origin repair changes only the Git remote URL. Lock repair refuses recent locks and never changes files or commits.</div></section></div>`;
}
function renderRepositoryWorkspace(repository) {
@@ -508,6 +511,7 @@ function renderSettings() {
<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>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)}"/><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>Appearance</h2><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></section>
@@ -584,7 +588,7 @@ function renderModal() {
const provider = ui.modal.provider || existing.provider || (servers.length ? 'ssh-unraid' : 'gitea-actions');
const ssh = provider === 'ssh-unraid';
const remoteFolder = existing.remoteFolder || safeCloneFolderName(repository);
return `<div class="modal-backdrop"><section class="modal wide-modal"><header class="modal-header"><h2>${existing.id ? 'Edit' : 'Add'} deployment environment</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="form-grid"><div class="field full"><label>Deployment provider</label><select id="profile-provider" class="select"><option value="ssh-unraid" ${ssh ? 'selected' : ''}>SSH / Unraid · direct controlled deployment</option><option value="gitea-actions" ${!ssh ? 'selected' : ''}>Gitea Actions · runner workflow</option></select></div><div class="field"><label>Profile name</label><input id="profile-name" class="input" value="${attr(existing.name || 'Production')}" /></div><div class="field"><label>Environment</label><input id="profile-environment" class="input" value="${attr(existing.environment || 'production')}" /></div><div class="field"><label>Allowed branch</label><input id="profile-branch" class="input" value="${attr(existing.branch || repository?.defaultBranch || 'main')}" /></div>${ssh ? `
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${existing.id ? 'Edit' : 'Add'} deployment environment</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="form-grid"><div class="field full"><label>Deployment provider</label><select id="profile-provider" class="select"><option value="ssh-unraid" ${ssh ? 'selected' : ''}>SSH / Unraid · direct controlled deployment</option><option value="gitea-actions" ${!ssh ? 'selected' : ''}>Gitea Actions · runner workflow</option></select></div><div class="field"><label>Profile name</label><input id="profile-name" class="input" value="${attr(existing.name || 'Production')}" /></div><div class="field"><label>Environment</label><input id="profile-environment" class="input" value="${attr(existing.environment || 'production')}" /></div><div class="field"><label>Allowed branch</label><input id="profile-branch" class="input" value="${attr(existing.branch || repository?.defaultBranch || 'main')}" /></div>${ssh ? `
<div class="field"><label>Unraid server</label><select id="profile-server" class="select">${servers.length ? servers.map((server) => `<option value="${attr(server.id)}" ${server.id === existing.serverId ? 'selected' : ''}>${escapeHtml(server.name)} · ${escapeHtml(server.host)}</option>`).join('') : '<option value="">Configure a server first</option>'}</select></div>
<div class="field"><label>Server folder name</label><input id="profile-remote-folder" class="input" value="${attr(remoteFolder)}"/></div>
<div class="field"><label>Git clone URL used by Unraid</label><input id="profile-clone-url" class="input" value="${attr(existing.cloneUrl || repository?.sshUrl || '')}" placeholder="ssh://git@gitea:222/Jens/project.git"/></div>
@@ -607,21 +611,21 @@ function renderModal() {
if (ui.modal.type === 'deployment-preflight') {
const profile = repository?.deploymentProfiles?.find((item) => item.id === ui.modal.profileId) || selectedProfile(repository);
const report = ui.deploymentPreflight;
return `<div class="modal-backdrop"><section class="modal wide-modal"><header class="modal-header"><h2>Deployment preflight</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="confirm-hero ${report?.summary?.ready ? '' : 'danger'}">${icon(report?.summary?.ready ? 'shield' : 'error')}<div><strong>${report?.summary?.ready ? 'Environment is ready to test' : 'Deployment is blocked'}</strong><span>${escapeHtml(repository?.fullName || '')}${escapeHtml(profile?.environment || '')}</span></div></div><div class="preflight-summary"><span class="status-pill ${report?.summary?.ready ? 'success' : 'danger'}">${report?.summary?.ready ? 'Ready' : `${report?.summary?.blocking?.length || 0} blocking`}</span><span>${report?.summary?.counts?.pass || 0} passed · ${report?.summary?.counts?.warning || 0} warnings · ${report?.summary?.counts?.fail || 0} failed</span></div>${renderPreflightChecks(report)}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button>${report?.summary?.ready ? `<button class="button success" data-action="continue-after-preflight" data-profile-id="${attr(profile?.id || '')}">${icon('rocket')}Continue</button>` : `<button class="button" data-action="edit-deployment-profile" data-profile-id="${attr(profile?.id || '')}">Edit environment</button>`}</footer></section></div>`;
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Deployment preflight</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="confirm-hero ${report?.summary?.ready ? '' : 'danger'}">${icon(report?.summary?.ready ? 'shield' : 'error')}<div><strong>${report?.summary?.ready ? 'Environment is ready to test' : 'Deployment is blocked'}</strong><span>${escapeHtml(repository?.fullName || '')}${escapeHtml(profile?.environment || '')}</span></div></div><div class="preflight-summary"><span class="status-pill ${report?.summary?.ready ? 'success' : 'danger'}">${report?.summary?.ready ? 'Ready' : `${report?.summary?.blocking?.length || 0} blocking`}</span><span>${report?.summary?.counts?.pass || 0} passed · ${report?.summary?.counts?.warning || 0} warnings · ${report?.summary?.counts?.fail || 0} failed</span></div>${renderPreflightChecks(report)}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button>${report?.summary?.ready ? `<button class="button success" data-action="continue-after-preflight" data-profile-id="${attr(profile?.id || '')}">${icon('rocket')}Continue</button>` : `<button class="button" data-action="edit-deployment-profile" data-profile-id="${attr(profile?.id || '')}">Edit environment</button>`}</footer></section></div>`;
}
if (ui.modal.type === 'deploy-confirm') {
const profile = repository?.deploymentProfiles?.find((item) => item.id === ui.modal.profileId) || selectedProfile(repository);
return `<div class="modal-backdrop"><section class="modal"><header class="modal-header"><h2>Confirm production action</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="confirm-hero">${icon('rocket')}<div><strong>Deploy ${escapeHtml(repository.localStatus.shortHead)}${escapeHtml(profile.environment)}</strong><span>${escapeHtml(repository.fullName)}</span></div></div><div class="confirm-grid"><span>Exact commit</span><strong class="mono">${escapeHtml(repository.localStatus.head)}</strong><span>Branch</span><strong>${escapeHtml(profile.branch)}</strong><span>Provider</span><strong>${profile.provider === 'ssh-unraid' ? `SSH → ${escapeHtml(profile.remoteFolder)}` : escapeHtml(profile.workflowFile)}</strong><span>Healthcheck</span><strong>${escapeHtml(profile.healthcheckUrl || 'Not configured')}</strong></div>${ui.deploymentPreflight ? `<div class="notice success" style="margin-top:12px">${icon('shield')}Preflight passed with ${ui.deploymentPreflight.summary.counts.warning} warning(s). Backend safety checks run again at dispatch time.</div>` : ''}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button success" data-action="confirm-deploy" data-profile-id="${attr(profile.id)}">Deploy exact commit</button></footer></section></div>`;
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Confirm production action</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="confirm-hero">${icon('rocket')}<div><strong>Deploy ${escapeHtml(repository.localStatus.shortHead)}${escapeHtml(profile.environment)}</strong><span>${escapeHtml(repository.fullName)}</span></div></div><div class="confirm-grid"><span>Exact commit</span><strong class="mono">${escapeHtml(repository.localStatus.head)}</strong><span>Branch</span><strong>${escapeHtml(profile.branch)}</strong><span>Provider</span><strong>${profile.provider === 'ssh-unraid' ? `SSH → ${escapeHtml(profile.remoteFolder)}` : escapeHtml(profile.workflowFile)}</strong><span>Healthcheck</span><strong>${escapeHtml(profile.healthcheckUrl || 'Not configured')}</strong></div>${ui.deploymentPreflight ? `<div class="notice success" style="margin-top:12px">${icon('shield')}Preflight passed with ${ui.deploymentPreflight.summary.counts.warning} warning(s). Backend safety checks run again at dispatch time.</div>` : ''}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button success" data-action="confirm-deploy" data-profile-id="${attr(profile.id)}">Deploy exact commit</button></footer></section></div>`;
}
if (ui.modal.type === 'rollback-confirm') {
const profile = repository?.deploymentProfiles?.find((item) => item.id === ui.modal.profileId);
const target = profile?.state?.previousSha;
return `<div class="modal-backdrop"><section class="modal"><header class="modal-header"><h2>Confirm rollback</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="confirm-hero danger">${icon('undo')}<div><strong>Rollback ${escapeHtml(profile?.environment || '')} to ${shortSha(target)}</strong><span>The target must still exist on origin/${escapeHtml(profile?.branch || '')}.</span></div></div><div class="confirm-grid"><span>Target commit</span><strong class="mono">${escapeHtml(target || 'Unavailable')}</strong><span>Provider</span><strong>${profile?.provider === 'ssh-unraid' ? 'SSH exact-SHA reset' : escapeHtml(profile?.rollbackWorkflowFile || 'Not configured')}</strong><span>Current live</span><strong class="mono">${escapeHtml(profile?.state?.liveSha || 'Unknown')}</strong></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button danger" data-action="confirm-rollback" data-profile-id="${attr(profile?.id || '')}" ${target ? '' : 'disabled'}>Rollback exact commit</button></footer></section></div>`;
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Confirm rollback</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="confirm-hero danger">${icon('undo')}<div><strong>Rollback ${escapeHtml(profile?.environment || '')} to ${shortSha(target)}</strong><span>The target must still exist on origin/${escapeHtml(profile?.branch || '')}.</span></div></div><div class="confirm-grid"><span>Target commit</span><strong class="mono">${escapeHtml(target || 'Unavailable')}</strong><span>Provider</span><strong>${profile?.provider === 'ssh-unraid' ? 'SSH exact-SHA reset' : escapeHtml(profile?.rollbackWorkflowFile || 'Not configured')}</strong><span>Current live</span><strong class="mono">${escapeHtml(profile?.state?.liveSha || 'Unknown')}</strong></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button danger" data-action="confirm-rollback" data-profile-id="${attr(profile?.id || '')}" ${target ? '' : 'disabled'}>Rollback exact commit</button></footer></section></div>`;
}
if (ui.modal.type === 'server-config') {
const server = (ui.boot.state.servers || []).find((item) => item.id === ui.modal.serverId) || {};
const authType = ui.modal.authType || server.authType || 'privateKey';
return `<div class="modal-backdrop"><section class="modal wide-modal"><header class="modal-header"><h2>${server.id ? 'Edit' : 'Add'} SSH / Unraid server</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Name</label><input id="server-name" class="input" value="${attr(server.name || 'Unraid')}"/></div><div class="field"><label>Host or IP</label><input id="server-host" class="input" value="${attr(server.host || '')}" placeholder="192.168.1.10"/></div><div class="field"><label>SSH port</label><input id="server-port" class="input" type="number" min="1" max="65535" value="${attr(server.port || 22)}"/></div><div class="field"><label>Username</label><input id="server-username" class="input" value="${attr(server.username || 'root')}"/></div><div class="field"><label>Authentication</label><select id="server-auth-type" class="select"><option value="privateKey" ${authType === 'privateKey' ? 'selected' : ''}>Private key · recommended</option><option value="password" ${authType === 'password' ? 'selected' : ''}>Password</option></select></div><div class="field"><label>Appdata base path</label><input id="server-base-path" class="input" value="${attr(server.basePath || '/mnt/user/appdata')}"/></div>${authType === 'privateKey' ? `<div class="field full"><label>Private key file</label><div class="input-action"><input id="server-private-key" class="input" value="${attr(server.privateKeyPath || '')}" placeholder="C:\\Users\\Jens\\.ssh\\id_ed25519"/><button class="button" data-action="select-private-key">Browse</button></div></div><div class="field full"><label>Private key passphrase</label><input id="server-passphrase" class="input" type="password" placeholder="${server.hasPassphrase ? 'Leave empty to keep stored passphrase' : 'Only when the key is encrypted'}"/></div>` : `<div class="field full"><label>SSH password</label><input id="server-password" class="input" type="password" placeholder="${server.hasPassword ? 'Leave empty to keep stored password' : 'Password'}"/></div>`}<div class="field full"><label>Trusted host fingerprint</label><input id="server-fingerprint" class="input mono" value="${attr(server.hostFingerprint || '')}" readonly placeholder="Filled automatically after Test & trust"/></div></div><div class="notice warning" style="margin-top:12px">${icon('key')}The first connection records the SSH host-key fingerprint. Later deployments fail closed when the server presents a different key.</div></div><footer class="modal-footer">${server.id ? `<button class="button danger" data-action="delete-server" data-server-id="${attr(server.id)}">Delete</button>` : ''}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-server" data-server-id="${attr(server.id || '')}">Save server</button></footer></section></div>`;
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${server.id ? 'Edit' : 'Add'} SSH / Unraid server</h2><button class="icon-button" data-action="close-modal">${icon('close')}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Name</label><input id="server-name" class="input" value="${attr(server.name || 'Unraid')}"/></div><div class="field"><label>Host or IP</label><input id="server-host" class="input" value="${attr(server.host || '')}" placeholder="192.168.1.10"/></div><div class="field"><label>SSH port</label><input id="server-port" class="input" type="number" min="1" max="65535" value="${attr(server.port || 22)}"/></div><div class="field"><label>Username</label><input id="server-username" class="input" value="${attr(server.username || 'root')}"/></div><div class="field"><label>Authentication</label><select id="server-auth-type" class="select"><option value="privateKey" ${authType === 'privateKey' ? 'selected' : ''}>Private key · recommended</option><option value="password" ${authType === 'password' ? 'selected' : ''}>Password</option></select></div><div class="field"><label>Appdata base path</label><input id="server-base-path" class="input" value="${attr(server.basePath || '/mnt/user/appdata')}"/></div>${authType === 'privateKey' ? `<div class="field full"><label>Private key file</label><div class="input-action"><input id="server-private-key" class="input" value="${attr(server.privateKeyPath || '')}" placeholder="C:\\Users\\Jens\\.ssh\\id_ed25519"/><button class="button" data-action="select-private-key">Browse</button></div></div><div class="field full"><label>Private key passphrase</label><input id="server-passphrase" class="input" type="password" placeholder="${server.hasPassphrase ? 'Leave empty to keep stored passphrase' : 'Only when the key is encrypted'}"/></div>` : `<div class="field full"><label>SSH password</label><input id="server-password" class="input" type="password" placeholder="${server.hasPassword ? 'Leave empty to keep stored password' : 'Password'}"/></div>`}<div class="field full"><label>Trusted host fingerprint</label><input id="server-fingerprint" class="input mono" value="${attr(server.hostFingerprint || '')}" readonly placeholder="Filled automatically after Test & trust"/></div></div><div class="notice warning" style="margin-top:12px">${icon('key')}The first connection records the SSH host-key fingerprint. Later deployments fail closed when the server presents a different key.</div></div><footer class="modal-footer">${server.id ? `<button class="button danger" data-action="delete-server" data-server-id="${attr(server.id)}">Delete</button>` : ''}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-server" data-server-id="${attr(server.id || '')}">Save server</button></footer></section></div>`;
}
if (ui.modal.type === 'command-palette') return renderCommandPalette();
return '';
@@ -950,9 +954,11 @@ app.addEventListener('click', async (event) => {
privateKeyPath: document.querySelector('#server-private-key')?.value.trim() || '',
hostFingerprint: document.querySelector('#server-fingerprint').value.trim()
};
const password = document.querySelector('#server-password')?.value || '';
const passphrase = document.querySelector('#server-passphrase')?.value || '';
setLoading(true, 'Saving encrypted SSH configuration…');
try {
const result = await window.forgeflow.saveServer(server, document.querySelector('#server-password')?.value || '', document.querySelector('#server-passphrase')?.value || '');
const result = await window.forgeflow.saveServer(server, password, passphrase);
ui.boot.state = result.state; ui.modal = null; showToast('Server saved', 'Run Test & trust before creating a deployment.', 'success');
} catch (error) { showToast('Could not save server', error.message, 'error'); }
setLoading(false);
@@ -973,6 +979,31 @@ app.addEventListener('click', async (event) => {
else if (action === 'save-roots') { const roots = [...document.querySelectorAll('[data-root-index]')].map((input) => input.value.trim()).filter(Boolean); setLoading(true, 'Saving workspace folders…'); try { ui.boot.state = await window.forgeflow.setWorkspaceRoots(roots); await refreshRepositories(false); showToast('Folders saved', 'Repository discovery has been refreshed.', 'success'); } catch (error) { showToast('Could not save folders', error.message, 'error'); } setLoading(false); }
else if (action === 'save-gitea-settings') { const baseUrl = document.querySelector('#settings-gitea-url').value.trim(); const token = document.querySelector('#settings-gitea-token').value.trim(); setLoading(true, 'Validating Gitea…'); try { const result = await window.forgeflow.updateGitea({ baseUrl, token }); ui.boot.state = result.state; await refreshRepositories(false); showToast('Gitea connected', `Signed in as ${result.validation.user.login}.`, 'success'); } catch (error) { showToast('Connection failed', error.message, 'error'); } setLoading(false); }
else if (action === 'save-preferences') { const preferences = { autoRefresh: document.querySelector('#pref-auto-refresh').value === 'true', repositoryPollSeconds: Number(document.querySelector('#pref-repo-poll').value), operationPollSeconds: Number(document.querySelector('#pref-operation-poll').value), preferredCloneProtocol: document.querySelector('#pref-clone-protocol').value }; setLoading(true, 'Saving background settings…'); try { ui.boot.state = await window.forgeflow.setPreferences(preferences); await refreshRepositories(false); showToast('Settings saved', 'Background awareness has been updated.', 'success'); } catch (error) { showToast('Could not save settings', error.message, 'error'); } setLoading(false); }
else if (action === 'repair-origin') {
if (!repository?.localPath || !repository.sshUrl) return;
if (!confirm(`Replace origin with ${repository.sshUrl}? Local files and commits are not changed.`)) return;
setLoading(true, 'Updating Git origin…');
try { await window.forgeflow.setOrigin(repository.localPath, repository.sshUrl); await refreshRepositories(false); showToast('Git origin updated', repository.sshUrl, 'success'); }
catch (error) { showToast('Could not update origin', error.message, 'error'); }
setLoading(false);
}
else if (action === 'normalize-origins') {
if (!confirm('Replace legacy origin URLs for every linked repository with the current Gitea SSH URL? Local files and commits are not changed.')) return;
setLoading(true, 'Normalizing linked Git origins…');
try {
const result = await window.forgeflow.normalizeOrigins();
ui.repositories = result.repositories;
showToast('Git origins normalized', `${result.changes.length} repository origin${result.changes.length === 1 ? '' : 's'} updated.`, 'success');
} catch (error) { showToast('Could not normalize origins', error.message, 'error'); }
setLoading(false); render();
}
else if (action === 'repair-index-lock') {
if (!repository?.localPath || !confirm('Remove the stale Git index.lock for this repository? Only continue after other Git tools have stopped.')) return;
setLoading(true, 'Repairing stale Git lock…');
try { await window.forgeflow.repairIndexLock(repository.localPath); await refreshRepositories(false); showToast('Git lock removed', 'The repository can accept Git changes again.', 'success'); }
catch (error) { showToast('Could not remove Git lock', error.message, 'error'); }
setLoading(false);
}
else if (action === 'run-system-preflight') await runSystemPreflight();
else if (action === 'save-diagnostics-preferences') {
const preferences = {
+7 -3
View File
@@ -250,7 +250,7 @@
}
window.forgeflow = Object.freeze({
async bootstrap() { await wait(80); snapshot(); return { appVersion: '0.4.0-demo', platform: 'win32', state: clone(state), git: { available: true, version: 'git version 2.47.3' }, diagnostics: { enabled: true, level: state.preferences.diagnosticLevel, retentionDays: state.preferences.logRetentionDays, maxFileMb: state.preferences.maxLogFileMb, directory: '<HOME>/AppData/Roaming/ForgeFlow/diagnostics', fileCount: 2, totalBytes: 18432, totalSize: '18.0 KB', latestAt: iso(-2000), lastWriteError: null } }; },
async bootstrap() { await wait(80); snapshot(); return { appVersion: '0.5.1-demo', platform: 'win32', state: clone(state), git: { available: true, version: 'git version 2.47.3' }, diagnostics: { enabled: true, level: state.preferences.diagnosticLevel, retentionDays: state.preferences.logRetentionDays, maxFileMb: state.preferences.maxLogFileMb, directory: '<HOME>/AppData/Roaming/ForgeFlow/diagnostics', fileCount: 2, totalBytes: 18432, totalSize: '18.0 KB', latestAt: iso(-2000), lastWriteError: null } }; },
async selectDirectory() { await wait(); return 'C:\\Development'; },
async selectKeyFile() { await wait(); return 'C:\\Users\\Jens\\.ssh\\id_ed25519'; },
async setupPreflight({ baseUrl, token, roots = [] }) { await wait(240); const checks = [
@@ -269,9 +269,9 @@
async setAppearance(appearance) { state.appearance = appearance; storage.set('forgeflow-theme', appearance); return clone(state); },
async setPreferences(preferences) { state.preferences = { ...state.preferences, ...preferences }; snapshot(); return clone(state); },
async setUpdatePreferences(updates) { state.updates = { ...state.updates, ...updates }; return clone(state); },
async checkForUpdates() { await wait(300); return { checkedAt: iso(), owner: state.updates.owner, repo: state.updates.repo, branch: state.updates.branch, currentVersion: '0.4.0', remoteVersion: '0.4.1', remoteSha: 'a'.repeat(40), shortSha: 'aaaaaaa', available: true, mode: 'source' }; },
async checkForUpdates() { await wait(300); return { checkedAt: iso(), owner: state.updates.owner, repo: state.updates.repo, branch: state.updates.branch, currentVersion: '0.5.1', remoteVersion: '0.5.2', remoteSha: 'a'.repeat(40), shortSha: 'aaaaaaa', available: true, mode: 'source' }; },
async downloadUpdate() { await wait(500); return { ...(await this.checkForUpdates()), downloaded: true, archivePath: 'C:\\Temp\\ForgeFlow-0.4.1.zip', sha256: 'b'.repeat(64) }; },
async applyUpdate() { await wait(200); return { launched: true, version: '0.4.1' }; },
async applyUpdate() { await wait(200); return { launched: true, version: '0.5.1' }; },
async saveServer(server) { const saved = { ...server, id: server.id || `server-${Date.now()}`, hasPassword: server.authType === 'password', hasPassphrase: false }; state.servers = [saved, ...state.servers.filter((item) => item.id !== saved.id)]; return { server: clone(saved), state: clone(state) }; },
async deleteServer(serverId) { state.servers = state.servers.filter((item) => item.id !== serverId); return clone(state); },
async testServer(serverId) { const server = state.servers.find((item) => item.id === serverId); server.hostFingerprint = server.hostFingerprint || 'SHA256:demo'; return { connected: true, fingerprint: server.hostFingerprint, server: clone(server), output: 'Linux\n/usr/bin/git\nDocker Compose version v2', state: clone(state) }; },
@@ -297,6 +297,10 @@
async stash(localPath, message) { const repo = findRepo(localPath); const list = stashesByRepo.get(localPath) || []; list.unshift({ ref: `stash@{${list.length}}`, subject: message || 'ForgeFlow stash', date: iso() }); stashesByRepo.set(localPath, list); repo.localStatus.files = []; recompute(repo); emitRepositories(); return { output: 'Saved working directory and index state.', status: clone(repo.localStatus), stashes: clone(list) }; },
async stashList(localPath) { return clone(stashesByRepo.get(localPath) || []); },
async popStash(localPath, ref) { const repo = findRepo(localPath); const list = stashesByRepo.get(localPath) || []; const index = list.findIndex((item) => item.ref === ref); if (index < 0) throw new Error('Stash not found.'); list.splice(index, 1); stashesByRepo.set(localPath, list); repo.localStatus.files = [makeFile('src/restored-from-stash.ts')]; recompute(repo); emitRepositories(); return { output: 'Stash applied.', status: clone(repo.localStatus), stashes: clone(list) }; },
async indexLockInfo() { return { exists: false, ageMs: 0 }; },
async repairIndexLock() { return { removed: true }; },
async setOrigin(localPath, remoteUrl) { const repo = findRepo(localPath); repo.localStatus.remoteUrl = remoteUrl; repo.sshUrl = remoteUrl; emitRepositories(); return clone(repo.localStatus); },
async normalizeOrigins() { const changes = []; repositories.filter((repo) => repo.localPath && repo.sshUrl).forEach((repo) => { if (repo.localStatus.remoteUrl !== repo.sshUrl) { changes.push({ fullName: repo.fullName, previous: repo.localStatus.remoteUrl, next: repo.sshUrl }); repo.localStatus.remoteUrl = repo.sshUrl; } }); emitRepositories(); return { changes, repositories: snapshot() }; },
async cloneRepository(fullName, mode = 'default') {
await wait(620);
const repository = repositories.find((item) => item.fullName === fullName);
+25 -5
View File
@@ -326,12 +326,12 @@ html[data-theme="light"] .diff-line.remove { color: #a31f1f; }
.discovery-row strong { display: block; font-size: 12px; }
.discovery-row span { display: block; margin-top: 2px; color: var(--text-faint); font: 10px var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 20px; background: rgba(4,7,12,.7); backdrop-filter: blur(4px); }
.modal { width: min(520px,94vw); border: 1px solid var(--line); border-radius: 9px; background: var(--surface-1); box-shadow: var(--shadow); overflow: hidden; }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 15px 17px; border-bottom: 1px solid var(--line); }
.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; align-items: center; justify-items: center; padding: clamp(8px,2vh,20px); overflow: auto; overscroll-behavior: contain; background: rgba(4,7,12,.7); backdrop-filter: blur(4px); }
.modal { width: min(520px,94vw); max-height: calc(100dvh - clamp(16px,4vh,40px)); min-height: 0; display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-1); box-shadow: var(--shadow); overflow: hidden; }
.modal-header { flex: 0 0 auto; display: flex; justify-content: space-between; align-items: center; padding: 15px 17px; border-bottom: 1px solid var(--line); background: var(--surface-1); }
.modal-header h2 { margin: 0; font-size: 15px; }
.modal-body { padding: 17px; }
.modal-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 17px; border-top: 1px solid var(--line-soft); background: var(--surface-0); }
.modal-body { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 17px; }
.modal-footer { flex: 0 0 auto; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; padding: 12px 17px; border-top: 1px solid var(--line-soft); background: var(--surface-0); box-shadow: 0 -8px 18px rgba(0,0,0,.08); }
.statusbar { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 0 9px; border-top: 1px solid var(--line); background: var(--surface-1); color: var(--text-faint); font-size: 10px; font-weight: 620; }
.statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 13px; min-width: 0; }
@@ -533,3 +533,23 @@ kbd { min-width: 24px; padding: 2px 5px; border: 1px solid var(--line); border-b
}
.setup-brand-logo { width: 150px; height: auto; display: block; margin-bottom: 12px; }
/* v0.5 viewport-safe dialogs */
.modal-body > .preflight-list:last-child { margin-bottom: 2px; }
.modal-footer .button { flex: 0 0 auto; }
@media (max-height: 720px) {
.modal-backdrop { align-items: start; }
.modal { max-height: calc(100dvh - 16px); }
.modal-header { padding-block: 11px; }
.modal-body { padding-block: 13px; }
.modal-footer { padding-block: 10px; }
}
@media (max-width: 680px) {
.modal-backdrop { padding: 0; align-items: stretch; }
.modal, .wide-modal { width: 100vw; max-height: 100dvh; border-radius: 0; }
.modal-footer .modal-spacer { display: none; }
.modal-footer .button { flex: 1 1 auto; }
.form-grid { grid-template-columns: 1fr; }
.field.full, .check-field.full { grid-column: 1; }
}
+55 -1
View File
@@ -30,7 +30,61 @@ function bashSyntaxCheckInvocation(root, scriptPath = 'examples/server/forgeflow
};
}
function bashSyntaxCheckFromTextInvocation(scriptText) {
if (typeof scriptText !== 'string' || !scriptText.trim()) {
throw new Error('Shell script text is required for syntax validation.');
}
return {
command: 'bash',
args: ['-n'],
options: {
input: scriptText,
encoding: 'utf8',
windowsHide: true
}
};
}
function shouldRunExternalBash(platform = process.platform) {
return platform !== 'win32';
}
function validateShellScriptStructure(scriptText) {
if (typeof scriptText !== 'string' || !scriptText.trim()) {
throw new Error('Shell script text is required for structural validation.');
}
if (scriptText.includes('\0')) {
throw new Error('Shell script may not contain NUL bytes.');
}
const normalized = scriptText.replace(/\r\n/g, '\n');
const firstLine = normalized.split('\n', 1)[0];
if (!/^#!\/(?:usr\/bin\/env bash|bin\/bash)$/.test(firstLine)) {
throw new Error('Server deployment script must declare Bash in its shebang.');
}
if (!/^set -E?euo pipefail$/m.test(normalized)) {
throw new Error('Server deployment script must enable strict Bash error handling.');
}
for (const marker of [
'readonly CONFIG_FILE="/etc/forgeflow/targets.conf"',
'Target configuration must be owned by root',
'flock -n 9',
'git -C "$APP_DIR" fetch',
'git -C "$APP_DIR" reset --hard "$SHA"',
'docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans',
'write_status "healthy"',
'write_status "unhealthy"'
]) {
if (!normalized.includes(marker)) {
throw new Error(`Server deployment script is missing required safety marker: ${marker}`);
}
}
return true;
}
module.exports = {
bashSyntaxCheckInvocation,
normalizeRelativePosixPath
bashSyntaxCheckFromTextInvocation,
normalizeRelativePosixPath,
shouldRunExternalBash,
validateShellScriptStructure
};