diff --git a/packages/daemon/src/project-scanner.test.ts b/packages/daemon/src/project-scanner.test.ts new file mode 100644 index 000000000..6812c3871 --- /dev/null +++ b/packages/daemon/src/project-scanner.test.ts @@ -0,0 +1,235 @@ +/** + * Tests for the project scanner module. + */ + +import { describe, it, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, chmodSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir, platform } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { scanForProjects } from './project-scanner.js'; + +// ---------- helpers ---------- + +function tmpDir(): string { + return mkdtempSync(join(tmpdir(), `scanner-test-${randomUUID().slice(0, 8)}-`)); +} + +const cleanupDirs: string[] = []; +afterEach(() => { + while (cleanupDirs.length) { + const d = cleanupDirs.pop()!; + if (existsSync(d)) rmSync(d, { recursive: true, force: true }); + } +}); + +/** Create a project directory with specified marker files/dirs */ +function createProject(root: string, name: string, markers: string[]): string { + const projDir = join(root, name); + mkdirSync(projDir, { recursive: true }); + for (const marker of markers) { + const markerPath = join(projDir, marker); + if (marker.startsWith('.') && !marker.includes('.')) { + // Likely a directory marker (.git, .gsd) + mkdirSync(markerPath, { recursive: true }); + } else { + // File marker (package.json, Cargo.toml, etc.) + writeFileSync(markerPath, '{}'); + } + } + return projDir; +} + +// ---------- tests ---------- + +describe('scanForProjects', () => { + it('finds projects with marker files', async () => { + const root = tmpDir(); + cleanupDirs.push(root); + + createProject(root, 'my-app', ['.git', 'package.json']); + + const results = await scanForProjects([root]); + + assert.equal(results.length, 1); + assert.equal(results[0]!.name, 'my-app'); + assert.equal(results[0]!.path, join(root, 'my-app')); + assert.ok(results[0]!.markers.includes('git')); + assert.ok(results[0]!.markers.includes('node')); + assert.ok(results[0]!.lastModified > 0); + }); + + it('handles missing scan_root gracefully', async () => { + const results = await scanForProjects(['/nonexistent/path/that/does/not/exist']); + assert.deepEqual(results, []); + }); + + it('handles permission errors on entries', { skip: platform() === 'win32' ? 'chmod not reliable on Windows' : undefined }, async () => { + const root = tmpDir(); + cleanupDirs.push(root); + + // Create an accessible project + createProject(root, 'accessible', ['.git']); + + // Create an inaccessible directory + const noAccess = join(root, 'locked'); + mkdirSync(noAccess); + chmodSync(noAccess, 0o000); + + const results = await scanForProjects([root]); + + // Restore permissions for cleanup + chmodSync(noAccess, 0o755); + + // Should find the accessible project but skip the locked one + assert.equal(results.length, 1); + assert.equal(results[0]!.name, 'accessible'); + }); + + it('detects multiple marker types', async () => { + const root = tmpDir(); + cleanupDirs.push(root); + + createProject(root, 'full-stack', ['.git', 'package.json', '.gsd']); + + const results = await scanForProjects([root]); + + assert.equal(results.length, 1); + assert.equal(results[0]!.markers.length, 3); + assert.ok(results[0]!.markers.includes('git')); + assert.ok(results[0]!.markers.includes('node')); + assert.ok(results[0]!.markers.includes('gsd')); + }); + + it('returns results sorted alphabetically by name', async () => { + const root = tmpDir(); + cleanupDirs.push(root); + + createProject(root, 'zebra-project', ['.git']); + createProject(root, 'alpha-project', ['.git']); + createProject(root, 'middle-project', ['.git']); + + const results = await scanForProjects([root]); + + assert.equal(results.length, 3); + assert.equal(results[0]!.name, 'alpha-project'); + assert.equal(results[1]!.name, 'middle-project'); + assert.equal(results[2]!.name, 'zebra-project'); + }); + + it('ignores hidden directories', async () => { + const root = tmpDir(); + cleanupDirs.push(root); + + createProject(root, 'visible', ['.git']); + createProject(root, '.hidden', ['.git']); + + const results = await scanForProjects([root]); + + assert.equal(results.length, 1); + assert.equal(results[0]!.name, 'visible'); + }); + + it('ignores node_modules', async () => { + const root = tmpDir(); + cleanupDirs.push(root); + + createProject(root, 'real-project', ['package.json']); + createProject(root, 'node_modules', ['package.json']); + + const results = await scanForProjects([root]); + + assert.equal(results.length, 1); + assert.equal(results[0]!.name, 'real-project'); + }); + + it('skips directories with no markers', async () => { + const root = tmpDir(); + cleanupDirs.push(root); + + createProject(root, 'has-markers', ['.git']); + // Create a plain directory with no markers + mkdirSync(join(root, 'no-markers')); + + const results = await scanForProjects([root]); + + assert.equal(results.length, 1); + assert.equal(results[0]!.name, 'has-markers'); + }); + + it('scans multiple roots', async () => { + const root1 = tmpDir(); + const root2 = tmpDir(); + cleanupDirs.push(root1, root2); + + createProject(root1, 'proj-a', ['.git']); + createProject(root2, 'proj-b', ['Cargo.toml']); + + const results = await scanForProjects([root1, root2]); + + assert.equal(results.length, 2); + assert.equal(results[0]!.name, 'proj-a'); + assert.ok(results[0]!.markers.includes('git')); + assert.equal(results[1]!.name, 'proj-b'); + assert.ok(results[1]!.markers.includes('rust')); + }); + + it('detects all supported marker types', async () => { + const root = tmpDir(); + cleanupDirs.push(root); + + createProject(root, 'git-proj', ['.git']); + createProject(root, 'node-proj', ['package.json']); + createProject(root, 'gsd-proj', ['.gsd']); + createProject(root, 'rust-proj', ['Cargo.toml']); + createProject(root, 'python-proj', ['pyproject.toml']); + createProject(root, 'go-proj', ['go.mod']); + + const results = await scanForProjects([root]); + + assert.equal(results.length, 6); + + const byName = new Map(results.map(r => [r.name, r])); + assert.deepEqual(byName.get('git-proj')!.markers, ['git']); + assert.deepEqual(byName.get('node-proj')!.markers, ['node']); + assert.deepEqual(byName.get('gsd-proj')!.markers, ['gsd']); + assert.deepEqual(byName.get('rust-proj')!.markers, ['rust']); + assert.deepEqual(byName.get('python-proj')!.markers, ['python']); + assert.deepEqual(byName.get('go-proj')!.markers, ['go']); + }); + + it('skips non-directory entries', async () => { + const root = tmpDir(); + cleanupDirs.push(root); + + createProject(root, 'real-project', ['.git']); + // Create a regular file at the root level — should be ignored + writeFileSync(join(root, 'some-file.txt'), 'not a directory'); + + const results = await scanForProjects([root]); + + assert.equal(results.length, 1); + assert.equal(results[0]!.name, 'real-project'); + }); + + it('returns empty array for empty scan_roots', async () => { + const results = await scanForProjects([]); + assert.deepEqual(results, []); + }); + + it('deduplicates when same root appears twice', async () => { + const root = tmpDir(); + cleanupDirs.push(root); + + createProject(root, 'only-once', ['.git']); + + const results = await scanForProjects([root, root]); + + // Same directory scanned twice — results will have duplicates + // (this is acceptable; the caller can deduplicate by path if needed) + assert.equal(results.length, 2); + assert.equal(results[0]!.name, 'only-once'); + assert.equal(results[1]!.name, 'only-once'); + }); +}); diff --git a/packages/daemon/src/project-scanner.ts b/packages/daemon/src/project-scanner.ts new file mode 100644 index 000000000..3eb9b5926 --- /dev/null +++ b/packages/daemon/src/project-scanner.ts @@ -0,0 +1,99 @@ +/** + * Project scanner — discovers projects in configured scan_roots by detecting + * marker files/directories. Reads one level deep (immediate children only). + */ + +import { readdir, stat, access } from 'node:fs/promises'; +import { join, basename } from 'node:path'; +import type { ProjectInfo, ProjectMarker } from './types.js'; + +// --------------------------------------------------------------------------- +// Marker file → project type mapping +// --------------------------------------------------------------------------- + +const MARKER_MAP: ReadonlyMap = new Map([ + ['.git', 'git'], + ['package.json', 'node'], + ['.gsd', 'gsd'], + ['Cargo.toml', 'rust'], + ['pyproject.toml', 'python'], + ['go.mod', 'go'], +]); + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Scan configured roots for project directories. + * + * Behaviour: + * - Reads immediate children of each root (1 level deep, not recursive) + * - Skips hidden directories (starting with `.`) and `node_modules` + * - Skips missing roots and permission-denied entries gracefully + * - Detects markers via MARKER_MAP; directories with no markers are excluded + * - Results are sorted alphabetically by name + * - lastModified is the most recent mtime among detected marker files/dirs + */ +export async function scanForProjects(scanRoots: string[]): Promise { + const results: ProjectInfo[] = []; + + for (const root of scanRoots) { + let entries: string[]; + try { + entries = await readdir(root); + } catch { + // Missing root or permission error — skip gracefully + continue; + } + + for (const entry of entries) { + // Skip hidden directories and node_modules + if (entry.startsWith('.') || entry === 'node_modules') continue; + + const entryPath = join(root, entry); + + // Must be a directory + let entryStat; + try { + entryStat = await stat(entryPath); + } catch { + // Permission error or disappeared entry — skip + continue; + } + if (!entryStat.isDirectory()) continue; + + // Detect markers + const markers: ProjectMarker[] = []; + let latestMtime = 0; + + for (const [markerFile, markerType] of MARKER_MAP) { + const markerPath = join(entryPath, markerFile); + try { + const markerStat = await stat(markerPath); + markers.push(markerType); + if (markerStat.mtimeMs > latestMtime) { + latestMtime = markerStat.mtimeMs; + } + } catch { + // Marker doesn't exist — not an error + } + } + + // Only include directories with at least one marker + if (markers.length === 0) continue; + + results.push({ + name: basename(entryPath), + path: entryPath, + markers, + lastModified: latestMtime, + }); + } + } + + // Sort alphabetically by name + results.sort((a, b) => a.name.localeCompare(b.name)); + + return results; +} diff --git a/packages/daemon/src/types.ts b/packages/daemon/src/types.ts index c6a2d12cc..9cc3f5051 100644 --- a/packages/daemon/src/types.ts +++ b/packages/daemon/src/types.ts @@ -32,3 +32,139 @@ export interface DaemonConfig { max_size_mb: number; }; } + +// --------------------------------------------------------------------------- +// Session Status +// --------------------------------------------------------------------------- + +export type SessionStatus = 'starting' | 'running' | 'blocked' | 'completed' | 'error' | 'cancelled'; + +// --------------------------------------------------------------------------- +// Managed Session +// --------------------------------------------------------------------------- + +/** + * A daemon-managed GSD headless session. + * + * The `client` and `events` fields use generic types here. T02 will add + * @gsd-build/rpc-client as a dependency and narrow these to RpcClient and + * SdkAgentEvent respectively. + */ +export interface ManagedSession { + /** Unique session ID returned from RpcClient.init() */ + sessionId: string; + + /** Absolute path to the project directory */ + projectDir: string; + + /** Human-readable project name (basename of projectDir) */ + projectName: string; + + /** Current lifecycle status */ + status: SessionStatus; + + /** The RpcClient instance managing the agent process (typed when rpc-client is wired) */ + client: unknown; + + /** Ring buffer of recent events (capped at MAX_EVENTS) */ + events: unknown[]; + + /** Pending blocker requiring user response, if any */ + pendingBlocker: PendingBlocker | null; + + /** Cumulative cost tracking (max pattern per K004) */ + cost: CostAccumulator; + + /** Session start timestamp */ + startTime: number; + + /** Error message if status is 'error' */ + error?: string; + + /** Cleanup function to unsubscribe from events */ + unsubscribe?: () => void; +} + +// --------------------------------------------------------------------------- +// Pending Blocker +// --------------------------------------------------------------------------- + +export interface PendingBlocker { + /** The extension_ui_request id */ + id: string; + + /** The request method (e.g. 'select', 'confirm', 'input') */ + method: string; + + /** Human-readable message or title */ + message: string; + + /** Full event payload for inspection */ + event: unknown; +} + +// --------------------------------------------------------------------------- +// Cost Accumulator (K004 — cumulative-max) +// --------------------------------------------------------------------------- + +export interface CostAccumulator { + totalCost: number; + tokens: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + }; +} + +// --------------------------------------------------------------------------- +// Project Info — scanner output +// --------------------------------------------------------------------------- + +/** Marker types detectable by the project scanner */ +export type ProjectMarker = 'git' | 'node' | 'gsd' | 'rust' | 'python' | 'go'; + +export interface ProjectInfo { + /** Directory name (basename) */ + name: string; + + /** Absolute path to the project directory */ + path: string; + + /** Detected marker types */ + markers: ProjectMarker[]; + + /** Most recent mtime of detected marker files/dirs (epoch ms) */ + lastModified: number; +} + +// --------------------------------------------------------------------------- +// Start Session Options +// --------------------------------------------------------------------------- + +export interface StartSessionOptions { + /** Absolute path to the project directory */ + projectDir: string; + + /** Command to send after '/gsd auto' (default: none) */ + command?: string; + + /** Model ID override */ + model?: string; + + /** Run in bare mode (skip user config) */ + bare?: boolean; + + /** Path to CLI binary (overrides GSD_CLI_PATH and which resolution) */ + cliPath?: string; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Maximum number of events kept in the ring buffer (larger than mcp-server's 50 — daemon forwards events to Discord) */ +export const MAX_EVENTS = 100; + +/** Timeout for RpcClient initialization (ms) */ +export const INIT_TIMEOUT_MS = 30_000;