From 3cbef2e389d5e9ef35ac51df95a4accca17e79a3 Mon Sep 17 00:00:00 2001 From: NovakPAai Date: Fri, 24 Jul 2026 13:40:15 +0300 Subject: [PATCH] feat: detect missing project folders + offer GitHub re-clone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a registered project folder is deleted/moved on disk, the Projects launcher now flags it instead of failing with a confusing "invalid path". - projects.js: pathExists() on-disk check; cloneRepo hardening — anchored GitHub-remote regex + realpath-of-nearest-ancestor containment guard (closes a symlinked-parent escape reachable via the "folder missing" window) - server.js: GET /api/projects/manual returns `exists`; /api/launch returns {missing:true, remoteUrl, projectId} before the generic safety check; new POST /api/projects/reclone restores the folder at its original path, with a per-id in-flight guard (409) - frontend: missing tiles show a role="note" disclaimer + Re-clone/Remove; launch-time misses (app.js + detail.js resume) offer a re-clone dialog; shared anchored isGithubRemote(); a11y — dialog semantics, Escape close, focus-on-open, aria-busy, AA-contrast warning text - tests: pathExists + cloneRepo guardrails (symlink-ancestor, control chars); headless render check in scratchpad; end-to-end server smoke green --- docs/design/missing-project-detection.md | 69 +++++++++++ specs/missing-project-detection.feature | 50 ++++++++ src/frontend/app.js | 143 +++++++++++++++++++++-- src/frontend/detail.js | 5 + src/frontend/index.html | 2 +- src/frontend/styles.css | 26 +++++ src/projects.js | 54 ++++++++- src/server.js | 73 +++++++++++- test/projects-missing.test.js | 115 ++++++++++++++++++ 9 files changed, 523 insertions(+), 14 deletions(-) create mode 100644 docs/design/missing-project-detection.md create mode 100644 specs/missing-project-detection.feature create mode 100644 test/projects-missing.test.js diff --git a/docs/design/missing-project-detection.md b/docs/design/missing-project-detection.md new file mode 100644 index 0000000..a248d91 --- /dev/null +++ b/docs/design/missing-project-detection.md @@ -0,0 +1,69 @@ +# Missing-project detection + re-clone offer + +## Цель +Когда зарегистрированный проект удалён/перемещён на диске, Projects-лаунчер должен +это заметить: показать дисклеймер на плитке и, при попытке запуска, вернуть понятную +ошибку «папка отсутствует» с предложением заново скачать актуальную версию с GitHub. + +## Проблема +`projects.json` хранит путь проекта. Если пользователь удалил репо (`rm -rf`), +плитка остаётся, а запуск (`POST /api/launch`) падает с общей ошибкой +`invalid or unsafe project path` — пользователь не понимает причину. + +## Данные +- Реестр: `~/.codedash/projects.json` — `{ id, name, path, source, remoteUrl, defaultBranch }`. +- Признак существования на диске выводится динамически (`fs.statSync().isDirectory()`), + в файл не пишется — состояние диска может меняться между запросами. + +## Изменения API +- `GET /api/projects/manual` — к каждому проекту добавляется `exists: boolean`. + `git` вычисляется только когда `exists === true`. +- `POST /api/launch` — если `project` передан и папки нет на диске, вернуть + `400 { ok:false, error:'project folder is missing on disk', missing:true, + remoteUrl, projectId }` вместо общей ошибки. Проверка идёт до `isSafeLaunchPath`, + чтобы отличить «удалено» от «небезопасный путь». +- `POST /api/projects/reclone` — новый маршрут. Тело `{ id }`. Находит проект в + реестре, требует непустой `remoteUrl`, клонирует `remoteUrl` в **исходный** `path` + (не в свежий `~/code/`), чтобы восстановить папку ровно там, где она была. + Переиспользует `cloneRepo` (та же защита: только GitHub-remote, назначение под `$HOME`, + существующий-тот-же-repo → успех). + +## Стыки +- `src/projects.js` — новый экспорт `pathExists(p)`; `cloneRepo` переиспользуется. +- `src/server.js` — GET manual enrich, launch guard, новый reclone-маршрут. +- `src/frontend/app.js` — `mergeRegistryWithSessions` пробрасывает `_exists`/`_remoteUrl`; + `renderLauncherCard` рисует missing-состояние; `recloneProject()`; launch-функции + обрабатывают `data.missing`. +- `src/frontend/styles.css` — `.launcher-card-missing`, `.launcher-card-warning`. + +## UX & Accessibility +**Required UI states:** +- [x] Normal — папка на месте: обычные кнопки запуска. +- [x] Missing — папка удалена: дисклеймер `role="status"`, кнопки «Re-clone» (если есть + GitHub-remote) и «Remove»; кнопки запуска скрыты. +- [x] Loading — кнопка Re-clone: `disabled` + текст «Cloning…». +- [x] Error — reclone/launch fail: toast с текстом ошибки, кнопка возвращается в исходное. +- [x] Success — toast «Re-cloned … from GitHub», перезагрузка реестра → плитка снова обычная. + +**Keyboard/SR:** дисклеймер — `role="status"` (объявляется screen reader'ом); кнопки +имеют `aria-label`; фокус-ринг наследуется от `.git-project-launch-btn:focus-visible`. + +## Риски +- TOCTOU (папку удалили между рендером и кликом) — `addressed_in`: launch-guard в + `/api/launch` возвращает `missing:true`; `handleMissingProjectLaunch` предлагает reclone. +- `missing:true` глобален для `/api/launch`, поэтому обрабатывается на ВСЕХ трёх точках + запуска — `addressed_in`: `app.js launchNewProjectSession`/`resumeLastProjectSession` и + `detail.js launchSession` (session-detail «Resume»). +- Symlink-ancestor обход home-containment в `cloneRepo` (окно «папка отсутствует») — + `addressed_in`: `projects.js realpathOfNearestAncestor` + `isUnderHome` перед `git clone`. +- reclone для manual-проекта вне `$HOME` или с non-GitHub remote — `cloneRepo` вернёт + понятную ошибку, показываем toast (`addressed`: сообщение об ошибке). +- Проект без `remoteUrl` (локальная папка) — кнопка Re-clone не рисуется, только Remove. +- Параллельные reclone одного id — `addressed_in`: `_inFlightReclone` Set в `server.js` (409). +- HTTP-уровневые тесты новых маршрутов (`/api/launch missing`, `/api/projects/reclone`) — + `deferred_to`: follow-up. `startServer` не имеет teardown-seam (интервалы autoSync/heartbeat + держат event loop), поэтому route-тест требует рефактора извлечения маршрутов. Interim + evidence: scratchpad smoke (register → delete → `exists:false` → `missing:true` → reclone + guardrails) — GREEN. Unit-тесты `pathExists`/`cloneRepo` покрыты. +- Systemic (pre-existing, не в этом PR): mutating POST-маршруты не проверяют `Origin` — + `deferred_to`: отдельный follow-up (repo-wide CSRF hardening). diff --git a/specs/missing-project-detection.feature b/specs/missing-project-detection.feature new file mode 100644 index 0000000..a349232 --- /dev/null +++ b/specs/missing-project-detection.feature @@ -0,0 +1,50 @@ +Feature: Missing-project detection and re-clone offer on the Projects launcher + + Background: + Given a project "my-repo" is registered with remoteUrl "https://github.com/me/my-repo.git" + + Scenario: Happy path — folder present renders normal launch controls + Given the folder for "my-repo" exists on disk + When I open the Projects landing + Then the tile shows the ▶ New / Last / Terminal launch controls + And no "folder is missing" disclaimer is shown + + Scenario: Empty/missing state — deleted folder shows disclaimer + Given the folder for "my-repo" was deleted from disk + When I open the Projects landing + Then the tile is marked as missing + And a disclaimer says the folder is missing and can be re-cloned from GitHub + And the ▶ New / Last launch controls are hidden + And a "Re-clone" and a "Remove" button are shown + + Scenario: Loading state — re-clone in progress + Given the folder for "my-repo" is missing + When I click "Re-clone" + Then the button is disabled and shows "Cloning…" + + Scenario: Success — re-clone restores the folder and normal controls + Given the folder for "my-repo" is missing + When I click "Re-clone" and the clone succeeds + Then a toast confirms the folder was re-cloned from GitHub + And the registry is refreshed and the tile returns to the normal launch state + + Scenario: Error — launch of a deleted folder returns an actionable response + Given the folder for "my-repo" was deleted after the page loaded + When I click ▶ New on the tile + Then the server responds 400 with missing=true and the project's remoteUrl + And the UI tells me the folder is missing and offers to re-clone it + + Scenario: Negative — local project without a GitHub remote cannot be re-cloned + Given a project "local-only" is registered with no remoteUrl + And its folder is missing on disk + When I open the Projects landing + Then the disclaimer tells me to restore the folder or remove it from the list + And no "Re-clone" button is shown, only "Remove" + + Scenario: Edge — re-clone into a path outside the home directory fails cleanly + Given a project whose stored path is outside the home directory is missing + When I click "Re-clone" + Then a toast shows the clone error and the button returns to "Re-clone" + + # N/A: keyboard-only — reuses existing .git-project-launch-btn focus-ring and aria-labels; + # no new focus-trap or shortcut introduced. diff --git a/src/frontend/app.js b/src/frontend/app.js index 0fd1f8d..77a5eac 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -2075,6 +2075,14 @@ function scrollToInstallAgents() { if (section && section.scrollIntoView) section.scrollIntoView({ behavior: 'smooth', block: 'center' }); } +// Anchored GitHub-remote check, matching the server's cloneRepo regex +// (src/projects.js). Used to decide whether to offer "Re-clone" — an unanchored +// substring test would show the button for a spoofed URL like +// https://evil.com/github.com/... that the server would then reject. +function isGithubRemote(url) { + return typeof url === 'string' && /^(https:\/\/github\.com\/|git@github\.com:)/.test(url); +} + // Live Workspace panes whose resolved cwd is this project folder. function _projectLiveTerminals(projPath) { if (!projPath || typeof _wsAllPanes !== 'function') return []; @@ -2100,9 +2108,15 @@ function renderLauncherCard(projKey, projInfo) { var preferredTool = pickPreferredTool(projPath, lastSession); var installed = window.installedAgents || []; - var canLaunch = installed.length > 0 && !!projPath; - - var html = '
'; + // A registered folder can be deleted from disk at any time; `_exists === false` + // comes from the server's on-disk check. When missing, we suppress the launch + // controls (they'd fail) and surface a re-clone/remove path instead. + var exists = projInfo._exists !== false; + var remoteUrl = projInfo._remoteUrl || ''; + var canReclone = !exists && isGithubRemote(remoteUrl); + var canLaunch = installed.length > 0 && !!projPath && exists; + + var html = '
'; html += '
'; html += ''; html += '' + escHtml(projName) + ''; @@ -2111,10 +2125,46 @@ function renderLauncherCard(projKey, projInfo) { if (projPath) html += '
' + escHtml(projPath) + '
'; html += '
'; html += '' + (totalSessions === 0 ? 'no sessions yet' : (totalSessions + ' session' + (totalSessions === 1 ? '' : 's'))) + ''; - if (preferredTool) html += '· next: ' + escHtml(agentLabel(preferredTool)) + ''; + if (exists && preferredTool) html += '· next: ' + escHtml(agentLabel(preferredTool)) + ''; html += '
'; - html += '
'; + // Disclaimer for a deleted/moved folder — persistent descriptive content, so + // role="note" (not a live region: it's present on render, not a transient + // status update — the launch-fail case is announced via toast instead). + if (!exists) { + html += '
' + + '⚠ Folder is missing on disk — it was moved or deleted. ' + + (canReclone + ? 'Re-clone the latest version from GitHub.' + : 'Restore the folder, or remove it from the list.') + + '
'; + } + + if (!exists) { + // Missing folder: no launch controls. Offer Re-clone (when we have a GitHub + // remote) and Remove-from-registry. Only emit the actions row if at least + // one control will render, so an edge-case card isn't left with an empty box. + var missingActions = ''; + if (canReclone) { + var recloneAria = 'Re-clone ' + projName + ' from GitHub'; + missingActions += ''; + } + if (projInfo.manualId) { + missingActions += ''; + } + if (missingActions) html += '
' + missingActions + '
'; + // Keep History drill-in available even when the folder is gone (sessions + // live in the agent history dirs, not the repo). + if (totalSessions > 0) { + html += ''; + } + html += '
'; + return html; + } if (canLaunch && preferredTool) { var newAria = 'Start new ' + agentLabel(preferredTool) + ' session in ' + projName; var pickerAria = 'Pick a different agent for ' + projName; @@ -2190,8 +2240,11 @@ function mergeRegistryWithSessions(sessions) { byGit[info.key].list.push(s); }); (window.manualProjects || []).forEach(function(p) { + // `exists` is undefined for older payloads / session-derived merges — treat + // absence as "present" so we never falsely flag a folder as missing. + var exists = p.exists !== false; 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 }; + byGit[p.path] = { name: p.name, list: [], path: p.path, source: p.source || 'manual', manualId: p.id, _git: p.git, _lastAdded: p.addedAt, _exists: exists, _remoteUrl: p.remoteUrl || '' }; } else { // When a registry entry overlaps a session-derived entry, the registry's // `source` (manual / github-clone / auto) is the authoritative one — only @@ -2203,7 +2256,7 @@ function mergeRegistryWithSessions(sessions) { var resolvedSource = keepRegistrySource ? p.source : ((!existing.source || existing.source === 'session') ? 'manual' : existing.source); - byGit[p.path] = { ...existing, manualId: p.id, source: resolvedSource }; + byGit[p.path] = { ...existing, manualId: p.id, source: resolvedSource, _exists: exists, _remoteUrl: p.remoteUrl || existing._remoteUrl || '' }; } }); return byGit; @@ -2979,7 +3032,13 @@ document.addEventListener('keydown', function(e) { return; } if (e.key === 'Escape') { - if (pendingDelete) { + // Close the confirm overlay whenever it's on screen — it's shared by the + // delete dialog (sets pendingDelete) AND the "project folder is missing" + // re-clone dialog (does not), so keying off pendingDelete alone left the + // latter undismissable. + var confirmOverlay = document.getElementById('confirmOverlay'); + var confirmOpen = confirmOverlay && confirmOverlay.style.display === 'flex'; + if (pendingDelete || confirmOpen) { closeConfirm(); } else { closeDetail(); @@ -3713,6 +3772,8 @@ async function launchNewProjectSession(projectPath, tool, btn) { window.codbashSettings.lastUsedByPath = window.codbashSettings.lastUsedByPath || {}; window.codbashSettings.lastUsedByPath[projectPath] = t; } + } else if (data.missing) { + handleMissingProjectLaunch(data, projectPath.split('/').pop()); } else { showToast('Launch failed: ' + (data.error || 'unknown')); } @@ -4028,6 +4089,7 @@ async function resumeLastProjectSession(sessionId, tool, projectPath, btn) { }); var data = await resp.json(); if (data.ok) showToast('Resuming ' + sessionId.slice(0, 8) + '…'); + else if (data.missing) handleMissingProjectLaunch(data, (projectPath || '').split('/').pop()); else showToast('Resume failed: ' + (data.error || 'unknown')); } catch (e) { showToast('Resume failed: ' + e.message); @@ -4458,6 +4520,71 @@ async function cloneRepoAndAdd(btn) { } } +// Re-clone a registered project whose folder was deleted, restoring it at its +// original path. Driven by the "Re-clone" button on a missing launcher card and +// by the re-clone confirm offered after a launch hits a missing folder. +async function recloneProject(id, name, btn) { + if (!id) { showToast('Missing project id'); return; } + var safeName = name || 'project'; + if (btn) { + btn.disabled = true; + btn.setAttribute('aria-busy', 'true'); + btn.innerHTML = '↓ Cloning…'; + } else { + // Driven from the confirm dialog (no button to relabel) — give immediate + // feedback so the multi-second clone isn't silent. + showToast('Cloning ' + safeName + ' from GitHub…'); + } + try { + var resp = await fetch('/api/projects/reclone', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: id }), + }); + var data = await resp.json(); + if (data.ok) { + showToast(data.alreadyExisted ? 'Folder already present for ' + safeName : 'Re-cloned ' + safeName + ' from GitHub'); + await loadManualProjects(); + } else { + if (btn) { btn.disabled = false; btn.removeAttribute('aria-busy'); btn.innerHTML = '↓ Retry'; } + showToast('Re-clone failed: ' + (data.error || 'unknown')); + } + } catch (e) { + if (btn) { btn.disabled = false; btn.removeAttribute('aria-busy'); btn.innerHTML = '↓ Retry'; } + showToast('Re-clone failed: ' + (e && e.message)); + } +} + +// Shared handler for a launch that failed because the project folder is gone. +// Refreshes the registry (so the tile flips to its missing state) and, when we +// know a GitHub remote, offers a one-click re-clone via the confirm overlay. +function handleMissingProjectLaunch(data, name) { + var safeName = String(name || 'This project').replace(/[\r\n\t\x00-\x1f]/g, ' ').slice(0, 200); + loadManualProjects(); + var overlay = document.getElementById('confirmOverlay'); + var canReclone = data && data.projectId && isGithubRemote(data.remoteUrl); + if (!canReclone || !overlay) { + // No re-clone possible (or no overlay in the DOM) — a plain toast with the + // recovery hint is the fallback. + showToast('"' + safeName + '" folder is missing on disk — restore it or remove it from Projects'); + return; + } + document.getElementById('confirmTitle').textContent = 'Project folder is missing'; + document.getElementById('confirmText').textContent = + '"' + safeName + '" was moved or deleted from disk. Re-clone the latest version from GitHub?'; + document.getElementById('confirmId').textContent = ''; + var btn = document.getElementById('confirmAction'); + btn.textContent = 'Re-clone'; + btn.className = 'launch-btn btn-primary'; + btn.onclick = function() { + overlay.style.display = 'none'; + recloneProject(data.projectId, safeName, null); + }; + overlay.style.display = 'flex'; + // Move focus into the dialog so keyboard/SR users land on the primary action. + setTimeout(function() { if (btn && btn.focus) btn.focus(); }, 0); +} + // ── Initialization ───────────────────────────────────────────── async function loadAgentsAndSettings() { diff --git a/src/frontend/detail.js b/src/frontend/detail.js index 6563e95..3fb7cd1 100644 --- a/src/frontend/detail.js +++ b/src/frontend/detail.js @@ -438,6 +438,11 @@ function launchSession(sessionId, tool, project, flags, resumeTarget) { return resp.json(); }).then(function(data) { if (data.ok) showToast('Launched in terminal'); + // Deleted project folder — surface the same "missing → offer re-clone" flow + // the Projects launcher uses, instead of a dead-end error toast. + else if (data.missing && typeof handleMissingProjectLaunch === 'function') { + handleMissingProjectLaunch(data, (project || '').split('/').pop()); + } else showToast('Launch failed: ' + (data.error || 'unknown')); }).catch(function() { showToast('Launch failed'); diff --git a/src/frontend/index.html b/src/frontend/index.html index 64268ac..e2ab508 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -276,7 +276,7 @@
-
+