From 326677e848907ae34294bc4fb07aae65d2b1fcc4 Mon Sep 17 00:00:00 2001 From: sean10 Date: Mon, 8 Jun 2026 18:01:10 +0800 Subject: [PATCH 1/9] feat(kiro): support new file-based session format (~May 2026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kiro CLI moved to per-session files under ~/.kiro/sessions/cli/: .json — metadata (session_id, cwd, title, created_at, updated_at) .jsonl — events: Prompt / AssistantMessage / ToolResults - Add KIRO_SESSIONS_DIR + scanKiroCliSessions() and loadKiroCliDetail() - Index the .jsonl files in _buildSessionFileIndex so they resolve to { format: 'kiro-cli' }, wiring detail/preview/search/replay/export end-to-end (previously listed but "Session file not found" on open) - Validate the sessionId as a strict UUID in loadKiroCliDetail before path.join to close a path-traversal vector on untrusted input - Add fixture-based tests covering scan, detail, path-traversal rejection, index resolution, and end-to-end wiring Old SQLite-based Kiro sessions remain fully supported alongside. --- src/data.js | 137 +++++++++++++++++++++++++++++++++ test/kiro-cli-session.test.js | 141 ++++++++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 test/kiro-cli-session.test.js diff --git a/src/data.js b/src/data.js index 601599b..ddbb749 100644 --- a/src/data.js +++ b/src/data.js @@ -164,6 +164,7 @@ const OMP_AGENT_DIR = process.env.OMP_CODING_AGENT_DIR || path.join(ALL_HOMES[0] const PI_SESSIONS_DIR = path.join(PI_AGENT_DIR, 'sessions'); const OMP_SESSIONS_DIR = path.join(OMP_AGENT_DIR, 'sessions'); const KIRO_DB = path.join(ALL_HOMES[0], 'Library', 'Application Support', 'kiro-cli', 'data.sqlite3'); +const KIRO_SESSIONS_DIR = path.join(ALL_HOMES[0], '.kiro', 'sessions', 'cli'); const COPILOT_SESSION_DIR = path.join(ALL_HOMES[0], '.copilot', 'session-state'); const COPILOT_JB_DIR = path.join(ALL_HOMES[0], '.copilot', 'jb'); const KILO_DB = path.join(ALL_HOMES[0], '.local', 'share', 'kilo', 'kilo.db'); @@ -1837,6 +1838,89 @@ function loadKiroDetail(conversationId) { } } +// ── Kiro CLI (new format: ~/.kiro/sessions/cli/, since ~May 2026) ───────────── + +function scanKiroCliSessions() { + const sessions = []; + if (!fs.existsSync(KIRO_SESSIONS_DIR)) return sessions; + + let files; + try { files = fs.readdirSync(KIRO_SESSIONS_DIR); } catch { return sessions; } + + for (const f of files) { + if (!f.endsWith('.json')) continue; + const sessionId = f.slice(0, -5); + // skip if not a strict UUID name + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) continue; + + try { + const meta = JSON.parse(fs.readFileSync(path.join(KIRO_SESSIONS_DIR, f), 'utf8')); + const createdMs = meta.created_at ? new Date(meta.created_at).getTime() : 0; + const updatedMs = meta.updated_at ? new Date(meta.updated_at).getTime() : 0; + const jsonlPath = path.join(KIRO_SESSIONS_DIR, sessionId + '.jsonl'); + const fileSize = fs.existsSync(jsonlPath) ? fs.statSync(jsonlPath).size : 0; + + sessions.push({ + id: sessionId, + tool: 'kiro', + format: 'kiro-cli', + project: meta.cwd || '', + project_short: (meta.cwd || '').replace(os.homedir(), '~'), + first_ts: createdMs || Date.now(), + last_ts: updatedMs || Date.now(), + messages: fileSize > 0 ? Math.max(2, Math.floor(fileSize / 3000)) : 0, + first_message: meta.title || '', + has_detail: fs.existsSync(jsonlPath), + file_size: fileSize, + detail_messages: 0, + }); + } catch {} + } + + return sessions; +} + +function loadKiroCliDetail(sessionId) { + // sessionId is untrusted here (resolved from a request param) — require a + // strict UUID before building the path to close a path-traversal vector. + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) { + return { messages: [] }; + } + const jsonlPath = path.join(KIRO_SESSIONS_DIR, sessionId + '.jsonl'); + if (!fs.existsSync(jsonlPath)) return { messages: [] }; + + const messages = []; + try { + const lines = fs.readFileSync(jsonlPath, 'utf8').split('\n'); + for (const line of lines) { + if (!line.trim()) continue; + let entry; + try { entry = JSON.parse(line); } catch { continue; } + + const { kind, data } = entry; + if (!data) continue; + + if (kind === 'Prompt') { + // data.content is array of {kind, data} blocks + const text = (data.content || []) + .filter(b => b.kind === 'text') + .map(b => b.data || '') + .join('').trim(); + if (text) messages.push({ role: 'user', content: text.slice(0, 2000), uuid: data.message_id || '' }); + + } else if (kind === 'AssistantMessage') { + const text = (data.content || []) + .filter(b => b.kind === 'text') + .map(b => b.data || '') + .join('').trim(); + if (text) messages.push({ role: 'assistant', content: text.slice(0, 2000), uuid: data.message_id || '' }); + } + } + } catch {} + + return { messages: messages.slice(0, 200) }; +} + // ── Copilot Chat (VS Code extension) ───────────────────────── // Build workspace-hash -> project path mapping for VS Code workspaceStorage @@ -3468,6 +3552,14 @@ function loadSessions() { } } catch {} + // Load Kiro CLI sessions (new format: ~/.kiro/sessions/cli/, since ~May 2026) + try { + const kiroCliSessions = scanKiroCliSessions(); + for (const ks of kiroCliSessions) { + sessions[ks.id] = ks; + } + } catch {} + // Load Copilot CLI sessions try { const copilotSessions = scanCopilotCliSessions(); @@ -3737,6 +3829,9 @@ function loadSessionDetail(sessionId, project) { if (found.format === 'kiro') { return loadKiroDetail(sessionId); } + if (found.format === 'kiro-cli') { + return loadKiroCliDetail(sessionId); + } // Copilot CLI uses JSONL events if (found.format === 'copilot') { @@ -3972,6 +4067,7 @@ function exportSessionMarkdown(sessionId, project) { found.format === 'cursor' ? loadCursorDetail(sessionId) : found.format === 'opencode' ? loadOpenCodeDetail(sessionId) : found.format === 'kiro' ? loadKiroDetail(sessionId) : + found.format === 'kiro-cli' ? loadKiroCliDetail(sessionId) : found.format === 'kilo' ? loadKiloCliDetail(sessionId) : found.format === 'qwen' ? loadQwenDetail(sessionId, found.file) : found.format === 'pi' ? loadPiDetail(sessionId, found.file) : @@ -4117,6 +4213,20 @@ function _buildSessionFileIndex() { } catch {} } + // Index Kiro CLI file-based sessions (~/.kiro/sessions/cli/, since ~May 2026) + if (fs.existsSync(KIRO_SESSIONS_DIR)) { + try { + for (const f of fs.readdirSync(KIRO_SESSIONS_DIR)) { + if (!f.endsWith('.jsonl')) continue; + const sid = f.slice(0, -6); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sid)) continue; + if (!_sessionFileIndex[sid]) { + _sessionFileIndex[sid] = { file: path.join(KIRO_SESSIONS_DIR, f), format: 'kiro-cli', sessionId: sid }; + } + } + } catch {} + } + _sessionFileIndexTs = now; } @@ -4621,6 +4731,12 @@ function getSessionPreview(sessionId, project, limit) { return { role: m.role, content: m.content.slice(0, 300) }; }); } + if (found.format === 'kiro-cli') { + var detail = loadKiroCliDetail(sessionId); + return detail.messages.slice(0, limit).map(function(m) { + return { role: m.role, content: m.content.slice(0, 300) }; + }); + } // OpenCode: use loadOpenCodeDetail and slice if (found.format === 'opencode') { @@ -4753,6 +4869,13 @@ function buildSearchIndex(sessions) { texts.push({ role: msg.role, content: msg.content.slice(0, 500) }); } } + } else if (found.format === 'kiro-cli') { + const detail = loadKiroCliDetail(s.id); + for (const msg of detail.messages) { + if (msg.content && !isSystemMessage(msg.content)) { + texts.push({ role: msg.role, content: msg.content.slice(0, 500) }); + } + } } else if (found.format === 'cursor') { const detail = loadCursorDetail(s.id); for (const msg of detail.messages) { @@ -4909,6 +5032,18 @@ function getSessionReplay(sessionId, project) { }); } } + } else if (found.format === 'kiro-cli') { + const detail = loadKiroCliDetail(sessionId); + for (const msg of detail.messages) { + if (msg.content && !isSystemMessage(msg.content)) { + messages.push({ + role: msg.role, + content: msg.content.slice(0, 3000), + timestamp: 0, + ms: 0, + }); + } + } } else if (found.format === 'cursor') { const detail = loadCursorDetail(sessionId); for (const msg of detail.messages) { @@ -6393,5 +6528,7 @@ module.exports = { findPiSessionByResumeTarget, _sessionsNeedRescan, _updateScanMarkers, + scanKiroCliSessions, + loadKiroCliDetail, }, }; diff --git a/test/kiro-cli-session.test.js b/test/kiro-cli-session.test.js new file mode 100644 index 0000000..37ffd92 --- /dev/null +++ b/test/kiro-cli-session.test.js @@ -0,0 +1,141 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// Reload src/data with os.homedir() pointed at a temp home so KIRO_SESSIONS_DIR +// (~/.kiro/sessions/cli) resolves inside the fixture. +function freshDataWithHome(home) { + const dataPath = require.resolve('../src/data'); + const handoffPath = require.resolve('../src/handoff'); + delete require.cache[handoffPath]; + delete require.cache[dataPath]; + const oldHome = os.homedir; + os.homedir = () => home; + try { + return require('../src/data'); + } finally { + os.homedir = oldHome; + } +} + +function tmpHome() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'codbash-kiro-')); +} + +// Write a Kiro CLI session pair: .json metadata + .jsonl events. +function writeKiroCliSession(home, sessionId, meta, events) { + const dir = path.join(home, '.kiro', 'sessions', 'cli'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, sessionId + '.json'), JSON.stringify(meta)); + fs.writeFileSync( + path.join(dir, sessionId + '.jsonl'), + events.map(e => JSON.stringify(e)).join('\n') + '\n' + ); +} + +const UUID = '12345678-90ab-cdef-1234-567890abcdef'; + +function sampleMeta(cwd) { + return { + session_id: UUID, + cwd, + title: 'Fix the parser', + created_at: '2026-05-24T10:00:00.000Z', + updated_at: '2026-05-24T10:05:00.000Z', + }; +} + +function sampleEvents() { + return [ + { version: 1, kind: 'Prompt', data: { message_id: 'u1', content: [{ kind: 'text', data: 'Please fix the parser' }] } }, + { version: 1, kind: 'AssistantMessage', data: { message_id: 'a1', content: [{ kind: 'text', data: 'Parser fixed' }] } }, + { version: 1, kind: 'ToolResults', data: { results: [{ ok: true }] } }, + ]; +} + +test('scanKiroCliSessions reads metadata files into session summaries', () => { + const home = tmpHome(); + writeKiroCliSession(home, UUID, sampleMeta('/tmp/project'), sampleEvents()); + + const data = freshDataWithHome(home); + const sessions = data.__test.scanKiroCliSessions(); + + assert.equal(sessions.length, 1); + assert.equal(sessions[0].id, UUID); + assert.equal(sessions[0].tool, 'kiro'); + assert.equal(sessions[0].format, 'kiro-cli'); + assert.equal(sessions[0].project, '/tmp/project'); + assert.equal(sessions[0].first_message, 'Fix the parser'); + assert.equal(sessions[0].has_detail, true); + assert.equal(sessions[0].first_ts, Date.parse('2026-05-24T10:00:00.000Z')); + assert.equal(sessions[0].last_ts, Date.parse('2026-05-24T10:05:00.000Z')); +}); + +test('scanKiroCliSessions ignores non-UUID metadata files', () => { + const home = tmpHome(); + const dir = path.join(home, '.kiro', 'sessions', 'cli'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'not-a-uuid.json'), JSON.stringify({ title: 'nope' })); + + const data = freshDataWithHome(home); + assert.deepEqual(data.__test.scanKiroCliSessions(), []); +}); + +test('loadKiroCliDetail parses Prompt/AssistantMessage and skips ToolResults', () => { + const home = tmpHome(); + writeKiroCliSession(home, UUID, sampleMeta('/tmp/project'), sampleEvents()); + + const data = freshDataWithHome(home); + const detail = data.__test.loadKiroCliDetail(UUID); + + assert.equal(detail.messages.length, 2); + assert.deepEqual(detail.messages.map(m => m.role), ['user', 'assistant']); + assert.equal(detail.messages[0].content, 'Please fix the parser'); + assert.equal(detail.messages[1].content, 'Parser fixed'); +}); + +test('loadKiroCliDetail rejects path-traversal ids before touching the filesystem', () => { + const home = tmpHome(); + writeKiroCliSession(home, UUID, sampleMeta('/tmp/project'), sampleEvents()); + + const data = freshDataWithHome(home); + // A crafted id would resolve outside KIRO_SESSIONS_DIR without the UUID guard. + assert.deepEqual(data.__test.loadKiroCliDetail('../../../../etc/passwd'), { messages: [] }); + assert.deepEqual(data.__test.loadKiroCliDetail('..%2f..%2fsecret'), { messages: [] }); + assert.deepEqual(data.__test.loadKiroCliDetail(''), { messages: [] }); +}); + +test('findSessionFile resolves file-based Kiro sessions to the kiro-cli format', () => { + const home = tmpHome(); + writeKiroCliSession(home, UUID, sampleMeta('/tmp/project'), sampleEvents()); + + const data = freshDataWithHome(home); + const found = data.findSessionFile(UUID, '/tmp/project'); + + assert.ok(found, 'expected findSessionFile to resolve the kiro-cli session'); + assert.equal(found.format, 'kiro-cli'); + assert.equal(found.sessionId, UUID); + assert.match(found.file, /\.kiro[\/\\]sessions[\/\\]cli[\/\\]/); +}); + +test('detail, preview, search, replay, and export are wired end-to-end for kiro-cli', () => { + const home = tmpHome(); + writeKiroCliSession(home, UUID, sampleMeta('/tmp/project'), sampleEvents()); + + const data = freshDataWithHome(home); + + const detail = data.loadSessionDetail(UUID, '/tmp/project'); + assert.deepEqual(detail.messages.map(m => m.content), ['Please fix the parser', 'Parser fixed']); + + const preview = data.getSessionPreview(UUID, '/tmp/project', 10); + assert.deepEqual(preview.map(m => m.content), ['Please fix the parser', 'Parser fixed']); + + const replay = data.getSessionReplay(UUID, '/tmp/project'); + assert.deepEqual(replay.messages.map(m => m.content), ['Please fix the parser', 'Parser fixed']); + + const md = data.exportSessionMarkdown(UUID, '/tmp/project'); + assert.match(md, /Please fix the parser/); + assert.match(md, /Parser fixed/); +}); From 63f1ce79ad741aa5fda06c45e19f3e74a2f47ee4 Mon Sep 17 00:00:00 2001 From: NovakPAai Date: Fri, 24 Jul 2026 13:07:25 +0300 Subject: [PATCH 2/9] feat: real in-app auto-update for the desktop app (electron-updater) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop app showed an "Update Now" banner that called POST /api/update (npm i -g codbash-app@latest + restart). In the packaged Electron app that never worked: it updated an unrelated npm-global copy while the app kept running its bundled server, so the restart landed back on the old version. Replace it with a real in-place update via electron-updater: - Desktop: check GitHub Releases (latest-mac.yml/latest.yml), Download on click, progress, then Restart to relaunch onto the new version. autoDownload=false so the user controls the download; allowDowngrade/allowPrerelease pinned false. - Add mac `zip` target (Squirrel.Mac can't apply a DMG) and a Windows `nsis` target; document the zip/blockmap publish flow in RELEASE.md. - The npm-CLI self-update path is unchanged; the server refuses /api/update with 400 when CODBASH_DESKTOP=1 (set by desktop/main.js) so it can't half-update. Hardening (from security review): - will-navigate guard pins the window to the local server origin, so the powerful updater IPC bridge can't be reached by an off-origin page. - Main-process validates state before download/install (renderer buttons are UX, not the security boundary) and validates the IPC sender frame. Correctness (from code review): - Event listeners attach at autoUpdater creation so no check result is lost to a boot race; a single initial check (renderer-triggered, reload-safe). - Periodic 6h re-check skips while downloading/downloaded so it can't wipe the "ready to restart" banner. - Error state offers Check-again + Open-download-page; download is idempotent against a fast double-click. Test: test/desktop-update-guard.test.js asserts /api/update → 400 in desktop mode. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 1 + desktop/RELEASE.md | 69 +++++++++-- desktop/main.js | 197 +++++++++++++++++++++--------- desktop/package.json | 32 ++++- desktop/preload.js | 13 ++ src/frontend/app.js | 121 ++++++++++++++++++ src/frontend/index.html | 4 +- src/server.js | 9 ++ test/desktop-update-guard.test.js | 83 +++++++++++++ 9 files changed, 458 insertions(+), 71 deletions(-) create mode 100644 test/desktop-update-guard.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 653d6b4..9669e74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,7 @@ docs/ - **Running-agents sidebar tree** (Workspace) is built from `activeSessions` grouped by real `cwd` and labeled by agent — do NOT reconstruct it from static project config - **Saved layouts round-trip the full pane** — `sanitizePane` preserves `cmd` + `prefill` + `cwd` (not just `cmd`); dropping any of these silently loses the user's launch command on restore - **No `window.prompt` in Electron** — use `codbashPrompt()` (app.js) for any text input; the native prompt is a no-op in the desktop shell +- **Two update paths, mutually exclusive** — the npm CLI self-updates via `POST /api/update` (`npm i -g codbash-app@latest` + restart). The **desktop app updates in-place via `electron-updater`** (download-on-click → restart, driven by the frontend banner over `window.codbashDesktop.updater` IPC and `main.js`). `desktop/main.js` sets `CODBASH_DESKTOP=1` so the server **refuses `/api/update` (400)** — running `npm i -g` inside the signed, read-only app bundle would update an unrelated global copy and the restart would land back on the bundled old version. macOS in-place update needs the **`.zip` target + `latest-mac.yml`** (Squirrel.Mac can't apply a DMG) and a signed build; on failure the banner falls back to opening the releases page (`codbash:open-releases`). See `desktop/RELEASE.md` §4. ## API routes diff --git a/desktop/RELEASE.md b/desktop/RELEASE.md index 4603283..ef212c0 100644 --- a/desktop/RELEASE.md +++ b/desktop/RELEASE.md @@ -61,6 +61,10 @@ env -u HTTPS_PROXY -u HTTP_PROXY -u ALL_PROXY -u https_proxy -u http_proxy -u al - Pass `--arm64 --x64` explicitly. Passing a target on the CLI (`… dmg`) **overrides** the `arch` array in `package.json`, so `npm run dist:mac` builds only the host arch. +- The `mac.target` array now builds **both `dmg` and `zip`** for each arch. The + DMG is the first-install download; the **`.zip` is what electron-updater + installs from** (Squirrel.Mac can't apply a DMG). `latest-mac.yml` must + reference the zips, so ship the zips + their blockmaps in the Release too. - The signing identity is pinned in `package.json` → `build.mac.identity` as `"Valeriy Kovalsky (A933C2TJXU)"` — **without** the `Developer ID Application:` prefix (electron-builder rejects the prefix). @@ -81,12 +85,16 @@ done # then rewrite dist/latest-mac.yml with the new size+sha512 for both DMGs. ``` -Publish: +Publish (include the **zips + their blockmaps** — electron-updater installs from +the zip, and `latest-mac.yml` points at it; a Release with only the DMGs makes +in-app update fail with a 404 for the zip): ```bash gh release create v --title "codbash (macOS desktop)" --target main \ - dist/codbash--arm64.dmg dist/codbash--arm64.dmg.blockmap \ - dist/codbash-.dmg dist/codbash-.dmg.blockmap \ + dist/codbash--arm64.dmg dist/codbash--arm64.dmg.blockmap \ + dist/codbash-.dmg dist/codbash-.dmg.blockmap \ + dist/codbash--arm64-mac.zip dist/codbash--arm64-mac.zip.blockmap \ + dist/codbash--mac.zip dist/codbash--mac.zip.blockmap \ dist/latest-mac.yml ``` @@ -109,15 +117,52 @@ hdiutil attach /tmp/q.dmg -nobrowse; spctl -a -vvv -t exec "/Volumes/codbash .exe (+ .blockmap) and dist/latest.yml +gh release upload v \ + "dist/codbash Setup .exe" "dist/codbash Setup .exe.blockmap" \ + dist/latest.yml +``` + +> The `nsis` target (not `portable`) is required for electron-updater. +> +> **⚠️ Security — do not ship Windows *auto-update* to real users unsigned.** +> electron-updater's `verifyUpdateCodeSignature` compares the running app's +> Authenticode publisher against the downloaded installer's; with no Windows +> code-signing cert on either side that check is a no-op, leaving only the +> `sha512` in `latest.yml` (which comes from the same release pipeline an +> attacker would compromise). An unsigned *in-place auto-updater* is strictly +> worse than a manual download-and-run, because it removes the last human +> checkpoint. Until an OV/EV cert is in place, keep Windows on the notify-only +> fallback (open the releases page) rather than enabling silent download+install. +> `allowDowngrade` and `allowPrerelease` are pinned `false` in `main.js` so a +> mistagged or rolled-back release can't reach stable users regardless. ## CI note diff --git a/desktop/main.js b/desktop/main.js index b272059..87b9b40 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -10,7 +10,6 @@ const { app, BrowserWindow, shell, dialog, Menu, ipcMain } = require('electron'); const { spawn } = require('child_process'); const http = require('http'); -const https = require('https'); const net = require('net'); const path = require('path'); const fs = require('fs'); @@ -111,7 +110,11 @@ function startServer(port) { const entry = resolveServerEntry(); const nodeBin = resolveNodeBin(); serverProc = spawn(nodeBin, [entry, 'run', '--port=' + port, '--host=127.0.0.1', '--no-browser'], { - env: Object.assign({}, process.env, { CODEDASH_HOST: '127.0.0.1' }), + // CODBASH_DESKTOP=1 tells the server it runs inside the Electron shell, so the + // web self-update route (`POST /api/update` → `npm i -g`) refuses: it would + // update an unrelated npm-global copy while the app keeps running its bundled + // server. In the desktop app, updates go through electron-updater (below). + env: Object.assign({}, process.env, { CODEDASH_HOST: '127.0.0.1', CODBASH_DESKTOP: '1' }), stdio: ['ignore', 'pipe', 'pipe'], }); serverProc.stdout.on('data', function (d) { process.stdout.write('[codbash] ' + d); }); @@ -163,6 +166,66 @@ function registerIpc() { // The renderer decides a shortcut had no in-page meaning (e.g. Cmd+W outside // the Workspace) and asks us to close the window instead. ipcMain.on('codbash:close-window', function () { if (win) { try { win.close(); } catch (_e) {} } }); + + // ── In-app updater (electron-updater) ────────────────────────────────────── + // The renderer's update banner drives these. autoDownload is off, so the flow + // is: check → 'available' → user clicks Download → downloadUpdate() → progress + // → 'downloaded' → user clicks Restart → quitAndInstall(). See initAutoUpdater. + // + // Defense in depth, because these calls are powerful (force-download + + // quitAndInstall = force-relaunch of the whole app): + // 1. isTrustedSender — only honor calls from a frame served by our own local + // server, so a page the window somehow navigated to can't drive updates. + // 2. _updateState gating — download only when an update is 'available', + // install only once it's 'downloaded'. The renderer's button visibility is + // a UX convenience, NOT the security boundary; the main process enforces + // the sequence so an out-of-order/forged call can't force a relaunch. + ipcMain.handle('codbash:update-check', function (event) { + if (!isTrustedSender(event)) return { error: 'forbidden' }; + if (!getAutoUpdater()) return { unavailable: true }; + // Won't clobber an in-flight/downloaded update (see maybeCheckForUpdates). + return maybeCheckForUpdates().then(function () { return { ok: true }; }); + }); + ipcMain.handle('codbash:update-download', function (event) { + if (!isTrustedSender(event)) return { error: 'forbidden' }; + if (_updateState !== 'available') return { error: 'no update available' }; + if (_downloadInFlight) return { ok: true, already: true }; // second click before first progress + const u = getAutoUpdater(); + if (!u) return { unavailable: true }; + _downloadInFlight = true; // set synchronously so a fast double-click can't double-download + return u.downloadUpdate().then(function () { return { ok: true }; }) + .catch(function (e) { _downloadInFlight = false; return { error: String((e && e.message) || e) }; }); + }); + ipcMain.handle('codbash:update-install', function (event) { + if (!isTrustedSender(event)) return { error: 'forbidden' }; + if (_updateState !== 'downloaded') return { error: 'update not downloaded' }; + const u = getAutoUpdater(); + if (!u) return { unavailable: true }; + // Defer so the IPC reply is sent before the app tears down. before-quit sets + // app.isQuitting and kills the server child, so its exit handler stays quiet. + setImmediate(function () { try { u.quitAndInstall(); } catch (_e) {} }); + return { ok: true }; + }); + // Fallback when in-place update can't apply (e.g. unsigned build): open the + // GitHub releases page so the user can still grab the installer manually. + ipcMain.on('codbash:open-releases', function (event) { + if (!isTrustedSender(event)) return; + shell.openExternal('https://github.com/vakovalskii/codbash/releases/latest') + .catch(function (e) { process.stderr.write('[desktop] openExternal failed: ' + ((e && e.message) || e) + '\n'); }); + }); +} + +// Only trust IPC from a frame our own local server actually served. Blocks a +// page the window was somehow navigated to (see the will-navigate guard in +// createWindow — this is the second, independent layer) from reaching the +// updater bridge. serverPort is 0 until the server binds; reject until then. +function isTrustedSender(event) { + try { + const url = event && event.senderFrame && event.senderFrame.url; + return !!url && serverPort > 0 && url.indexOf('http://127.0.0.1:' + serverPort + '/') === 0; + } catch (_e) { + return false; + } } async function createWindow() { @@ -188,6 +251,18 @@ async function createWindow() { return { action: 'allow' }; }); + // Pin top-level navigation to our own local server. The preload bridge + // (window.codbashDesktop, incl. the powerful updater) is bound to the WINDOW, + // not an origin — so without this, navigating the window elsewhere (a stray + // location.href, a target=_top link, a future CSP regression) would hand that + // page the update-download/install IPC. External http(s) is opened in the real + // browser instead; anything else off-origin is simply blocked. + win.webContents.on('will-navigate', function (event, url) { + if (serverPort > 0 && url.indexOf('http://127.0.0.1:' + serverPort + '/') === 0) return; + event.preventDefault(); + if (/^https?:/i.test(url)) shell.openExternal(url).catch(function () {}); + }); + // Cmd/Ctrl+W would hit the native menu's "Close Window" before the page ever // sees the keystroke. Intercept it here so it can close the ACTIVE TAB instead // (Chrome-like). We forward it to the renderer, which closes a tab or — if @@ -211,65 +286,77 @@ async function createWindow() { } } -// Simple semver-ish "is a newer than b" (major.minor.patch). -function isNewerVersion(a, b) { - const pa = String(a).split('.').map(Number); - const pb = String(b).split('.').map(Number); - for (let i = 0; i < 3; i++) { - if ((pa[i] || 0) > (pb[i] || 0)) return true; - if ((pa[i] || 0) < (pb[i] || 0)) return false; +// ── In-app auto-update (electron-updater) ──────────────────────────────────── +// Real in-place update: the app downloads the new build from GitHub Releases +// (read via latest-mac.yml / latest.yml) and relaunches onto it — no manual DMG +// download. macOS in-place update REQUIRES a signed build (codbash is signed + +// notarized since v7.14.4); Windows uses the NSIS installer. autoDownload is off +// so the renderer's banner controls when the download starts (Download button) +// and when to relaunch (Restart button). If the updater can't apply (unsigned / +// dev / download error) we emit 'error' and the renderer falls back to opening +// the releases page (codbash:open-releases IPC). +let _autoUpdater = null; +let _autoUpdaterDisabled = false; // hard off for this run (dev/smoke) — never retry +// Lifecycle state, the security boundary for the download/install IPC calls (not +// the renderer's button state). Updated from the autoUpdater events below. +let _updateState = 'idle'; // idle | checking | available | downloading | downloaded | error +let _downloadInFlight = false; // guards against a double downloadUpdate() (fast double-click) +let _updateTimer = null; // 6h re-check interval id (so it can be cleared) + +// Lazy + guarded: electron-updater is a packaged runtime dep. In a from-source +// run (npm start) or an unpacked build it can't apply an update, so we skip it +// and let the renderer degrade to the manual releases page. A dev/smoke run is a +// permanent skip; a transient require() failure is NOT latched, so a later call +// can retry rather than silently disabling updates for the whole session. +// +// Event listeners are attached HERE, at creation, so any checkForUpdates() call +// — whoever triggers it and whenever — always has listeners (EventEmitter drops +// events that fire with none attached, which would silently lose a check result). +function getAutoUpdater() { + if (_autoUpdaterDisabled) return null; + if (_autoUpdater) return _autoUpdater; + if (SMOKE || !app.isPackaged) { _autoUpdaterDisabled = true; return null; } + try { + _autoUpdater = require('electron-updater').autoUpdater; + _autoUpdater.autoDownload = false; // renderer controls when to download + _autoUpdater.autoInstallOnAppQuit = true; + _autoUpdater.allowDowngrade = false; // never move users backwards + _autoUpdater.allowPrerelease = false; // stable channel only, explicit + _autoUpdater.on('checking-for-update', function () { _updateState = 'checking'; sendUpdateState('checking'); }); + _autoUpdater.on('update-available', function (info) { _updateState = 'available'; sendUpdateState('available', { version: info && info.version }); }); + _autoUpdater.on('update-not-available', function () { _updateState = 'idle'; sendUpdateState('none'); }); + _autoUpdater.on('download-progress', function (p) { _updateState = 'downloading'; sendUpdateState('downloading', { percent: p ? Math.round(p.percent) : 0 }); }); + _autoUpdater.on('update-downloaded', function (info) { _downloadInFlight = false; _updateState = 'downloaded'; sendUpdateState('downloaded', { version: info && info.version }); }); + _autoUpdater.on('error', function (err) { _downloadInFlight = false; _updateState = 'error'; sendUpdateState('error', { message: String((err && err.message) || err) }); }); + } catch (e) { + // Not latched: a corrupted node_modules today shouldn't kill updates forever. + process.stderr.write('[desktop] electron-updater load failed: ' + ((e && e.message) || e) + '\n'); + return null; } - return false; + return _autoUpdater; } -// Update check via GitHub Releases. We use a NOTIFY model (fetch the latest -// release, and if it's newer, offer to open the download page) rather than -// silent in-place replacement: silent auto-update on macOS requires a signed -// build, and codbash currently ships unsigned. Once a Developer ID signing -// identity is in place this can be swapped for electron-updater's silent flow. -function checkForUpdates(interactive) { - if (SMOKE) return; - const current = app.getVersion(); - const opts = { - host: 'api.github.com', - path: '/repos/vakovalskii/codbash/releases/latest', - headers: { 'User-Agent': 'codbash-desktop', 'Accept': 'application/vnd.github+json' }, - timeout: 8000, - }; - const req = https.get(opts, function (res) { - let d = ''; - res.on('data', function (c) { d += c; }); - res.on('end', function () { - let rel; - try { rel = JSON.parse(d); } catch (_e) { return; } - const latest = String(rel.tag_name || '').replace(/^v/, ''); - if (latest && isNewerVersion(latest, current)) { - const { dialog } = require('electron'); - dialog.showMessageBox({ - type: 'info', - message: 'codbash ' + latest + ' is available', - detail: 'You have ' + current + '. Open the download page?', - buttons: ['Download', 'Later'], - defaultId: 0, - cancelId: 1, - }).then(function (r) { - if (r.response === 0) shell.openExternal(rel.html_url || 'https://github.com/vakovalskii/codbash/releases/latest'); - }); - } else if (interactive) { - const { dialog } = require('electron'); - dialog.showMessageBox({ type: 'info', message: 'codbash is up to date', detail: 'Version ' + current + '.', buttons: ['OK'] }); - } - }); - }); - req.on('error', function () {}); - req.on('timeout', function () { req.destroy(); }); +function sendUpdateState(state, extra) { + if (!win || win.isDestroyed()) return; + try { win.webContents.send('codbash:update-state', Object.assign({ state: state }, extra || {})); } catch (_e) {} +} + +// A check would call update-not-available → 'none' → hide banner. If an update is +// already downloading or sitting downloaded-and-waiting-to-restart, that would +// wipe the user's "ready to restart" affordance. So skip checks in those states. +function maybeCheckForUpdates() { + const u = getAutoUpdater(); + if (!u) return Promise.resolve(); + if (_updateState === 'downloading' || _updateState === 'downloaded') return Promise.resolve(); + return u.checkForUpdates().catch(function () {}); } function initAutoUpdater() { - if (SMOKE) return; - checkForUpdates(false); - // Re-check every 6 hours while the app stays open. - setInterval(function () { checkForUpdates(false); }, 6 * 60 * 60 * 1000); + const u = getAutoUpdater(); + if (!u) return; // dev / smoke / unpacked — renderer stays on its default UI + // The initial check is triggered by the renderer (wireDesktopUpdater → check), + // which also re-triggers on page reload. Here we only own the periodic re-check. + _updateTimer = setInterval(function () { maybeCheckForUpdates(); }, 6 * 60 * 60 * 1000); } app.whenReady().then(async function () { diff --git a/desktop/package.json b/desktop/package.json index 6c7e74e..bbd0a9b 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -10,11 +10,15 @@ "scripts": { "start": "electron .", "smoke": "CODBASH_SMOKE=1 electron .", - "dist:mac": "electron-builder --mac dmg", - "release:mac": "electron-builder --mac dmg --publish always", + "dist:mac": "electron-builder --mac --arm64 --x64", + "dist:win": "electron-builder --win nsis", + "release:mac": "electron-builder --mac --arm64 --x64 --publish always", "pack": "electron-builder --dir", "dev": "bash dev.sh" }, + "dependencies": { + "electron-updater": "^6.8.9" + }, "devDependencies": { "@electron/notarize": "^2.5.0", "electron": "^33.2.0", @@ -63,6 +67,13 @@ "arm64", "x64" ] + }, + { + "target": "zip", + "arch": [ + "arm64", + "x64" + ] } ], "icon": "build/icon.png", @@ -74,6 +85,23 @@ }, "dmg": { "title": "codbash ${version}" + }, + "win": { + "target": [ + { + "target": "nsis", + "arch": [ + "x64", + "arm64" + ] + } + ], + "icon": "build/icon.png" + }, + "nsis": { + "oneClick": true, + "perMachine": false, + "allowToChangeInstallationDirectory": false } } } diff --git a/desktop/preload.js b/desktop/preload.js index da58087..f834c78 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -15,4 +15,17 @@ contextBridge.exposeInMainWorld('codbashDesktop', { onShortcut: (cb) => ipcRenderer.on('codbash:shortcut', (_e, name) => cb(name)), // Ask main to close the window (used when a shortcut has no in-page meaning). closeWindow: () => ipcRenderer.send('codbash:close-window'), + // In-app updater (electron-updater). The dashboard's update banner drives this: + // onState(cb) → receives {state, version?, percent?, message?} pushes + // download() → start downloading the available update + // install() → relaunch onto the downloaded update + // check() → force a check now + // openReleases() → fallback: open the GitHub releases page in the browser + updater: { + onState: (cb) => ipcRenderer.on('codbash:update-state', (_e, s) => cb(s)), + check: () => ipcRenderer.invoke('codbash:update-check'), + download: () => ipcRenderer.invoke('codbash:update-download'), + install: () => ipcRenderer.invoke('codbash:update-install'), + openReleases: () => ipcRenderer.send('codbash:open-releases'), + }, }); diff --git a/src/frontend/app.js b/src/frontend/app.js index 0fd1f8d..2f50aa7 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -3578,6 +3578,10 @@ function showExportDialog() { // ── Update check ────────────────────────────────────────────── +function _isDesktopUpdater() { + return !!(window.codbashDesktop && window.codbashDesktop.isDesktop && window.codbashDesktop.updater); +} + async function checkForUpdates() { try { var resp = await fetch('/api/version'); @@ -3605,6 +3609,15 @@ async function checkForUpdates() { } localStorage.setItem('codedash-last-version', data.current); + // Desktop app: updates are driven by electron-updater (real in-place update), + // NOT the npm-based `/api/update` route (which would update an unrelated + // npm-global copy while the bundled server keeps running the old version). + // The banner is state-driven via IPC — ignore the npm `updateAvailable` here. + if (_isDesktopUpdater()) { + wireDesktopUpdater(); + return; + } + if (data.updateAvailable) { if (badge) { badge.textContent = 'v' + data.current + ' → v' + data.latest; @@ -3629,7 +3642,115 @@ async function checkForUpdates() { } catch {} } +// ── Desktop in-app updater (electron-updater via IPC) ───────────────────────── +// State-driven banner: 'available' → Download → 'downloading' (%) → 'downloaded' +// → Restart. Mirrors what main.js emits over 'codbash:update-state'. +var _desktopUpdaterWired = false; +function wireDesktopUpdater() { + if (_desktopUpdaterWired || !_isDesktopUpdater()) return; + _desktopUpdaterWired = true; + // The npm command doesn't apply in the desktop app — hide the Copy button. + var copyBtn = document.getElementById('updateCopyBtn'); + if (copyBtn) copyBtn.style.display = 'none'; + window.codbashDesktop.updater.onState(function (s) { renderDesktopUpdateState(s || {}); }); + // Kick off a check now; periodic re-checks run in main.js. + try { window.codbashDesktop.updater.check(); } catch (e) {} +} + +function _setUpdatePrimary(label, handler, disabled) { + var btn = document.getElementById('updatePrimaryBtn'); + if (!btn) return; + btn.textContent = label; + btn.onclick = disabled ? null : handler; + btn.disabled = !!disabled; + btn.style.opacity = disabled ? '0.6' : '1'; +} + +// The banner's second button (the npm "Copy Command" button in browser mode) is +// repurposed as an optional secondary action in the desktop updater (e.g. the +// manual "Open download page" fallback next to "Check again" on error). +function _setUpdateSecondary(label, handler) { + var btn = document.getElementById('updateCopyBtn'); + if (!btn) return; + if (!label) { btn.style.display = 'none'; btn.onclick = null; return; } + btn.textContent = label; + btn.onclick = handler; + btn.style.display = ''; + btn.style.opacity = '0.7'; +} + +// True once we've surfaced an actual update to the user (available/downloading/ +// downloaded). Gates the error banner so a transient hiccup on the routine 6h +// background check doesn't pop an alarming "auto-update unavailable" out of +// nowhere — the error is only worth showing if the user was mid-flow. +var _desktopUpdateSurfaced = false; +function renderDesktopUpdateState(s) { + var banner = document.getElementById('updateBanner'); + var text = document.getElementById('updateText'); + var badge = document.getElementById('versionBadge'); + if (!banner || !text) return; + var U = window.codbashDesktop.updater; + switch (s.state) { + case 'available': + _desktopUpdateSurfaced = true; + _setUpdateSecondary(null); + text.textContent = 'v' + (s.version || '') + ' available'; + // Optimistically disable on click (before the IPC round-trip) so a fast + // double-click can't fire two downloads; main.js also guards server-side. + _setUpdatePrimary('Download', function () { _setUpdatePrimary('Downloading…', null, true); U.download(); }, false); + banner.style.display = 'flex'; + if (badge) { + badge.classList.add('update-available'); + badge.title = 'Download update'; + badge.onclick = function () { U.download(); }; + } + break; + case 'downloading': + _desktopUpdateSurfaced = true; + _setUpdateSecondary(null); + text.textContent = 'Downloading update… ' + (s.percent != null ? s.percent + '%' : ''); + _setUpdatePrimary('Downloading…', null, true); + banner.style.display = 'flex'; + break; + case 'downloaded': + _desktopUpdateSurfaced = true; + _setUpdateSecondary(null); + text.textContent = 'v' + (s.version || '') + ' ready — restart to apply'; + _setUpdatePrimary('Restart to update', function () { U.install(); }, false); + banner.style.display = 'flex'; + if (badge) { badge.title = 'Restart to update'; badge.onclick = function () { U.install(); }; } + break; + case 'error': + // In-place update couldn't apply. Only surface it if the user was already + // mid-flow (they clicked Download and it failed) — degrade gracefully with + // a retry plus a manual fallback. A background-check error with nothing + // offered stays silent. + if (!_desktopUpdateSurfaced) break; + text.textContent = 'Update failed — retry or open the download page'; + _setUpdatePrimary('Check again', function () { U.check(); }, false); + _setUpdateSecondary('Open download page', function () { U.openReleases(); }); + banner.style.display = 'flex'; + break; + case 'none': + // Re-check found nothing new: clear any stale banner we had shown. + _desktopUpdateSurfaced = false; + _setUpdateSecondary(null); + banner.style.display = 'none'; + break; + case 'checking': + default: + // Transient — leave the banner as it is. + break; + } +} + async function selfUpdate() { + // Desktop app: route to the in-place updater (download → restart), never the + // npm-based route which can't touch the running bundled server. + if (_isDesktopUpdater()) { + try { window.codbashDesktop.updater.download(); } catch (e) {} + return; + } if (!confirm('Update codbash to latest version? The page will reload.')) return; showToast('Updating...'); try { diff --git a/src/frontend/index.html b/src/frontend/index.html index 64268ac..15a88ec 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -368,8 +368,8 @@

Projects settings

diff --git a/src/server.js b/src/server.js index f8710db..0c3821c 100644 --- a/src/server.js +++ b/src/server.js @@ -988,6 +988,15 @@ function startServer(host, port, openBrowser = true) { // ── Self-update ───────────────────────── else if (req.method === 'POST' && pathname === '/api/update') { + // In the desktop app (Electron shell), this npm-based self-update is wrong: + // it would `npm i -g` an unrelated global copy while the app keeps running + // its bundled server, so the restart lands back on the old version. The + // desktop uses electron-updater instead — refuse here so nothing silently + // half-updates. CODBASH_DESKTOP=1 is set by desktop/main.js when it spawns us. + if (process.env.CODBASH_DESKTOP === '1') { + json(res, { ok: false, error: 'Use the built-in updater in the desktop app.' }, 400); + return; + } const pkg = require('../package.json'); log('UPDATE', `Starting self-update from v${pkg.version}...`); json(res, { ok: true, message: 'Updating... Page will reload.' }); diff --git a/test/desktop-update-guard.test.js b/test/desktop-update-guard.test.js new file mode 100644 index 0000000..2f7bb2a --- /dev/null +++ b/test/desktop-update-guard.test.js @@ -0,0 +1,83 @@ +// Guards the desktop-mode refusal of the npm-based self-update route. +// +// In the Electron desktop app, `POST /api/update` (which runs `npm i -g +// codbash-app@latest`) is wrong: it would update an unrelated npm-global copy +// while the app keeps running its bundled server, so the "restart" lands back on +// the old version. desktop/main.js sets CODBASH_DESKTOP=1 when it spawns the +// server, and the server must refuse the route with 400. This is the single +// feature flag that makes the desktop use electron-updater instead — a future +// refactor of the spawn options must not silently drop it, so we pin it here. +// +// We only assert the desktop (guarded) path: the non-desktop path actually runs +// `npm i -g` + restart and is destructive, so it is deliberately NOT exercised. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('http'); +const net = require('net'); +const path = require('path'); +const { spawn } = require('child_process'); + +const CLI = path.join(__dirname, '..', 'bin', 'cli.js'); + +function freePort() { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.on('error', reject); + srv.listen(0, '127.0.0.1', () => { + const { port } = srv.address(); + srv.close(() => resolve(port)); + }); + }); +} + +function waitForReady(port, deadlineMs) { + const deadline = Date.now() + deadlineMs; + return new Promise((resolve, reject) => { + const attempt = () => { + const req = http.get({ host: '127.0.0.1', port, path: '/api/version', timeout: 1500 }, (res) => { + res.resume(); + resolve(); + }); + req.on('error', () => { + if (Date.now() > deadline) reject(new Error('server did not become ready')); + else setTimeout(attempt, 200); + }); + req.on('timeout', () => { req.destroy(); if (Date.now() > deadline) reject(new Error('timeout')); else setTimeout(attempt, 200); }); + }; + attempt(); + }); +} + +function post(port, urlPath) { + return new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port, method: 'POST', path: urlPath, timeout: 5000 }, (res) => { + let buf = ''; + res.on('data', (c) => buf += c); + res.on('end', () => { + let body = null; + try { body = buf ? JSON.parse(buf) : null; } catch {} + resolve({ status: res.statusCode, body }); + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('request timeout')); }); + req.end(); + }); +} + +test('POST /api/update is refused (400) when CODBASH_DESKTOP=1', async () => { + const port = await freePort(); + const child = spawn(process.execPath, [CLI, 'run', `--port=${port}`, '--host=127.0.0.1', '--no-browser'], { + env: Object.assign({}, process.env, { CODBASH_DESKTOP: '1' }), + stdio: 'ignore', + }); + try { + await waitForReady(port, 15000); + const res = await post(port, '/api/update'); + assert.equal(res.status, 400, 'desktop mode must refuse the npm self-update route'); + assert.ok(res.body && res.body.ok === false, 'response should carry ok:false'); + } finally { + child.kill('SIGKILL'); + } +}); From 3cbef2e389d5e9ef35ac51df95a4accca17e79a3 Mon Sep 17 00:00:00 2001 From: NovakPAai Date: Fri, 24 Jul 2026 13:40:15 +0300 Subject: [PATCH 3/9] 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 @@
-
+