diff --git a/.gitignore b/.gitignore index f8cf31e..a5bc0ee 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,6 @@ yarn-error.log* # Generated PDFs *.pdf + +# Internal product backlog (local only — not for community) +docs/ROADMAP.md diff --git a/hub/dashboard.html b/hub/dashboard.html index be45d5a..f8624ca 100644 --- a/hub/dashboard.html +++ b/hub/dashboard.html @@ -6,33 +6,41 @@ GitDock Hub - + @@ -735,15 +1101,20 @@

Create account

- '; return html; @@ -1262,55 +1751,17 @@

emptyEl.style.display = 'none'; filtered.forEach(function(r) { var d = buildRepoData(r); - var status = d.status, ahead = d.ahead, behind = d.behind, msg = d.msg, timeAgo = d.timeAgo, author = d.author, absoluteDate = d.absoluteDate, dirtyLine = d.dirtyLine, dirtyText = d.dirtyText, aheadBehind = d.aheadBehind, aheadBehindBar = d.aheadBehindBar, needsAttention = d.needsAttention; - var dateTitle = absoluteDate ? ' title="' + escapeHtml(absoluteDate) + '"' : ''; + var needsAttention = d.needsAttention; if (machineDetailViewMode === 'cards') { var card = document.createElement('li'); card.className = 'repo-card ' + (needsAttention ? 'rc--attention' : 'rc--clean'); - var counters = buildStatHtml('Staged', 'S', d.staged, 'staged') + - buildStatHtml('Modified', 'M', d.unstaged, 'modified') + - buildStatHtml('Untracked', '?', d.untracked, 'untracked') + - buildStatHtml('Conflicts', '!', d.conflict, 'conflict'); - var syncHtml = ''; - if (ahead > 0 || behind > 0) { - syncHtml = '
'; - if (ahead > 0) syncHtml += '\u2191' + ahead + ' ahead'; - if (behind > 0) syncHtml += '\u2193' + behind + ' behind'; - syncHtml += '
'; - } - card.innerHTML = - '
' + - '
' + - '' + - '' + escapeHtml(r.name) + '' + - '' + escapeHtml(r.branch || '\u2014') + '' + - '\u25B8' + - '
' + - '
' + - (timeAgo ? '' + escapeHtml(timeAgo) + '' : '') + - '
' + - '
' + - '
' + counters + '
' + - '' + status + '' + - '
' + - syncHtml + - '
' + - buildCardDetailHtml(r, d, false); + card.innerHTML = buildRepoCardHtml(r, d, { showMachine: false }); card.addEventListener('click', function() { card.classList.toggle('rc-expanded'); }); list.appendChild(card); } else { var row = document.createElement('li'); - row.className = 'repo-row' + (needsAttention ? ' repo-row-needs-attention' : ''); - row.innerHTML = - '
' + - '' + escapeHtml(r.name) + '' + - '' + escapeHtml(r.account || '') + '' + - '' + escapeHtml(r.branch || '—') + '' + - (aheadBehind || '') + - '' + status + '' + - '
' + - (author ? '
' + escapeHtml(author) + '
' : '') + - (msg || timeAgo || dirtyLine ? '
' + (msg || timeAgo ? '' + escapeHtml(msg) + (timeAgo ? ' \u2022 ' + escapeHtml(timeAgo) + '' : '') + '' : '') + (dirtyLine ? dirtyLine : '') + '
' : ''); + row.className = 'repo-row ' + (needsAttention ? 'rr--attention' : 'rr--clean'); + row.innerHTML = buildRepoRowHtml(r, d, { showMachine: false }); list.appendChild(row); } }); @@ -1364,85 +1815,47 @@

return; } emptyEl.style.display = 'none'; - var keyToRepos = {}; - allReposData.forEach(function(x) { - var k = repoKey(x); - if (!keyToRepos[k]) keyToRepos[k] = []; - keyToRepos[k].push(x); - }); filtered.forEach(function(r) { var d = buildRepoData(r); - var status = d.status, ahead = d.ahead, behind = d.behind, msg = d.msg, timeAgo = d.timeAgo, author = d.author, absoluteDate = d.absoluteDate, dirtyLine = d.dirtyLine, dirtyText = d.dirtyText, aheadBehind = d.aheadBehind, aheadBehindBar = d.aheadBehindBar, needsAttention = d.needsAttention; - var dateTitle = absoluteDate ? ' title="' + escapeHtml(absoluteDate) + '"' : ''; - var sameKeyRepos = keyToRepos[repoKey(r)] || []; - var statusPerMachine = (r.multiMachineCount > 1 && sameKeyRepos.length > 1) ? sameKeyRepos.map(function(x) { return escapeHtml(x.machineName || '') + ': ' + (x.localStatus || 'clean'); }).join(', ') : ''; + var needsAttention = d.needsAttention; if (allReposViewMode === 'cards') { var card = document.createElement('li'); card.className = 'repo-card ' + (needsAttention ? 'rc--attention' : 'rc--clean'); - var counters = buildStatHtml('Staged', 'S', d.staged, 'staged') + - buildStatHtml('Modified', 'M', d.unstaged, 'modified') + - buildStatHtml('Untracked', '?', d.untracked, 'untracked') + - buildStatHtml('Conflicts', '!', d.conflict, 'conflict'); - var syncHtml = ''; - if (ahead > 0 || behind > 0) { - syncHtml = '
'; - if (ahead > 0) syncHtml += '\u2191' + ahead + ' ahead'; - if (behind > 0) syncHtml += '\u2193' + behind + ' behind'; - syncHtml += '
'; - } - card.innerHTML = - '
' + - '
' + - '' + - '' + escapeHtml(r.name) + '' + - '' + escapeHtml(r.branch || '\u2014') + '' + - '\u25B8' + - '
' + - '
' + - (timeAgo ? '' + escapeHtml(timeAgo) + '' : '') + - '' + escapeHtml(r.machineName || r.machineId || '') + '' + - (r.multiMachineCount > 1 ? 'on ' + r.multiMachineCount + ' machines' : '') + - '
' + - '
' + - '
' + counters + '
' + - '' + status + '' + - '
' + - syncHtml + - '
' + - buildCardDetailHtml(r, d, true); + card.innerHTML = buildRepoCardHtml(r, d, { showMachine: true }); card.addEventListener('click', function() { card.classList.toggle('rc-expanded'); }); list.appendChild(card); } else { var row = document.createElement('li'); - row.className = 'repo-row' + (needsAttention ? ' repo-row-needs-attention' : ''); - row.innerHTML = - '
' + - '' + escapeHtml(r.name) + '' + - '' + escapeHtml(r.account || '') + '' + - '' + escapeHtml(r.branch || '—') + '' + - '' + escapeHtml(r.machineName || '') + '' + - (aheadBehind || '') + - '' + status + '' + - '
' + - (author ? '
' + escapeHtml(author) + '
' : '') + - (msg || timeAgo || dirtyLine ? '
' + (msg || timeAgo ? '' + escapeHtml(msg) + (timeAgo ? ' \u2022 ' + escapeHtml(timeAgo) + '' : '') + '' : '') + (dirtyLine ? dirtyLine : '') + '
' : ''); + row.className = 'repo-row ' + (needsAttention ? 'rr--attention' : 'rr--clean'); + row.innerHTML = buildRepoRowHtml(r, d, { showMachine: true }); list.appendChild(row); } }); } - function openMachineDetail(id) { + function openMachineDetail(id, hint) { currentMachineDetailId = id; document.getElementById('machineDetailRenameWrap').style.display = 'none'; - showView('machineDetail'); document.getElementById('machineDetailSearch').value = ''; document.querySelectorAll('.machine-detail-filters .fbtn').forEach(function(b) { b.classList.toggle('on', b.getAttribute('data-filter') === 'all'); }); machineDetailFilter = 'all'; + var nameEl = document.getElementById('machineDetailName'); + var metaEl = document.getElementById('machineDetailMeta'); + var countEl = document.getElementById('machineDetailRepoCount'); + if (hint && hint.name) nameEl.textContent = hint.name; + else if (!machineDetailRepos.length) nameEl.textContent = 'Loading…'; + if (hint && hint.platform) { + metaEl.textContent = hint.platform + (hint.last_seen ? ' \u2022 Last seen: ' + formatHubTime(hint.last_seen) : ''); + } else if (!machineDetailRepos.length) { + metaEl.textContent = 'Loading…'; + } + if (countEl && !machineDetailRepos.length) countEl.textContent = 'Loading…'; + showView('machineDetail'); apiGet('/api/machines/' + encodeURIComponent(id)).then(function(data) { var m = data.machine; var snap = data.snapshot; - document.getElementById('machineDetailName').textContent = m.name || m.id; - document.getElementById('machineDetailMeta').textContent = (m.platform || '') + ' \u2022 Last seen: ' + (data.received_at ? new Date(data.received_at).toLocaleString() : '—'); + nameEl.textContent = m.name || m.id; + metaEl.textContent = (m.platform || '') + ' \u2022 Last seen: ' + formatHubTime(data.received_at || m.last_seen); machineDetailRepos = (snap && snap.repos) ? snap.repos : []; renderMachineDetailRepos(); }); @@ -1536,14 +1949,18 @@

apiGet('/api/keys').then(function(data) { var keys = data.keys || []; var list = document.getElementById('keysList'); + var emptyEl = document.getElementById('keysEmpty'); list.innerHTML = ''; + if (emptyEl) emptyEl.style.display = keys.length ? 'none' : 'block'; + var keysCount = document.getElementById('keysCount'); + if (keysCount) keysCount.textContent = String(keys.length); keys.forEach(function(k) { var li = document.createElement('li'); li.className = 'key-row'; - li.innerHTML = '
' + escapeHtml(k.label) + '
Created ' + (k.created_at || '') + (k.last_seen ? ' \u2022 Last seen: ' + k.last_seen : '') + '
' + + li.innerHTML = '
' + escapeHtml(k.label) + '
Created ' + escapeHtml(formatHubTime(k.created_at)) + (k.last_seen ? ' \u2022 Last seen ' + escapeHtml(formatHubTime(k.last_seen)) : '') + '
' + ''; li.querySelector('button').addEventListener('click', function() { - if (confirm('Revoke this key? Agents using it will no longer be able to send snapshots.')) { + if (confirm('Revoke this key? Machines using it will stop sending snapshots until you create a new key and update them.')) { apiDelete('/api/keys/' + encodeURIComponent(k.id)).then(function() { loadKeys(); }); } }); @@ -1557,12 +1974,20 @@

apiPost('/api/keys', { label: label }).then(function(data) { var result = document.getElementById('newKeyResult'); result.style.display = 'block'; - result.innerHTML = 'New API key (copy now \u2014 it won\'t be shown again):
' + escapeHtml(data.key) + '
'; + result.innerHTML = + '

Copy this key now. It will not be shown again. Use the same key on every machine.

' + + '' + escapeHtml(data.key) + '' + + ''; result.querySelector('.copy-btn').addEventListener('click', function() { - navigator.clipboard.writeText(data.key); - this.textContent = 'Copied'; + var btn = this; + navigator.clipboard.writeText(data.key).then(function() { + btn.textContent = 'Copied'; + }).catch(function() { + btn.textContent = 'Select and copy manually'; + }); }); document.getElementById('newKeyLabel').value = ''; + loadKeys(); }); }); @@ -1573,6 +1998,29 @@

return div.innerHTML; } + function parseHubDate(raw) { + if (raw == null || raw === '') return null; + var s = String(raw).trim(); + if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(s) && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(s)) { + s = s.replace(' ', 'T'); + if (!/[zZ]$/.test(s)) s += 'Z'; + } + var d = new Date(s); + return isNaN(d.getTime()) ? null : d; + } + + function formatHubTime(raw) { + var d = parseHubDate(raw); + if (!d) return raw == null || raw === '' ? 'n/a' : String(raw); + return d.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + checkAuth(); })(); diff --git a/server.js b/server.js index 037ec19..aa4b11d 100644 --- a/server.js +++ b/server.js @@ -531,7 +531,8 @@ function runCommand(cmd, cwd = BASE_DIR, timeoutMs = 60000) { timeout: timeoutMs, stdio: ["pipe", "pipe", "pipe"], }); - return { success: true, output: result.trim() }; + // trimEnd only: leading spaces are meaningful in `git status --porcelain` (e.g. " M file") + return { success: true, output: String(result).replace(/\s+$/, "") }; } catch (err) { return { success: false, @@ -551,7 +552,8 @@ function runGit(args, cwd = BASE_DIR, timeoutMs = 60000) { timeout: timeoutMs, stdio: ["pipe", "pipe", "pipe"], }); - return { success: true, output: result.trim() }; + // trimEnd only: leading spaces are meaningful in `git status --porcelain` (e.g. " M file") + return { success: true, output: String(result).replace(/\s+$/, "") }; } catch (err) { return { success: false, @@ -3069,9 +3071,11 @@ app.post("/api/hub/config", async (req, res) => { if (trimmed.length > 0 && trimmed.length < 2048) { try { const parsedUrl = new URL(trimmed); - // SECURITY: Enforce HTTPS for Hub communication - if (parsedUrl.protocol !== "https:") { - return res.status(400).json({ error: "Hub URL must use HTTPS for secure communication" }); + const host = (parsedUrl.hostname || "").toLowerCase(); + const isLocalHost = host === "localhost" || host === "127.0.0.1"; + // SECURITY: HTTPS required in production; HTTP allowed only for local Hub development + if (parsedUrl.protocol !== "https:" && !(parsedUrl.protocol === "http:" && isLocalHost)) { + return res.status(400).json({ error: "Hub URL must use HTTPS (http://localhost is allowed for local Hub only)" }); } if (!config.hub) config.hub = {}; config.hub.url = trimmed; @@ -3086,8 +3090,14 @@ app.post("/api/hub/config", async (req, res) => { const key = String(apiKey).trim(); if (key.length > 0 && key.length < 512) { await setHubApiKey(key); + if (!config.hub) config.hub = {}; + config.hub.apiKey = key; } else { await deleteHubApiKey(); + if (config.hub) { + delete config.hub.apiKey; + delete config.hub.apiKeySecure; + } } } if (intervalMinutes !== undefined) { diff --git a/test/git-parse.test.js b/test/git-parse.test.js index d57a051..e83ed7f 100644 --- a/test/git-parse.test.js +++ b/test/git-parse.test.js @@ -25,6 +25,18 @@ describe("parseStatusPorcelain", () => { assert.equal(summary.unstagedCount, 1); }); + it("keeps unstaged-only lines as unstaged (leading space must be preserved)", () => { + const out = " M README.md\n"; + const { files, summary } = gitParse.parseStatusPorcelain(out); + assert.equal(files[0].path, "README.md"); + assert.equal(summary.stagedCount, 0); + assert.equal(summary.unstagedCount, 1); + // Regression: full .trim() on porcelain turns " M README.md" into "M README.md" + const broken = gitParse.parseStatusPorcelain(out.trim()); + assert.equal(broken.summary.stagedCount, 1); + assert.equal(broken.summary.unstagedCount, 0); + }); + it("returns empty summary for clean tree", () => { const r = gitParse.parseStatusPorcelain(""); assert.deepEqual(r.summary, {