feat(v0.6): project card launchers — Claude/Codex/VS Code logos + active highlight
Replace each card's "+ start claude here" with a row of three brand-logo launcher buttons (logo + caption, touch-friendly, tooltips): - Claude (coral burst) → new terminal tab running `claude` - Codex (OpenAI mark) → new terminal tab running `codex` - VS Code (blue ribbon) → opens the repo in VS Code ON THE HOST VS Code: new guarded POST /open-in-editor runs `<EDITOR_CMD> <path>` (default `code`) on the host via execFile (no shell); path validated as an existing absolute dir; Origin guard (CSRF) like the DELETE routes. EDITOR_CMD config added. Active-session highlight (user request): when a project has a running Claude session (Claude-hook status != unknown — plain shells/codex stay unknown), the Claude logo gets a coral ring + tint, with a count badge when more than one. New: public/icons.ts (simple-icons SVGs, fill=currentColor), src/http/editor.ts. onOpenProject gains an optional cmd; makeProjectCard exported for tests. Tests +16 (431 total): editor validation/launch, EDITOR_CMD config, launcher row (3 buttons, claude/codex commands), and the active-Claude highlight + badge. Verified: coverage >=80x4, build OK, both typechecks clean; in-browser the 3 logos render with brand colours, and POST /open-in-editor actually launched VS Code on the host (403 no-Origin / 400 bad-path / 204 valid-dir all correct).
This commit is contained in:
@@ -34,6 +34,7 @@ const DEFAULT_REAP_INTERVAL_MS = 60_000 // idle reaper sweeps every minute
|
||||
const DEFAULT_PREVIEW_BYTES = 24 * 1024 // manage-page preview: tail of scrollback
|
||||
const DEFAULT_PROJECT_SCAN_DEPTH = 4 // v0.6: how deep to scan roots for .git
|
||||
const DEFAULT_PROJECT_SCAN_TTL_MS = 10_000 // v0.6: /projects discovery cache TTL
|
||||
const DEFAULT_EDITOR_CMD = 'code' // v0.6: POST /open-in-editor launches this on the host
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -242,6 +243,7 @@ export function loadConfig(env: EnvLike): Config {
|
||||
DEFAULT_PROJECT_SCAN_TTL_MS,
|
||||
)
|
||||
const projectDirtyCheck = parseBool(env['PROJECT_DIRTY_CHECK'], true)
|
||||
const editorCmd = env['EDITOR_CMD']?.trim() || DEFAULT_EDITOR_CMD
|
||||
|
||||
return Object.freeze({
|
||||
port,
|
||||
@@ -263,5 +265,6 @@ export function loadConfig(env: EnvLike): Config {
|
||||
projectScanDepth,
|
||||
projectScanTtlMs,
|
||||
projectDirtyCheck,
|
||||
editorCmd,
|
||||
} satisfies Config)
|
||||
}
|
||||
|
||||
63
src/http/editor.ts
Normal file
63
src/http/editor.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* src/http/editor.ts (v0.6) — launch a desktop editor on the HOST for a project.
|
||||
*
|
||||
* POST /open-in-editor runs `<cfg.editorCmd> <repoPath>` (default `code`) so the
|
||||
* Projects panel's VS Code button opens the repo where the files actually live —
|
||||
* the host machine, regardless of which device clicked. This is a state-changing
|
||||
* route, so server.ts guards it with the Origin whitelist (CSRF defence).
|
||||
*
|
||||
* Safety: execFile (NOT a shell) — the path is passed as an argv element, never
|
||||
* interpolated into a command line, so no shell-injection surface. The path is
|
||||
* validated to be an existing absolute directory before spawning. This adds no
|
||||
* privilege beyond what the app already grants (a full shell on the host).
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import type { Config } from '../types.js'
|
||||
|
||||
export interface OpenEditorResult {
|
||||
ok: boolean
|
||||
status: number // HTTP status to return
|
||||
error?: string // user-facing reason on failure
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate `rawPath` (must be an absolute, existing directory) then launch the
|
||||
* configured editor on it (fire-and-forget; the GUI process is detached). Never
|
||||
* throws — returns a structured result the route maps to an HTTP response.
|
||||
*/
|
||||
export async function openInEditor(cfg: Config, rawPath: unknown): Promise<OpenEditorResult> {
|
||||
if (typeof rawPath !== 'string' || rawPath.trim() === '') {
|
||||
return { ok: false, status: 400, error: 'path is required' }
|
||||
}
|
||||
if (!path.isAbsolute(rawPath)) {
|
||||
return { ok: false, status: 400, error: 'path must be absolute' }
|
||||
}
|
||||
|
||||
let stat
|
||||
try {
|
||||
stat = await fs.stat(rawPath)
|
||||
} catch {
|
||||
return { ok: false, status: 404, error: 'path not found' }
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return { ok: false, status: 400, error: 'path is not a directory' }
|
||||
}
|
||||
|
||||
try {
|
||||
// No shell: argv array, so the path can never be interpreted as a command.
|
||||
const child = execFile(cfg.editorCmd, [rawPath], { windowsHide: true })
|
||||
// A ChildProcess 'error' (e.g. editor binary not in PATH) is emitted async;
|
||||
// handle it so it never bubbles to an uncaughtException, and don't keep the
|
||||
// server alive waiting on the GUI process.
|
||||
child.on('error', (err) => {
|
||||
console.error('[editor] failed to launch', JSON.stringify(cfg.editorCmd), '-', err.message)
|
||||
})
|
||||
child.unref()
|
||||
return { ok: true, status: 204 }
|
||||
} catch (err) {
|
||||
return { ok: false, status: 500, error: err instanceof Error ? err.message : String(err) }
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import { isOriginAllowed } from './http/origin.js'
|
||||
import { parseHookEvent } from './http/hook.js'
|
||||
import { listSessions } from './http/history.js'
|
||||
import { buildProjects } from './http/projects.js'
|
||||
import { openInEditor } from './http/editor.js'
|
||||
import { createSessionManager } from './session/manager.js'
|
||||
import { detachWs, writeInput, setClientDims } from './session/session.js'
|
||||
import type { Config } from './types.js'
|
||||
@@ -217,6 +218,21 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
res.status(ok ? 204 : 404).end()
|
||||
})
|
||||
|
||||
// Open a project in the host's desktop editor (v0.6 Projects panel — VS Code
|
||||
// logo). State-changing (spawns a GUI process), so it carries the same Origin
|
||||
// guard as the DELETE routes. The path is validated + passed via execFile (no
|
||||
// shell) in openInEditor.
|
||||
app.post('/open-in-editor', express.json({ limit: '4kb' }), async (req, res) => {
|
||||
if (!requireAllowedOrigin(req, res)) return
|
||||
const body = (req.body ?? {}) as Record<string, unknown>
|
||||
const result = await openInEditor(cfg, body['path'])
|
||||
if (result.ok) {
|
||||
res.status(204).end()
|
||||
return
|
||||
}
|
||||
res.status(result.status).json({ error: result.error })
|
||||
})
|
||||
|
||||
// ── Claude Code hook side-channel (H2) ────────────────────────────────────
|
||||
// Hooks running inside a spawned shell POST status here (loopback only — the
|
||||
// shell runs on the host). sessionId arrives in the X-Webterm-Session header.
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface Config {
|
||||
readonly projectScanDepth: number; // PROJECT_SCAN_DEPTH, default 4
|
||||
readonly projectScanTtlMs: number; // PROJECT_SCAN_TTL, default 10000 (discovery cache)
|
||||
readonly projectDirtyCheck: boolean; // PROJECT_DIRTY_CHECK, default true (git status per repo)
|
||||
readonly editorCmd: string; // EDITOR_CMD, default 'code' — POST /open-in-editor launches `<editorCmd> <repoPath>` on the host
|
||||
}
|
||||
|
||||
/** process.env is structurally assignable to this; keeps the file free of `NodeJS.*`. */
|
||||
|
||||
Reference in New Issue
Block a user