diff --git a/src/frontend/app.js b/src/frontend/app.js
index 84b3e42..0f3b8dc 100644
--- a/src/frontend/app.js
+++ b/src/frontend/app.js
@@ -1246,47 +1246,97 @@ function renderQACard(s, idx) {
}
function renderProjects(container, sessions) {
- var byGit = {}; // key → { name, list }
+ var byGit = {}; // key → { name, list, path, source, manualId }
sessions.forEach(function(s) {
var info = getRepoInfo(s.project, s.git_root);
- if (!byGit[info.key]) byGit[info.key] = { name: info.name, list: [] };
+ if (!byGit[info.key]) byGit[info.key] = { name: info.name, list: [], path: info.key !== 'unknown' ? info.key : '', source: 'session', manualId: '' };
byGit[info.key].list.push(s);
});
+ // Merge manually-registered projects (local-only or cloned from GitHub) so
+ // they appear as project cards even before any session exists for them.
+ (window.manualProjects || []).forEach(function(p) {
+ if (!byGit[p.path]) {
+ byGit[p.path] = { name: p.name, list: [], path: p.path, source: p.source || 'manual', manualId: p.id, _git: p.git, _lastAdded: p.addedAt };
+ } else {
+ // Existing key from sessions — annotate with manual id so the launcher
+ // buttons can still show source and offer "remove from registry".
+ byGit[p.path].manualId = p.id;
+ if (!byGit[p.path].source || byGit[p.path].source === 'session') byGit[p.path].source = p.source || 'manual';
+ }
+ });
+
var sorted = Object.entries(byGit).sort(function(a, b) {
- return b[1].list[0].last_ts - a[1].list[0].last_ts;
+ var aTs = a[1].list[0] ? a[1].list[0].last_ts : (a[1]._lastAdded ? Date.parse(a[1]._lastAdded) : 0);
+ var bTs = b[1].list[0] ? b[1].list[0].last_ts : (b[1]._lastAdded ? Date.parse(b[1]._lastAdded) : 0);
+ return bTs - aTs;
});
+ var html = '
No projects found.
';
+ container.innerHTML = html + 'No projects yet. Click + Add Project to register a local folder or clone one from GitHub.
';
return;
}
var globalIdx = 0;
- var html = '';
sorted.forEach(function(entry) {
var projKey = entry[0];
- var projName = entry[1].name;
- var list = entry[1].list.slice().sort(function(a, b) { return b.last_ts - a.last_ts; });
+ var projInfo = entry[1];
+ var projName = projInfo.name;
+ var projPath = projInfo.path;
+ var list = projInfo.list.slice().sort(function(a, b) { return b.last_ts - a.last_ts; });
var color = getProjectColor(projName);
var totalMsgs = list.reduce(function(s, e) { return s + (e.messages || 0); }, 0);
var totalCost = list.reduce(function(s, e) { return s + getEstimatedSessionCost(e); }, 0);
var costLabel = totalCost > 0 ? ' · ~$' + totalCost.toFixed(2) : '';
+ var lastSession = list[0]; // most recent
+
+ var sourceTag = '';
+ if (projInfo.source === 'github-clone') sourceTag = '
github ';
+ else if (projInfo.source === 'manual') sourceTag = '
added ';
- html += '
';
+ var statsLine = list.length === 0
+ ? 'no sessions yet'
+ : (list.length + ' sessions · ' + totalMsgs + ' msgs' + costLabel);
+
+ html += '
';
html += '';
html += '
';
- list.forEach(function(s) { html += renderQACard(s, globalIdx++); });
+ if (list.length === 0) {
+ html += '
No sessions yet. Click ▶ New above to start one.
';
+ } else {
+ list.forEach(function(s) { html += renderQACard(s, globalIdx++); });
+ }
html += '
';
html += '
';
});
@@ -2106,12 +2156,438 @@ function dismissUpdate() {
if (banner) banner.style.display = 'none';
}
+// ── Project launcher: manual registry, New/Last session, Add Project modal ──
+
+window.manualProjects = window.manualProjects || [];
+var _githubReposCache = { owned: null, contributing: null };
+
+async function loadManualProjects() {
+ try {
+ var resp = await fetch('/api/projects/manual');
+ var data = await resp.json();
+ window.manualProjects = Array.isArray(data) ? data : [];
+ if (currentView === 'projects') render();
+ } catch (e) {
+ console.warn('[codbash] loadManualProjects failed:', e && e.message);
+ }
+}
+
+function _currentTerminalId() {
+ return localStorage.getItem('codedash-terminal') || '';
+}
+
+async function launchNewProjectSession(projectPath, tool) {
+ if (!projectPath) { showToast('No project path'); return; }
+ var t = tool || 'claude';
+ try {
+ var resp = await fetch('/api/launch', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ mode: 'fresh',
+ tool: t,
+ flags: [],
+ project: projectPath,
+ terminal: _currentTerminalId(),
+ }),
+ });
+ var data = await resp.json();
+ if (data.ok) showToast('Starting new ' + t + ' session in ' + projectPath.split('/').pop());
+ else showToast('Launch failed: ' + (data.error || 'unknown'));
+ } catch (e) {
+ showToast('Launch failed: ' + e.message);
+ }
+}
+
+async function resumeLastProjectSession(sessionId, tool, projectPath) {
+ if (!sessionId) { showToast('No previous session to resume'); return; }
+ try {
+ var resp = await fetch('/api/launch', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ sessionId: sessionId,
+ tool: tool || 'claude',
+ flags: [],
+ project: projectPath || '',
+ terminal: _currentTerminalId(),
+ }),
+ });
+ var data = await resp.json();
+ if (data.ok) showToast('Resuming ' + sessionId.slice(0, 8) + '…');
+ else showToast('Resume failed: ' + (data.error || 'unknown'));
+ } catch (e) {
+ showToast('Resume failed: ' + e.message);
+ }
+}
+
+async function unregisterProject(id, name) {
+ if (!id) return;
+ if (!confirm('Remove “' + name + '” from project registry? Files on disk are untouched.')) return;
+ try {
+ var resp = await fetch('/api/projects/manual/' + encodeURIComponent(id), { method: 'DELETE' });
+ var data = await resp.json();
+ if (data.ok) {
+ showToast('Removed ' + name);
+ await loadManualProjects();
+ } else {
+ showToast('Remove failed');
+ }
+ } catch (e) {
+ showToast('Remove failed: ' + e.message);
+ }
+}
+
+// ── Add Project modal ─────────────────────────────────────────
+
+function openAddProject() {
+ var overlay = document.getElementById('addProjectOverlay');
+ if (!overlay) return;
+ overlay.classList.add('open');
+ addProjectSwitchTab('local');
+ var input = document.getElementById('apLocalPath');
+ if (input) { input.value = ''; setTimeout(function() { input.focus(); }, 50); }
+ var err = document.getElementById('apLocalError'); if (err) err.textContent = '';
+}
+
+function closeAddProject() {
+ var overlay = document.getElementById('addProjectOverlay');
+ if (overlay) overlay.classList.remove('open');
+ // Stop any in-flight device-code polling when the user dismisses the modal.
+ _stopRepoScopePolling();
+ _repoScopeDeviceCode = '';
+}
+
+function addProjectSwitchTab(tab) {
+ ['local', 'owned', 'contributing'].forEach(function(t) {
+ var btn = document.querySelector('.ap-tab[data-tab="' + t + '"]');
+ if (btn) btn.classList.toggle('active', t === tab);
+ });
+ document.getElementById('apPaneLocal').style.display = tab === 'local' ? '' : 'none';
+ document.getElementById('apPaneOwned').style.display = tab === 'owned' ? '' : 'none';
+ document.getElementById('apPaneContrib').style.display = tab === 'contributing' ? '' : 'none';
+ var addBtn = document.getElementById('apLocalAddBtn');
+ if (addBtn) addBtn.style.display = tab === 'local' ? '' : 'none';
+
+ if (tab === 'owned' || tab === 'contributing') {
+ var apiType = tab === 'owned' ? 'owned' : 'contributing';
+ if (!_githubReposCache[apiType]) loadGithubRepos(apiType);
+ else renderRepoList(apiType);
+ }
+}
+
+async function submitAddLocalProject() {
+ var input = document.getElementById('apLocalPath');
+ var err = document.getElementById('apLocalError');
+ if (err) err.textContent = '';
+ var p = input ? input.value.trim() : '';
+ if (!p) { if (err) err.textContent = 'Path required'; return; }
+ try {
+ var resp = await fetch('/api/projects/manual', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ path: p, source: 'manual' }),
+ });
+ var data = await resp.json();
+ if (data.ok) {
+ showToast('Added ' + (data.project.name || p));
+ closeAddProject();
+ await loadManualProjects();
+ } else {
+ if (err) err.textContent = data.error || 'Failed to add';
+ }
+ } catch (e) {
+ if (err) err.textContent = e.message;
+ }
+}
+
+async function loadGithubRepos(apiType) {
+ var containerId = apiType === 'owned' ? 'apOwnedRepos' : 'apContribRepos';
+ var container = document.getElementById(containerId);
+ if (!container) return;
+ // Re-entering this view cancels any stale polling that was running for a
+ // previous device-code flow.
+ _stopRepoScopePolling();
+ container.innerHTML = '
Loading repos…
';
+ try {
+ var resp = await fetch('/api/github/repos?type=' + apiType);
+ if (resp.status === 401) {
+ var data401 = await resp.json().catch(function() { return {}; });
+ if (data401.needsRepoScope) {
+ renderRepoScopeConnectPanel(containerId, apiType);
+ } else {
+ container.innerHTML = '
GitHub not connected. Open Cloud view → connect GitHub, then return here.
';
+ }
+ return;
+ }
+ if (!resp.ok) {
+ var errBody = await resp.json().catch(function() { return {}; });
+ container.innerHTML = '
' + escHtml(errBody.error || ('HTTP ' + resp.status)) + '
';
+ return;
+ }
+ var data = await resp.json();
+ if (!Array.isArray(data)) {
+ container.innerHTML = '
' + escHtml(data.error || 'Failed to load') + '
';
+ return;
+ }
+ _githubReposCache[apiType] = data;
+ renderRepoList(apiType);
+ } catch (e) {
+ container.innerHTML = '
Failed to load: ' + escHtml(e.message) + '
';
+ }
+}
+
+// ── Repo-scope connect panel + device-code polling ──────────────
+
+var _repoScopePolling = null;
+var _repoScopeInterval = 0; // current poll interval in ms (mutable on slow_down)
+var _repoScopeDeadline = 0;
+var _repoScopeDeviceCode = '';
+var _repoScopeApiType = '';
+var _repoScopeErrorStreak = 0;
+
+function _stopRepoScopePolling() {
+ if (_repoScopePolling) { clearInterval(_repoScopePolling); _repoScopePolling = null; }
+}
+
+function renderRepoScopeConnectPanel(containerId, apiType) {
+ // A user re-entering the connect panel cancels any prior in-flight polling.
+ _stopRepoScopePolling();
+ var container = document.getElementById(containerId);
+ if (!container) return;
+ container.innerHTML = ''
+ + '
'
+ + '
Repo access not granted yet
'
+ + '
'
+ + ' To list your repositories the dashboard needs a separate GitHub authorization. The new token is stored locally and is never sent to the leaderboard — it is used only here in the project launcher.'
+ + '
'
+ + '
'
+ + ' Public repos only (skips private/collaborator listings) '
+ + ' '
+ + '
'
+ + ' Connect GitHub for repo access '
+ + '
'
+ + '
'
+ + '
';
+}
+
+async function startRepoScopeConnect(apiType, btn) {
+ if (btn) btn.disabled = true; // prevent double-clicks racing two device flows
+ var status = document.getElementById('apConnectStatus');
+ if (status) status.textContent = 'Requesting device code…';
+ var publicOnly = !!(document.getElementById('apPublicOnlyToggle') && document.getElementById('apPublicOnlyToggle').checked);
+ try {
+ var resp = await fetch('/api/github/repo-scope/device-code', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ publicOnly: publicOnly }),
+ });
+ var data = await resp.json();
+ if (data.error) {
+ if (status) status.textContent = 'Failed: ' + data.error;
+ if (btn) btn.disabled = false;
+ return;
+ }
+ showDeviceCodePanel(data, apiType);
+ } catch (e) {
+ if (status) status.textContent = 'Failed: ' + e.message;
+ showToast('Connect failed: ' + e.message);
+ if (btn) btn.disabled = false;
+ }
+}
+
+function showDeviceCodePanel(deviceData, apiType) {
+ var status = document.getElementById('apConnectStatus');
+ if (!status) return;
+ // user_code goes into innerHTML via escHtml AND into a data-attribute on the
+ // Copy button — the click handler reads from `dataset.code` instead of the
+ // attribute being interpolated into an inline JS string, so any quote
+ // characters in a future user_code value cannot break out of the handler.
+ status.innerHTML = ''
+ + '
'
+ + '
'
+ + '
2. Enter code: ' + escHtml(deviceData.user_code) + ' '
+ + 'Copy
'
+ + '
Waiting for authorization…
'
+ + '
';
+ _stopRepoScopePolling();
+ _repoScopeDeadline = Date.now() + (deviceData.expires_in || 900) * 1000;
+ _repoScopeInterval = (deviceData.interval || 5) * 1000;
+ _repoScopeDeviceCode = deviceData.device_code;
+ _repoScopeApiType = apiType;
+ _repoScopeErrorStreak = 0;
+ _repoScopePolling = setInterval(_repoScopeTick, _repoScopeInterval);
+}
+
+function _repoScopeTick() {
+ if (Date.now() > _repoScopeDeadline) {
+ _stopRepoScopePolling();
+ var st = document.getElementById('apPollStatus');
+ if (st) st.textContent = 'Code expired. Click Connect to try again.';
+ return;
+ }
+ pollRepoScopeOnce();
+}
+
+async function pollRepoScopeOnce() {
+ var deviceCode = _repoScopeDeviceCode;
+ var apiType = _repoScopeApiType;
+ try {
+ var resp = await fetch('/api/github/repo-scope/poll-token', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ device_code: deviceCode }),
+ });
+ var data = await resp.json();
+ // Bail out if a different flow took over while this poll was in flight.
+ if (deviceCode !== _repoScopeDeviceCode) return;
+ if (data.error) {
+ var st = document.getElementById('apPollStatus');
+ if (st) st.textContent = 'Error: ' + data.error;
+ _stopRepoScopePolling();
+ return;
+ }
+ if (data.status === 'pending') {
+ _repoScopeErrorStreak = 0;
+ return;
+ }
+ if (data.status === 'slow_down') {
+ // RFC 8628 §3.5: add at least 5s on slow_down and wait the new interval.
+ _stopRepoScopePolling();
+ _repoScopeInterval = _repoScopeInterval + 5000;
+ _repoScopePolling = setInterval(_repoScopeTick, _repoScopeInterval);
+ return;
+ }
+ if (data.status === 'expired') {
+ var st2 = document.getElementById('apPollStatus');
+ if (st2) st2.textContent = 'Code expired. Click Connect to try again.';
+ _stopRepoScopePolling();
+ return;
+ }
+ if (data.status === 'ok') {
+ _stopRepoScopePolling();
+ showToast('GitHub repo access granted');
+ _githubReposCache.owned = null;
+ _githubReposCache.contributing = null;
+ loadGithubRepos(apiType);
+ }
+ } catch (e) {
+ // Transient network failures shouldn't kill the flow. Persistent failures
+ // (3 in a row) surface to the user instead of polling silently forever.
+ _repoScopeErrorStreak += 1;
+ if (_repoScopeErrorStreak >= 3) {
+ _stopRepoScopePolling();
+ var st3 = document.getElementById('apPollStatus');
+ if (st3) st3.textContent = 'Connection error. Please retry.';
+ }
+ }
+}
+
+async function disconnectRepoScope() {
+ if (!confirm('Disconnect repo-scope GitHub access locally? You can re-connect any time. To fully revoke the OAuth authorization you must also visit github.com → Settings → Applications.')) return;
+ // Cancel any in-flight polling first — otherwise a poll could race the
+ // disconnect and silently restore the token if GitHub returns ok at the
+ // same moment.
+ _stopRepoScopePolling();
+ _repoScopeDeviceCode = '';
+ try {
+ var resp = await fetch('/api/github/repo-scope/disconnect', { method: 'POST' });
+ var data = await resp.json().catch(function() { return {}; });
+ _githubReposCache.owned = null;
+ _githubReposCache.contributing = null;
+ showToast('Repo access disconnected locally');
+ if (data.revokeUrl) {
+ // Nudge the user toward fully revoking on github.com, since clearing
+ // locally does not invalidate the token at GitHub.
+ var go = confirm('Also fully revoke the GitHub OAuth authorization?\n\nThis opens github.com so you can disconnect the app there too.');
+ if (go) window.open(data.revokeUrl, '_blank', 'noopener');
+ }
+ var tab = document.querySelector('.ap-tab.active');
+ if (tab) addProjectSwitchTab(tab.getAttribute('data-tab'));
+ } catch (e) {
+ showToast('Disconnect failed: ' + e.message);
+ }
+}
+
+function renderRepoList(apiType) {
+ var containerId = apiType === 'owned' ? 'apOwnedRepos' : 'apContribRepos';
+ var filterId = apiType === 'owned' ? 'apOwnedFilter' : 'apContribFilter';
+ var container = document.getElementById(containerId);
+ var filter = document.getElementById(filterId);
+ if (!container) return;
+ var repos = _githubReposCache[apiType] || [];
+ var q = filter ? filter.value.trim().toLowerCase() : '';
+ var filtered = q
+ ? repos.filter(function(r) {
+ return (r.fullName || '').toLowerCase().indexOf(q) >= 0
+ || (r.description || '').toLowerCase().indexOf(q) >= 0;
+ })
+ : repos;
+
+ if (filtered.length === 0) {
+ container.innerHTML = '
' + (q ? 'No repos match "' + escHtml(q) + '"' : 'No repos found') + '
';
+ return;
+ }
+ var registeredPaths = (window.manualProjects || []).map(function(p) { return p.remoteUrl; }).filter(Boolean);
+ var html = ''
+ + '
'
+ + 'Showing ' + filtered.length + ' repos '
+ + 'Disconnect repo access '
+ + '
';
+ // No client-side truncation: the backend already caps at 300 repos and the
+ // filter has narrowed the list. Truncating here would hide matching repos
+ // when the user has many.
+ filtered.forEach(function(r) {
+ var already = registeredPaths.indexOf(r.cloneUrl) >= 0;
+ var meta = (r.private ? 'private · ' : '') + (r.description ? r.description : (r.htmlUrl || ''));
+ html += '
';
+ html += '
';
+ html += '
' + escHtml(r.fullName) + '
';
+ html += '
' + escHtml(meta) + '
';
+ html += '
';
+ html += '
' + (already ? 'Added' : 'Clone & Add') + ' ';
+ html += '
';
+ });
+ container.innerHTML = html;
+}
+
+async function cloneRepoAndAdd(btn) {
+ var fullName = btn.getAttribute('data-full-name');
+ var cloneUrl = btn.getAttribute('data-clone-url');
+ var sshUrl = btn.getAttribute('data-ssh-url');
+ var defaultBranch = btn.getAttribute('data-default-branch');
+ btn.disabled = true;
+ btn.textContent = 'Cloning…';
+ try {
+ var resp = await fetch('/api/projects/clone', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ fullName: fullName, cloneUrl: cloneUrl, sshUrl: sshUrl, defaultBranch: defaultBranch }),
+ });
+ var data = await resp.json();
+ if (data.ok) {
+ showToast(data.alreadyExisted ? 'Linked existing clone of ' + fullName : 'Cloned ' + fullName);
+ btn.textContent = 'Added';
+ await loadManualProjects();
+ } else {
+ btn.disabled = false;
+ btn.textContent = 'Retry';
+ showToast('Clone failed: ' + (data.error || 'unknown'));
+ }
+ } catch (e) {
+ btn.disabled = false;
+ btn.textContent = 'Retry';
+ showToast('Clone failed: ' + e.message);
+ }
+}
+
// ── Initialization ─────────────────────────────────────────────
(function init() {
// Load data
loadSessions();
loadTerminals();
+ loadManualProjects();
checkForUpdates();
setInterval(checkForUpdates, 10000); // check every 10s
setInterval(loadSessions, 60000); // refresh sessions + invalidate analytics cache every 60s
diff --git a/src/frontend/index.html b/src/frontend/index.html
index 8748325..8a64ddf 100644
--- a/src/frontend/index.html
+++ b/src/frontend/index.html
@@ -191,6 +191,38 @@
Delete Session?
+
+
0 selected
⚠ hidden by filter — deselect hidden
diff --git a/src/frontend/styles.css b/src/frontend/styles.css
index 250c8cc..ce56717 100644
--- a/src/frontend/styles.css
+++ b/src/frontend/styles.css
@@ -737,6 +737,104 @@ body {
.btn-delete { background: var(--accent-red); color: #fff; border-color: var(--accent-red); }
.btn-delete:hover { opacity: 0.85; }
+/* ── Add Project modal ────────────────────────────────────────── */
+.add-project-box { max-width: 640px; width: 92%; }
+.ap-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 12px; }
+.ap-tab {
+ padding: 8px 14px;
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ font-size: 13px;
+ font-weight: 500;
+}
+.ap-tab.active { color: var(--text-primary); border-bottom-color: #3b82f6; }
+.ap-tab:hover { color: var(--text-primary); }
+.ap-body { min-height: 240px; }
+.ap-pane label { display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
+.ap-pane input[type="text"] {
+ width: 100%;
+ padding: 8px 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-card);
+ color: var(--text-primary);
+ font-size: 13px;
+}
+.ap-hint { font-size: 11px; color: var(--text-muted); margin-top: 6px; }
+.ap-error { font-size: 12px; color: var(--accent-red); margin-top: 8px; min-height: 18px; }
+.ap-filter { margin-bottom: 8px; }
+.ap-repos { max-height: 360px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; }
+.ap-loading { padding: 16px; text-align: center; color: var(--text-muted); font-size: 13px; }
+.ap-repo {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--border);
+ gap: 12px;
+}
+.ap-repo:last-child { border-bottom: none; }
+.ap-repo-info { min-width: 0; flex: 1; }
+.ap-repo-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
+.ap-repo-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ap-repo-clone {
+ padding: 6px 12px;
+ background: #3b82f6;
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+.ap-repo-clone:hover { opacity: 0.9; }
+.ap-repo-clone:disabled { opacity: 0.5; cursor: not-allowed; }
+
+.ap-connect { padding: 16px; }
+.ap-connect-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; }
+.ap-connect-body { font-size: 12px; color: var(--text-secondary); line-height: 1.5; margin-bottom: 10px; }
+.ap-connect-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-secondary); cursor: pointer; }
+.ap-connect-status { margin-top: 12px; font-size: 12px; color: var(--text-secondary); }
+.ap-device-code { padding: 12px; background: var(--bg-card); border-radius: 6px; line-height: 1.7; }
+.ap-device-code a { color: #3b82f6; }
+.ap-code { font-family: monospace; font-size: 14px; padding: 2px 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px; letter-spacing: 2px; }
+.ap-copy { padding: 2px 8px; font-size: 11px; background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; }
+.ap-copy:hover { background: var(--bg-card-hover); }
+.ap-poll-status { margin-top: 8px; color: var(--text-muted); font-size: 11px; }
+.ap-disconnect { font-size: 11px; color: var(--text-muted); cursor: pointer; text-decoration: underline; background: none; border: none; padding: 0; margin-left: auto; }
+.ap-disconnect:hover { color: var(--accent-red); }
+
+/* New / Last session buttons in the project group header */
+.git-project-launch-btn {
+ padding: 4px 10px;
+ background: var(--bg-card);
+ color: var(--text-primary);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-size: 11px;
+ cursor: pointer;
+ margin-left: 6px;
+}
+.git-project-launch-btn:hover { background: var(--bg-card-hover); }
+.git-project-launch-btn.primary { background: #3b82f6; color: #fff; border-color: #3b82f6; }
+.git-project-launch-btn.primary:hover { opacity: 0.9; }
+
+.git-project-source-tag {
+ display: inline-block;
+ padding: 1px 6px;
+ background: var(--bg-card);
+ color: var(--text-muted);
+ border-radius: 3px;
+ font-size: 10px;
+ margin-left: 6px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
/* ── Detail Panel ──────────────────────────────────────────────── */
.detail-panel {
diff --git a/src/projects.js b/src/projects.js
new file mode 100644
index 0000000..005a00f
--- /dev/null
+++ b/src/projects.js
@@ -0,0 +1,264 @@
+// Manual project registry + GitHub repo discovery + clone helper.
+//
+// Storage: ~/.codedash/projects.json
+// { projects: [{ id, name, path, source, remoteUrl, defaultBranch, addedAt }] }
+//
+// "source" is one of: 'manual' | 'github-clone'
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const https = require('https');
+const crypto = require('crypto');
+const { execFile, execFileSync } = require('child_process');
+
+const PROJECTS_FILE = path.join(os.homedir(), '.codedash', 'projects.json');
+const DEFAULT_CLONE_ROOT = path.join(os.homedir(), 'code');
+
+function ensureDir(dir) {
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+}
+
+function loadProjects() {
+ try {
+ const data = JSON.parse(fs.readFileSync(PROJECTS_FILE, 'utf8'));
+ return Array.isArray(data.projects) ? data.projects : [];
+ } catch {
+ return [];
+ }
+}
+
+// Atomic write — temp file + rename so a crashed write never leaves a partial
+// file behind. The mutex below serializes read-modify-write operations to
+// prevent the classic interleaved-load lost-update race.
+function saveProjects(list) {
+ ensureDir(path.dirname(PROJECTS_FILE));
+ const tmp = PROJECTS_FILE + '.tmp';
+ fs.writeFileSync(tmp, JSON.stringify({ projects: list }, null, 2));
+ fs.renameSync(tmp, PROJECTS_FILE);
+}
+
+let _writeLock = Promise.resolve();
+function withWriteLock(fn) {
+ const next = _writeLock.then(fn, fn);
+ _writeLock = next.catch(() => {}); // never break the chain on error
+ return next;
+}
+
+function normalizePath(p) {
+ if (!p) return '';
+ // Expand ~ and strip trailing slash
+ let out = p;
+ if (out.startsWith('~')) out = path.join(os.homedir(), out.slice(1));
+ out = path.resolve(out);
+ return out;
+}
+
+function validatePath(p) {
+ if (!p || typeof p !== 'string') throw new Error('path required');
+ const abs = normalizePath(p);
+ if (!fs.existsSync(abs)) throw new Error('path does not exist: ' + abs);
+ // Use lstat first so symlink-to-directory cannot smuggle a hidden target in;
+ // realpath then resolves the actual on-disk location for storage.
+ const lst = fs.lstatSync(abs);
+ if (lst.isSymbolicLink()) throw new Error('symlinks are not allowed as project paths');
+ if (!lst.isDirectory()) throw new Error('path is not a directory: ' + abs);
+ return fs.realpathSync(abs);
+}
+
+// Sanity-check a path string that will be embedded in a shell command later
+// (terminals.js builds a `cd "..." && ...` line on macOS/Linux). We refuse
+// characters that have meaning to bash inside double-quoted strings even after
+// JSON.stringify quoting, and additionally require that the path resolve to an
+// existing directory on disk — a real filesystem entry cannot contain `$()` or
+// backticks so the check doubles as injection defense.
+const UNSAFE_PATH_CHARS = /[$`\n\r;|&<>()*?{}\[\]"']/;
+function isSafeLaunchPath(p) {
+ if (!p || typeof p !== 'string') return false;
+ if (p.length > 1024) return false;
+ if (UNSAFE_PATH_CHARS.test(p)) return false;
+ try {
+ const abs = normalizePath(p);
+ if (!fs.existsSync(abs)) return false;
+ if (!fs.statSync(abs).isDirectory()) return false;
+ } catch {
+ return false;
+ }
+ return true;
+}
+
+const ALLOWED_SOURCES = new Set(['manual', 'github-clone']);
+
+function addProject({ name, path: projectPath, source, remoteUrl, defaultBranch }) {
+ const abs = validatePath(projectPath);
+ return withWriteLock(() => {
+ const list = loadProjects();
+ const existing = list.find(p => p.path === abs);
+ if (existing) return existing;
+
+ const project = {
+ id: crypto.randomBytes(8).toString('hex'),
+ name: String(name || path.basename(abs)).slice(0, 200),
+ path: abs,
+ source: ALLOWED_SOURCES.has(source) ? source : 'manual',
+ remoteUrl: String(remoteUrl || '').slice(0, 500),
+ defaultBranch: String(defaultBranch || '').slice(0, 100),
+ addedAt: new Date().toISOString(),
+ };
+ const next = list.concat([project]);
+ saveProjects(next);
+ return project;
+ });
+}
+
+function removeProject(id) {
+ return withWriteLock(() => {
+ const list = loadProjects();
+ const next = list.filter(p => p.id !== id);
+ if (next.length === list.length) return false;
+ saveProjects(next);
+ return true;
+ });
+}
+
+// ── GitHub API helpers ─────────────────────────────────────────
+
+function githubApiGet(token, reqPath) {
+ return new Promise((resolve, reject) => {
+ const req = https.request({
+ hostname: 'api.github.com',
+ path: reqPath,
+ method: 'GET',
+ headers: {
+ 'Authorization': 'Bearer ' + token,
+ 'Accept': 'application/vnd.github+json',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ 'User-Agent': 'codbash',
+ },
+ timeout: 15000,
+ }, (res) => {
+ let data = '';
+ res.on('data', c => data += c);
+ res.on('end', () => {
+ if (res.statusCode >= 400) {
+ let msg = 'HTTP ' + res.statusCode;
+ try { const j = JSON.parse(data); if (j.message) msg += ' — ' + j.message; } catch {}
+ return reject(new Error(msg));
+ }
+ try { resolve(JSON.parse(data)); }
+ catch (e) { reject(e); }
+ });
+ });
+ req.on('error', reject);
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
+ req.end();
+ });
+}
+
+function mapRepo(repo) {
+ return {
+ name: repo.name,
+ fullName: repo.full_name,
+ description: repo.description || '',
+ private: !!repo.private,
+ htmlUrl: repo.html_url,
+ cloneUrl: repo.clone_url,
+ sshUrl: repo.ssh_url,
+ defaultBranch: repo.default_branch || 'main',
+ updatedAt: repo.updated_at,
+ pushedAt: repo.pushed_at,
+ owner: repo.owner ? repo.owner.login : '',
+ permissions: repo.permissions || null,
+ };
+}
+
+// type: 'owned' (the user is the owner) or 'contributing' (collaborator/org member)
+async function listGithubRepos(token, type) {
+ if (!token) throw new Error('GitHub not connected');
+ const safeType = type === 'contributing' ? 'contributing' : 'owned';
+ const affiliation = safeType === 'owned'
+ ? 'owner'
+ : 'collaborator,organization_member';
+ // Page through up to 3 pages = 300 repos. Plenty for most users.
+ const all = [];
+ for (let page = 1; page <= 3; page++) {
+ const data = await githubApiGet(
+ token,
+ `/user/repos?affiliation=${affiliation}&sort=updated&per_page=100&page=${page}`
+ );
+ if (!Array.isArray(data) || data.length === 0) break;
+ for (const r of data) all.push(mapRepo(r));
+ if (data.length < 100) break;
+ }
+ return all;
+}
+
+// ── Clone helper ───────────────────────────────────────────────
+
+function isSafeRepoName(name) {
+ return typeof name === 'string' && /^[A-Za-z0-9._-]{1,100}$/.test(name);
+}
+
+function suggestCloneDir(repoName, cloneRoot) {
+ if (!isSafeRepoName(repoName)) throw new Error('invalid repo name');
+ return path.join(cloneRoot || DEFAULT_CLONE_ROOT, repoName);
+}
+
+// Returns a Promise<{ path, alreadyExisted }>
+// If destDir exists and is a git repo with the same remote → treat as success (alreadyExisted=true).
+// If destDir exists and is something else → throw.
+function cloneRepo(remoteUrl, destDir) {
+ return new Promise((resolve, reject) => {
+ if (!remoteUrl || typeof remoteUrl !== 'string') {
+ return reject(new Error('remoteUrl required'));
+ }
+ if (!/^(https:\/\/github\.com\/|git@github\.com:)/.test(remoteUrl)) {
+ return reject(new Error('only GitHub remotes are supported'));
+ }
+ const abs = path.resolve(destDir);
+ if (!abs.startsWith(os.homedir() + path.sep) && abs !== os.homedir()) {
+ return reject(new Error('clone destination must be under your home directory'));
+ }
+ ensureDir(path.dirname(abs));
+
+ if (fs.existsSync(abs)) {
+ // If it's already the same repo, accept it.
+ const gitDir = path.join(abs, '.git');
+ if (fs.existsSync(gitDir)) {
+ try {
+ const existing = execFileSync('git', ['-C', abs, 'config', '--get', 'remote.origin.url'], {
+ encoding: 'utf8', timeout: 3000, windowsHide: true,
+ }).trim();
+ if (existing === remoteUrl || existing.replace(/\.git$/, '') === remoteUrl.replace(/\.git$/, '')) {
+ return resolve({ path: abs, alreadyExisted: true });
+ }
+ } catch {}
+ return reject(new Error('directory exists with a different git remote: ' + abs));
+ }
+ const entries = fs.readdirSync(abs).filter(n => !n.startsWith('.DS_Store'));
+ if (entries.length > 0) {
+ return reject(new Error('destination already exists and is not empty: ' + abs));
+ }
+ }
+
+ execFile('git', ['clone', '--', remoteUrl, abs], { timeout: 120000, windowsHide: true }, (err, stdout, stderr) => {
+ if (err) return reject(new Error('git clone failed: ' + (stderr || err.message).trim().slice(0, 500)));
+ resolve({ path: abs, alreadyExisted: false });
+ });
+ });
+}
+
+module.exports = {
+ PROJECTS_FILE,
+ DEFAULT_CLONE_ROOT,
+ loadProjects,
+ addProject,
+ removeProject,
+ validatePath,
+ normalizePath,
+ listGithubRepos,
+ cloneRepo,
+ suggestCloneDir,
+ isSafeRepoName,
+ isSafeLaunchPath,
+};
diff --git a/src/server.js b/src/server.js
index cbc8c83..ef95201 100644
--- a/src/server.js
+++ b/src/server.js
@@ -9,6 +9,7 @@ const { convertSession } = require('./convert');
const { generateHandoff } = require('./handoff');
const { CHANGELOG } = require('./changelog');
const { getHTML } = require('./html');
+const projectsApi = require('./projects');
// ── Logging ──────────────────────────────────
const LOG_VERBOSE = process.env.CODEDASH_LOG !== '0';
@@ -28,7 +29,20 @@ function log(tag, msg, data) {
function startServer(host, port, openBrowser = true) {
const browserUrl = getBrowserUrl(host, port);
+ // DNS rebinding defense: only enforce when bound to a loopback address.
+ // Users who deliberately bind to a LAN address (e.g. for cross-device
+ // access) skip this check so they can hit the server by IP/hostname.
+ const isLoopbackBind = host === '127.0.0.1' || host === 'localhost' || host === '::1';
+ const allowedHosts = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
const server = http.createServer((req, res) => {
+ if (isLoopbackBind) {
+ const hostName = String(req.headers.host || '').toLowerCase().split(':')[0];
+ if (hostName && !allowedHosts.has(hostName)) {
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
+ res.end('Forbidden: host header must be localhost');
+ return;
+ }
+ }
// req.url is usually relative, so this base is only for URL parsing.
// Keep it stable instead of reusing the bind host, which may be a wildcard listen address.
const parsed = new URL(req.url, `http://localhost:${port}`);
@@ -109,12 +123,24 @@ function startServer(host, port, openBrowser = true) {
else if (req.method === 'POST' && pathname === '/api/launch') {
readBody(req, body => {
try {
- const { sessionId, tool, flags, project, terminal } = JSON.parse(body);
- if (!/^[A-Za-z0-9._-]{1,128}$/.test(String(sessionId || ''))) {
+ const { sessionId, tool, flags, project, terminal, mode } = JSON.parse(body);
+ const fresh = mode === 'fresh';
+ if (!fresh && !/^[A-Za-z0-9._-]{1,128}$/.test(String(sessionId || ''))) {
throw new Error('invalid sessionId');
}
- log('LAUNCH', `session=${sessionId} tool=${tool || 'claude'} terminal=${terminal || 'default'} project=${project || '(none)'} flags=${(flags || []).join(',') || '(none)'}`);
- openInTerminal(sessionId, tool || 'claude', flags || [], project || '', terminal || '');
+ if (fresh && !project) {
+ throw new Error('project path required for fresh session');
+ }
+ // The project path flows into a shell command string in terminals.js
+ // (`cd "..." && claude ...`). Even though JSON.stringify wraps the
+ // value in double quotes, bash still expands $() and backticks
+ // inside double-quoted strings. We refuse anything other than a
+ // plain on-disk directory path.
+ if (project && !projectsApi.isSafeLaunchPath(project)) {
+ throw new Error('invalid or unsafe project path');
+ }
+ log('LAUNCH', `mode=${fresh ? 'fresh' : 'resume'} session=${sessionId || '(none)'} tool=${tool || 'claude'} terminal=${terminal || 'default'} project=${project || '(none)'} flags=${(flags || []).join(',') || '(none)'}`);
+ openInTerminal(fresh ? '' : sessionId, tool || 'claude', flags || [], project || '', terminal || '', fresh ? 'fresh' : 'resume');
log('LAUNCH', 'ok');
json(res, { ok: true });
} catch (e) {
@@ -394,7 +420,11 @@ function startServer(host, port, openBrowser = true) {
else if (req.method === 'GET' && pathname === '/api/github/profile') {
const profile = loadGitHubProfile();
- json(res, profile || { authenticated: false });
+ if (!profile) return json(res, { authenticated: false });
+ // Never vend raw tokens to the browser. The frontend only needs to know
+ // *whether* the user is connected and what the display fields are.
+ const { token: _t, repoToken: _rt, ...safe } = profile;
+ json(res, safe);
}
else if (req.method === 'POST' && pathname === '/api/github/logout') {
@@ -402,6 +432,172 @@ function startServer(host, port, openBrowser = true) {
json(res, { ok: true });
}
+ // ── Repo-scope auth: separate token for /user/repos enumeration ──
+ else if (req.method === 'POST' && pathname === '/api/github/repo-scope/device-code') {
+ readBody(req, body => {
+ let payload = {};
+ try { payload = body ? JSON.parse(body) : {}; } catch {}
+ githubRepoScopeDeviceCode(!!payload.publicOnly)
+ .then(data => json(res, data))
+ .catch(e => json(res, { error: e.message }, 400));
+ });
+ }
+
+ else if (req.method === 'POST' && pathname === '/api/github/repo-scope/poll-token') {
+ readBody(req, body => {
+ try {
+ const { device_code } = JSON.parse(body);
+ if (!device_code) throw new Error('device_code required');
+ githubRepoScopePollToken(device_code)
+ .then(data => json(res, data))
+ .catch(e => json(res, { error: e.message }, 400));
+ } catch (e) {
+ json(res, { error: e.message }, 400);
+ }
+ });
+ }
+
+ else if (req.method === 'GET' && pathname === '/api/github/repo-scope/status') {
+ const profile = loadGitHubProfile();
+ if (!profile || !profile.repoToken) {
+ return json(res, { connected: false });
+ }
+ json(res, {
+ connected: true,
+ scope: profile.repoTokenScope || 'read:user repo',
+ connectedAt: profile.repoTokenConnectedAt || null,
+ });
+ }
+
+ else if (req.method === 'POST' && pathname === '/api/github/repo-scope/disconnect') {
+ try {
+ updateGitHubProfile({ repoToken: null, repoTokenScope: null, repoTokenConnectedAt: null });
+ log('AUTH', 'Repo-scope GitHub token cleared locally (GitHub authorization must be revoked manually)');
+ // Provide the deep link so the UI can nudge the user to also revoke
+ // the OAuth authorization on github.com — clearing locally does not
+ // invalidate the token at GitHub.
+ json(res, {
+ ok: true,
+ revokeUrl: 'https://github.com/settings/connections/applications/' + GITHUB_CLIENT_ID,
+ });
+ } catch (e) {
+ json(res, { ok: false, error: e.message }, 500);
+ }
+ }
+
+ // ── GitHub repos (for project launcher) ────────────────────
+ // GET /api/github/repos?type=owned|contributing
+ // Requires the repo-scope token (separate from the leaderboard token) —
+ // the `read:user` scope alone is not enough for /user/repos to return any
+ // entries.
+ else if (req.method === 'GET' && pathname === '/api/github/repos') {
+ const profile = loadGitHubProfile();
+ if (!profile || !profile.token) {
+ return json(res, { error: 'GitHub not connected', needsRepoScope: true }, 401);
+ }
+ if (!profile.repoToken) {
+ return json(res, { error: 'Repo access not granted yet', needsRepoScope: true }, 401);
+ }
+ const type = parsed.searchParams.get('type') || 'owned';
+ projectsApi.listGithubRepos(profile.repoToken, type)
+ .then(repos => json(res, repos))
+ .catch(e => {
+ const status = /401|unauthorized|bad credentials/i.test(e.message) ? 401 : 500;
+ log('ERROR', `github/repos failed: ${e.message}`);
+ json(res, { error: e.message, needsRepoScope: status === 401 }, status);
+ });
+ }
+
+ // ── Manual / cloned projects registry ──────────────────────
+ else if (req.method === 'GET' && pathname === '/api/projects/manual') {
+ // Enrich each with current git info so the UI can render branch/last commit.
+ const list = projectsApi.loadProjects().map(p => {
+ const info = getProjectGitInfo(p.path) || null;
+ return { ...p, git: info };
+ });
+ json(res, list);
+ }
+
+ else if (req.method === 'POST' && pathname === '/api/projects/manual') {
+ readBody(req, body => {
+ let payload;
+ try { payload = JSON.parse(body || '{}'); }
+ catch (e) { return json(res, { ok: false, error: 'invalid json' }, 400); }
+ Promise.resolve()
+ .then(() => projectsApi.addProject({
+ name: payload.name,
+ path: payload.path,
+ source: payload.source,
+ remoteUrl: payload.remoteUrl,
+ defaultBranch: payload.defaultBranch,
+ }))
+ .then(project => {
+ log('PROJECT', `registered ${project.name} (${project.path})`);
+ json(res, { ok: true, project });
+ })
+ .catch(e => {
+ log('ERROR', `register project failed: ${e.message}`);
+ json(res, { ok: false, error: e.message }, 400);
+ });
+ });
+ }
+
+ else if (req.method === 'DELETE' && pathname.startsWith('/api/projects/manual/')) {
+ const id = pathname.split('/').pop();
+ if (!/^[a-f0-9]{16}$/.test(String(id || ''))) {
+ return json(res, { ok: false, error: 'invalid id' }, 400);
+ }
+ projectsApi.removeProject(id)
+ .then(removed => json(res, { ok: removed }))
+ .catch(e => json(res, { ok: false, error: e.message }, 500));
+ }
+
+ // POST /api/projects/clone — { fullName, cloneUrl, sshUrl } → clone into ~/code/
+ register
+ else if (req.method === 'POST' && pathname === '/api/projects/clone') {
+ readBody(req, body => {
+ try {
+ const { fullName, cloneUrl, sshUrl, defaultBranch } = JSON.parse(body || '{}');
+ if (!fullName || !cloneUrl) throw new Error('fullName and cloneUrl required');
+ // Validate fullName shape (owner/repo) and reject anything weird so
+ // it cannot poison log output or downstream string handling.
+ if (!/^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/.test(String(fullName))) {
+ throw new Error('invalid fullName (must be owner/repo)');
+ }
+ // Cross-check: the cloneUrl must point to the same owner/repo so a
+ // crafted request can't show "victim/legit" in the UI while cloning
+ // attacker-controlled code.
+ const urlMatch = String(cloneUrl).match(/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i);
+ if (!urlMatch) throw new Error('cloneUrl must be a github.com https URL');
+ const urlFullName = urlMatch[1] + '/' + urlMatch[2].replace(/\.git$/, '');
+ if (urlFullName.toLowerCase() !== String(fullName).toLowerCase()) {
+ throw new Error('fullName does not match cloneUrl');
+ }
+ const repoName = urlMatch[2].replace(/\.git$/, '');
+ if (!projectsApi.isSafeRepoName(repoName)) throw new Error('invalid repo name');
+ const destDir = projectsApi.suggestCloneDir(repoName);
+ log('CLONE', `start ${urlFullName} → ${destDir}`);
+ projectsApi.cloneRepo(cloneUrl, destDir)
+ .then(result => projectsApi.addProject({
+ name: repoName,
+ path: result.path,
+ source: 'github-clone',
+ remoteUrl: cloneUrl,
+ defaultBranch: defaultBranch || '',
+ }).then(project => ({ project, result })))
+ .then(({ project, result }) => {
+ log('CLONE', `done ${urlFullName} (${result.alreadyExisted ? 'reused' : 'cloned'})`);
+ json(res, { ok: true, project, alreadyExisted: result.alreadyExisted });
+ })
+ .catch(e => {
+ log('ERROR', `clone failed: ${e.message}`);
+ json(res, { ok: false, error: e.message, sshFallback: sshUrl || null }, 400);
+ });
+ } catch (e) {
+ json(res, { ok: false, error: e.message }, 400);
+ }
+ });
+ }
+
// ── Cloud Sync Proxy ─────────────────────
else if (pathname.startsWith('/api/cloud/')) {
handleCloudProxy(req, res, pathname).catch(e => json(res, { error: e.message }, 500));
@@ -885,6 +1081,9 @@ function githubRequest(hostname, reqPath, method, body) {
}
async function githubDeviceCode() {
+ // Keep scope minimal: the token is forwarded to leaderboard.neuraldeep.ru by
+ // syncLeaderboard, so broader scopes would leak write access to a 3rd party.
+ // Use the separate repo-scope flow below if you need /user/repos coverage.
const data = await githubRequest('github.com', '/login/device/code', 'POST',
JSON.stringify({ client_id: GITHUB_CLIENT_ID, scope: 'read:user' }));
if (data.error) throw new Error(data.error_description || data.error);
@@ -925,10 +1124,95 @@ async function githubPollToken(deviceCode) {
return { status: 'ok', profile: { username: profile.username, avatar: profile.avatar, name: profile.name, url: profile.url } };
}
+// ── Repo-scope auth (separate from leaderboard token) ──────────
+//
+// The base /login/device/code flow gets only `read:user` so the token is safe
+// to forward to leaderboard.neuraldeep.ru. To enumerate the user's repos for
+// the project launcher we run a second, scope-tagged device flow and store
+// the resulting token in `repoToken` — kept strictly away from leaderboard
+// sync. The user must explicitly opt in via the Add Project modal.
+
+// In-memory map of pending device codes → requested scope. Bounded by GitHub's
+// 15 min device-code TTL plus a small slack. Survives the lifetime of the
+// process only — a restart invalidates all in-flight codes, which is fine.
+const _pendingRepoScopeCodes = new Map();
+const PENDING_CODE_TTL = 16 * 60 * 1000;
+
+function _rememberPendingCode(deviceCode, scope) {
+ _pendingRepoScopeCodes.set(deviceCode, { scope, exp: Date.now() + PENDING_CODE_TTL });
+ // Cheap GC: prune expired entries opportunistically.
+ for (const [code, meta] of _pendingRepoScopeCodes) {
+ if (meta.exp < Date.now()) _pendingRepoScopeCodes.delete(code);
+ }
+}
+
+async function githubRepoScopeDeviceCode(publicOnly) {
+ const scope = publicOnly ? 'read:user public_repo' : 'read:user repo';
+ const data = await githubRequest('github.com', '/login/device/code', 'POST',
+ JSON.stringify({ client_id: GITHUB_CLIENT_ID, scope }));
+ if (data.error) throw new Error(data.error_description || data.error);
+ // Don't log the user_code itself — anyone with log access could authorize
+ // the device flow against the user's account before they enter it.
+ log('AUTH', `Repo-scope device code issued (scope=${scope})`);
+ _rememberPendingCode(data.device_code, scope);
+ return {
+ user_code: data.user_code,
+ verification_uri: data.verification_uri,
+ device_code: data.device_code,
+ interval: data.interval || 5,
+ expires_in: data.expires_in,
+ scope,
+ };
+}
+
+async function githubRepoScopePollToken(deviceCode) {
+ const data = await githubRequest('github.com', '/login/oauth/access_token', 'POST',
+ JSON.stringify({ client_id: GITHUB_CLIENT_ID, device_code: deviceCode, grant_type: 'urn:ietf:params:oauth:grant-type:device_code' }));
+ if (data.error === 'authorization_pending') return { status: 'pending' };
+ if (data.error === 'slow_down') return { status: 'slow_down' };
+ if (data.error === 'expired_token') {
+ _pendingRepoScopeCodes.delete(deviceCode);
+ return { status: 'expired' };
+ }
+ if (data.error) throw new Error(data.error_description || data.error);
+ if (!data.access_token) throw new Error('No access token received');
+
+ // Prefer the scope returned by GitHub on the token itself; fall back to the
+ // scope we recorded when we issued the device code. We deliberately do not
+ // accept a client-supplied scope to keep the stored label trustworthy.
+ const requestedScope = (_pendingRepoScopeCodes.get(deviceCode) || {}).scope;
+ const grantedScope = data.scope
+ ? String(data.scope).split(',').map(s => s.trim()).join(' ')
+ : (requestedScope || 'read:user repo');
+ _pendingRepoScopeCodes.delete(deviceCode);
+
+ updateGitHubProfile({
+ repoToken: data.access_token,
+ repoTokenScope: grantedScope,
+ repoTokenConnectedAt: new Date().toISOString(),
+ });
+ log('AUTH', `Repo-scope GitHub token saved (scope=${grantedScope})`);
+ return { status: 'ok', scope: grantedScope };
+}
+
function loadGitHubProfile() {
try {
const data = JSON.parse(fs.readFileSync(GITHUB_PROFILE_FILE, 'utf8'));
- if (data.authenticated) return { authenticated: true, username: data.username, avatar: data.avatar, name: data.name, url: data.url, token: data.token };
+ if (data.authenticated) return {
+ authenticated: true,
+ username: data.username,
+ avatar: data.avatar,
+ name: data.name,
+ url: data.url,
+ token: data.token,
+ connectedAt: data.connectedAt,
+ // Optional separate token with broader scope, used only by the project
+ // launcher. Kept distinct from `token` so the leaderboard sync never
+ // sees a write-capable credential.
+ repoToken: data.repoToken || null,
+ repoTokenScope: data.repoTokenScope || null,
+ repoTokenConnectedAt: data.repoTokenConnectedAt || null,
+ };
} catch {}
return null;
}
@@ -937,12 +1221,40 @@ function saveGitHubProfile(profile) {
const dir = path.dirname(GITHUB_PROFILE_FILE);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
if (profile) {
- fs.writeFileSync(GITHUB_PROFILE_FILE, JSON.stringify(profile, null, 2));
+ // Atomic write + restrictive perms (owner read/write only). The token file
+ // would otherwise be world-readable on shared boxes/CI runners.
+ const tmp = GITHUB_PROFILE_FILE + '.tmp';
+ fs.writeFileSync(tmp, JSON.stringify(profile, null, 2), { mode: 0o600 });
+ fs.renameSync(tmp, GITHUB_PROFILE_FILE);
} else {
try { fs.unlinkSync(GITHUB_PROFILE_FILE); } catch {}
}
}
+// Merge partial fields into the existing profile so endpoints that touch
+// just one slice (e.g. repoToken) don't have to rebuild the whole object.
+// Bails on parse error so a corrupt file can't silently erase the user's
+// existing leaderboard token. Setting a field to null *removes* it from the
+// stored JSON instead of persisting an explicit null.
+function updateGitHubProfile(partial) {
+ let current;
+ if (fs.existsSync(GITHUB_PROFILE_FILE)) {
+ try {
+ current = JSON.parse(fs.readFileSync(GITHUB_PROFILE_FILE, 'utf8')) || {};
+ } catch (e) {
+ throw new Error('github profile file is corrupt; refusing to overwrite');
+ }
+ } else {
+ current = {};
+ }
+ const next = { ...current, ...partial };
+ for (const key of Object.keys(partial)) {
+ if (partial[key] === null) delete next[key];
+ }
+ saveGitHubProfile(next);
+ return next;
+}
+
// ── Leaderboard Sync ──────────────────────
const LEADERBOARD_API = 'https://leaderboard.neuraldeep.ru';
@@ -966,6 +1278,8 @@ async function syncLeaderboard() {
avatar: profile.avatar,
name: profile.name,
deviceId: anon.id || require('crypto').randomUUID(),
+ // SECURITY: leaderboard receives only the read:user-scoped token. Never
+ // forward `repoToken` here — that token has repo write access.
token: profile.token, // for server-side GitHub verification
version: pkg.version,
integrity: integrity,
diff --git a/src/terminals.js b/src/terminals.js
index 731bf95..d18f779 100644
--- a/src/terminals.js
+++ b/src/terminals.js
@@ -192,11 +192,29 @@ function termLog(tag, msg) {
console.log(` ${color}${ts} [${tag}]\x1b[0m ${msg}`);
}
-function openInTerminal(sessionId, tool, flags, projectDir, terminalId) {
+function openInTerminal(sessionId, tool, flags, projectDir, terminalId, mode) {
const skipPerms = flags.includes('skip-permissions');
+ const fresh = mode === 'fresh';
let cmd;
- if (tool === 'codex') {
+ if (fresh) {
+ // Start a brand new session in projectDir (no --resume). We map known
+ // tools to their CLI entry point; unrecognised tools fall back to claude
+ // so a misconfigured UI doesn't silently open the wrong agent.
+ switch (tool) {
+ case 'codex': cmd = 'codex'; break;
+ case 'qwen': cmd = 'qwen'; break;
+ case 'kilo': cmd = 'kilo'; break;
+ case 'kiro': cmd = 'kiro-cli'; break;
+ case 'opencode': cmd = 'opencode'; break;
+ case 'cursor': cmd = 'cursor-agent'; break;
+ case 'copilot':
+ case 'copilot-chat': cmd = 'gh copilot suggest'; break;
+ default:
+ cmd = 'claude';
+ if (skipPerms) cmd += ' --dangerously-skip-permissions';
+ }
+ } else if (tool === 'codex') {
cmd = `codex resume ${sessionId}`;
} else if (tool === 'qwen') {
cmd = `qwen -r ${sessionId}`;
@@ -291,8 +309,14 @@ function openInTerminal(sessionId, tool, flags, projectDir, terminalId) {
}
}
} else if (platform === 'linux' && isWSL()) {
- assertSafeSessionId(sessionId);
- const tag = sessionTag(sessionId);
+ let effectiveSessionId = sessionId;
+ if (fresh) {
+ // No session yet — generate a placeholder so window tagging still works.
+ effectiveSessionId = 'fresh-' + Date.now().toString(36);
+ } else {
+ assertSafeSessionId(sessionId);
+ }
+ const tag = sessionTag(effectiveSessionId);
const tid = terminalId || 'wsl-windows-terminal';
const winSide = isWindowsSidePath(projectDir);
termLog('TERM', `WSL launch: winSide=${winSide} terminal=${tid}`);