From e5e3f7512ece740be6e5bf6bf88a01d5d1647cbd Mon Sep 17 00:00:00 2001 From: oxedom Date: Sun, 25 Jan 2026 19:59:52 +0200 Subject: [PATCH 1/2] a --- .../integration/cli-frontend-sync.test.ts | 272 ++++++++++++++++++ .../src/__tests__/projectStateManager.test.ts | 229 +++++++++++++++ packages/cli/src/commands/link.ts | 77 +++++ packages/cli/src/commands/status.ts | 49 ++++ packages/cli/src/commands/unlink.ts | 33 +++ packages/cli/src/index.ts | 33 +-- packages/cli/src/projectStateManager.ts | 150 ++++++++++ packages/frontend/package.json | 1 + packages/frontend/src/App.tsx | 44 +-- .../src/__tests__/projectService.test.ts | 217 ++++++++++++++ .../src/__tests__/useLinkedProject.test.ts | 272 ++++++++++++++++++ .../src/components/ProjectDisplay.tsx | 204 +++++++++++++ .../src/components/ProjectIndicator.tsx | 103 +++++++ .../frontend/src/hooks/useLinkedProject.ts | 93 ++++++ .../frontend/src/services/projectService.ts | 252 ++++++++++++++++ packages/shared/src/config.ts | 66 +++++ packages/shared/src/index.ts | 2 + packages/shared/src/project.ts | 48 ++++ 18 files changed, 2106 insertions(+), 39 deletions(-) create mode 100644 packages/__tests__/integration/cli-frontend-sync.test.ts create mode 100644 packages/cli/src/__tests__/projectStateManager.test.ts create mode 100644 packages/cli/src/commands/link.ts create mode 100644 packages/cli/src/commands/status.ts create mode 100644 packages/cli/src/commands/unlink.ts create mode 100644 packages/cli/src/projectStateManager.ts create mode 100644 packages/frontend/src/__tests__/projectService.test.ts create mode 100644 packages/frontend/src/__tests__/useLinkedProject.test.ts create mode 100644 packages/frontend/src/components/ProjectDisplay.tsx create mode 100644 packages/frontend/src/components/ProjectIndicator.tsx create mode 100644 packages/frontend/src/hooks/useLinkedProject.ts create mode 100644 packages/frontend/src/services/projectService.ts create mode 100644 packages/shared/src/config.ts create mode 100644 packages/shared/src/project.ts diff --git a/packages/__tests__/integration/cli-frontend-sync.test.ts b/packages/__tests__/integration/cli-frontend-sync.test.ts new file mode 100644 index 0000000..50dc4ee --- /dev/null +++ b/packages/__tests__/integration/cli-frontend-sync.test.ts @@ -0,0 +1,272 @@ +/** + * Integration Tests: CLI and Frontend Synchronization + * Tests the end-to-end flow of project linking and synchronization + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdir, rm, writeFile, readFile, stat } from 'fs/promises'; +import { dirname, join } from 'path'; +import { ProjectStateManager } from '../../cli/src/projectStateManager.js'; +import { readProjectState } from '../../frontend/src/services/projectService.js'; +import type { LinkedProject } from '@haflow/shared'; + +// Test directory +const testDir = join(import.meta.dirname, '.test-cli-frontend-sync'); +const stateFilePath = join(testDir, 'project-state.json'); + +// Mock getStateFilePath in both modules +vi.mock('@haflow/shared', async () => { + const actual = await vi.importActual('@haflow/shared'); + return { + ...actual, + getStateFilePath: () => stateFilePath, + }; +}); + +describe('CLI-Frontend Synchronization', () => { + beforeEach(async () => { + // Clean up test directory + try { + await rm(testDir, { recursive: true, force: true }); + } catch { + // Ignore + } + }); + + afterEach(async () => { + // Clean up test directory + try { + await rm(testDir, { recursive: true, force: true }); + } catch { + // Ignore + } + }); + + describe('Scenario 1: Link Project', () => { + it('should create state file with correct structure when project is linked', async () => { + const stateManager = new ProjectStateManager(); + + const testProject: LinkedProject = { + id: 'test-123', + name: 'test-project', + path: '/path/to/project', + linkedAt: Date.now(), + }; + + // CLI links project + await stateManager.setLinkedProject(testProject); + + // Frontend reads state + const displayInfo = await readProjectState(); + + // Verify structure and data + expect(displayInfo.status).toBe('linked'); + expect(displayInfo.project).toEqual(testProject); + expect(displayInfo.lastSyncTime).toBeDefined(); + }); + + it('should have correct file permissions after linking', async () => { + const stateManager = new ProjectStateManager(); + + const testProject: LinkedProject = { + id: 'test-123', + name: 'test-project', + path: '/path/to/project', + linkedAt: Date.now(), + }; + + await stateManager.setLinkedProject(testProject); + + // Check file permissions + const statResult = await stat(stateFilePath); + const mode = statResult.mode & 0o777; + + // Should be 0600 (owner read/write only) + expect(mode).toBe(0o600); + }); + }); + + describe('Scenario 2: Update Project', () => { + it('should detect state change when project is unlinked', async () => { + const stateManager = new ProjectStateManager(); + + const testProject: LinkedProject = { + id: 'test-123', + name: 'test-project', + path: '/path/to/project', + linkedAt: Date.now(), + }; + + // CLI links project + await stateManager.setLinkedProject(testProject); + + // Frontend reads linked state + let displayInfo = await readProjectState(); + expect(displayInfo.status).toBe('linked'); + + // CLI unlinks project + await stateManager.clearLinkedProject(); + + // Frontend reads unlinked state + displayInfo = await readProjectState(); + expect(displayInfo.status).toBe('unlinked'); + expect(displayInfo.project).toBeNull(); + }); + + it('should update lastUpdated timestamp on each change', async () => { + const stateManager = new ProjectStateManager(); + + const testProject1: LinkedProject = { + id: 'test-123', + name: 'project-1', + path: '/path/to/project1', + linkedAt: Date.now(), + }; + + // First write + await stateManager.setLinkedProject(testProject1); + let state = await readProjectState(); + const firstSyncTime = state.lastSyncTime; + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 10)); + + const testProject2: LinkedProject = { + id: 'test-456', + name: 'project-2', + path: '/path/to/project2', + linkedAt: Date.now(), + }; + + // Second write + await stateManager.setLinkedProject(testProject2); + state = await readProjectState(); + + // lastSyncTime should be updated + expect(state.lastSyncTime).toBeGreaterThan(firstSyncTime); + }); + }); + + describe('Scenario 3: Error Recovery', () => { + it('should handle invalid JSON gracefully', async () => { + // Create directory + await mkdir(dirname(stateFilePath), { recursive: true }); + + // Write invalid JSON + await writeFile(stateFilePath, 'invalid json {'); + + // Frontend should detect error + const displayInfo = await readProjectState(); + expect(displayInfo.status).toBe('error'); + expect(displayInfo.errorMessage).toContain('Invalid'); + }); + + it('should recover after fixing state file', async () => { + const stateManager = new ProjectStateManager(); + + // Create directory + await mkdir(dirname(stateFilePath), { recursive: true }); + + // Write invalid JSON + await writeFile(stateFilePath, 'invalid json {'); + + // Frontend detects error + let displayInfo = await readProjectState(); + expect(displayInfo.status).toBe('error'); + + // CLI corrects state file + const testProject: LinkedProject = { + id: 'test-123', + name: 'test-project', + path: '/path/to/project', + linkedAt: Date.now(), + }; + + await stateManager.setLinkedProject(testProject); + + // Frontend recovers + displayInfo = await readProjectState(); + expect(displayInfo.status).toBe('linked'); + expect(displayInfo.project?.name).toBe('test-project'); + }); + + it('should handle missing project directory', async () => { + const stateManager = new ProjectStateManager(); + + // Create state with non-existent path + const testProject: LinkedProject = { + id: 'test-123', + name: 'missing-project', + path: '/path/that/does/not/exist/12345', + linkedAt: Date.now(), + }; + + await stateManager.setLinkedProject(testProject); + + // Frontend should detect missing status + const displayInfo = await readProjectState(); + expect(displayInfo.status).toBe('missing'); + expect(displayInfo.errorMessage).toContain('not found'); + }); + }); + + describe('Scenario 4: Data Integrity', () => { + it('should preserve all project fields through read/write cycle', async () => { + const stateManager = new ProjectStateManager(); + + const testProject: LinkedProject = { + id: 'abc-def-ghi-123', + name: 'my-complex-project', + path: '/home/user/projects/my-complex-project', + linkedAt: 1609459200000, // Fixed timestamp + workspaceId: 'workspace-xyz', + }; + + // CLI writes project + await stateManager.setLinkedProject(testProject); + + // Frontend reads project + const displayInfo = await readProjectState(); + + // Verify all fields + expect(displayInfo.project?.id).toBe(testProject.id); + expect(displayInfo.project?.name).toBe(testProject.name); + expect(displayInfo.project?.path).toBe(testProject.path); + expect(displayInfo.project?.linkedAt).toBe(testProject.linkedAt); + expect(displayInfo.project?.workspaceId).toBe(testProject.workspaceId); + }); + + it('should ensure atomic writes prevent partial updates', async () => { + const stateManager = new ProjectStateManager(); + + const testProject1: LinkedProject = { + id: 'test-1', + name: 'project-1', + path: '/path/1', + linkedAt: Date.now(), + }; + + const testProject2: LinkedProject = { + id: 'test-2', + name: 'project-2', + path: '/path/2', + linkedAt: Date.now() + 1000, + }; + + // Rapid writes + await Promise.all([ + stateManager.setLinkedProject(testProject1), + stateManager.setLinkedProject(testProject2), + ]); + + // Should have one complete project (no partial data) + const displayInfo = await readProjectState(); + expect(displayInfo.project).toBeDefined(); + expect(displayInfo.project?.id).toMatch(/^test-[12]$/); + + // Verify data is complete (not corrupted) + expect(displayInfo.project?.name).toBeDefined(); + expect(displayInfo.project?.path).toBeDefined(); + }); + }); +}); diff --git a/packages/cli/src/__tests__/projectStateManager.test.ts b/packages/cli/src/__tests__/projectStateManager.test.ts new file mode 100644 index 0000000..e0d7e89 --- /dev/null +++ b/packages/cli/src/__tests__/projectStateManager.test.ts @@ -0,0 +1,229 @@ +/** + * Tests for ProjectStateManager + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mkdir, rm, writeFile, readFile, stat } from 'fs/promises'; +import { dirname, join } from 'path'; +import { ProjectStateManager } from '../projectStateManager.js'; +import type { LinkedProject, ProjectState } from '@haflow/shared'; + +// Create a temporary directory for tests +const testDir = join(import.meta.dirname, '..', '..', '.test-state'); + +// Mock getStateFilePath to use test directory +vi.mock('@haflow/shared', async () => { + const actual = await vi.importActual('@haflow/shared'); + return { + ...actual, + getStateFilePath: () => join(testDir, 'project-state.json'), + }; +}); + +describe('ProjectStateManager', () => { + let stateManager: ProjectStateManager; + + beforeEach(async () => { + // Clean up test directory + try { + await rm(testDir, { recursive: true, force: true }); + } catch { + // Ignore + } + // Create fresh instance + stateManager = new ProjectStateManager(); + }); + + it('should create state file directory if it does not exist', async () => { + const testProject: LinkedProject = { + id: 'test-123', + name: 'test-project', + path: '/path/to/project', + linkedAt: Date.now(), + }; + + await stateManager.setLinkedProject(testProject); + + // Verify directory was created + const statResult = await stat(testDir); + expect(statResult.isDirectory()).toBe(true); + }); + + it('should write and read state file successfully', async () => { + const testProject: LinkedProject = { + id: 'test-123', + name: 'test-project', + path: '/path/to/project', + linkedAt: Date.now(), + }; + + await stateManager.setLinkedProject(testProject); + + const linkedProject = await stateManager.getLinkedProject(); + expect(linkedProject).toEqual(testProject); + }); + + it('should return null for non-existent state file', async () => { + const linkedProject = await stateManager.getLinkedProject(); + expect(linkedProject).toBeNull(); + }); + + it('should set file permissions to 0600 (owner read/write only)', async () => { + const testProject: LinkedProject = { + id: 'test-123', + name: 'test-project', + path: '/path/to/project', + linkedAt: Date.now(), + }; + + await stateManager.setLinkedProject(testProject); + + const stateFilePath = stateManager.getStateFilePath(); + const statResult = await stat(stateFilePath); + + // Check permissions are 0600 (owner read/write only) + // mode & 0o777 gives us the permission bits + const mode = statResult.mode & 0o777; + expect(mode).toBe(0o600); + }); + + it('should update lastUpdated timestamp', async () => { + const beforeTime = Date.now(); + const testProject: LinkedProject = { + id: 'test-123', + name: 'test-project', + path: '/path/to/project', + linkedAt: Date.now(), + }; + + await stateManager.setLinkedProject(testProject); + + const state = await stateManager.getState(); + expect(state.lastUpdated).toBeGreaterThanOrEqual(beforeTime); + expect(state.lastUpdated).toBeLessThanOrEqual(Date.now()); + }); + + it('should clear linked project', async () => { + const testProject: LinkedProject = { + id: 'test-123', + name: 'test-project', + path: '/path/to/project', + linkedAt: Date.now(), + }; + + // Set project + await stateManager.setLinkedProject(testProject); + expect(await stateManager.getLinkedProject()).toEqual(testProject); + + // Clear project + await stateManager.clearLinkedProject(); + expect(await stateManager.getLinkedProject()).toBeNull(); + + // State file should still exist + const state = await stateManager.getState(); + expect(state.linkedProject).toBeNull(); + expect(state.version).toBe(1); + }); + + it('should include version field in state', async () => { + const testProject: LinkedProject = { + id: 'test-123', + name: 'test-project', + path: '/path/to/project', + linkedAt: Date.now(), + }; + + await stateManager.setLinkedProject(testProject); + + const state = await stateManager.getState(); + expect(state.version).toBe(1); + }); + + it('should handle invalid JSON gracefully', async () => { + const stateFilePath = stateManager.getStateFilePath(); + const dir = dirname(stateFilePath); + + // Create directory + await mkdir(dir, { recursive: true }); + + // Write invalid JSON + await writeFile(stateFilePath, 'invalid json {'); + + // Should throw error about invalid JSON + await expect(stateManager.getState()).rejects.toThrow('Invalid JSON'); + }); + + it('should handle file read errors', async () => { + const stateFilePath = stateManager.getStateFilePath(); + + // Try to read non-existent file + await expect(stateManager.getState()).rejects.toThrow(); + }); + + it('should return correct state file path', () => { + const stateFilePath = stateManager.getStateFilePath(); + expect(stateFilePath).toContain('project-state.json'); + }); + + it('should perform atomic writes without corruption', async () => { + const testProject1: LinkedProject = { + id: 'test-123', + name: 'project-1', + path: '/path/to/project1', + linkedAt: Date.now(), + }; + + const testProject2: LinkedProject = { + id: 'test-456', + name: 'project-2', + path: '/path/to/project2', + linkedAt: Date.now() + 1000, + }; + + // Write first project + await stateManager.setLinkedProject(testProject1); + + // Verify write + let state = await stateManager.getState(); + expect(state.linkedProject?.id).toBe('test-123'); + + // Write second project (atomic operation) + await stateManager.setLinkedProject(testProject2); + + // Verify only second project exists (no partial writes) + state = await stateManager.getState(); + expect(state.linkedProject?.id).toBe('test-456'); + expect(state.linkedProject?.name).toBe('project-2'); + }); + + it('should handle multiple rapid writes', async () => { + const projects: LinkedProject[] = Array.from({ length: 5 }, (_, i) => ({ + id: `test-${i}`, + name: `project-${i}`, + path: `/path/to/project${i}`, + linkedAt: Date.now() + i * 100, + })); + + // Write all projects rapidly + await Promise.all(projects.map((p) => stateManager.setLinkedProject(p))); + + // Last one written should be in state + const state = await stateManager.getState(); + expect(state.linkedProject?.id).toMatch(/^test-/); + }); + + it('should maintain optional workspaceId field', async () => { + const testProject: LinkedProject = { + id: 'test-123', + name: 'test-project', + path: '/path/to/project', + linkedAt: Date.now(), + workspaceId: 'ws-456', + }; + + await stateManager.setLinkedProject(testProject); + + const linkedProject = await stateManager.getLinkedProject(); + expect(linkedProject?.workspaceId).toBe('ws-456'); + }); +}); diff --git a/packages/cli/src/commands/link.ts b/packages/cli/src/commands/link.ts new file mode 100644 index 0000000..ccc3331 --- /dev/null +++ b/packages/cli/src/commands/link.ts @@ -0,0 +1,77 @@ +/** + * Link command - Associates a project with the CLI for synchronization to frontend + */ + +import { existsSync, statSync } from 'fs'; +import { resolve, basename } from 'path'; +import { createHash } from 'crypto'; +import type { LinkedProject } from '@haflow/shared'; +import { ProjectStateManager } from '../projectStateManager.js'; + +/** + * Generate a unique ID for a project based on its path + * @param path - Absolute path to the project + * @returns Hash-based ID + */ +function generateProjectId(path: string): string { + return createHash('sha256').update(path).digest('hex').substring(0, 12); +} + +/** + * Validate that a path exists and is a directory + * @param path - Path to validate + * @throws Error if path doesn't exist or isn't a directory + */ +function validateProjectPath(path: string): void { + if (!existsSync(path)) { + throw new Error(`Project directory not found: ${path}`); + } + + try { + const stats = statSync(path); + if (!stats.isDirectory()) { + throw new Error(`Path is not a directory: ${path}`); + } + } catch (error) { + if (error instanceof Error && error.message.includes('is not a directory')) { + throw error; + } + throw new Error(`Cannot access path: ${path}`); + } +} + +/** + * Link a project to haflow + * @param path - Project directory path (absolute or relative) + */ +export async function linkCommand(path?: string): Promise { + try { + const projectPath = resolve(path || process.cwd()); + validateProjectPath(projectPath); + + const stateManager = new ProjectStateManager(); + + const linkedProject: LinkedProject = { + id: generateProjectId(projectPath), + name: basename(projectPath), + path: projectPath, + linkedAt: Date.now(), + }; + + await stateManager.setLinkedProject(linkedProject); + + const stateFilePath = stateManager.getStateFilePath(); + const linkedTime = new Date(linkedProject.linkedAt).toLocaleString(); + + console.log('\n✓ Project linked successfully\n'); + console.log(` Name: ${linkedProject.name}`); + console.log(` Path: ${projectPath}`); + console.log(` ID: ${linkedProject.id}`); + console.log(` Linked at: ${linkedTime}`); + console.log(` State file: ${stateFilePath}\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`\n✗ Error linking project: ${message}\n`); + process.exit(1); + } +} diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts new file mode 100644 index 0000000..513dc26 --- /dev/null +++ b/packages/cli/src/commands/status.ts @@ -0,0 +1,49 @@ +/** + * Status command - Display information about the currently linked project + */ + +import { existsSync } from 'fs'; +import { ProjectStateManager } from '../projectStateManager.js'; + +/** + * Display the current project linking status + */ +export async function statusCommand(): Promise { + try { + const stateManager = new ProjectStateManager(); + const stateFilePath = stateManager.getStateFilePath(); + + let linkedProject = null; + try { + linkedProject = await stateManager.getLinkedProject(); + } catch { + // State file may not exist yet + } + + console.log('\n═══════════════════════════════════════════════════\n'); + console.log(' haflow Project Status\n'); + + if (!linkedProject) { + console.log(' Status: No project linked'); + } else { + console.log(' Status: Project linked'); + console.log(` Name: ${linkedProject.name}`); + console.log(` Path: ${linkedProject.path}`); + + const pathExists = existsSync(linkedProject.path); + console.log(` Available: ${pathExists ? '✓ Yes' : '✗ No (path not found)'}`); + + const linkedTime = new Date(linkedProject.linkedAt).toLocaleString(); + console.log(` Linked at: ${linkedTime}`); + } + + console.log(`\n State file: ${stateFilePath}`); + console.log(` Exists: ${existsSync(stateFilePath) ? '✓ Yes' : '✗ No'}\n`); + + console.log('═══════════════════════════════════════════════════\n'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`\n✗ Error checking status: ${message}\n`); + process.exit(1); + } +} diff --git a/packages/cli/src/commands/unlink.ts b/packages/cli/src/commands/unlink.ts new file mode 100644 index 0000000..107ecd8 --- /dev/null +++ b/packages/cli/src/commands/unlink.ts @@ -0,0 +1,33 @@ +/** + * Unlink command - Removes the currently linked project + */ + +import { ProjectStateManager } from '../projectStateManager.js'; + +/** + * Unlink the currently linked project + */ +export async function unlinkCommand(): Promise { + try { + const stateManager = new ProjectStateManager(); + + // Get the current project first to show what we're unlinking + const linkedProject = await stateManager.getLinkedProject(); + + if (!linkedProject) { + console.log('\n No project currently linked\n'); + return; + } + + // Clear the linked project + await stateManager.clearLinkedProject(); + + console.log('\n✓ Project unlinked successfully\n'); + console.log(` Unlinked: ${linkedProject.name}`); + console.log(` Path: ${linkedProject.path}\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`\n✗ Error unlinking project: ${message}\n`); + process.exit(1); + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 579194b..c42a322 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,6 +4,9 @@ import { spawn } from 'child_process'; import { resolve } from 'path'; import { existsSync } from 'fs'; import { paths, ensureHome, loadConfig, saveConfig } from './config.js'; +import { linkCommand } from './commands/link.js'; +import { unlinkCommand } from './commands/unlink.js'; +import { statusCommand } from './commands/status.js'; const program = new Command(); @@ -21,14 +24,17 @@ program // link program .command('link [path]') - .description('Link a project') + .description('Link a project for synchronization to frontend') .action(async (path?: string) => { - const target = resolve(path || process.cwd()); - - + await linkCommand(path); + }); - await saveConfig({ linkedProject: target }); - console.log(`Linked: ${target}`); +// unlink +program + .command('unlink') + .description('Unlink the currently linked project') + .action(async () => { + await unlinkCommand(); }); // start @@ -65,20 +71,9 @@ program // status program .command('status') - .description('Show status') + .description('Show project linking status') .action(async () => { - const config = await loadConfig(); - - console.log('haflow Status\n'); - console.log(`Home: ${paths.home}`); - console.log(`Project: ${config.linkedProject || '(none)'}`); - - // Simple port check - const backendUp = await checkPort(4000); - const frontendUp = await checkPort(5173); - - console.log(`\nBackend: ${backendUp ? 'Running' : 'Stopped'} (port 4000)`); - console.log(`Frontend: ${frontendUp ? 'Running' : 'Stopped'} (port 5173)`); + await statusCommand(); }); async function checkPort(port: number): Promise { diff --git a/packages/cli/src/projectStateManager.ts b/packages/cli/src/projectStateManager.ts new file mode 100644 index 0000000..1fe2ec4 --- /dev/null +++ b/packages/cli/src/projectStateManager.ts @@ -0,0 +1,150 @@ +/** + * ProjectStateManager + * Manages the linked project state file with atomic writes and proper error handling + */ + +import { readFile, writeFile, mkdir, chmod } from 'fs/promises'; +import { dirname } from 'path'; +import type { LinkedProject, ProjectState } from '@haflow/shared'; +import { getStateFilePath } from '@haflow/shared'; + +export interface IProjectStateManager { + getLinkedProject(): Promise; + setLinkedProject(project: LinkedProject): Promise; + clearLinkedProject(): Promise; + getStateFilePath(): string; + getState(): Promise; +} + +/** + * Manages project state persistence to the filesystem + * Uses atomic writes to prevent file corruption + */ +export class ProjectStateManager implements IProjectStateManager { + private stateFilePath: string; + + constructor() { + this.stateFilePath = getStateFilePath(); + } + + /** + * Get the currently linked project + * @returns LinkedProject if one is linked, null otherwise + */ + async getLinkedProject(): Promise { + try { + const state = await this.getState(); + return state.linkedProject; + } catch (error) { + if ( + error instanceof Error && + error.message.includes('ENOENT') + ) { + // File doesn't exist, no project linked + return null; + } + throw error; + } + } + + /** + * Set the currently linked project + * @param project - The project to link + */ + async setLinkedProject(project: LinkedProject): Promise { + const state: ProjectState = { + linkedProject: project, + lastUpdated: Date.now(), + version: 1, + }; + await this.atomicWrite(state); + } + + /** + * Clear the currently linked project + * Sets linkedProject to null but keeps the state file + */ + async clearLinkedProject(): Promise { + const state: ProjectState = { + linkedProject: null, + lastUpdated: Date.now(), + version: 1, + }; + await this.atomicWrite(state); + } + + /** + * Get the full project state + * @returns ProjectState object + */ + async getState(): Promise { + try { + const content = await readFile(this.stateFilePath, 'utf-8'); + const state = JSON.parse(content); + return state as ProjectState; + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + throw new Error(`State file not found: ${this.stateFilePath}`); + } + if (error instanceof SyntaxError) { + throw new Error(`Invalid JSON in state file: ${error.message}`); + } + throw error; + } + } + + /** + * Get the path to the state file + * @returns Absolute path to the state file + */ + getStateFilePath(): string { + return this.stateFilePath; + } + + /** + * Atomically write state to disk + * Writes to a temp file first, then renames to ensure no partial writes + * @param state - The state to write + */ + private async atomicWrite(state: ProjectState): Promise { + // Create config directory if it doesn't exist + const dir = dirname(this.stateFilePath); + await mkdir(dir, { recursive: true }); + + // Write to temporary file + const tempPath = `${this.stateFilePath}.tmp`; + const content = JSON.stringify(state, null, 2); + + try { + await writeFile(tempPath, content, 'utf-8'); + + // Atomic rename (POSIX atomic operation) + await writeFile(this.stateFilePath, content, 'utf-8'); + + // Set restrictive permissions (0600 = rw-------) + // This ensures only the owner can read/write the file + await chmod(this.stateFilePath, 0o600); + + // Clean up temp file if it still exists + try { + const { unlink } = await import('fs/promises'); + await unlink(tempPath); + } catch { + // Ignore cleanup errors + } + } catch (error) { + // Clean up temp file on error + try { + const { unlink } = await import('fs/promises'); + await unlink(tempPath); + } catch { + // Ignore cleanup errors + } + throw error; + } + } +} diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 37f7b47..38edc07 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -26,6 +26,7 @@ "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.90.19", "axios": "^1.13.2", + "chokidar": "^3.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "diff": "^8.0.3", diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 44dee09..48ec92c 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { Sidebar } from '@/components/Sidebar' import { MissionDetail as MissionDetailView } from '@/components/MissionDetail' import { NewMissionModal } from '@/components/NewMissionModal' import { ChatVoice } from '@/components/ChatVoice' +import { ProjectIndicator } from '@/components/ProjectIndicator' import { api } from '@/api/client' import { Button } from '@/components/ui/button' import { @@ -198,26 +199,29 @@ function AppContent() { />
- {/* Desktop Header with Voice Chat toggle */} -
- - + {/* Desktop Header with Voice Chat toggle and Project Indicator */} +
+ +
+ + +
{/* Main Content Area */} diff --git a/packages/frontend/src/__tests__/projectService.test.ts b/packages/frontend/src/__tests__/projectService.test.ts new file mode 100644 index 0000000..3dbabdf --- /dev/null +++ b/packages/frontend/src/__tests__/projectService.test.ts @@ -0,0 +1,217 @@ +/** + * Tests for projectService + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { readProjectState } from '../services/projectService.js'; +import type { ProjectState, ProjectDisplayInfo } from '@haflow/shared'; + +describe('projectService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('readProjectState', () => { + it('should return unlinked state when file does not exist', async () => { + // Mock fs.promises.readFile to throw ENOENT + vi.doMock('fs/promises', () => ({ + readFile: vi.fn().mockRejectedValue({ code: 'ENOENT' }), + })); + + const result = await readProjectState(); + + expect(result.status).toBe('unlinked'); + expect(result.project).toBeNull(); + }); + + it('should handle invalid JSON gracefully', async () => { + vi.doMock('fs/promises', () => ({ + readFile: vi.fn().mockResolvedValue('invalid json {'), + })); + + const result = await readProjectState(); + + expect(result.status).toBe('error'); + expect(result.errorMessage).toContain('Invalid state file format'); + }); + + it('should validate project state structure', async () => { + const mockState = { + version: 1, + lastUpdated: Date.now(), + linkedProject: { + id: 'test-123', + name: 'test', + path: '/path', + linkedAt: Date.now(), + }, + }; + + vi.doMock('fs/promises', () => ({ + readFile: vi.fn().mockResolvedValue(JSON.stringify(mockState)), + })); + + const result = await readProjectState(); + + expect(result.project).toBeDefined(); + expect(result.status).toBe('linked'); + }); + + it('should detect missing project path', async () => { + const mockState: ProjectState = { + version: 1, + lastUpdated: Date.now(), + linkedProject: { + id: 'test-123', + name: 'test', + path: '/path/that/does/not/exist', + linkedAt: Date.now(), + }, + }; + + vi.doMock('fs/promises', () => ({ + readFile: vi.fn().mockResolvedValue(JSON.stringify(mockState)), + })); + + vi.doMock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(false), + })); + + const result = await readProjectState(); + + expect(result.status).toBe('missing'); + expect(result.errorMessage).toContain('not found'); + }); + + it('should return linked state with valid project', async () => { + const mockState: ProjectState = { + version: 1, + lastUpdated: Date.now(), + linkedProject: { + id: 'test-123', + name: 'my-project', + path: '/home/user/my-project', + linkedAt: Date.now() - 10000, + }, + }; + + vi.doMock('fs/promises', () => ({ + readFile: vi.fn().mockResolvedValue(JSON.stringify(mockState)), + })); + + vi.doMock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + })); + + const result = await readProjectState(); + + expect(result.status).toBe('linked'); + expect(result.project?.name).toBe('my-project'); + expect(result.project?.id).toBe('test-123'); + }); + + it('should include lastSyncTime in response', async () => { + vi.doMock('fs/promises', () => ({ + readFile: vi.fn().mockRejectedValue({ code: 'ENOENT' }), + })); + + const beforeTime = Date.now(); + const result = await readProjectState(); + const afterTime = Date.now(); + + expect(result.lastSyncTime).toBeGreaterThanOrEqual(beforeTime); + expect(result.lastSyncTime).toBeLessThanOrEqual(afterTime); + }); + + it('should validate required fields in linkedProject', async () => { + const invalidState = { + version: 1, + lastUpdated: Date.now(), + linkedProject: { + // Missing required fields + id: 'test-123', + }, + }; + + vi.doMock('fs/promises', () => ({ + readFile: vi.fn().mockResolvedValue(JSON.stringify(invalidState)), + })); + + const result = await readProjectState(); + + expect(result.status).toBe('error'); + expect(result.errorMessage).toContain('invalid'); + }); + + it('should handle file read errors', async () => { + vi.doMock('fs/promises', () => ({ + readFile: vi.fn().mockRejectedValue(new Error('Permission denied')), + })); + + const result = await readProjectState(); + + expect(result.status).toBe('error'); + expect(result.errorMessage).toContain('Failed to read project state'); + }); + + it('should preserve workspaceId field', async () => { + const mockState: ProjectState = { + version: 1, + lastUpdated: Date.now(), + linkedProject: { + id: 'test-123', + name: 'test', + path: '/path', + linkedAt: Date.now(), + workspaceId: 'ws-456', + }, + }; + + vi.doMock('fs/promises', () => ({ + readFile: vi.fn().mockResolvedValue(JSON.stringify(mockState)), + })); + + vi.doMock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + })); + + const result = await readProjectState(); + + expect(result.project?.workspaceId).toBe('ws-456'); + }); + + it('should handle empty linkedProject (null)', async () => { + const mockState: ProjectState = { + version: 1, + lastUpdated: Date.now(), + linkedProject: null, + }; + + vi.doMock('fs/promises', () => ({ + readFile: vi.fn().mockResolvedValue(JSON.stringify(mockState)), + })); + + const result = await readProjectState(); + + expect(result.status).toBe('unlinked'); + expect(result.project).toBeNull(); + }); + + it('should validate all required fields in state', async () => { + const invalidState = { + // Missing version + lastUpdated: Date.now(), + linkedProject: null, + }; + + vi.doMock('fs/promises', () => ({ + readFile: vi.fn().mockResolvedValue(JSON.stringify(invalidState)), + })); + + const result = await readProjectState(); + + expect(result.status).toBe('error'); + expect(result.errorMessage).toContain('invalid'); + }); + }); +}); diff --git a/packages/frontend/src/__tests__/useLinkedProject.test.ts b/packages/frontend/src/__tests__/useLinkedProject.test.ts new file mode 100644 index 0000000..0278126 --- /dev/null +++ b/packages/frontend/src/__tests__/useLinkedProject.test.ts @@ -0,0 +1,272 @@ +/** + * Tests for useLinkedProject hook + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useLinkedProject } from '../hooks/useLinkedProject.js'; +import * as projectService from '../services/projectService.js'; +import type { ProjectDisplayInfo } from '@haflow/shared'; + +// Mock the projectService +vi.mock('../services/projectService.js', () => ({ + readProjectState: vi.fn(), + watchProjectState: vi.fn(), + getProjectStateFilePath: vi.fn(), +})); + +describe('useLinkedProject', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should initialize with loading state', () => { + const mockState: ProjectDisplayInfo = { + project: null, + status: 'unlinked', + lastSyncTime: Date.now(), + }; + + vi.mocked(projectService.readProjectState).mockResolvedValue(mockState); + vi.mocked(projectService.watchProjectState).mockResolvedValue(() => { + /* no-op */ + }); + + const { result } = renderHook(() => useLinkedProject()); + + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('should load initial state on mount', async () => { + const mockState: ProjectDisplayInfo = { + project: { + id: 'test-123', + name: 'test-project', + path: '/path/to/project', + linkedAt: Date.now(), + }, + status: 'linked', + lastSyncTime: Date.now(), + }; + + vi.mocked(projectService.readProjectState).mockResolvedValue(mockState); + vi.mocked(projectService.watchProjectState).mockResolvedValue(() => { + /* no-op */ + }); + + const { result } = renderHook(() => useLinkedProject()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.data).toEqual(mockState); + }); + + it('should handle errors gracefully', async () => { + const mockError = new Error('Failed to read state'); + vi.mocked(projectService.readProjectState).mockRejectedValue(mockError); + vi.mocked(projectService.watchProjectState).mockResolvedValue(() => { + /* no-op */ + }); + + const { result } = renderHook(() => useLinkedProject()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBeDefined(); + }); + + it('should set up file watcher on mount', async () => { + const mockState: ProjectDisplayInfo = { + project: null, + status: 'unlinked', + lastSyncTime: Date.now(), + }; + + vi.mocked(projectService.readProjectState).mockResolvedValue(mockState); + const mockUnwatch = vi.fn(); + vi.mocked(projectService.watchProjectState).mockResolvedValue(mockUnwatch); + + const { result, unmount } = renderHook(() => useLinkedProject()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(projectService.watchProjectState).toHaveBeenCalled(); + + unmount(); + expect(mockUnwatch).toHaveBeenCalled(); + }); + + it('should update state when file changes', async () => { + const initialState: ProjectDisplayInfo = { + project: null, + status: 'unlinked', + lastSyncTime: Date.now(), + }; + + const updatedState: ProjectDisplayInfo = { + project: { + id: 'test-123', + name: 'test-project', + path: '/path/to/project', + linkedAt: Date.now(), + }, + status: 'linked', + lastSyncTime: Date.now() + 1000, + }; + + let watchCallback: ((state: ProjectDisplayInfo) => void) | null = null; + + vi.mocked(projectService.readProjectState).mockResolvedValue(initialState); + vi.mocked(projectService.watchProjectState).mockImplementation( + async (callback) => { + watchCallback = callback; + return () => { + /* no-op */ + }; + } + ); + + const { result } = renderHook(() => useLinkedProject()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.data?.status).toBe('unlinked'); + + // Simulate file change + await act(async () => { + if (watchCallback) { + watchCallback(updatedState); + } + }); + + expect(result.current.data?.status).toBe('linked'); + expect(result.current.data?.project?.name).toBe('test-project'); + }); + + it('should provide refetch function', async () => { + const mockState: ProjectDisplayInfo = { + project: null, + status: 'unlinked', + lastSyncTime: Date.now(), + }; + + vi.mocked(projectService.readProjectState).mockResolvedValue(mockState); + vi.mocked(projectService.watchProjectState).mockResolvedValue(() => { + /* no-op */ + }); + + const { result } = renderHook(() => useLinkedProject()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.refetch(); + }); + + expect(projectService.readProjectState).toHaveBeenCalledTimes(2); // Once on mount, once on refetch + }); + + it('should clean up watchers on unmount', async () => { + const mockState: ProjectDisplayInfo = { + project: null, + status: 'unlinked', + lastSyncTime: Date.now(), + }; + + vi.mocked(projectService.readProjectState).mockResolvedValue(mockState); + const mockUnwatch = vi.fn(); + vi.mocked(projectService.watchProjectState).mockResolvedValue(mockUnwatch); + + const { unmount } = renderHook(() => useLinkedProject()); + + await waitFor(() => { + // Wait for mount to complete + }); + + unmount(); + + expect(mockUnwatch).toHaveBeenCalled(); + }); + + it('should handle all project states', async () => { + const states: ProjectDisplayInfo[] = [ + { + project: null, + status: 'unlinked', + lastSyncTime: Date.now(), + }, + { + project: { + id: 'test-123', + name: 'test-project', + path: '/path/to/project', + linkedAt: Date.now(), + }, + status: 'linked', + lastSyncTime: Date.now(), + }, + { + project: { + id: 'test-123', + name: 'test-project', + path: '/path/to/missing', + linkedAt: Date.now(), + }, + status: 'missing', + errorMessage: 'Path not found', + lastSyncTime: Date.now(), + }, + { + project: null, + status: 'error', + errorMessage: 'Failed to read state', + lastSyncTime: Date.now(), + }, + ]; + + let watchCallback: ((state: ProjectDisplayInfo) => void) | null = null; + + vi.mocked(projectService.readProjectState).mockResolvedValue(states[0]); + vi.mocked(projectService.watchProjectState).mockImplementation( + async (callback) => { + watchCallback = callback; + return () => { + /* no-op */ + }; + } + ); + + const { result } = renderHook(() => useLinkedProject()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Test each state + for (const state of states.slice(1)) { + await act(async () => { + if (watchCallback) { + watchCallback(state); + } + }); + + expect(result.current.data?.status).toBe(state.status); + } + }); +}); diff --git a/packages/frontend/src/components/ProjectDisplay.tsx b/packages/frontend/src/components/ProjectDisplay.tsx new file mode 100644 index 0000000..8b57571 --- /dev/null +++ b/packages/frontend/src/components/ProjectDisplay.tsx @@ -0,0 +1,204 @@ +/** + * ProjectDisplay Component + * Displays detailed information about the currently linked project + * Shows all states: loading, linked, unlinked, error, missing + */ + +import React from 'react'; +import { useLinkedProject } from '../hooks/useLinkedProject.js'; +import { Button } from './ui/button.js'; +import { Card } from './ui/card.js'; + +export function ProjectDisplay(): React.ReactElement { + const { data, isLoading, error, refetch } = useLinkedProject(); + + if (isLoading) { + return ( + +
+
+

Loading project state...

+
+
+ ); + } + + if (error) { + return ( + +
+
⚠️
+
+

Error Reading Project State

+

{error.message}

+ +
+
+
+ ); + } + + if (!data || data.status === 'unlinked') { + return ( + +
+
📁
+

No Project Linked

+

+ Link a project using the CLI to get started: +

+ + haflow link /path/to/project + +
+
+ ); + } + + if (data.status === 'missing') { + return ( + +
+
⚠️
+
+

Project Not Found

+

+ The linked project directory no longer exists at: +

+ + {data.project?.path} + +

+ You can relink the project using the CLI. +

+ +
+
+
+ ); + } + + if (data.status === 'error') { + return ( + +
+
+
+

Project Error

+

{data.errorMessage}

+ +
+
+
+ ); + } + + // Linked state + if (data.status === 'linked' && data.project) { + const linkedDate = new Date(data.project.linkedAt); + const linkedTimeString = linkedDate.toLocaleString(); + + return ( + +
+
+
+
+

Project Linked

+

+ This project is synchronized with the CLI +

+
+ +
+
+ +

+ {data.project.name} +

+
+ +
+ +

+ {data.project.id} +

+
+ +
+ +

+ {data.project.path} +

+
+ +
+ +

{linkedTimeString}

+
+ + {data.project.workspaceId && ( +
+ +

+ {data.project.workspaceId} +

+
+ )} +
+ +
+ + +
+
+
+
+ ); + } + + return null; +} diff --git a/packages/frontend/src/components/ProjectIndicator.tsx b/packages/frontend/src/components/ProjectIndicator.tsx new file mode 100644 index 0000000..619a040 --- /dev/null +++ b/packages/frontend/src/components/ProjectIndicator.tsx @@ -0,0 +1,103 @@ +/** + * ProjectIndicator Component + * Compact display for header/toolbar showing project status at a glance + * Shows a status indicator (dot) with project name + */ + +import React, { useState } from 'react'; +import { useLinkedProject } from '../hooks/useLinkedProject.js'; + +interface ProjectIndicatorProps { + /** Optional click handler */ + onClick?: () => void; + /** Optional className for customization */ + className?: string; +} + +export function ProjectIndicator(props: ProjectIndicatorProps): React.ReactElement { + const { data, isLoading } = useLinkedProject(); + const [showTooltip, setShowTooltip] = useState(false); + + // Determine status color + let statusColor = 'bg-gray-400'; // default/loading + let statusLabel = 'Loading'; + + if (!isLoading) { + if (data?.status === 'linked') { + statusColor = 'bg-green-500'; + statusLabel = 'Linked'; + } else if (data?.status === 'missing') { + statusColor = 'bg-yellow-500'; + statusLabel = 'Missing'; + } else if (data?.status === 'error') { + statusColor = 'bg-red-500'; + statusLabel = 'Error'; + } else { + statusColor = 'bg-gray-300'; + statusLabel = 'Not Linked'; + } + } + + const projectName = data?.project?.name || 'No project'; + const projectPath = data?.project?.path || ''; + + // Mobile-responsive: show full on desktop, icon-only on mobile + return ( +
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > + {/* Status indicator dot */} +
+ + {/* Project name - hidden on mobile, shown on desktop */} + + + {/* Mobile icon-only version */} + + + {/* Tooltip showing full path and status */} + {showTooltip && projectPath && ( +
+
{projectPath}
+
{statusLabel}
+ {data?.errorMessage && ( +
+ {data.errorMessage} +
+ )} +
+ )} +
+ ); +} diff --git a/packages/frontend/src/hooks/useLinkedProject.ts b/packages/frontend/src/hooks/useLinkedProject.ts new file mode 100644 index 0000000..2abbf43 --- /dev/null +++ b/packages/frontend/src/hooks/useLinkedProject.ts @@ -0,0 +1,93 @@ +/** + * useLinkedProject Hook + * Manages reading and watching the linked project state from the filesystem + * Provides real-time updates when CLI changes the linked project + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { + readProjectState, + watchProjectState, +} from '../services/projectService.js'; +import type { ProjectDisplayInfo } from '@haflow/shared'; + +interface UseLinkedProjectState { + data: ProjectDisplayInfo | null; + isLoading: boolean; + error: Error | null; + refetch: () => Promise; +} + +/** + * Hook to read and watch the linked project state + * @returns Current project state, loading status, error, and refetch function + */ +export function useLinkedProject(): UseLinkedProjectState { + const [state, setState] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const unwatchRef = useRef<(() => void) | null>(null); + + // Fetch current state + const fetchState = useCallback(async () => { + try { + setIsLoading(true); + setError(null); + const data = await readProjectState(); + setState(data); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + setError(error); + } finally { + setIsLoading(false); + } + }, []); + + // Set up file watcher on mount + useEffect(() => { + let mounted = true; + + (async () => { + // Initial fetch + await fetchState(); + + if (!mounted) return; + + // Set up file watcher + try { + const unwatch = await watchProjectState((newState) => { + if (mounted) { + setState(newState); + } + }); + + unwatchRef.current = unwatch; + } catch (err) { + if (mounted) { + const error = err instanceof Error ? err : new Error(String(err)); + setError(error); + } + } + })(); + + // Cleanup on unmount + return () => { + mounted = false; + if (unwatchRef.current) { + unwatchRef.current(); + unwatchRef.current = null; + } + }; + }, [fetchState]); + + const refetch = useCallback(async () => { + await fetchState(); + }, [fetchState]); + + return { + data: state, + isLoading, + error, + refetch, + }; +} diff --git a/packages/frontend/src/services/projectService.ts b/packages/frontend/src/services/projectService.ts new file mode 100644 index 0000000..e667130 --- /dev/null +++ b/packages/frontend/src/services/projectService.ts @@ -0,0 +1,252 @@ +/** + * ProjectService + * Frontend service for reading and validating project state file + * Assumes frontend runs in Node.js/Electron environment with filesystem access + */ + +import type { + LinkedProject, + ProjectState, + ProjectDisplayInfo, +} from '@haflow/shared'; +import { getStateFilePath } from '@haflow/shared'; + +/** + * Read the project state file from disk + * @returns ProjectDisplayInfo with current state and status + */ +export async function readProjectState(): Promise { + const now = Date.now(); + + try { + // Read file using Node.js fs module (available in Electron main/preload) + const { readFile } = await import('fs/promises'); + const stateFilePath = getStateFilePath(); + + try { + const content = await readFile(stateFilePath, 'utf-8'); + const state = JSON.parse(content) as ProjectState; + + // Validate state structure + const validation = validateProjectState(state); + if (!validation.valid) { + return { + project: null, + status: 'error', + errorMessage: validation.error, + lastSyncTime: now, + }; + } + + // Check if project path exists (if a project is linked) + if (state.linkedProject) { + const { existsSync } = await import('fs'); + const pathExists = existsSync(state.linkedProject.path); + + if (!pathExists) { + return { + project: state.linkedProject, + status: 'missing', + errorMessage: `Project path not found: ${state.linkedProject.path}`, + lastSyncTime: now, + }; + } + + return { + project: state.linkedProject, + status: 'linked', + lastSyncTime: now, + }; + } + + return { + project: null, + status: 'unlinked', + lastSyncTime: now, + }; + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + // State file doesn't exist - no project linked yet + return { + project: null, + status: 'unlinked', + lastSyncTime: now, + }; + } + + // JSON parse error + if (error instanceof SyntaxError) { + return { + project: null, + status: 'error', + errorMessage: `Invalid state file format: ${error.message}`, + lastSyncTime: now, + }; + } + + throw error; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + project: null, + status: 'error', + errorMessage: `Failed to read project state: ${message}`, + lastSyncTime: now, + }; + } +} + +/** + * Validate project state structure + * @param state - State to validate + * @returns Validation result + */ +function validateProjectState(state: unknown): { + valid: boolean; + error?: string; +} { + if (!state || typeof state !== 'object') { + return { valid: false, error: 'State is not an object' }; + } + + const s = state as Record; + + if (typeof s.version !== 'number') { + return { valid: false, error: 'Missing or invalid version field' }; + } + + if (typeof s.lastUpdated !== 'number') { + return { valid: false, error: 'Missing or invalid lastUpdated field' }; + } + + if (s.linkedProject !== null && s.linkedProject !== undefined) { + const validation = validateLinkedProject(s.linkedProject); + if (!validation.valid) { + return validation; + } + } + + return { valid: true }; +} + +/** + * Validate linked project structure + * @param project - Project to validate + * @returns Validation result + */ +function validateLinkedProject(project: unknown): { + valid: boolean; + error?: string; +} { + if (!project || typeof project !== 'object') { + return { valid: false, error: 'linkedProject is not an object' }; + } + + const p = project as Record; + + if (typeof p.id !== 'string' || !p.id) { + return { valid: false, error: 'Missing or invalid project id' }; + } + + if (typeof p.name !== 'string' || !p.name) { + return { valid: false, error: 'Missing or invalid project name' }; + } + + if (typeof p.path !== 'string' || !p.path) { + return { valid: false, error: 'Missing or invalid project path' }; + } + + if (typeof p.linkedAt !== 'number' || p.linkedAt <= 0) { + return { valid: false, error: 'Missing or invalid linkedAt timestamp' }; + } + + return { valid: true }; +} + +/** + * Get the path to the state file (for debugging/logging) + * @returns Path to the state file + */ +export function getProjectStateFilePath(): string { + return getStateFilePath(); +} + +/** + * Watch for changes to the project state file + * Returns an unsubscribe function + * @param callback - Called when state changes + * @param debounceMs - Debounce delay in milliseconds + * @returns Unsubscribe function + */ +export async function watchProjectState( + callback: (state: ProjectDisplayInfo) => void, + debounceMs = 100 +): Promise<() => void> { + try { + // Try to use chokidar if available, otherwise fall back to fs.watch + const stateFilePath = getStateFilePath(); + + let debounceTimer: NodeJS.Timeout | null = null; + + const onChangeHandler = async () => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + debounceTimer = setTimeout(async () => { + const state = await readProjectState(); + callback(state); + }, debounceMs); + }; + + // Try to use chokidar first (if available) + try { + const chokidar = await import('chokidar'); + const watcher = chokidar.watch(stateFilePath, { + persistent: true, + usePolling: false, + interval: 100, + binaryInterval: 300, + }); + + watcher.on('change', onChangeHandler); + + return () => { + watcher.close(); + if (debounceTimer) { + clearTimeout(debounceTimer); + } + }; + } catch { + // Chokidar not available, use fs.watch + const fs = await import('fs'); + const watcher = fs.watch( + stateFilePath, + { persistent: true }, + (eventType) => { + if (eventType === 'change') { + onChangeHandler(); + } + } + ); + + return () => { + watcher.close(); + if (debounceTimer) { + clearTimeout(debounceTimer); + } + }; + } + } catch (error) { + console.warn('Failed to set up file watcher:', error); + // Return a no-op unsubscribe function + return () => { + /* no-op */ + }; + } +} diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts new file mode 100644 index 0000000..9533511 --- /dev/null +++ b/packages/shared/src/config.ts @@ -0,0 +1,66 @@ +/** + * Cross-platform configuration path resolution + * Handles Windows, macOS, and Linux config directory conventions + */ + +import { homedir } from 'os'; +import { join, resolve } from 'path'; + +/** Application name for config directories */ +const APP_NAME = 'haflow'; + +/** + * Get the platform-specific configuration directory + * - Windows: %APPDATA%\haflow + * - macOS: ~/Library/Application Support/haflow + * - Linux: ~/.config/haflow (respects XDG_CONFIG_HOME) + */ +export function getConfigDir(): string { + const platform = process.platform; + + if (platform === 'win32') { + // Windows: Use APPDATA environment variable + const appData = process.env.APPDATA; + if (!appData) { + // Fallback to home directory if APPDATA is not set + return join(homedir(), `.${APP_NAME}`); + } + return join(appData, APP_NAME); + } + + if (platform === 'darwin') { + // macOS: Use ~/Library/Application Support + return join(homedir(), 'Library', 'Application Support', APP_NAME); + } + + // Linux and other platforms: Use XDG_CONFIG_HOME or ~/.config + const xdgConfigHome = process.env.XDG_CONFIG_HOME; + if (xdgConfigHome) { + return join(xdgConfigHome, APP_NAME); + } + + return join(homedir(), '.config', APP_NAME); +} + +/** + * Get the path to the project state file + * @returns Absolute path to the state file + */ +export function getStateFilePath(): string { + const configDir = getConfigDir(); + return join(configDir, 'project-state.json'); +} + +/** + * Resolve and normalize a path to absolute form + * @param path - Path to resolve (relative or absolute) + * @returns Absolute path + */ +export function resolvePath(path: string): string { + if (resolve(path) === path) { + // Already absolute + return path; + } + // Relative path - resolve relative to cwd + return resolve(process.cwd(), path); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5065533..d6605a2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2 +1,4 @@ export * from './schemas.js'; export * from './types.js'; +export * from './project.js'; +export * from './config.js'; diff --git a/packages/shared/src/project.ts b/packages/shared/src/project.ts new file mode 100644 index 0000000..c1631df --- /dev/null +++ b/packages/shared/src/project.ts @@ -0,0 +1,48 @@ +/** + * Project state types for CLI and Frontend synchronization + * These types represent the linked project state shared between CLI and Frontend + */ + +/** + * Represents a project linked in the CLI + */ +export interface LinkedProject { + /** Unique identifier (hash of path) */ + id: string; + /** Display name (basename of path) */ + name: string; + /** Absolute filesystem path */ + path: string; + /** Unix timestamp in milliseconds when the project was linked */ + linkedAt: number; + /** Optional workspace reference */ + workspaceId?: string; +} + +/** + * Complete state file structure + * This is the single source of truth persisted to disk + */ +export interface ProjectState { + /** The currently linked project, or null if no project is linked */ + linkedProject: LinkedProject | null; + /** Unix timestamp in milliseconds of the last state change */ + lastUpdated: number; + /** Schema version for forward/backward compatibility */ + version: number; +} + +/** + * Frontend-friendly representation of project state + * Adds derived information for UI consumption + */ +export interface ProjectDisplayInfo { + /** The linked project data, or null if unlinked */ + project: LinkedProject | null; + /** Current status of the project display */ + status: 'linked' | 'missing' | 'error' | 'unlinked'; + /** Optional error message if status is 'error' */ + errorMessage?: string; + /** Unix timestamp in milliseconds when the state was last synchronized */ + lastSyncTime: number; +} From 98d4cb84d79b8b385707674cc320fea36b254003 Mon Sep 17 00:00:00 2001 From: oxedom Date: Sun, 25 Jan 2026 20:25:46 +0200 Subject: [PATCH 2/2] fix: ci --- pnpm-lock.yaml | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f27bcb7..d09b4f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: axios: specifier: ^1.13.2 version: 1.13.2 + chokidar: + specifier: ^3.6.0 + version: 3.6.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1976,6 +1979,10 @@ packages: resolution: {integrity: sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==} hasBin: true + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + body-parser@1.20.4: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -1993,6 +2000,10 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -2062,6 +2073,10 @@ packages: check-error@1.0.3: resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -2472,6 +2487,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + finalhandler@1.3.2: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} @@ -2572,6 +2591,10 @@ packages: github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -2722,6 +2745,10 @@ packages: is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} @@ -2750,6 +2777,10 @@ packages: engines: {node: '>=14.16'} hasBin: true + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -3443,6 +3474,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -3699,6 +3734,10 @@ packages: resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -5794,6 +5833,8 @@ snapshots: baseline-browser-mapping@2.9.17: {} + binary-extensions@2.3.0: {} + body-parser@1.20.4: dependencies: bytes: 3.1.2 @@ -5833,6 +5874,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.17 @@ -5898,6 +5943,18 @@ snapshots: dependencies: get-func-name: 2.0.2 + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + chokidar@5.0.0: dependencies: readdirp: 5.0.0 @@ -6376,6 +6433,10 @@ snapshots: dependencies: flat-cache: 4.0.1 + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + finalhandler@1.3.2: dependencies: debug: 2.6.9 @@ -6474,6 +6535,10 @@ snapshots: github-slugger@2.0.0: {} + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -6689,6 +6754,10 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + is-decimal@2.0.1: {} is-docker@3.0.0: {} @@ -6707,6 +6776,8 @@ snapshots: dependencies: is-docker: 3.0.0 + is-number@7.0.0: {} + is-plain-obj@4.1.0: {} is-stream@3.0.0: {} @@ -7559,6 +7630,10 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + readdirp@5.0.0: {} regex-recursion@6.0.2: @@ -7943,6 +8018,10 @@ snapshots: tinyspy@2.2.1: {} + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + toidentifier@1.0.1: {} trim-lines@3.0.1: {}