diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.test.ts b/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.test.ts index 2cfc4427be..8001382f33 100644 --- a/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.test.ts +++ b/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.test.ts @@ -1,11 +1,9 @@ import path from 'node:path'; +import type { IFileSystem } from '@emdash/core/files'; +import { err, ok } from '@emdash/shared'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import { - FileSystemError, - FileSystemErrorCodes, - type FileSystemProvider, -} from '@main/core/fs/types'; +import type { IFilesRuntime } from '@main/core/runtime/types'; import { ClaudeTrustService } from './claude-trust-service'; const mockReadFile = vi.hoisted(() => vi.fn()); @@ -38,14 +36,6 @@ vi.mock('@main/lib/logger', () => ({ }, })); -function notFound(pathName: string): FileSystemError { - return new FileSystemError( - `File not found: ${pathName}`, - FileSystemErrorCodes.NOT_FOUND, - pathName - ); -} - function makeService(overrides: { autoTrustWorktrees?: boolean } = {}): ClaudeTrustService { return new ClaudeTrustService({ getTaskSettings: () => @@ -54,16 +44,49 @@ function makeService(overrides: { autoTrustWorktrees?: boolean } = {}): ClaudeTr } function makeRemoteFs( - overrides: Partial> = {} -): Pick { + overrides: Partial> = {} +): Pick { return { - realPath: vi.fn(async (p: string) => p), - read: vi.fn().mockRejectedValue(notFound('/home/remote-user/.claude.json')), - write: vi.fn().mockResolvedValue({ success: true, bytesWritten: 0 }), + realPath: vi.fn(async () => ok('/remote/worktree')), + readText: vi.fn(async (p: string) => + err({ + type: 'fs-error' as const, + path: p, + message: `File not found: ${p}`, + code: 'NOT_FOUND', + }) + ), + writeText: vi.fn(async (_path: string, content: string) => + ok({ bytesWritten: content.length }) + ), ...overrides, }; } +function makeFilesRuntime(args: { + fs: Pick; +}): IFilesRuntime { + return { + path: { + join: (...parts: string[]) => path.posix.join(...parts), + dirname: (value: string) => path.posix.dirname(value), + basename: (value: string) => path.posix.basename(value), + isAbsolute: (value: string) => path.posix.isAbsolute(value), + relative: (from: string, to: string) => path.posix.relative(from, to), + contains: (parent: string, child: string) => { + const rel = path.posix.relative(parent, child); + return ( + rel === '' || (rel !== '..' && !rel.startsWith('../') && !path.posix.isAbsolute(rel)) + ); + }, + }, + openTree: vi.fn(), + watchChanges: vi.fn(), + fileSystem: vi.fn(() => ok(args.fs as IFileSystem)), + dispose: vi.fn(), + } as unknown as IFilesRuntime; +} + describe('ClaudeTrustService', () => { beforeEach(() => { vi.clearAllMocks(); @@ -79,7 +102,7 @@ describe('ClaudeTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'codex', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -92,7 +115,7 @@ describe('ClaudeTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'claude', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -105,7 +128,7 @@ describe('ClaudeTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'claude', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', force: true, }); @@ -116,11 +139,11 @@ describe('ClaudeTrustService', () => { it('writes local config atomically when missing', async () => { const service = makeService(); - const relPath = './relative/path'; + const workspacePath = '/absolute/path'; await service.maybeAutoTrustLocal({ providerId: 'claude', - cwd: relPath, + workspacePath, homedir: '/home/local-user', }); @@ -136,19 +159,36 @@ describe('ClaudeTrustService', () => { expect(renameTo).toBe('/home/local-user/.claude.json'); const written = JSON.parse(String(content)); - expect(written.projects[path.resolve(relPath)]).toEqual({ + expect(written.projects[workspacePath]).toEqual({ hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true, }); }); + it('refuses to auto-trust relative local workspace paths', async () => { + const service = makeService(); + + await service.maybeAutoTrustLocal({ + providerId: 'claude', + workspacePath: './relative/path', + homedir: '/home/local-user', + }); + + expect(mockReadFile).not.toHaveBeenCalled(); + expect(mockWriteFile).not.toHaveBeenCalled(); + expect(mockWarn).toHaveBeenCalledWith( + 'ClaudeTrustService: refusing to auto-trust non-absolute workspace path', + { path: './relative/path' } + ); + }); + it('adds Copilot trusted folders', async () => { const service = makeService(); mockReadFile.mockResolvedValue(JSON.stringify({ trustedFolders: ['/already/trusted'] })); await service.maybeAutoTrustLocal({ providerId: 'copilot', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -170,7 +210,7 @@ describe('ClaudeTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'copilot', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -194,7 +234,7 @@ describe('ClaudeTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'claude', - cwd: trustedPath, + workspacePath: trustedPath, homedir: '/home/local-user', }); @@ -208,7 +248,7 @@ describe('ClaudeTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'claude', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -226,7 +266,7 @@ describe('ClaudeTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'claude', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -250,12 +290,12 @@ describe('ClaudeTrustService', () => { await Promise.all([ service.maybeAutoTrustLocal({ providerId: 'claude', - cwd: '/worktree/a', + workspacePath: '/worktree/a', homedir: '/home/local-user', }), service.maybeAutoTrustLocal({ providerId: 'claude', - cwd: '/worktree/b', + workspacePath: '/worktree/b', homedir: '/home/local-user', }), ]); @@ -275,8 +315,9 @@ describe('ClaudeTrustService', () => { it('writes ssh config and renames tmp file remotely', async () => { const service = makeService(); const remoteFs = makeRemoteFs({ - realPath: vi.fn().mockResolvedValue('/remote/worktree'), + realPath: vi.fn(async () => ok('/remote/worktree')), }); + const files = makeFilesRuntime({ fs: remoteFs }); const ctx: IExecutionContext = { root: undefined, @@ -301,17 +342,16 @@ describe('ClaudeTrustService', () => { await service.maybeAutoTrustSsh({ providerId: 'claude', - cwd: '/remote/worktree', + workspacePath: '/remote/worktree', ctx, - remoteFs, + files, }); - expect(remoteFs.read).toHaveBeenCalledWith( - '/home/remote-user/.claude.json', - expect.any(Number) - ); - expect(remoteFs.write).toHaveBeenCalledTimes(1); - const [tmpPath, content] = vi.mocked(remoteFs.write).mock.calls[0]; + expect(remoteFs.readText).toHaveBeenCalledWith('/home/remote-user/.claude.json', { + maxBytes: expect.any(Number), + }); + expect(remoteFs.writeText).toHaveBeenCalledTimes(1); + const [tmpPath, content] = vi.mocked(remoteFs.writeText).mock.calls[0]; expect(tmpPath).toContain('/home/remote-user/.claude.json.'); const written = JSON.parse(String(content)); expect(written.projects['/remote/worktree']).toEqual({ @@ -320,4 +360,32 @@ describe('ClaudeTrustService', () => { }); expect(ctx.exec).toHaveBeenCalledWith('mv', [tmpPath, '/home/remote-user/.claude.json']); }); + + it('refuses to auto-trust relative ssh workspace paths', async () => { + const service = makeService(); + const remoteFs = makeRemoteFs(); + const files = makeFilesRuntime({ fs: remoteFs }); + const ctx: IExecutionContext = { + root: undefined, + supportsLocalSpawn: false, + exec: vi.fn(), + execStreaming: vi.fn(), + dispose: vi.fn(), + }; + + await service.maybeAutoTrustSsh({ + providerId: 'claude', + workspacePath: 'relative/worktree', + ctx, + files, + }); + + expect(remoteFs.realPath).not.toHaveBeenCalled(); + expect(remoteFs.writeText).not.toHaveBeenCalled(); + expect(ctx.exec).not.toHaveBeenCalled(); + expect(mockWarn).toHaveBeenCalledWith( + 'ClaudeTrustService: refusing to auto-trust non-absolute workspace path', + { path: 'relative/worktree' } + ); + }); }); diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.ts b/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.ts index 91022fa690..7731a32ef7 100644 --- a/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.ts +++ b/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.ts @@ -1,16 +1,15 @@ import { randomUUID } from 'node:crypto'; import { promises as fs } from 'node:fs'; import path from 'node:path'; +import { isFileNotFoundError, isFileNotFoundException, type IFileSystem } from '@emdash/core/files'; +import { err, ok, type Result } from '@emdash/shared'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import { - FileSystemError, - FileSystemErrorCodes, - type FileSystemProvider, -} from '@main/core/fs/types'; import { appSettingsService } from '@main/core/settings/settings-service'; import { resolveRemoteHome } from '@main/core/ssh/lifecycle/remote-shell-profile'; import { log } from '@main/lib/logger'; import type { AgentProviderId } from '@shared/core/agents/agent-provider-registry'; +import { normalizeLocalWorkspacePath, normalizeSshWorkspacePath } from './workspace-trust-paths'; +import type { WorkspaceTrustLocalArgs, WorkspaceTrustSshArgs } from './workspace-trust-types'; const CLAUDE_PROVIDER_ID: AgentProviderId = 'claude'; const COPILOT_PROVIDER_ID: AgentProviderId = 'copilot'; @@ -29,19 +28,14 @@ export class ClaudeTrustService { async maybeAutoTrustLocal({ providerId, - cwd, + workspacePath, homedir, force = false, - }: { - providerId: AgentProviderId; - cwd?: string; - homedir: string; - force?: boolean; - }): Promise { - if (!cwd) return; + }: WorkspaceTrustLocalArgs): Promise { const trustConfig = await this.getTrustConfig(providerId, force); if (!trustConfig) return; - const normalizedPath = path.resolve(cwd); + const normalizedPath = normalizeLocalWorkspacePath(workspacePath, 'ClaudeTrustService'); + if (!normalizedPath) return; const configPath = path.join(homedir, trustConfig.configName); await this.withLock(configPath, () => this.ensureTrusted(normalizedPath, { @@ -54,29 +48,35 @@ export class ClaudeTrustService { async maybeAutoTrustSsh({ providerId, - cwd, + workspacePath, ctx, - remoteFs, + files, force = false, - }: { - providerId: AgentProviderId; - cwd?: string; - ctx: IExecutionContext; - remoteFs: Pick; - force?: boolean; - }): Promise { - if (!cwd) return; + }: WorkspaceTrustSshArgs): Promise { const trustConfig = await this.getTrustConfig(providerId, force); if (!trustConfig) return; - const normalizedPath = await remoteFs.realPath(cwd).catch(() => path.posix.resolve('/', cwd)); + const normalizedPath = await normalizeSshWorkspacePath( + files, + workspacePath, + 'ClaudeTrustService' + ); + if (!normalizedPath) return; const homeDir = await resolveRemoteHome(ctx); + const homeFs = files.fileSystem(); + if (!homeFs.success) { + log.warn('ClaudeTrustService: failed to open filesystem for auto-trust', { + path: normalizedPath, + error: homeFs.error.message, + }); + return; + } const configPath = path.posix.join(homeDir, trustConfig.configName); await this.withLock(configPath, () => this.ensureTrusted(normalizedPath, { - readConfig: () => readRemoteConfig(remoteFs, configPath), - writeConfig: (content) => writeRemoteConfigAtomic(remoteFs, ctx, configPath, content), + readConfig: () => readRemoteConfig(homeFs.data, configPath), + writeConfig: (content) => writeRemoteConfigAtomic(homeFs.data, ctx, configPath, content), trustConfig, }) ); @@ -117,18 +117,31 @@ export class ClaudeTrustService { private async ensureTrusted( normalizedPath: string, io: { - readConfig: () => Promise; - writeConfig: (content: string) => Promise; + readConfig: () => Promise>; + writeConfig: (content: string) => Promise>; trustConfig: TrustConfig; } ): Promise { try { const rawConfig = await io.readConfig(); - const config = parseConfig(rawConfig, io.trustConfig.parseWarningName); + if (!rawConfig.success) { + log.warn('ClaudeTrustService: failed to read auto-trust config', { + path: normalizedPath, + error: rawConfig.error.message, + }); + return; + } + const config = parseConfig(rawConfig.data, io.trustConfig.parseWarningName); if (!config) return; const nextConfig = io.trustConfig.withTrustedPath(config, normalizedPath); if (!nextConfig) return; - await io.writeConfig(JSON.stringify(nextConfig, null, 2) + '\n'); + const written = await io.writeConfig(JSON.stringify(nextConfig, null, 2) + '\n'); + if (!written.success) { + log.warn('ClaudeTrustService: failed to write auto-trust config', { + path: normalizedPath, + error: written.error.message, + }); + } } catch (error: unknown) { log.warn('ClaudeTrustService: failed to auto-trust worktree', { path: normalizedPath, @@ -151,6 +164,9 @@ type TrustConfig = { ) => Record | null; }; +type TrustIoError = { message: string }; +type TrustIoResult = Result; + function parseConfig(raw: string | null, warningName: string): Record | null { if (!raw || raw.trim() === '') return {}; @@ -205,67 +221,66 @@ function withCopilotTrustedFolder( }; } -async function readLocalConfig(configPath: string): Promise { +async function readLocalConfig(configPath: string): Promise> { try { - return await fs.readFile(configPath, 'utf8'); + return ok(await fs.readFile(configPath, 'utf8')); } catch (error: unknown) { - if (isNodeNotFound(error)) return null; - throw error; + if (isFileNotFoundException(error)) return ok(null); + return err({ message: errorMessage(error) }); } } -async function writeLocalConfigAtomic(configPath: string, content: string): Promise { +async function writeLocalConfigAtomic( + configPath: string, + content: string +): Promise> { const tmpPath = `${configPath}.${randomUUID()}.tmp`; try { await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile(tmpPath, content, 'utf8'); await fs.rename(tmpPath, configPath); + return ok(); } catch (error: unknown) { try { await fs.rm(tmpPath, { force: true }); } catch {} - throw error; + return err({ message: errorMessage(error) }); } } async function readRemoteConfig( - remoteFs: Pick, + remoteFs: Pick, configPath: string -): Promise { - try { - const result = await remoteFs.read(configPath, CLAUDE_CONFIG_MAX_BYTES); - return result.content; - } catch (error: unknown) { - if (isFsNotFound(error)) return null; - throw error; - } +): Promise> { + const result = await remoteFs.readText(configPath, { maxBytes: CLAUDE_CONFIG_MAX_BYTES }); + if (result.success) return ok(result.data.content); + if (isFileNotFoundError(result.error)) return ok(null); + return err(result.error); } async function writeRemoteConfigAtomic( - remoteFs: Pick, + remoteFs: Pick, ctx: IExecutionContext, configPath: string, content: string -): Promise { +): Promise> { const tmpPath = `${configPath}.${randomUUID()}.tmp`; try { await ctx.exec('mkdir', ['-p', path.posix.dirname(configPath)]); - await remoteFs.write(tmpPath, content); + const written = await remoteFs.writeText(tmpPath, content); + if (!written.success) return err(written.error); await ctx.exec('mv', [tmpPath, configPath]); + return ok(); } catch (error: unknown) { try { await ctx.exec('rm', ['-f', tmpPath]); } catch {} - throw error; + return err({ message: errorMessage(error) }); } } -function isNodeNotFound(error: unknown): boolean { - return (error as NodeJS.ErrnoException)?.code === 'ENOENT'; -} - -function isFsNotFound(error: unknown): boolean { - return error instanceof FileSystemError && error.code === FileSystemErrorCodes.NOT_FOUND; +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); } function isPlainObject(value: unknown): value is Record { diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.test.ts b/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.test.ts index 08f4f3f03d..2e37c92e8c 100644 --- a/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.test.ts +++ b/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.test.ts @@ -1,10 +1,9 @@ +import path from 'node:path'; +import type { IFileSystem } from '@emdash/core/files'; +import { ok } from '@emdash/shared'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import { - FileSystemError, - FileSystemErrorCodes, - type FileSystemProvider, -} from '@main/core/fs/types'; +import type { IFilesRuntime } from '@main/core/runtime/types'; import { CursorTrustService } from './cursor-trust-service'; const mockAccess = vi.hoisted(() => vi.fn()); @@ -37,14 +36,6 @@ function nodeNotFound() { return Object.assign(new Error('not found'), { code: 'ENOENT' }); } -function fsNotFound(pathName: string): FileSystemError { - return new FileSystemError( - `File not found: ${pathName}`, - FileSystemErrorCodes.NOT_FOUND, - pathName - ); -} - function makeService(overrides: { autoTrustWorktrees?: boolean } = {}): CursorTrustService { return new CursorTrustService({ getTaskSettings: () => @@ -53,16 +44,42 @@ function makeService(overrides: { autoTrustWorktrees?: boolean } = {}): CursorTr } function makeRemoteFs( - overrides: Partial> = {} -): Pick { + overrides: Partial> = {} +): Pick { return { - realPath: vi.fn(async (p: string) => p), - read: vi.fn().mockRejectedValue(fsNotFound('/home/remote-user/.cursor/projects/worktree')), - write: vi.fn().mockResolvedValue({ success: true, bytesWritten: 0 }), + realPath: vi.fn(async () => ok('/remote/worktree')), + exists: vi.fn(async () => ok(false)), + writeText: vi.fn(async (_path: string, content: string) => + ok({ bytesWritten: content.length }) + ), ...overrides, }; } +function makeFilesRuntime(args: { + fs: Pick; +}): IFilesRuntime { + return { + path: { + join: (...parts: string[]) => path.posix.join(...parts), + dirname: (value: string) => path.posix.dirname(value), + basename: (value: string) => path.posix.basename(value), + isAbsolute: (value: string) => path.posix.isAbsolute(value), + relative: (from: string, to: string) => path.posix.relative(from, to), + contains: (parent: string, child: string) => { + const rel = path.posix.relative(parent, child); + return ( + rel === '' || (rel !== '..' && !rel.startsWith('../') && !path.posix.isAbsolute(rel)) + ); + }, + }, + openTree: vi.fn(), + watchChanges: vi.fn(), + fileSystem: vi.fn(() => ok(args.fs as IFileSystem)), + dispose: vi.fn(), + } as unknown as IFilesRuntime; +} + function makeCtx(): IExecutionContext { return { root: undefined, @@ -89,7 +106,7 @@ describe('CursorTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'claude', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -102,7 +119,7 @@ describe('CursorTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'cursor', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -115,7 +132,7 @@ describe('CursorTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'cursor', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', force: true, }); @@ -131,7 +148,7 @@ describe('CursorTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'cursor', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -150,13 +167,30 @@ describe('CursorTrustService', () => { }); }); + it('refuses to auto-trust relative local workspace paths', async () => { + const service = makeService(); + + await service.maybeAutoTrustLocal({ + providerId: 'cursor', + workspacePath: './relative/path', + homedir: '/home/local-user', + }); + + expect(mockAccess).not.toHaveBeenCalled(); + expect(mockWriteFile).not.toHaveBeenCalled(); + expect(mockWarn).toHaveBeenCalledWith( + 'CursorTrustService: refusing to auto-trust non-absolute workspace path', + { path: './relative/path' } + ); + }); + it('is idempotent when the local marker already exists', async () => { const service = makeService(); mockAccess.mockResolvedValue(undefined); await service.maybeAutoTrustLocal({ providerId: 'cursor', - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', }); @@ -169,7 +203,7 @@ describe('CursorTrustService', () => { await service.maybeAutoTrustLocal({ providerId: 'cursor', - cwd: '/Users/janburzinski/emdash/worktrees/emdash-official/tough-falcons-notice', + workspacePath: '/Users/janburzinski/emdash/worktrees/emdash-official/tough-falcons-notice', homedir: '/Users/janburzinski', }); @@ -183,26 +217,50 @@ describe('CursorTrustService', () => { it('writes the ssh Cursor workspace trust marker remotely', async () => { const service = makeService(); const remoteFs = makeRemoteFs({ - realPath: vi.fn().mockResolvedValue('/remote/worktree'), + realPath: vi.fn(async () => ok('/remote/worktree')), }); + const files = makeFilesRuntime({ fs: remoteFs }); const ctx = makeCtx(); await service.maybeAutoTrustSsh({ providerId: 'cursor', - cwd: '/remote/worktree', + workspacePath: '/remote/worktree', ctx, - remoteFs, + files, }); const markerPath = '/home/remote-user/.cursor/projects/remote-worktree/.workspace-trusted'; - expect(remoteFs.read).toHaveBeenCalledWith(markerPath, expect.any(Number)); - expect(remoteFs.write).toHaveBeenCalledWith(markerPath, expect.any(String)); + expect(remoteFs.exists).toHaveBeenCalledWith(markerPath); + expect(remoteFs.writeText).toHaveBeenCalledWith(markerPath, expect.any(String)); - const marker = JSON.parse(String(vi.mocked(remoteFs.write).mock.calls[0][1])); + const marker = JSON.parse(String(vi.mocked(remoteFs.writeText).mock.calls[0][1])); expect(marker).toEqual({ trustedAt: expect.any(String), workspacePath: '/remote/worktree', trustMethod: 'emdash-auto-trust', }); }); + + it('refuses to auto-trust relative ssh workspace paths', async () => { + const service = makeService(); + const remoteFs = makeRemoteFs(); + const files = makeFilesRuntime({ fs: remoteFs }); + const ctx = makeCtx(); + + await service.maybeAutoTrustSsh({ + providerId: 'cursor', + workspacePath: 'relative/worktree', + ctx, + files, + }); + + expect(remoteFs.realPath).not.toHaveBeenCalled(); + expect(remoteFs.exists).not.toHaveBeenCalled(); + expect(remoteFs.writeText).not.toHaveBeenCalled(); + expect(ctx.exec).not.toHaveBeenCalled(); + expect(mockWarn).toHaveBeenCalledWith( + 'CursorTrustService: refusing to auto-trust non-absolute workspace path', + { path: 'relative/worktree' } + ); + }); }); diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.ts b/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.ts index adbcd86405..89ae19b989 100644 --- a/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.ts +++ b/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.ts @@ -1,21 +1,18 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { IExecutionContext } from '@main/core/execution-context/types'; -import { - FileSystemError, - FileSystemErrorCodes, - type FileSystemProvider, -} from '@main/core/fs/types'; +import { isFileNotFoundException, type IFileSystem } from '@emdash/core/files'; +import { err, ok, type Result } from '@emdash/shared'; import { appSettingsService } from '@main/core/settings/settings-service'; import { resolveRemoteHome } from '@main/core/ssh/lifecycle/remote-shell-profile'; import { log } from '@main/lib/logger'; import type { AgentProviderId } from '@shared/core/agents/agent-provider-registry'; +import { normalizeLocalWorkspacePath, normalizeSshWorkspacePath } from './workspace-trust-paths'; +import type { WorkspaceTrustLocalArgs, WorkspaceTrustSshArgs } from './workspace-trust-types'; const CURSOR_PROVIDER_ID: AgentProviderId = 'cursor'; const CURSOR_DATA_DIR_NAME = '.cursor'; const CURSOR_PROJECTS_DIR_NAME = 'projects'; const CURSOR_TRUST_MARKER_NAME = '.workspace-trusted'; -const CURSOR_TRUST_MARKER_MAX_BYTES = 1024; export class CursorTrustService { constructor( @@ -26,26 +23,24 @@ export class CursorTrustService { async maybeAutoTrustLocal({ providerId, - cwd, + workspacePath, homedir, force = false, - }: { - providerId: AgentProviderId; - cwd?: string; - homedir: string; - force?: boolean; - }): Promise { - if (!cwd) return; + }: WorkspaceTrustLocalArgs): Promise { if (!(await this.shouldAutoTrust(providerId, force))) return; - const workspacePath = path.resolve(cwd); + const normalizedWorkspacePath = normalizeLocalWorkspacePath( + workspacePath, + 'CursorTrustService' + ); + if (!normalizedWorkspacePath) return; const dataDir = path.join(homedir, CURSOR_DATA_DIR_NAME); const markerPath = path.join( - cursorProjectDir(workspacePath, dataDir, path), + cursorProjectDir(normalizedWorkspacePath, dataDir, path), CURSOR_TRUST_MARKER_NAME ); - await this.ensureTrusted(markerPath, workspacePath, { + await this.ensureTrusted(markerPath, normalizedWorkspacePath, { exists: () => localExists(markerPath), write: (content) => writeLocalMarker(markerPath, content), }); @@ -53,31 +48,37 @@ export class CursorTrustService { async maybeAutoTrustSsh({ providerId, - cwd, + workspacePath, ctx, - remoteFs, + files, force = false, - }: { - providerId: AgentProviderId; - cwd?: string; - ctx: IExecutionContext; - remoteFs: Pick; - force?: boolean; - }): Promise { - if (!cwd) return; + }: WorkspaceTrustSshArgs): Promise { if (!(await this.shouldAutoTrust(providerId, force))) return; - const workspacePath = await remoteFs.realPath(cwd).catch(() => path.posix.resolve('/', cwd)); + const normalizedWorkspacePath = await normalizeSshWorkspacePath( + files, + workspacePath, + 'CursorTrustService' + ); + if (!normalizedWorkspacePath) return; const homeDir = await resolveRemoteHome(ctx); + const homeFs = files.fileSystem(); + if (!homeFs.success) { + log.warn('CursorTrustService: failed to open filesystem for auto-trust', { + path: normalizedWorkspacePath, + error: homeFs.error.message, + }); + return; + } const dataDir = path.posix.join(homeDir, CURSOR_DATA_DIR_NAME); const markerPath = path.posix.join( - cursorProjectDir(workspacePath, dataDir, path.posix), + cursorProjectDir(normalizedWorkspacePath, dataDir, path.posix), CURSOR_TRUST_MARKER_NAME ); - await this.ensureTrusted(markerPath, workspacePath, { - exists: () => remoteExists(remoteFs, markerPath), - write: (content) => remoteFs.write(markerPath, content).then(() => undefined), + await this.ensureTrusted(markerPath, normalizedWorkspacePath, { + exists: () => remoteExists(homeFs.data, markerPath), + write: (content) => writeRemoteText(homeFs.data, markerPath, content), }); } @@ -92,14 +93,32 @@ export class CursorTrustService { markerPath: string, workspacePath: string, io: { - exists: () => Promise; - write: (content: string) => Promise; + exists: () => Promise>; + write: (content: string) => Promise>; } ): Promise { try { - if (await io.exists()) return; - - await io.write(JSON.stringify(createTrustMarker(workspacePath), null, 2) + '\n'); + const exists = await io.exists(); + if (!exists.success) { + log.warn('CursorTrustService: failed to check auto-trust marker', { + path: workspacePath, + markerPath, + error: exists.error.message, + }); + return; + } + if (exists.data) return; + + const written = await io.write( + JSON.stringify(createTrustMarker(workspacePath), null, 2) + '\n' + ); + if (!written.success) { + log.warn('CursorTrustService: failed to write auto-trust marker', { + path: workspacePath, + markerPath, + error: written.error.message, + }); + } } catch (error: unknown) { log.warn('CursorTrustService: failed to auto-trust worktree', { path: workspacePath, @@ -110,6 +129,9 @@ export class CursorTrustService { } } +type TrustIoError = { message: string }; +type TrustIoResult = Result; + export const cursorTrustService = new CursorTrustService({ getTaskSettings: () => appSettingsService.get('tasks'), }); @@ -138,38 +160,42 @@ function slugifyPath(value: string): string { .replace(/^-+|-+$/g, ''); } -async function localExists(markerPath: string): Promise { +async function localExists(markerPath: string): Promise> { try { await fs.access(markerPath); - return true; + return ok(true); } catch (error: unknown) { - if (isNodeNotFound(error)) return false; - throw error; + if (isFileNotFoundException(error)) return ok(false); + return err({ message: errorMessage(error) }); } } async function remoteExists( - remoteFs: Pick, + remoteFs: Pick, markerPath: string -): Promise { +): Promise> { + return remoteFs.exists(markerPath); +} + +async function writeLocalMarker(markerPath: string, content: string): Promise> { try { - await remoteFs.read(markerPath, CURSOR_TRUST_MARKER_MAX_BYTES); - return true; + await fs.mkdir(path.dirname(markerPath), { recursive: true }); + await fs.writeFile(markerPath, content, 'utf8'); + return ok(); } catch (error: unknown) { - if (isFsNotFound(error)) return false; - throw error; + return err({ message: errorMessage(error) }); } } -async function writeLocalMarker(markerPath: string, content: string): Promise { - await fs.mkdir(path.dirname(markerPath), { recursive: true }); - await fs.writeFile(markerPath, content, 'utf8'); -} - -function isNodeNotFound(error: unknown): boolean { - return (error as NodeJS.ErrnoException)?.code === 'ENOENT'; +async function writeRemoteText( + remoteFs: Pick, + absPath: string, + content: string +): Promise> { + const result = await remoteFs.writeText(absPath, content); + return result.success ? ok() : result; } -function isFsNotFound(error: unknown): boolean { - return error instanceof FileSystemError && error.code === FileSystemErrorCodes.NOT_FOUND; +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); } diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-paths.ts b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-paths.ts new file mode 100644 index 0000000000..a2aff73296 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-paths.ts @@ -0,0 +1,42 @@ +import path from 'node:path'; +import type { IFilesRuntime } from '@main/core/runtime/types'; +import { log } from '@main/lib/logger'; + +export function normalizeLocalWorkspacePath( + workspacePath: string, + serviceName: string +): string | null { + if (!path.isAbsolute(workspacePath)) { + log.warn(`${serviceName}: refusing to auto-trust non-absolute workspace path`, { + path: workspacePath, + }); + return null; + } + + return path.normalize(workspacePath); +} + +export async function normalizeSshWorkspacePath( + files: IFilesRuntime, + workspacePath: string, + serviceName: string +): Promise { + if (!files.path.isAbsolute(workspacePath)) { + log.warn(`${serviceName}: refusing to auto-trust non-absolute workspace path`, { + path: workspacePath, + }); + return null; + } + + const opened = files.fileSystem(); + if (!opened.success) { + log.warn(`${serviceName}: failed to open filesystem for workspace trust`, { + path: workspacePath, + error: opened.error.message, + }); + return null; + } + + const realPath = await opened.data.realPath(workspacePath); + return realPath.success ? realPath.data : path.posix.normalize(workspacePath); +} diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.test.ts b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.test.ts index d325c48138..ff6282acb7 100644 --- a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.test.ts +++ b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { FileSystemProvider } from '@main/core/fs/types'; +import type { IFilesRuntime } from '@main/core/runtime/types'; vi.mock('@main/core/settings/settings-service', () => ({ appSettingsService: { get: vi.fn() }, @@ -25,12 +25,8 @@ function makeCtx(): IExecutionContext { }; } -function makeRemoteFs(): Pick { - return { - realPath: vi.fn(), - read: vi.fn(), - write: vi.fn(), - }; +function makeFilesRuntime(): IFilesRuntime { + return { fileSystem: vi.fn() } as unknown as IFilesRuntime; } describe('WorkspaceTrustService', () => { @@ -40,7 +36,7 @@ describe('WorkspaceTrustService', () => { const service = new WorkspaceTrustService([first, second]); const args = { providerId: 'cursor' as const, - cwd: '/tmp/worktree', + workspacePath: '/tmp/worktree', homedir: '/home/local-user', force: true, }; @@ -57,9 +53,9 @@ describe('WorkspaceTrustService', () => { const service = new WorkspaceTrustService([first, second]); const args = { providerId: 'cursor' as const, - cwd: '/remote/worktree', + workspacePath: '/remote/worktree', ctx: makeCtx(), - remoteFs: makeRemoteFs(), + files: makeFilesRuntime(), force: true, }; diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.ts b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.ts index 36a0cc1262..f0b0adfa92 100644 --- a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.ts +++ b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.ts @@ -1,28 +1,10 @@ -import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { FileSystemProvider } from '@main/core/fs/types'; -import type { AgentProviderId } from '@shared/core/agents/agent-provider-registry'; import { claudeTrustService } from './claude-trust-service'; import { cursorTrustService } from './cursor-trust-service'; - -type WorkspaceTrustLocalArgs = { - providerId: AgentProviderId; - cwd?: string; - homedir: string; - force?: boolean; -}; - -type WorkspaceTrustSshArgs = { - providerId: AgentProviderId; - cwd?: string; - ctx: IExecutionContext; - remoteFs: Pick; - force?: boolean; -}; - -type WorkspaceTrustProvider = { - maybeAutoTrustLocal(args: WorkspaceTrustLocalArgs): Promise; - maybeAutoTrustSsh(args: WorkspaceTrustSshArgs): Promise; -}; +import type { + WorkspaceTrustLocalArgs, + WorkspaceTrustProvider, + WorkspaceTrustSshArgs, +} from './workspace-trust-types'; export class WorkspaceTrustService { constructor(private readonly providers: readonly WorkspaceTrustProvider[]) {} diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-types.ts b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-types.ts new file mode 100644 index 0000000000..ec3cfe3716 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-types.ts @@ -0,0 +1,23 @@ +import type { IExecutionContext } from '@main/core/execution-context/types'; +import type { IFilesRuntime } from '@main/core/runtime/types'; +import type { AgentProviderId } from '@shared/core/agents/agent-provider-registry'; + +export type WorkspaceTrustLocalArgs = { + providerId: AgentProviderId; + workspacePath: string; + homedir: string; + force?: boolean; +}; + +export type WorkspaceTrustSshArgs = { + providerId: AgentProviderId; + workspacePath: string; + ctx: IExecutionContext; + files: IFilesRuntime; + force?: boolean; +}; + +export type WorkspaceTrustProvider = { + maybeAutoTrustLocal(args: WorkspaceTrustLocalArgs): Promise; + maybeAutoTrustSsh(args: WorkspaceTrustSshArgs): Promise; +}; diff --git a/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts b/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts index 96ae778cee..4f5483133a 100644 --- a/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts +++ b/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CONVERSATION_FRESH_RECOVERY_GRACE_MS } from '@main/core/conversations/conversation-session-supervisor'; import type { Pty, PtyExitInfo } from '@main/core/pty/pty'; import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; +import type { IFilesRuntime } from '@main/core/runtime/types'; import { agentSessionExitedChannel } from '@shared/core/agents/agentEvents'; import type { Conversation } from '@shared/core/conversations/conversations'; import { ptyExitChannel } from '@shared/core/pty/ptyEvents'; @@ -204,6 +205,7 @@ function sshProvider( tmux, ctx, proxy: proxy as never, + filesRuntime: {} as IFilesRuntime, }); } diff --git a/apps/emdash-desktop/src/main/core/conversations/impl/local-conversation.ts b/apps/emdash-desktop/src/main/core/conversations/impl/local-conversation.ts index 374201c9b4..c738ae3e33 100644 --- a/apps/emdash-desktop/src/main/core/conversations/impl/local-conversation.ts +++ b/apps/emdash-desktop/src/main/core/conversations/impl/local-conversation.ts @@ -119,7 +119,7 @@ export class LocalConversationProvider implements ConversationProvider { try { await workspaceTrustService.maybeAutoTrustLocal({ providerId: conversation.providerId, - cwd: this.taskPath, + workspacePath: this.taskPath, homedir: homedir(), force: conversation.autoApprove === true, }); diff --git a/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts b/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts index b761408dc8..69c73a7d91 100644 --- a/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts +++ b/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts @@ -5,7 +5,6 @@ import { resolveAgentSessionCommandArgs } from '@main/core/conversations/resolve import type { ConversationProvider } from '@main/core/conversations/types'; import { hostDependencyStore } from '@main/core/dependencies/host-dependency-store'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import type { Pty } from '@main/core/pty/pty'; import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; import { resolveSshCommand } from '@main/core/pty/spawn-utils'; @@ -13,6 +12,7 @@ import { openSsh2Pty } from '@main/core/pty/ssh2-pty'; import { getTerminalColorEnv } from '@main/core/pty/terminal-color-scheme'; import { killTmuxSessionTree } from '@main/core/pty/tmux-reaper'; import { makeTmuxSessionName } from '@main/core/pty/tmux-session-name'; +import type { IFilesRuntime } from '@main/core/runtime/types'; import { providerOverrideSettings } from '@main/core/settings/provider-settings-service'; import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; import { events } from '@main/lib/events'; @@ -46,6 +46,7 @@ export class SshConversationProvider implements ConversationProvider { private readonly shellSetup?: string; private readonly ctx: IExecutionContext; private readonly proxy: SshClientProxy; + private readonly filesRuntime: IFilesRuntime; constructor({ projectId, @@ -56,6 +57,7 @@ export class SshConversationProvider implements ConversationProvider { shellSetup, ctx, proxy, + filesRuntime, }: { projectId: string; taskPath: string; @@ -65,6 +67,7 @@ export class SshConversationProvider implements ConversationProvider { shellSetup?: string; ctx: IExecutionContext; proxy: SshClientProxy; + filesRuntime: IFilesRuntime; }) { this.projectId = projectId; this.taskPath = taskPath; @@ -74,6 +77,7 @@ export class SshConversationProvider implements ConversationProvider { this.shellSetup = shellSetup; this.ctx = ctx; this.proxy = proxy; + this.filesRuntime = filesRuntime; } async startSession( @@ -115,9 +119,9 @@ export class SshConversationProvider implements ConversationProvider { try { await workspaceTrustService.maybeAutoTrustSsh({ providerId: conversation.providerId, - cwd: this.taskPath, + workspacePath: this.taskPath, ctx: this.ctx, - remoteFs: new SshFileSystem(this.proxy, '/'), + files: this.filesRuntime, force: conversation.autoApprove === true, }); @@ -189,7 +193,12 @@ export class SshConversationProvider implements ConversationProvider { sessionId, error: result.error.message, }); - throw new Error(result.error.message); + this.supervisor.failSpawn(sessionId, spawnToken); + events.emit(agentSessionExitedChannel, { + conversationId: conversation.id, + taskId: conversation.taskId, + }); + return; } const pty = result.data; diff --git a/apps/emdash-desktop/src/main/core/files/browse-directory.test.ts b/apps/emdash-desktop/src/main/core/files/browse-directory.test.ts new file mode 100644 index 0000000000..82b9806acd --- /dev/null +++ b/apps/emdash-desktop/src/main/core/files/browse-directory.test.ts @@ -0,0 +1,189 @@ +import path from 'node:path'; +import { err, ok, type Result } from '@emdash/shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { browseDirectory } from './browse-directory'; + +const mocks = vi.hoisted(() => ({ + acquireRuntimeMock: vi.fn(), + runtimeReleaseMock: vi.fn(), + fileSystemMock: vi.fn(), + globMock: vi.fn(), + statMock: vi.fn(), +})); + +vi.mock('@main/core/runtime/runtime-manager', () => ({ + runtimeManager: { + acquire: mocks.acquireRuntimeMock, + }, +})); + +function expectOk(result: Result): T { + expect(result.success).toBe(true); + if (!result.success) throw new Error(`Expected success, got ${JSON.stringify(result.error)}`); + return result.data; +} + +function makeFilesRuntime() { + return { + path: { + join: (...parts: string[]) => path.posix.join(...parts), + dirname: (value: string) => path.posix.dirname(value), + basename: (value: string) => path.posix.basename(value), + isAbsolute: (value: string) => path.posix.isAbsolute(value), + relative: (from: string, to: string) => path.posix.relative(from, to), + contains: () => true, + }, + fileSystem: mocks.fileSystemMock.mockImplementation(() => + ok({ + glob: mocks.globMock, + stat: mocks.statMock, + }) + ), + }; +} + +async function* directoryMatches(paths: string[]) { + for (const entry of paths) yield entry; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.acquireRuntimeMock.mockResolvedValue({ + value: { + files: makeFilesRuntime(), + git: {}, + }, + release: mocks.runtimeReleaseMock, + }); +}); + +describe('browseDirectory', () => { + it('lists directories through the machine file runtime', async () => { + const firstModifiedAt = new Date('2026-01-01T00:00:00.000Z'); + const secondModifiedAt = new Date('2026-01-02T00:00:00.000Z'); + const thirdModifiedAt = new Date('2026-01-03T00:00:00.000Z'); + + mocks.globMock.mockReturnValueOnce( + ok( + directoryMatches([ + '/remote/worktree/package.json', + '/remote/worktree/src', + '/remote/worktree/.env', + ]) + ) + ); + mocks.statMock.mockImplementation(async (filePath: string) => { + if (filePath === '/remote/worktree/src') { + return ok({ + path: '/remote/worktree/src', + type: 'directory', + size: 128, + mtime: secondModifiedAt, + ctime: secondModifiedAt, + mode: 0o755, + }); + } + if (filePath === '/remote/worktree/.env') { + return ok({ + path: '/remote/worktree/.env', + type: 'file', + size: 256, + mtime: thirdModifiedAt, + ctime: thirdModifiedAt, + mode: 0o644, + }); + } + return ok({ + path: '/remote/worktree/package.json', + type: 'file', + size: 512, + mtime: firstModifiedAt, + ctime: firstModifiedAt, + mode: 0o644, + }); + }); + + const entries = expectOk( + await browseDirectory({ + type: 'ssh', + connectionId: 'connection-id', + path: '/remote/worktree', + }) + ); + + expect(mocks.acquireRuntimeMock).toHaveBeenCalledWith({ + kind: 'ssh', + connectionId: 'connection-id', + }); + expect(mocks.fileSystemMock).toHaveBeenCalledWith(); + expect(mocks.globMock).toHaveBeenCalledWith(['*'], { cwd: '/remote/worktree', dot: true }); + expect(mocks.runtimeReleaseMock).toHaveBeenCalledTimes(1); + expect(entries).toEqual([ + { + path: '/remote/worktree/src', + name: 'src', + type: 'directory', + size: 128, + modifiedAt: secondModifiedAt, + }, + { + path: '/remote/worktree/.env', + name: '.env', + type: 'file', + size: 256, + modifiedAt: thirdModifiedAt, + }, + { + path: '/remote/worktree/package.json', + name: 'package.json', + type: 'file', + size: 512, + modifiedAt: firstModifiedAt, + }, + ]); + }); + + it('skips entries that vanish between glob and stat (not-found)', async () => { + mocks.globMock.mockReturnValueOnce( + ok(directoryMatches(['/remote/worktree/gone.txt', '/remote/worktree/here.txt'])) + ); + mocks.statMock.mockImplementation(async (filePath: string) => { + if (filePath === '/remote/worktree/gone.txt') { + return err({ type: 'fs-error', path: filePath, message: 'missing', code: 'ENOENT' }); + } + return ok({ + path: '/remote/worktree/here.txt', + type: 'file', + size: 1, + mtime: new Date('2026-01-01T00:00:00.000Z'), + ctime: new Date('2026-01-01T00:00:00.000Z'), + mode: 0o644, + }); + }); + + const entries = expectOk( + await browseDirectory({ type: 'ssh', connectionId: 'c', path: '/remote/worktree' }) + ); + expect(entries.map((entry) => entry.name)).toEqual(['here.txt']); + }); + + it('surfaces non-not-found stat failures as an error result', async () => { + mocks.globMock.mockReturnValueOnce(ok(directoryMatches(['/remote/worktree/secret.txt']))); + mocks.statMock.mockImplementation(async (filePath: string) => + err({ type: 'fs-error', path: filePath, message: 'permission denied', code: 'EACCES' }) + ); + + const result = await browseDirectory({ + type: 'ssh', + connectionId: 'c', + path: '/remote/worktree', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toMatchObject({ + type: 'filesystem-error', + message: 'permission denied', + }); + } + }); +}); diff --git a/apps/emdash-desktop/src/main/core/files/browse-directory.ts b/apps/emdash-desktop/src/main/core/files/browse-directory.ts new file mode 100644 index 0000000000..e779dc4ccf --- /dev/null +++ b/apps/emdash-desktop/src/main/core/files/browse-directory.ts @@ -0,0 +1,78 @@ +import { isFileNotFoundError } from '@emdash/core/files'; +import { err, ok, withLease } from '@emdash/shared'; +import { runtimeManager } from '@main/core/runtime/runtime-manager'; +import type { MachineRef } from '@main/core/runtime/types'; +import type { + BrowseDirectoryParams, + BrowseDirectoryResult, + DirectoryEntry, +} from '@shared/core/fs/fs'; + +export async function browseDirectory( + params: BrowseDirectoryParams +): Promise { + return withLease(runtimeManager.acquire(machineForDirectory(params)), async (runtime) => { + const files = runtime.files; + if (!files.path.isAbsolute(params.path)) { + return err({ + type: 'invalid-path', + path: params.path, + message: `Expected absolute path: ${params.path}`, + }); + } + + const opened = files.fileSystem(); + if (!opened.success) { + return err({ + type: 'filesystem-error', + path: params.path, + message: opened.error.message, + }); + } + + const matched = opened.data.glob(['*'], { cwd: params.path, dot: true }); + if (!matched.success) { + return err({ + type: 'filesystem-error', + path: params.path, + message: matched.error.message, + }); + } + + const entries: DirectoryEntry[] = []; + for await (const absPath of matched.data) { + if (files.path.dirname(absPath) !== params.path) continue; + + const stat = await opened.data.stat(absPath); + if (!stat.success) { + if (isFileNotFoundError(stat.error)) continue; + return err({ + type: 'filesystem-error', + path: absPath, + message: stat.error.message, + }); + } + + entries.push({ + path: stat.data.path, + name: files.path.basename(stat.data.path), + type: stat.data.type, + size: stat.data.size, + modifiedAt: stat.data.mtime, + }); + } + + return ok(entries.sort(compareDirectoryEntries)); + }); +} + +function machineForDirectory(params: BrowseDirectoryParams): MachineRef { + if (params.type === 'local') return { kind: 'local' }; + return { kind: 'ssh', connectionId: params.connectionId }; +} + +function compareDirectoryEntries(left: DirectoryEntry, right: DirectoryEntry): number { + if (left.type === 'directory' && right.type !== 'directory') return -1; + if (left.type !== 'directory' && right.type === 'directory') return 1; + return left.name.localeCompare(right.name); +} diff --git a/apps/emdash-desktop/src/main/core/files/controller.ts b/apps/emdash-desktop/src/main/core/files/controller.ts new file mode 100644 index 0000000000..176c28d7eb --- /dev/null +++ b/apps/emdash-desktop/src/main/core/files/controller.ts @@ -0,0 +1,6 @@ +import { createRPCController } from '@shared/lib/ipc/rpc'; +import { browseDirectory } from './browse-directory'; + +export const machineFilesController = createRPCController({ + browseDirectory, +}); diff --git a/apps/emdash-desktop/src/main/core/files/file-system/controller.ts b/apps/emdash-desktop/src/main/core/files/file-system/controller.ts new file mode 100644 index 0000000000..219fb34792 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/files/file-system/controller.ts @@ -0,0 +1,96 @@ +import { err, ok } from '@emdash/shared'; +import { events } from '@main/lib/events'; +import { planEventChannel } from '@shared/events/appEvents'; +import { createRPCController } from '@shared/lib/ipc/rpc'; +import { resolveWorkspace } from '../../projects/utils'; +import { fileErrorToMessage, isPermissionDenied } from './file-errors'; +import { readWorkspaceImage } from './image-support'; +import { copyLocalFilesToWorkspace } from './local-imports'; + +function resolveWorkspaceFiles(projectId: string, workspaceId: string) { + const env = resolveWorkspace(projectId, workspaceId); + if (!env) + return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); + + return ok({ env, fileSystem: env.fileSystem }); +} + +export const workspaceFileSystemController = createRPCController({ + readFile: async (projectId: string, workspaceId: string, filePath: string, maxBytes?: number) => { + const resolved = resolveWorkspaceFiles(projectId, workspaceId); + if (!resolved.success) return resolved; + const { fileSystem } = resolved.data; + + const result = await fileSystem.readText(filePath, { maxBytes }); + if (!result.success) { + return err({ type: 'fs_error' as const, message: fileErrorToMessage(result.error) }); + } + return ok(result.data); + }, + + writeFile: async (projectId: string, workspaceId: string, filePath: string, content: string) => { + const resolved = resolveWorkspaceFiles(projectId, workspaceId); + if (!resolved.success) return resolved; + const { fileSystem } = resolved.data; + + const result = await fileSystem.writeText(filePath, content); + if (!result.success) { + if (isPermissionDenied(result.error)) { + events.emit(planEventChannel, { + type: 'write_blocked' as const, + root: projectId, + path: filePath, + message: result.error.message, + }); + } + return err({ type: 'fs_error' as const, message: fileErrorToMessage(result.error) }); + } + return ok({ success: true as const, bytesWritten: result.data.bytesWritten }); + }, + + readImage: async (projectId: string, workspaceId: string, filePath: string) => { + const resolved = resolveWorkspaceFiles(projectId, workspaceId); + if (!resolved.success) return resolved; + const { fileSystem } = resolved.data; + + return await readWorkspaceImage(fileSystem, filePath); + }, + + fileExists: async (projectId: string, workspaceId: string, filePath: string) => { + const resolved = resolveWorkspaceFiles(projectId, workspaceId); + if (!resolved.success) return resolved; + const { fileSystem } = resolved.data; + + const result = await fileSystem.exists(filePath); + if (!result.success) { + return err({ type: 'fs_error' as const, message: fileErrorToMessage(result.error) }); + } + return ok({ exists: result.data }); + }, + + getAbsolutePath: async (projectId: string, workspaceId: string, filePath: string) => { + const resolved = resolveWorkspaceFiles(projectId, workspaceId); + if (!resolved.success) return resolved; + const { fileSystem } = resolved.data; + + const result = await fileSystem.realPath(filePath); + if (!result.success) { + return err({ type: 'fs_error' as const, message: fileErrorToMessage(result.error) }); + } + return ok({ path: result.data }); + }, + + copyLocalFiles: async ( + projectId: string, + workspaceId: string, + srcPaths: string[], + destDirPath: string, + options?: { overwrite?: boolean } + ) => { + const resolved = resolveWorkspaceFiles(projectId, workspaceId); + if (!resolved.success) return resolved; + const { env, fileSystem } = resolved.data; + + return await copyLocalFilesToWorkspace(fileSystem, env.path, srcPaths, destDirPath, options); + }, +}); diff --git a/apps/emdash-desktop/src/main/core/files/file-system/file-errors.ts b/apps/emdash-desktop/src/main/core/files/file-system/file-errors.ts new file mode 100644 index 0000000000..8db1c897c0 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/files/file-system/file-errors.ts @@ -0,0 +1,17 @@ +import type { FileError } from '@emdash/core/files'; + +const PERMISSION_DENIED = 'PERMISSION_DENIED'; + +export function fileErrorToMessage(error: FileError): string { + return error.message; +} + +export function isPermissionDenied(error: FileError): boolean { + if (error.type !== 'fs-error') return false; + return ( + error.code === PERMISSION_DENIED || + error.code === 'EACCES' || + error.code === 'EPERM' || + error.message.toLowerCase().includes('permission denied') + ); +} diff --git a/apps/emdash-desktop/src/main/core/files/file-system/image-support.ts b/apps/emdash-desktop/src/main/core/files/file-system/image-support.ts new file mode 100644 index 0000000000..2ba383310b --- /dev/null +++ b/apps/emdash-desktop/src/main/core/files/file-system/image-support.ts @@ -0,0 +1,58 @@ +import path from 'node:path'; +import type { IFileSystem } from '@emdash/core/files'; +import { ok } from '@emdash/shared'; +import { fileErrorToMessage } from './file-errors'; + +export const ALLOWED_IMAGE_EXTENSIONS = new Set([ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.svg', + '.bmp', + '.ico', +]); + +const IMAGE_MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', +}; + +const MAX_IMAGE_SIZE = 10 * 1024 * 1024; + +export async function readWorkspaceImage(fileSystem: IFileSystem, filePath: string) { + const ext = path.extname(filePath).toLowerCase(); + if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) { + return ok({ + success: false as const, + error: `Unsupported image format: ${ext}. Allowed: ${Array.from(ALLOWED_IMAGE_EXTENSIONS).join(', ')}`, + }); + } + + const result = await fileSystem.readBytes(filePath, { maxBytes: MAX_IMAGE_SIZE }); + if (!result.success) { + return ok({ success: false as const, error: fileErrorToMessage(result.error) }); + } + if (result.data.truncated) { + return ok({ + success: false as const, + error: `Image too large: ${result.data.totalSize} bytes (max ${MAX_IMAGE_SIZE})`, + }); + } + + const mimeType = IMAGE_MIME_TYPES[ext] || 'application/octet-stream'; + const base64 = Buffer.from(result.data.bytes).toString('base64'); + return ok({ + success: true as const, + dataUrl: `data:${mimeType};base64,${base64}`, + mimeType, + size: result.data.totalSize, + }); +} diff --git a/apps/emdash-desktop/src/main/core/files/file-system/local-imports.ts b/apps/emdash-desktop/src/main/core/files/file-system/local-imports.ts new file mode 100644 index 0000000000..8448a3550d --- /dev/null +++ b/apps/emdash-desktop/src/main/core/files/file-system/local-imports.ts @@ -0,0 +1,106 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { IFileSystem } from '@emdash/core/files'; +import { err, ok } from '@emdash/shared'; +import { + containsMachinePath, + displayPathInDirectory, + isAbsoluteMachinePath, + joinMachinePath, +} from '../path-utils'; +import { fileErrorToMessage } from './file-errors'; + +function normalizeRelativePath(filePath: string, options?: { allowEmpty?: boolean }): string { + if (filePath.includes('\0')) throw new Error('Path contains a null byte'); + const normalized = path.posix.normalize(filePath.replace(/\\/g, '/')); + if (normalized === '.') { + if (options?.allowEmpty) return ''; + throw new Error('Path must not be empty'); + } + if (path.posix.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) { + throw new Error('Path must be relative'); + } + const parts = normalized.split('/').filter(Boolean); + if (parts.includes('..')) throw new Error('Parent path segments are not allowed'); + return parts.join('/'); +} + +function resolveWorkspacePath( + workspacePath: string, + filePath: string, + options?: { allowEmpty?: boolean } +): string { + const absPath = isAbsoluteMachinePath(filePath) + ? filePath + : (() => { + const workspaceRelativePath = normalizeRelativePath(filePath, options); + return workspaceRelativePath + ? joinMachinePath(workspacePath, workspaceRelativePath) + : workspacePath; + })(); + if (!containsMachinePath(workspacePath, absPath)) { + throw new Error('Destination path must be inside the workspace'); + } + return absPath; +} + +export async function copyLocalFilesToWorkspace( + fileSystem: IFileSystem, + workspacePath: string, + srcPaths: string[], + destDirPath: string, + options?: { overwrite?: boolean } +) { + try { + const destDirAbsPath = resolveWorkspacePath(workspacePath, destDirPath, { + allowEmpty: true, + }); + const destDirDisplayPath = displayPathInDirectory(workspacePath, destDirAbsPath); + const madeDir = await fileSystem.mkdir(destDirAbsPath, { recursive: true }); + if (!madeDir.success) { + return err({ type: 'fs_error' as const, message: fileErrorToMessage(madeDir.error) }); + } + + const plannedCopies = await Promise.all( + srcPaths.map(async (srcPath) => { + if (!path.isAbsolute(srcPath)) throw new Error('Source path must be absolute'); + const fileName = path.basename(srcPath); + if (!fileName) throw new Error('Source path must include a file name'); + const srcStat = await fs.stat(srcPath); + if (srcStat.isDirectory()) throw new Error(`Cannot import directories: ${srcPath}`); + const destDisplayPath = destDirDisplayPath + ? path.posix.join(destDirDisplayPath, fileName) + : fileName; + const destAbsPath = joinMachinePath(destDirAbsPath, fileName); + return { srcPath, destDisplayPath, destAbsPath }; + }) + ); + + const seenDestPaths = new Set(); + const conflicts: string[] = []; + for (const { destDisplayPath, destAbsPath } of plannedCopies) { + if (seenDestPaths.has(destDisplayPath)) { + throw new Error(`Duplicate destination: ${destDisplayPath}`); + } + seenDestPaths.add(destDisplayPath); + const exists = await fileSystem.exists(destAbsPath); + if (!exists.success) { + return err({ type: 'fs_error' as const, message: fileErrorToMessage(exists.error) }); + } + if (!options?.overwrite && exists.data) conflicts.push(destDisplayPath); + } + if (conflicts.length > 0) throw new Error(`Files already exist:\n${conflicts.join('\n')}`); + + for (const { srcPath, destAbsPath } of plannedCopies) { + const bytes = await fs.readFile(srcPath); + const written = await fileSystem.writeBytes(destAbsPath, bytes); + if (!written.success) { + return err({ type: 'fs_error' as const, message: fileErrorToMessage(written.error) }); + } + } + + return ok({ copied: srcPaths.length }); + } catch (e) { + return err({ type: 'fs_error' as const, message: String(e) }); + } +} diff --git a/apps/emdash-desktop/src/main/core/files/file-tree/controller.ts b/apps/emdash-desktop/src/main/core/files/file-tree/controller.ts new file mode 100644 index 0000000000..7d7d9f98ac --- /dev/null +++ b/apps/emdash-desktop/src/main/core/files/file-tree/controller.ts @@ -0,0 +1,41 @@ +import type { NodeId } from '@emdash/core/files'; +import { err, ok } from '@emdash/shared'; +import { resolveWorkspace } from '@main/core/projects/utils'; +import type { FileTreeMutationResult, FileTreeSnapshotResult } from '@shared/core/fs/file-tree'; +import { createRPCController } from '@shared/lib/ipc/rpc'; + +export const fileTreeController = createRPCController({ + getSnapshot: async (projectId: string, workspaceId: string): Promise => { + const workspace = resolveWorkspace(projectId, workspaceId); + if (!workspace) return err({ type: 'not_found' }); + return await workspace.fileTree.getSnapshot(); + }, + + expandDir: async ( + projectId: string, + workspaceId: string, + dirId: NodeId | null + ): Promise => { + const workspace = resolveWorkspace(projectId, workspaceId); + if (!workspace) return err({ type: 'not_found' }); + const result = await workspace.fileTree.expandDir(dirId); + return result.success ? ok({ sequences: result.data }) : err(result.error); + }, + + revealPath: async ( + projectId: string, + workspaceId: string, + filePath: string + ): Promise => { + const workspace = resolveWorkspace(projectId, workspaceId); + if (!workspace) return err({ type: 'not_found' }); + const result = await workspace.fileTree.revealPath(filePath); + return result.success ? ok({ sequences: result.data }) : err(result.error); + }, + + refresh: async (projectId: string, workspaceId: string): Promise => { + const workspace = resolveWorkspace(projectId, workspaceId); + if (!workspace) return err({ type: 'not_found' }); + return await workspace.fileTree.refresh(); + }, +}); diff --git a/apps/emdash-desktop/src/main/core/files/path-utils.ts b/apps/emdash-desktop/src/main/core/files/path-utils.ts new file mode 100644 index 0000000000..7c39efbc08 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/files/path-utils.ts @@ -0,0 +1,30 @@ +import path from 'node:path'; + +export function isAbsoluteMachinePath(filePath: string): boolean { + return path.posix.isAbsolute(filePath) || path.win32.isAbsolute(filePath); +} + +export function joinMachinePath(basePath: string, ...segments: string[]): string { + let current = basePath; + for (const segment of segments) { + const normalized = segment.replace(/\\/g, '/').replace(/^\/+/, ''); + if (!normalized) continue; + current = + current.endsWith('/') || current.endsWith('\\') + ? `${current}${normalized}` + : `${current}/${normalized}`; + } + return current; +} + +export function containsMachinePath(parentPath: string, childPath: string): boolean { + const parent = parentPath.replace(/\\/g, '/'); + const child = childPath.replace(/\\/g, '/'); + const rel = path.posix.relative(parent, child); + return rel === '' || (rel !== '..' && !rel.startsWith('../') && !path.posix.isAbsolute(rel)); +} + +export function displayPathInDirectory(parentPath: string, childPath: string): string { + const rel = path.posix.relative(parentPath.replace(/\\/g, '/'), childPath.replace(/\\/g, '/')); + return rel === '.' ? '' : rel; +} diff --git a/apps/emdash-desktop/src/main/core/fs/controller.ts b/apps/emdash-desktop/src/main/core/fs/controller.ts deleted file mode 100644 index 1d888a47fa..0000000000 --- a/apps/emdash-desktop/src/main/core/fs/controller.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { err, ok } from '@emdash/shared'; -import { fsEvents } from '@main/core/fs/fs-events'; -import { events } from '@main/lib/events'; -import { fsWatchEventChannel } from '@shared/core/fs/fsEvents'; -import { planEventChannel } from '@shared/events/appEvents'; -import { createRPCController } from '@shared/lib/ipc/rpc'; -import { resolveWorkspace } from '../projects/utils'; -import { - FileSystemErrorCodes, - type FileWatcher, - type ListOptions, - type SearchOptions, -} from './types'; - -// One watcher per (projectId, workspaceId) pair, shared across all consumers via labels. -// Local: single recursive @parcel/watcher subscription — update() is a no-op. -// SSH: poll-based — update() receives the union of all labels' paths to poll. -const watcherRegistry = new Map(); -// Per-label path groups, keyed by `${projectId}::${workspaceId}` → label → paths. -// Paths are forwarded to update() for SSH compatibility; local ignores them. -const watcherLabeledPaths = new Map>(); - -function normalizeRelativePath(filePath: string, options?: { allowEmpty?: boolean }): string { - if (!filePath && options?.allowEmpty) return ''; - const normalized = path.posix.normalize(filePath.replaceAll('\\', '/')); - if ( - !filePath || - path.isAbsolute(filePath) || - path.posix.isAbsolute(normalized) || - path.win32.isAbsolute(filePath) || - normalized === '..' || - normalized.startsWith('../') || - normalized.includes('/../') - ) { - throw new Error('Invalid file path'); - } - return normalized === '.' ? '' : normalized; -} - -function joinWorkspacePath(rootPath: string, filePath: string): string { - if (!filePath) return rootPath; - const separator = - rootPath.includes('\\') && !rootPath.includes('/') ? path.win32.sep : path.posix.sep; - return rootPath.endsWith('/') || rootPath.endsWith('\\') - ? `${rootPath}${filePath}` - : `${rootPath}${separator}${filePath}`; -} - -export const filesController = createRPCController({ - listFiles: async ( - projectId: string, - workspaceId: string, - dirPath: string, - options?: ListOptions - ) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - try { - const result = await env.fs.list(dirPath, options); - return ok(result); - } catch (e) { - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - readFile: async (projectId: string, workspaceId: string, filePath: string, maxBytes?: number) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - try { - const result = await env.fs.read(filePath, maxBytes); - return ok(result); - } catch (e) { - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - writeFile: async (projectId: string, workspaceId: string, filePath: string, content: string) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - try { - const result = await env.fs.write(filePath, content); - return ok(result); - } catch (e) { - if ( - e instanceof Error && - (e as unknown as { code?: string }).code === FileSystemErrorCodes.PERMISSION_DENIED - ) { - events.emit(planEventChannel, { - type: 'write_blocked' as const, - root: projectId, - relPath: filePath, - message: e.message, - }); - } - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - removeFile: async (projectId: string, workspaceId: string, filePath: string) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - if (!env.fs.remove) { - return err({ - type: 'fs_error' as const, - message: 'remove not supported by this filesystem', - }); - } - - try { - const result = await env.fs.remove(filePath); - return ok(result); - } catch (e) { - if ( - e instanceof Error && - (e as unknown as { code?: string }).code === FileSystemErrorCodes.PERMISSION_DENIED - ) { - events.emit(planEventChannel, { - type: 'remove_blocked' as const, - root: projectId, - relPath: filePath, - message: e.message, - }); - } - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - readImage: async (projectId: string, workspaceId: string, filePath: string) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - if (!env.fs.readImage) { - return err({ - type: 'fs_error' as const, - message: 'readImage not supported by this filesystem', - }); - } - - try { - const result = await env.fs.readImage(filePath); - return ok(result); - } catch (e) { - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - searchFiles: async ( - projectId: string, - workspaceId: string, - query: string, - options?: SearchOptions - ) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - try { - const result = await env.fs.search(query, options); - return ok(result); - } catch (e) { - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - statFile: async (projectId: string, workspaceId: string, filePath: string) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - try { - const entry = await env.fs.stat(filePath); - return ok({ entry }); - } catch (e) { - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - fileExists: async (projectId: string, workspaceId: string, filePath: string) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - try { - const exists = await env.fs.exists(filePath); - return ok({ exists }); - } catch (e) { - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - getAbsolutePath: async (projectId: string, workspaceId: string, filePath: string) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - try { - return ok({ path: joinWorkspacePath(env.path, normalizeRelativePath(filePath)) }); - } catch (e) { - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - copyLocalFiles: async ( - projectId: string, - workspaceId: string, - srcPaths: string[], - destDirPath: string, - options?: { overwrite?: boolean } - ) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - if (!env.fs.copyLocalFile) { - return err({ - type: 'fs_error' as const, - message: 'copyLocalFile not supported by this filesystem', - }); - } - - try { - const destDir = normalizeRelativePath(destDirPath, { allowEmpty: true }); - await env.fs.mkdir(destDir || '.', { recursive: true }); - - const plannedCopies = await Promise.all( - srcPaths.map(async (srcPath) => { - if (!path.isAbsolute(srcPath)) throw new Error('Source path must be absolute'); - const fileName = path.basename(srcPath); - if (!fileName) throw new Error('Source path must include a file name'); - const srcStat = await fs.stat(srcPath); - if (srcStat.isDirectory()) throw new Error(`Cannot import directories: ${srcPath}`); - const destRelPath = destDir ? path.posix.join(destDir, fileName) : fileName; - return { srcPath, destRelPath }; - }) - ); - - const seenDestPaths = new Set(); - const conflicts: string[] = []; - for (const { destRelPath } of plannedCopies) { - if (seenDestPaths.has(destRelPath)) - throw new Error(`Duplicate destination: ${destRelPath}`); - seenDestPaths.add(destRelPath); - if (!options?.overwrite && (await env.fs.exists(destRelPath))) conflicts.push(destRelPath); - } - if (conflicts.length > 0) throw new Error(`Files already exist:\n${conflicts.join('\n')}`); - - for (const { srcPath, destRelPath } of plannedCopies) { - await env.fs.copyLocalFile(srcPath, destRelPath); - } - - return ok({ copied: srcPaths.length }); - } catch (e) { - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - saveAttachment: async ( - projectId: string, - workspaceId: string, - srcPath: string, - subdir?: string - ) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - - if (!env.fs.saveAttachment) { - return err({ - type: 'fs_error' as const, - message: 'saveAttachment not supported by this filesystem', - }); - } - - try { - const result = await env.fs.saveAttachment(srcPath, subdir); - return ok(result); - } catch (e) { - return err({ type: 'fs_error' as const, message: String(e) }); - } - }, - - watchSetPaths: async ( - projectId: string, - workspaceId: string, - paths: string[], - label = 'default' - ) => { - const env = resolveWorkspace(projectId, workspaceId); - if (!env) { - return err({ type: 'not_found' as const, entity: 'filesystem' as const, detail: undefined }); - } - - if (!env.fs.watch) { - return ok({ supported: false as const }); - } - - const key = `${projectId}::${workspaceId}`; - const groups = watcherLabeledPaths.get(key) ?? new Map(); - groups.set(label, paths); - watcherLabeledPaths.set(key, groups); - const union = [...new Set([...groups.values()].flat())]; - - const existing = watcherRegistry.get(key); - if (existing) { - existing.update(union); - } else { - const watcher = env.fs.watch((evts) => { - const event = { projectId, workspaceId, events: evts }; - events.emit(fsWatchEventChannel, event); - fsEvents.emitWatchEvent(event); - }); - watcher.update(union); - watcherRegistry.set(key, watcher); - } - return ok({ supported: true as const }); - }, - - watchStop: async (projectId: string, workspaceId: string, label = 'default') => { - const key = `${projectId}::${workspaceId}`; - const groups = watcherLabeledPaths.get(key); - groups?.delete(label); - - if (!groups?.size) { - watcherLabeledPaths.delete(key); - watcherRegistry.get(key)?.close(); - watcherRegistry.delete(key); - } else { - const union = [...new Set([...groups.values()].flat())]; - watcherRegistry.get(key)?.update(union); - } - return ok({}); - }, -}); diff --git a/apps/emdash-desktop/src/main/core/fs/fs-events.ts b/apps/emdash-desktop/src/main/core/fs/fs-events.ts deleted file mode 100644 index d3ad86a75f..0000000000 --- a/apps/emdash-desktop/src/main/core/fs/fs-events.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { HookCore, type Hookable } from '@main/lib/hookable'; -import { log } from '@main/lib/logger'; -import type { FileWatchEvent } from '@shared/core/fs/fs'; - -export type FsHooks = { - 'watch:event': (event: { - projectId: string; - workspaceId: string; - events: FileWatchEvent[]; - }) => void | Promise; -}; - -class FsEvents implements Hookable { - private readonly _core = new HookCore((name, e) => - log.error(`FsEvents: ${String(name)} hook error`, e) - ); - - on(name: K, handler: FsHooks[K]) { - return this._core.on(name, handler); - } - - emitWatchEvent(event: { - projectId: string; - workspaceId: string; - events: FileWatchEvent[]; - }): void { - this._core.callHookBackground('watch:event', event); - } -} - -export const fsEvents = new FsEvents(); diff --git a/apps/emdash-desktop/src/main/core/fs/impl/local-fs.test.ts b/apps/emdash-desktop/src/main/core/fs/impl/local-fs.test.ts deleted file mode 100644 index 1f6709bf4f..0000000000 --- a/apps/emdash-desktop/src/main/core/fs/impl/local-fs.test.ts +++ /dev/null @@ -1,523 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { FileSystemError } from '../types'; -import { LocalFileSystem } from './local-fs'; - -describe('LocalFileSystem', () => { - let tempDir: string; - let fsService: LocalFileSystem; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fs-test-')); - fsService = new LocalFileSystem(tempDir); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - describe('constructor', () => { - it('should throw error when project path is empty', () => { - expect(() => new LocalFileSystem('')).toThrow(FileSystemError); - expect(() => new LocalFileSystem('')).toThrow('Project path is required'); - }); - - it('should resolve project path', () => { - const relativePath = 'relative/project'; - const service = new LocalFileSystem(relativePath); - expect(service).toBeDefined(); - }); - }); - - describe('list', () => { - it('should list files in directory', async () => { - fs.writeFileSync(path.join(tempDir, 'file1.txt'), 'content1'); - fs.writeFileSync(path.join(tempDir, 'file2.txt'), 'content2'); - fs.mkdirSync(path.join(tempDir, 'subdir')); - - const result = await fsService.list(''); - - expect(result.entries).toHaveLength(3); - expect(result.entries.some((e) => e.path === 'file1.txt' && e.type === 'file')).toBe(true); - expect(result.entries.some((e) => e.path === 'file2.txt' && e.type === 'file')).toBe(true); - expect(result.entries.some((e) => e.path === 'subdir' && e.type === 'dir')).toBe(true); - }); - - it('should list files in subdirectory', async () => { - const subdir = path.join(tempDir, 'subdir'); - fs.mkdirSync(subdir); - fs.writeFileSync(path.join(subdir, 'nested.txt'), 'nested content'); - - const result = await fsService.list('subdir'); - - expect(result.entries).toHaveLength(1); - expect(result.entries[0].path).toBe('subdir/nested.txt'); - }); - - it('should list recursively', async () => { - fs.mkdirSync(path.join(tempDir, 'level1')); - fs.writeFileSync(path.join(tempDir, 'level1/file1.txt'), 'content1'); - fs.mkdirSync(path.join(tempDir, 'level1/level2')); - fs.writeFileSync(path.join(tempDir, 'level1/level2/file2.txt'), 'content2'); - - const result = await fsService.list('', { recursive: true }); - - expect(result.entries.some((e) => e.path === 'level1')).toBe(true); - expect(result.entries.some((e) => e.path === 'level1/file1.txt')).toBe(true); - expect(result.entries.some((e) => e.path === 'level1/level2')).toBe(true); - expect(result.entries.some((e) => e.path === 'level1/level2/file2.txt')).toBe(true); - }); - - it('should exclude hidden files by default', async () => { - fs.writeFileSync(path.join(tempDir, 'visible.txt'), 'content'); - fs.writeFileSync(path.join(tempDir, '.hidden'), 'hidden content'); - - const result = await fsService.list(''); - - expect(result.entries.some((e) => e.path === 'visible.txt')).toBe(true); - expect(result.entries.some((e) => e.path === '.hidden')).toBe(false); - }); - - it('should include hidden files when specified', async () => { - fs.writeFileSync(path.join(tempDir, 'visible.txt'), 'content'); - fs.writeFileSync(path.join(tempDir, '.hidden'), 'hidden content'); - - const result = await fsService.list('', { includeHidden: true }); - - expect(result.entries.some((e) => e.path === '.hidden')).toBe(true); - }); - - it('should apply filter pattern', async () => { - fs.writeFileSync(path.join(tempDir, 'test.ts'), 'typescript'); - fs.writeFileSync(path.join(tempDir, 'test.js'), 'javascript'); - fs.writeFileSync(path.join(tempDir, 'readme.md'), 'markdown'); - - const result = await fsService.list('', { filter: '.*\\.ts$' }); - - expect(result.entries).toHaveLength(1); - expect(result.entries[0].path).toBe('test.ts'); - }); - - it('should truncate when maxEntries reached', async () => { - for (let i = 0; i < 10; i++) { - fs.writeFileSync(path.join(tempDir, `file${i}.txt`), 'content'); - } - - const result = await fsService.list('', { maxEntries: 5 }); - - expect(result.total).toBe(5); - expect(result.truncated).toBe(true); - expect(result.truncateReason).toBe('maxEntries'); - }); - - it('should truncate when time budget exceeded', async () => { - // Create many files to ensure time budget is exceeded - for (let i = 0; i < 1000; i++) { - fs.writeFileSync(path.join(tempDir, `file${i}.txt`), 'content'); - } - - const result = await fsService.list('', { recursive: true, timeBudgetMs: 1 }); - - expect(result.truncated).toBe(true); - expect(result.truncateReason).toBe('timeBudget'); - }); - - it('should include file metadata', async () => { - const filePath = path.join(tempDir, 'test.txt'); - fs.writeFileSync(filePath, 'test content'); - - const result = await fsService.list(''); - - expect(result.entries[0].size).toBe(12); - expect(result.entries[0].mtime).toBeInstanceOf(Date); - expect(result.entries[0].mode).toBeDefined(); - }); - }); - - describe('read', () => { - it('should read file content', async () => { - fs.writeFileSync(path.join(tempDir, 'test.txt'), 'Hello, World!'); - - const result = await fsService.read('test.txt'); - - expect(result.content).toBe('Hello, World!'); - expect(result.truncated).toBe(false); - expect(result.totalSize).toBe(13); - }); - - it('should throw error when file not found', async () => { - await expect(fsService.read('nonexistent.txt')).rejects.toThrow(FileSystemError); - await expect(fsService.read('nonexistent.txt')).rejects.toThrow('File not found'); - }); - - it('should throw error when path is directory', async () => { - fs.mkdirSync(path.join(tempDir, 'subdir')); - - await expect(fsService.read('subdir')).rejects.toThrow(FileSystemError); - await expect(fsService.read('subdir')).rejects.toThrow('Path is a directory'); - }); - - it('should truncate large files', async () => { - const largeContent = 'x'.repeat(300 * 1024); // 300KB - fs.writeFileSync(path.join(tempDir, 'large.txt'), largeContent); - - const result = await fsService.read('large.txt', 200 * 1024); - - expect(result.truncated).toBe(true); - expect(result.content.length).toBe(200 * 1024); - expect(result.totalSize).toBe(300 * 1024); - }); - - it('should not truncate files under maxBytes', async () => { - const content = 'Small content'; - fs.writeFileSync(path.join(tempDir, 'small.txt'), content); - - const result = await fsService.read('small.txt', 200 * 1024); - - expect(result.truncated).toBe(false); - expect(result.content).toBe(content); - }); - }); - - describe('write', () => { - it('should write file content', async () => { - const result = await fsService.write('newfile.txt', 'New content'); - - expect(result.success).toBe(true); - expect(fs.readFileSync(path.join(tempDir, 'newfile.txt'), 'utf-8')).toBe('New content'); - }); - - it('should create parent directories', async () => { - const result = await fsService.write('nested/deep/file.txt', 'Deep content'); - - expect(result.success).toBe(true); - expect(fs.existsSync(path.join(tempDir, 'nested/deep/file.txt'))).toBe(true); - }); - - it('should return bytes written', async () => { - const content = 'Test content'; - const result = await fsService.write('test.txt', content); - - expect(result.bytesWritten).toBe(Buffer.byteLength(content, 'utf-8')); - }); - - it('should throw error when cannot create directory', async () => { - // Make tempDir read-only (on Unix systems) - if (process.platform !== 'win32') { - fs.chmodSync(tempDir, 0o555); - - try { - await expect(fsService.write('readonly/test.txt', 'content')).rejects.toThrow( - FileSystemError - ); - } finally { - fs.chmodSync(tempDir, 0o755); - } - } - }); - }); - - describe('exists', () => { - it('should return true for existing file', async () => { - fs.writeFileSync(path.join(tempDir, 'exists.txt'), 'content'); - - const result = await fsService.exists('exists.txt'); - - expect(result).toBe(true); - }); - - it('should return true for existing directory', async () => { - fs.mkdirSync(path.join(tempDir, 'subdir')); - - const result = await fsService.exists('subdir'); - - expect(result).toBe(true); - }); - - it('should return false for non-existent path', async () => { - const result = await fsService.exists('nonexistent.txt'); - - expect(result).toBe(false); - }); - }); - - describe('stat', () => { - it('should return file entry for file', async () => { - fs.writeFileSync(path.join(tempDir, 'test.txt'), 'content'); - - const result = await fsService.stat('test.txt'); - - expect(result).not.toBeNull(); - expect(result?.path).toBe('test.txt'); - expect(result?.type).toBe('file'); - expect(result?.size).toBe(7); - }); - - it('should return file entry for directory', async () => { - fs.mkdirSync(path.join(tempDir, 'subdir')); - - const result = await fsService.stat('subdir'); - - expect(result).not.toBeNull(); - expect(result?.path).toBe('subdir'); - expect(result?.type).toBe('dir'); - }); - - it('should return null for non-existent path', async () => { - const result = await fsService.stat('nonexistent.txt'); - - expect(result).toBeNull(); - }); - }); - - describe('search', () => { - beforeEach(() => { - fs.writeFileSync(path.join(tempDir, 'file1.ts'), 'const foo = "bar";\nfunction test() {}'); - fs.writeFileSync(path.join(tempDir, 'file2.ts'), 'let x = 1;\nconst foo = 2;'); - fs.writeFileSync(path.join(tempDir, 'readme.md'), '# README\nThis is documentation'); - - const subdir = path.join(tempDir, 'src'); - fs.mkdirSync(subdir); - fs.writeFileSync(path.join(subdir, 'main.ts'), 'function main() {\n console.log(foo);\n}'); - }); - - it('should find matches in files', async () => { - const result = await fsService.search('foo'); - - expect(result.total).toBeGreaterThan(0); - expect(result.matches.some((m) => m.filePath === 'file1.ts')).toBe(true); - expect(result.matches.some((m) => m.filePath === 'file2.ts')).toBe(true); - expect(result.matches.some((m) => m.filePath === 'src/main.ts')).toBe(true); - }); - - it('should return match details', async () => { - const result = await fsService.search('foo'); - - const match = result.matches.find((m) => m.filePath === 'file1.ts'); - expect(match).toBeDefined(); - expect(match?.line).toBe(1); - expect(match?.column).toBeGreaterThan(0); - expect(match?.content).toContain('foo'); - }); - - it('should respect maxResults', async () => { - const result = await fsService.search('foo', { maxResults: 2 }); - - expect(result.total).toBe(2); - expect(result.truncated).toBe(true); - }); - - it('should filter by file extensions', async () => { - const result = await fsService.search('foo', { fileExtensions: ['.ts'] }); - - expect(result.matches.every((m) => m.filePath.endsWith('.ts'))).toBe(true); - }); - - it('should filter by file pattern', async () => { - const result = await fsService.search('foo', { filePattern: '*.md' }); - - expect(result.total).toBe(0); - }); - - it('should be case-insensitive by default', async () => { - const result1 = await fsService.search('FOO'); - const result2 = await fsService.search('foo'); - - expect(result1.total).toBe(result2.total); - }); - - it('should respect caseSensitive option', async () => { - const result = await fsService.search('FOO', { caseSensitive: true }); - - expect(result.total).toBe(0); - }); - - it('should skip binary files', async () => { - // Create a "binary" file with null bytes - fs.writeFileSync(path.join(tempDir, 'binary.bin'), Buffer.from([0x00, 0x01, 0x02, 0x03])); - - const result = await fsService.search('\x00'); - - expect(result.matches).toHaveLength(0); - }); - - it('should skip ignored directories', async () => { - const nodeModules = path.join(tempDir, 'node_modules'); - fs.mkdirSync(nodeModules); - fs.writeFileSync(path.join(nodeModules, 'test.ts'), 'const foo = "ignored";'); - - const result = await fsService.search('foo'); - - expect(result.matches.some((m) => m.filePath.includes('node_modules'))).toBe(false); - }); - - it('should track files searched', async () => { - const result = await fsService.search('foo'); - - expect(result.filesSearched).toBeGreaterThan(0); - }); - }); - - describe('remove', () => { - it('should remove file', async () => { - fs.writeFileSync(path.join(tempDir, 'delete.txt'), 'content'); - - const result = await fsService.remove('delete.txt'); - - expect(result.success).toBe(true); - expect(fs.existsSync(path.join(tempDir, 'delete.txt'))).toBe(false); - }); - - it('should fail when file not found', async () => { - const result = await fsService.remove('nonexistent.txt'); - - expect(result.success).toBe(false); - expect(result.error).toContain('File not found'); - }); - - it('should fail when path is directory', async () => { - fs.mkdirSync(path.join(tempDir, 'subdir')); - - const result = await fsService.remove('subdir'); - - expect(result.success).toBe(false); - expect(result.error).toContain('directory'); - }); - - it('should retry with chmod on permission error', async () => { - if (process.platform !== 'win32') { - const filePath = path.join(tempDir, 'readonly.txt'); - fs.writeFileSync(filePath, 'content'); - fs.chmodSync(filePath, 0o444); - - try { - const result = await fsService.remove('readonly.txt'); - expect(result.success).toBe(true); - } finally { - // Restore permissions for cleanup - try { - fs.chmodSync(filePath, 0o666); - } catch { - // Ignore - } - } - } - }); - }); - - describe('readImage', () => { - it('should read image as data URL', async () => { - // Create a minimal valid PNG file (1x1 transparent pixel) - const pngBuffer = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', - 'base64' - ); - fs.writeFileSync(path.join(tempDir, 'test.png'), pngBuffer); - - const result = await fsService.readImage('test.png'); - - expect(result.success).toBe(true); - expect(result.dataUrl).toMatch(/^data:image\/png;base64,/); - expect(result.mimeType).toBe('image/png'); - expect(result.size).toBe(pngBuffer.length); - }); - - it('should reject unsupported image formats', async () => { - // bmp is not in the allowed list - fs.writeFileSync(path.join(tempDir, 'test.xyz'), 'fake-data'); - - const result = await fsService.readImage('test.xyz'); - - expect(result.success).toBe(false); - expect(result.error).toContain('Unsupported image format'); - }); - - it('should fail when image not found', async () => { - const result = await fsService.readImage('nonexistent.png'); - - expect(result.success).toBe(false); - expect(result.error).toContain('not found'); - }); - - it('should fail when path is directory', async () => { - fs.mkdirSync(path.join(tempDir, 'images')); - - // Directories don't have extensions, so this will fail with unsupported format - // or directory error depending on implementation order - const result = await fsService.readImage('images'); - - expect(result.success).toBe(false); - }); - - it('should reject oversized images', async () => { - // Create a fake large "image" file - const largeBuffer = Buffer.alloc(11 * 1024 * 1024); // 11MB - fs.writeFileSync(path.join(tempDir, 'large.png'), largeBuffer); - - const result = await fsService.readImage('large.png'); - - expect(result.success).toBe(false); - expect(result.error).toContain('too large'); - }); - }); - - describe('path traversal protection', () => { - it('should block absolute path traversal', async () => { - // Absolute paths get normalized by resolvePath - await expect(fsService.read('/etc/passwd')).rejects.toThrow(); - }); - - it('should block relative path traversal', async () => { - await expect(fsService.read('../package.json')).rejects.toThrow(); - }); - - it('should block nested path traversal', async () => { - fs.mkdirSync(path.join(tempDir, 'subdir')); - fs.writeFileSync(path.join(tempDir, 'subdir/file.txt'), 'content'); - - await expect(fsService.read('subdir/../../../etc/passwd')).rejects.toThrow(); - }); - - it('should normalize paths with double slashes', async () => { - fs.writeFileSync(path.join(tempDir, 'test.txt'), 'content'); - - const result = await fsService.read('//test.txt'); - - expect(result.content).toBe('content'); - }); - - it('should allow valid subpaths', async () => { - fs.mkdirSync(path.join(tempDir, 'valid')); - fs.mkdirSync(path.join(tempDir, 'valid/nested')); - fs.writeFileSync(path.join(tempDir, 'valid/nested/file.txt'), 'content'); - - const result = await fsService.read('valid/nested/file.txt'); - - expect(result.content).toBe('content'); - }); - }); - - describe('large file handling', () => { - it('should handle files larger than default maxBytes', async () => { - const largeContent = 'x'.repeat(500 * 1024); // 500KB - fs.writeFileSync(path.join(tempDir, 'large.txt'), largeContent); - - const result = await fsService.read('large.txt'); - - expect(result.truncated).toBe(true); - expect(result.content.length).toBe(200 * 1024); // Default limit - }); - - it('should handle custom maxBytes limit', async () => { - const content = 'x'.repeat(100); - fs.writeFileSync(path.join(tempDir, 'medium.txt'), content); - - const result = await fsService.read('medium.txt', 50); - - expect(result.truncated).toBe(true); - expect(result.content.length).toBe(50); - }); - }); -}); diff --git a/apps/emdash-desktop/src/main/core/fs/impl/local-fs.ts b/apps/emdash-desktop/src/main/core/fs/impl/local-fs.ts deleted file mode 100644 index 10102b1dce..0000000000 --- a/apps/emdash-desktop/src/main/core/fs/impl/local-fs.ts +++ /dev/null @@ -1,809 +0,0 @@ -import { createReadStream, promises as fs, statSync, type Stats } from 'node:fs'; -import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'; -import { createInterface } from 'node:readline'; -import parcelWatcher from '@parcel/watcher'; -import { glob } from 'glob'; -import ignore from 'ignore'; -import { log } from '@main/lib/logger'; -import type { FileWatchEvent } from '@shared/core/fs/fs'; -import { - FileSystemError, - FileSystemErrorCodes, - type FileEntry, - type FileListResult, - type FileSystemProvider, - type FileWatcher, - type ListOptions, - type ReadResult, - type SearchMatch, - type SearchOptions, - type SearchResult, - type WriteResult, -} from '../types'; - -// Binary file extensions to skip during search -const BINARY_EXTENSIONS = new Set([ - '.exe', - '.dll', - '.so', - '.dylib', - '.bin', - '.jpg', - '.jpeg', - '.png', - '.gif', - '.bmp', - '.ico', - '.svg', - '.mp3', - '.mp4', - '.avi', - '.mov', - '.wmv', - '.flv', - '.webm', - '.zip', - '.tar', - '.gz', - '.bz2', - '.7z', - '.rar', - '.pdf', - '.doc', - '.docx', - '.xls', - '.xlsx', - '.ppt', - '.pptx', - '.woff', - '.woff2', - '.ttf', - '.otf', - '.eot', - '.wasm', - '.class', - '.jar', - '.pyc', - '.o', - '.a', -]); - -// Directories to skip during search -const SEARCH_IGNORES = new Set([ - 'node_modules', - '.git', - '.svn', - '.hg', - 'dist', - 'build', - '.next', - '.nuxt', - 'coverage', - '.cache', - '.parcel-cache', -]); - -const WATCH_IGNORED_NAMES = [ - '.svn', - '.hg', - '.git', - 'node_modules', - 'dist', - 'build', - '.next', - '.nuxt', - 'coverage', - '__pycache__', - '.pytest_cache', - 'venv', - '.venv', - 'target', - '.terraform', - '.serverless', - 'worktrees', - '.emdash', - '.conductor', - '.cursor', - '.claude', - '.amp', - '.codex', - '.aider', - '.continue', - '.cody', - '.windsurf', -]; - -// Glob patterns for parcel/watcher ignore option, derived from WATCH_IGNORED_NAMES. -const WATCH_IGNORE_GLOBS = WATCH_IGNORED_NAMES.map((n) => `**/${n}/**`); - -// Allowed image extensions for readImage -const ALLOWED_IMAGE_EXTENSIONS = new Set([ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.svg', - '.bmp', - '.ico', -]); - -// MIME types for images -const IMAGE_MIME_TYPES: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.svg': 'image/svg+xml', - '.bmp': 'image/bmp', - '.ico': 'image/x-icon', -}; - -export class LocalFileSystem implements FileSystemProvider { - private listAbort: AbortController | null = null; - - constructor(private projectPath: string) { - if (!projectPath) { - throw new FileSystemError('Project path is required', FileSystemErrorCodes.INVALID_PATH); - } - this.projectPath = resolve(projectPath); - } - - /** - * Cancel any in-flight list() traversal. Used by the IPC layer for per-sender debouncing. - * The in-process traversal checks the abort signal and exits early on the next tick. - */ - cancelPendingList(): void { - if (this.listAbort) { - this.listAbort.abort(); - this.listAbort = null; - } - } - - /** - * Resolve and validate a relative path, ensuring it doesn't escape the project root - */ - private resolvePath(relPath: string): string { - // Normalize the path and resolve it against project root - const normalizedRelPath = relPath.replace(/\\/g, '/').replace(/^\//, ''); - const fullPath = resolve(join(this.projectPath, normalizedRelPath)); - - // Security: ensure path is within projectPath (handle trailing separator edge cases) - const projectPathWithSep = this.projectPath.endsWith(sep) - ? this.projectPath - : this.projectPath + sep; - const fullPathWithSep = fullPath.endsWith(sep) ? fullPath : fullPath + sep; - - if (!fullPathWithSep.startsWith(projectPathWithSep) && fullPath !== this.projectPath) { - throw new FileSystemError( - `Path traversal detected: ${relPath}`, - FileSystemErrorCodes.PATH_ESCAPE, - relPath - ); - } - - return fullPath; - } - - /** - * Get relative path from absolute path - */ - private relPath(fullPath: string): string { - return relative(this.projectPath, fullPath).replace(/\\/g, '/'); - } - - /** - * Check if a path should be ignored during search - */ - private shouldIgnore(name: string): boolean { - return SEARCH_IGNORES.has(name); - } - - /** - * Check if file is binary by extension - */ - private isBinaryFile(filePath: string): boolean { - const ext = extname(filePath).toLowerCase(); - return BINARY_EXTENSIONS.has(ext); - } - - /** - * Convert fs.Stats to FileEntry - */ - private statToEntry(fullPath: string, stat: Stats): FileEntry { - const relPath = this.relPath(fullPath); - return { - path: relPath, - type: stat.isDirectory() ? 'dir' : 'file', - size: stat.size, - mtime: stat.mtime, - ctime: stat.ctime, - mode: stat.mode, - }; - } - - async list(path: string = '', options: ListOptions = {}): Promise { - const startTime = Date.now(); - const fullPath = this.resolvePath(path); - const entries: FileEntry[] = []; - const maxEntries = options.maxEntries || 10000; - const timeBudgetMs = options.timeBudgetMs || 30000; - - const abort = new AbortController(); - this.listAbort = abort; - - let truncated = false; - let truncateReason: 'maxEntries' | 'timeBudget' | undefined; - - const listDir = async (dirPath: string, recursive: boolean) => { - if (abort.signal.aborted) return; - - if (Date.now() - startTime > timeBudgetMs) { - truncated = true; - truncateReason = 'timeBudget'; - return; - } - - if (entries.length >= maxEntries) { - truncated = true; - truncateReason = 'maxEntries'; - return; - } - - let items; - try { - items = await fs.readdir(dirPath, { withFileTypes: true }); - } catch { - return; - } - - for (const item of items) { - if (abort.signal.aborted) return; - - if (entries.length % 100 === 0 && Date.now() - startTime > timeBudgetMs) { - truncated = true; - truncateReason = 'timeBudget'; - return; - } - - if (!options.includeHidden && item.name.startsWith('.')) { - continue; - } - - if (this.shouldIgnore(item.name)) { - continue; - } - - const itemPath = join(dirPath, item.name); - - try { - const stat = await fs.stat(itemPath); - const entry: FileEntry = { - path: this.relPath(itemPath), - type: item.isDirectory() ? 'dir' : 'file', - size: stat.size, - mtime: stat.mtime, - ctime: stat.ctime, - mode: stat.mode, - }; - - if (options.filter) { - const filterRegex = new RegExp(options.filter); - if (!filterRegex.test(item.name)) { - continue; - } - } - - entries.push(entry); - - if (entries.length >= maxEntries) { - truncated = true; - truncateReason = 'maxEntries'; - return; - } - - if (recursive && item.isDirectory()) { - await listDir(itemPath, true); - } - } catch { - // Skip entries we can't stat - } - } - }; - - await listDir(fullPath, options.recursive || false); - - if (this.listAbort === abort) { - this.listAbort = null; - } - - return { - entries, - total: entries.length, - truncated, - truncateReason, - durationMs: Date.now() - startTime, - }; - } - - async read(path: string, maxBytes: number = 200 * 1024): Promise { - const fullPath = this.resolvePath(path); - - let stat; - try { - stat = await fs.stat(fullPath); - } catch (err) { - log.error('Failed to stat file', { path, error: err }); - throw new FileSystemError(`File not found: ${path}`, FileSystemErrorCodes.NOT_FOUND, path); - } - - if (stat.isDirectory()) { - throw new FileSystemError( - `Path is a directory: ${path}`, - FileSystemErrorCodes.IS_DIRECTORY, - path - ); - } - - // Handle large files with truncation - if (stat.size > maxBytes) { - const fd = await fs.open(fullPath, 'r'); - try { - const buffer = Buffer.alloc(maxBytes); - await fd.read(buffer, 0, maxBytes, 0); - - return { - content: buffer.toString('utf-8'), - truncated: true, - totalSize: stat.size, - }; - } finally { - await fd.close(); - } - } - - const content = await fs.readFile(fullPath, 'utf-8'); - return { - content, - truncated: false, - totalSize: stat.size, - }; - } - - async write(path: string, content: string): Promise { - const fullPath = this.resolvePath(path); - - // Ensure directory exists - const dir = dirname(fullPath); - try { - await fs.mkdir(dir, { recursive: true }); - } catch (err) { - log.error('Failed to create directory', { dir, error: err }); - throw new FileSystemError( - `Failed to create directory: ${dir}`, - FileSystemErrorCodes.PERMISSION_DENIED, - path - ); - } - - try { - await fs.writeFile(fullPath, content, 'utf-8'); - } catch (err) { - log.error('Failed to write file', { path, error: err }); - throw new FileSystemError( - `Failed to write file: ${path}`, - FileSystemErrorCodes.PERMISSION_DENIED, - path - ); - } - - const stat = await fs.stat(fullPath); - return { - success: true, - bytesWritten: stat.size, - }; - } - - async exists(path: string): Promise { - try { - await fs.access(this.resolvePath(path)); - return true; - } catch { - return false; - } - } - - async stat(path: string): Promise { - try { - const fullPath = this.resolvePath(path); - const stat = await fs.stat(fullPath); - return this.statToEntry(fullPath, stat); - } catch { - return null; - } - } - - async search(query: string, options: SearchOptions = {}): Promise { - const pattern = options.pattern || query; - const matches: SearchMatch[] = []; - const maxResults = options.maxResults || 10000; - const fileExtensions = options.fileExtensions; - const caseSensitive = options.caseSensitive ?? false; - - let filesSearched = 0; - let truncated = false; - - let gitIgnore: ReturnType | undefined; - try { - const gitIgnorePath = join(this.projectPath, '.gitignore'); - const content = await fs.readFile(gitIgnorePath, 'utf-8'); - gitIgnore = ignore().add(content); - } catch { - // Ignore error reading .gitignore - } - - const searchDir = async (dirPath: string) => { - let items; - try { - items = await fs.readdir(dirPath, { withFileTypes: true }); - } catch { - return; - } - - for (const item of items) { - if (matches.length >= maxResults) { - truncated = true; - return; - } - - const itemPath = join(dirPath, item.name); - - if (item.isDirectory()) { - const relPath = this.relPath(itemPath); - if (gitIgnore && gitIgnore.ignores(relPath)) { - continue; - } - - if (!this.shouldIgnore(item.name) && !item.name.startsWith('.')) { - await searchDir(itemPath); - } - } else if (item.isFile()) { - // Skip binary files - if (this.isBinaryFile(itemPath)) { - continue; - } - - // Check file extension filter - if (fileExtensions && fileExtensions.length > 0) { - const ext = extname(item.name).toLowerCase(); - if ( - !fileExtensions.some((e) => ext === e.toLowerCase() || ext === `.${e.toLowerCase()}`) - ) { - continue; - } - } - - // Check file pattern if specified - if (options.filePattern) { - const filePatternRegex = new RegExp(options.filePattern.replace(/\*/g, '.*')); - if (!filePatternRegex.test(item.name)) { - continue; - } - } - - filesSearched++; - - try { - const fileStream = createReadStream(itemPath, { encoding: 'utf-8' }); - const rl = createInterface({ - input: fileStream, - crlfDelay: Infinity, - }); - - let lineNum = 0; - for await (const line of rl) { - lineNum++; - - // Check for null bytes (binary file indicator) - if (line.includes('\0')) { - fileStream.destroy(); - break; - } - - const matchResult = caseSensitive - ? line.includes(pattern) - : line.toLowerCase().includes(pattern.toLowerCase()); - - if (matchResult) { - const column = - (caseSensitive - ? line.indexOf(pattern) - : line.toLowerCase().indexOf(pattern.toLowerCase())) + 1; - - matches.push({ - filePath: this.relPath(itemPath), - line: lineNum, - column, - content: line.trim(), - preview: line.trim().substring(0, 200), - }); - - if (matches.length >= maxResults) { - fileStream.destroy(); - truncated = true; - return; - } - } - } - } catch { - // Skip files that can't be read - } - } - } - }; - - await searchDir(this.projectPath); - - return { - matches, - total: matches.length, - truncated, - filesSearched, - }; - } - - async remove( - path: string, - options?: { recursive?: boolean } - ): Promise<{ success: boolean; error?: string }> { - const fullPath = this.resolvePath(path); - - let stat; - try { - stat = await fs.stat(fullPath); - } catch { - return { success: false, error: `File not found: ${path}` }; - } - - if (stat.isDirectory()) { - if (!options?.recursive) { - return { success: false, error: `Path is a directory: ${path}` }; - } - try { - await fs.rm(fullPath, { recursive: true, force: true }); - return { success: true }; - } catch (err: unknown) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - } - - try { - await fs.unlink(fullPath); - return { success: true }; - } catch (err: unknown) { - const code = (err as NodeJS.ErrnoException).code; - // Attempt chmod retry on permission error - if (code === 'EACCES' || code === 'EPERM') { - try { - await fs.chmod(fullPath, 0o666); - await fs.unlink(fullPath); - return { success: true }; - } catch { - return { success: false, error: `Permission denied: ${path}` }; - } - } - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - } - - async saveAttachment( - srcPath: string, - subdir?: string - ): Promise<{ - success: boolean; - absPath?: string; - relPath?: string; - fileName?: string; - error?: string; - }> { - const ALLOWED_ATTACHMENT_EXTENSIONS = new Set([ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.bmp', - '.svg', - ]); - - try { - try { - await fs.access(srcPath); - } catch { - return { success: false, error: 'Source file not found' }; - } - - const ext = extname(srcPath).toLowerCase(); - if (!ALLOWED_ATTACHMENT_EXTENSIONS.has(ext)) { - return { success: false, error: 'Unsupported attachment type' }; - } - - const destDir = join(this.projectPath, '.emdash', subdir ?? 'attachments'); - await fs.mkdir(destDir, { recursive: true }); - - const baseName = basename(srcPath); - let destName = baseName; - let counter = 1; - let destAbs = join(destDir, destName); - - while (true) { - try { - await fs.access(destAbs); - // File exists — try next name - const nameWithoutExt = basename(baseName, ext); - destName = `${nameWithoutExt}-${counter}${ext}`; - destAbs = join(destDir, destName); - counter++; - } catch { - // File does not exist — safe to write here - break; - } - } - - await fs.copyFile(srcPath, destAbs); - const relPath = relative(this.projectPath, destAbs); - return { success: true, absPath: destAbs, relPath, fileName: destName }; - } catch (err: unknown) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - } - - async readImage(path: string): Promise<{ - success: boolean; - dataUrl?: string; - mimeType?: string; - size?: number; - error?: string; - }> { - const fullPath = this.resolvePath(path); - - // Check file extension - const ext = extname(path).toLowerCase(); - if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) { - return { - success: false, - error: `Unsupported image format: ${ext}. Allowed: ${Array.from(ALLOWED_IMAGE_EXTENSIONS).join(', ')}`, - }; - } - - let stat; - try { - stat = await fs.stat(fullPath); - } catch { - return { success: false, error: `Image not found: ${path}` }; - } - - if (stat.isDirectory()) { - return { success: false, error: `Path is a directory: ${path}` }; - } - - // Size limit for images (10MB) - const MAX_IMAGE_SIZE = 10 * 1024 * 1024; - if (stat.size > MAX_IMAGE_SIZE) { - return { - success: false, - error: `Image too large: ${stat.size} bytes (max ${MAX_IMAGE_SIZE})`, - }; - } - - try { - const buffer = await fs.readFile(fullPath); - const base64 = buffer.toString('base64'); - const mimeType = IMAGE_MIME_TYPES[ext] || 'application/octet-stream'; - const dataUrl = `data:${mimeType};base64,${base64}`; - - return { - success: true, - dataUrl, - mimeType, - size: stat.size, - }; - } catch (err: unknown) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - } - - async mkdir(dirPath: string, options?: { recursive?: boolean }): Promise { - await fs.mkdir(this.resolvePath(dirPath), { recursive: options?.recursive ?? false }); - } - - async realPath(path: string): Promise { - return fs.realpath(this.resolvePath(path)); - } - - async glob(pattern: string, options?: { cwd?: string; dot?: boolean }): Promise { - const cwd = options?.cwd ? this.resolvePath(options.cwd) : this.projectPath; - return glob(pattern, { cwd, dot: options?.dot ?? false, absolute: false }); - } - - async copyFile(src: string, dest: string): Promise { - await fs.copyFile(this.resolvePath(src), this.resolvePath(dest)); - } - - async copyLocalFile(localAbsPath: string, destRelPath: string): Promise { - await fs.copyFile(localAbsPath, this.resolvePath(destRelPath)); - } - - watch( - callback: (events: FileWatchEvent[]) => void, - options: { debounceMs?: number } = {} - ): FileWatcher { - const stabilityMs = options.debounceMs ?? 200; - let pending: FileWatchEvent[] = []; - let flushTimer: ReturnType | null = null; - // Set when the async subscribe resolves; used by close() if it resolves after close() is called. - let resolvedSub: parcelWatcher.AsyncSubscription | null = null; - let closed = false; - - const flush = () => { - if (pending.length) { - callback(pending); - pending = []; - } - }; - - const enqueue = (evt: FileWatchEvent) => { - pending.push(evt); - if (flushTimer) clearTimeout(flushTimer); - flushTimer = setTimeout(flush, stabilityMs); - }; - - const toRel = (absPath: string) => relative(this.projectPath, absPath).replace(/\\/g, '/'); - - void parcelWatcher - .subscribe( - this.projectPath, - (err, events) => { - if (err) return; - for (const e of events) { - const rel = toRel(e.path); - // Skip paths outside the project root (shouldn't happen, but guard anyway). - if (rel.startsWith('..')) continue; - - let entryType: 'file' | 'directory' = 'file'; - if (e.type !== 'delete') { - try { - entryType = statSync(e.path).isDirectory() ? 'directory' : 'file'; - } catch { - // File removed between the event and the stat — treat as file. - } - } - const type = e.type === 'update' ? ('modify' as const) : e.type; - enqueue({ type, entryType, path: rel }); - } - }, - { ignore: WATCH_IGNORE_GLOBS } - ) - .then((sub) => { - if (closed) { - void sub.unsubscribe(); - } else { - resolvedSub = sub; - } - }) - .catch(() => { - // Subscription failed (e.g. project path removed before watch started). - }); - - return { - // No-op: the recursive subscription already covers the entire worktree. - update(_paths: string[]) {}, - close() { - closed = true; - if (flushTimer) clearTimeout(flushTimer); - if (resolvedSub) void resolvedSub.unsubscribe(); - }, - }; - } -} diff --git a/apps/emdash-desktop/src/main/core/fs/test-helpers/memory-fs.ts b/apps/emdash-desktop/src/main/core/fs/test-helpers/memory-fs.ts deleted file mode 100644 index 37da3b4380..0000000000 --- a/apps/emdash-desktop/src/main/core/fs/test-helpers/memory-fs.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { - FileEntry, - FileListResult, - FileSystemProvider, - ReadResult, - SearchResult, - WriteResult, -} from '../types'; - -export class MemoryFs implements FileSystemProvider { - readonly files = new Map(); - - async list(): Promise { - return { - entries: Array.from(this.files.keys()).map((path) => ({ path, type: 'file' as const })), - total: this.files.size, - }; - } - - async exists(path: string): Promise { - return this.files.has(path); - } - - async read(path: string): Promise { - const content = this.files.get(path); - if (content === undefined) { - throw new Error(`not found: ${path}`); - } - return { - content, - truncated: false, - totalSize: Buffer.byteLength(content), - }; - } - - async write(path: string, content: string): Promise { - this.files.set(path, content); - return { - success: true, - bytesWritten: Buffer.byteLength(content), - }; - } - - async stat(path: string): Promise { - const content = this.files.get(path); - if (content === undefined) return null; - return { - path, - type: 'file', - size: Buffer.byteLength(content), - }; - } - - async search(): Promise { - return { - matches: [], - total: 0, - }; - } - - async remove(path: string): Promise<{ success: boolean; error?: string }> { - this.files.delete(path); - return { success: true }; - } - - async realPath(path: string): Promise { - return path; - } - - async glob(): Promise { - return Array.from(this.files.keys()); - } - - async copyFile(src: string, dest: string): Promise { - const content = this.files.get(src); - if (content === undefined) throw new Error(`not found: ${src}`); - this.files.set(dest, content); - } - - async mkdir(): Promise {} - - watch(): { update(paths: string[]): void; close(): void } { - return { - update: () => {}, - close: () => {}, - }; - } -} diff --git a/apps/emdash-desktop/src/main/core/git/legacy/git-service.test.ts b/apps/emdash-desktop/src/main/core/git/legacy/git-service.test.ts index faaee8a1da..7a4ea0581c 100644 --- a/apps/emdash-desktop/src/main/core/git/legacy/git-service.test.ts +++ b/apps/emdash-desktop/src/main/core/git/legacy/git-service.test.ts @@ -1,8 +1,8 @@ import { createHash } from 'node:crypto'; +import type { IFileSystem } from '@emdash/core/files'; import { computeBaseRef } from '@emdash/core/git'; import { describe, expect, it } from 'vitest'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { GitService } from './git-service'; // --------------------------------------------------------------------------- @@ -45,7 +45,7 @@ const BRANCH_FORMAT = const OID = '1111111111111111111111111111111111111111'; const OID2 = '2222222222222222222222222222222222222222'; -const stubFs = {} as FileSystemProvider; +const stubFs = {} as IFileSystem; function makeContext(exec: MockExec, root = '/repo'): IExecutionContext { return { diff --git a/apps/emdash-desktop/src/main/core/git/legacy/git-service.ts b/apps/emdash-desktop/src/main/core/git/legacy/git-service.ts index dad1f19afc..41304cca1f 100644 --- a/apps/emdash-desktop/src/main/core/git/legacy/git-service.ts +++ b/apps/emdash-desktop/src/main/core/git/legacy/git-service.ts @@ -1,6 +1,7 @@ import { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import path from 'node:path'; +import type { IFileSystem } from '@emdash/core/files'; import { computeBaseRef, MAX_STATUS_FILES, @@ -29,7 +30,6 @@ import { import { err, ok, type Result } from '@emdash/shared'; import type { IDisposable } from '@emdash/shared'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { GIT_EXECUTABLE } from '@main/core/utils/exec'; import { log } from '@main/lib/logger'; import { @@ -131,7 +131,7 @@ export class GitService implements IDisposable { constructor( private readonly ctx: IExecutionContext, - private readonly fs: FileSystemProvider + private readonly fs: IFileSystem ) {} dispose(): void { @@ -281,9 +281,11 @@ export class GitService implements IDisposable { if (additions === 0 && deletions === 0 && code.includes('?')) { try { - const result = await this.fs.read(filePath, MAX_DIFF_CONTENT_BYTES); - if (!result.truncated) { - additions = (result.content.match(/\n/g) ?? []).length; + const result = await this.fs.readText(this.toFsPath(filePath), { + maxBytes: MAX_DIFF_CONTENT_BYTES, + }); + if (result.success && !result.data.truncated) { + additions = (result.data.content.match(/\n/g) ?? []).length; } } catch {} } @@ -371,12 +373,21 @@ export class GitService implements IDisposable { // Untracked files don't exist in git history — remove them from disk for (const filePath of untracked) { try { - const exists = await this.fs.exists(filePath); - if (exists) await this.fs.remove(filePath); + const fsPath = this.toFsPath(filePath); + const exists = await this.fs.exists(fsPath); + if (exists.success && exists.data) await this.fs.remove(fsPath); } catch {} } } + private toFsPath(filePath: string): string { + if (path.isAbsolute(filePath) || path.win32.isAbsolute(filePath)) return filePath; + const root = this.ctx.root; + return root + ? path.posix.join(root.replace(/\\/g, '/'), filePath.replace(/\\/g, '/')) + : filePath; + } + async revertAllFiles(): Promise { // Reset index and working tree for all tracked changes back to HEAD, // then remove any untracked files/directories. diff --git a/apps/emdash-desktop/src/main/core/project-setup/repository-setup.test.ts b/apps/emdash-desktop/src/main/core/project-setup/repository-setup.test.ts index 863610b670..8750e78e29 100644 --- a/apps/emdash-desktop/src/main/core/project-setup/repository-setup.test.ts +++ b/apps/emdash-desktop/src/main/core/project-setup/repository-setup.test.ts @@ -1,11 +1,12 @@ +import { ok } from '@emdash/shared'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { cloneProjectRepository, initializeProjectRepository } from './repository-setup'; const mocks = vi.hoisted(() => { const cloneRepository = vi.fn(); const commit = vi.fn(); - const connect = vi.fn(); const exists = vi.fn(); + const fileSystem = vi.fn(); const getHead = vi.fn(); const mkdir = vi.fn(); const openWorktree = vi.fn(); @@ -14,57 +15,61 @@ const mocks = vi.hoisted(() => { const releaseWorktree = vi.fn(); const runtimeAcquire = vi.fn(); const stage = vi.fn(); + const stat = vi.fn(); const write = vi.fn(); return { cloneRepository, commit, - connect, exists, + fileSystem, getHead, - localFileSystem: vi.fn(function () { - return { exists, mkdir, write }; - }), mkdir, openWorktree, publishBranch, releaseRuntime, releaseWorktree, runtimeAcquire, - sshFileSystem: vi.fn(function () { - return { exists, mkdir, write }; - }), stage, + stat, write, }; }); -vi.mock('@main/core/fs/impl/local-fs', () => ({ - LocalFileSystem: mocks.localFileSystem, -})); - -vi.mock('@main/core/fs/impl/ssh-fs', () => ({ - SshFileSystem: mocks.sshFileSystem, -})); - vi.mock('@main/core/runtime/runtime-manager', () => ({ runtimeManager: { acquire: mocks.runtimeAcquire, }, })); -vi.mock('@main/core/ssh/lifecycle/production-ssh-connection-manager', () => ({ - sshConnectionManager: { - connect: mocks.connect, - }, -})); +function makeFilesRuntime() { + return { + path: { + join: (...parts: string[]) => parts.join('/').replace(/\/+/g, '/'), + dirname: (value: string) => value.slice(0, value.lastIndexOf('/')) || '/', + basename: (value: string) => value.slice(value.lastIndexOf('/') + 1), + isAbsolute: (value: string) => value.startsWith('/'), + relative: (_from: string, to: string) => to, + contains: () => true, + }, + fileSystem: mocks.fileSystem.mockImplementation(() => + ok({ + exists: mocks.exists, + mkdir: mocks.mkdir, + stat: mocks.stat, + writeText: mocks.write, + }) + ), + }; +} describe('cloneProjectRepository', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.mkdir.mockResolvedValue(undefined); + mocks.exists.mockResolvedValue(ok(false)); + mocks.mkdir.mockResolvedValue(ok()); mocks.runtimeAcquire.mockResolvedValue({ - value: { git: { cloneRepository: mocks.cloneRepository } }, + value: { files: makeFilesRuntime(), git: { cloneRepository: mocks.cloneRepository } }, release: mocks.releaseRuntime, }); }); @@ -82,8 +87,8 @@ describe('cloneProjectRepository', () => { }) ).resolves.toEqual({ success: true }); - expect(mocks.localFileSystem).toHaveBeenCalledWith('/work'); - expect(mocks.mkdir).toHaveBeenCalledWith('.', { recursive: true }); + expect(mocks.fileSystem).toHaveBeenCalledWith(); + expect(mocks.mkdir).toHaveBeenCalledWith('/work', { recursive: true }); expect(mocks.runtimeAcquire).toHaveBeenCalledWith({ kind: 'local' }); expect(mocks.cloneRepository).toHaveBeenCalledWith( 'https://github.com/acme/repo.git', @@ -92,9 +97,7 @@ describe('cloneProjectRepository', () => { expect(mocks.releaseRuntime).toHaveBeenCalledOnce(); }); - it('uses the ssh filesystem and ssh machine runtime for remote clones', async () => { - const proxy = { connectionId: 'conn-1' }; - mocks.connect.mockResolvedValue(proxy); + it('uses the ssh machine runtime for remote clones', async () => { mocks.cloneRepository.mockResolvedValue({ success: true, data: { kind: 'repository', rootPath: '/home/jona/repo', baseRef: 'main' }, @@ -108,8 +111,6 @@ describe('cloneProjectRepository', () => { }) ).resolves.toEqual({ success: true }); - expect(mocks.connect).toHaveBeenCalledWith('conn-1'); - expect(mocks.sshFileSystem).toHaveBeenCalledWith(proxy, '/home/jona'); expect(mocks.runtimeAcquire).toHaveBeenCalledWith({ kind: 'ssh', connectionId: 'conn-1' }); expect(mocks.releaseRuntime).toHaveBeenCalledOnce(); }); @@ -136,8 +137,8 @@ describe('cloneProjectRepository', () => { describe('initializeProjectRepository', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.exists.mockResolvedValue(true); - mocks.write.mockResolvedValue({ success: true, bytesWritten: 20 }); + mocks.stat.mockResolvedValue(ok({ path: '/work/repo', type: 'directory' })); + mocks.write.mockResolvedValue(ok({ bytesWritten: 20 })); mocks.stage.mockResolvedValue({ success: true, data: {} }); mocks.commit.mockResolvedValue({ success: true, data: { hash: 'abc123', sequences: {} } }); mocks.getHead.mockResolvedValue({ kind: 'branch', name: 'main', oid: 'abc123' }); @@ -155,7 +156,7 @@ describe('initializeProjectRepository', () => { release: mocks.releaseWorktree, }); mocks.runtimeAcquire.mockResolvedValue({ - value: { git: { openWorktree: mocks.openWorktree } }, + value: { files: makeFilesRuntime(), git: { openWorktree: mocks.openWorktree } }, release: mocks.releaseRuntime, }); }); @@ -169,12 +170,12 @@ describe('initializeProjectRepository', () => { }) ).resolves.toEqual({ success: true }); - expect(mocks.localFileSystem).toHaveBeenCalledWith('/work/repo'); - expect(mocks.exists).toHaveBeenCalledWith('.'); - expect(mocks.write).toHaveBeenCalledWith('README.md', '# Repo\n\nDescription\n'); + expect(mocks.fileSystem).toHaveBeenCalledWith(); + expect(mocks.stat).toHaveBeenCalledWith('/work/repo'); + expect(mocks.write).toHaveBeenCalledWith('/work/repo/README.md', '# Repo\n\nDescription\n'); expect(mocks.runtimeAcquire).toHaveBeenCalledWith({ kind: 'local' }); expect(mocks.openWorktree).toHaveBeenCalledWith('/work/repo'); - expect(mocks.stage).toHaveBeenCalledWith(['README.md']); + expect(mocks.stage).toHaveBeenCalledWith(['/work/repo/README.md']); expect(mocks.commit).toHaveBeenCalledWith('Initial commit'); expect(mocks.publishBranch).toHaveBeenCalledWith('main', 'origin'); expect(mocks.releaseWorktree).toHaveBeenCalledOnce(); @@ -191,12 +192,15 @@ describe('initializeProjectRepository', () => { }) ).resolves.toEqual({ success: true }); - expect(mocks.write).toHaveBeenCalledWith('README.md', '# Repo\n'); + expect(mocks.write).toHaveBeenCalledWith('/work/repo/README.md', '# Repo\n'); expect(mocks.publishBranch).toHaveBeenCalledWith('trunk', 'origin'); }); it('returns a setup failure when the target path does not exist', async () => { - mocks.exists.mockResolvedValue(false); + mocks.stat.mockResolvedValue({ + success: false, + error: { type: 'fs-error', path: '/work/repo', message: 'missing', code: 'ENOENT' }, + }); await expect( initializeProjectRepository({ @@ -205,7 +209,8 @@ describe('initializeProjectRepository', () => { }) ).resolves.toEqual({ success: false, error: 'Local path does not exist' }); - expect(mocks.runtimeAcquire).not.toHaveBeenCalled(); + expect(mocks.runtimeAcquire).toHaveBeenCalledWith({ kind: 'local' }); + expect(mocks.releaseRuntime).toHaveBeenCalledOnce(); }); it('returns a setup failure when the initial commit fails', async () => { diff --git a/apps/emdash-desktop/src/main/core/project-setup/repository-setup.ts b/apps/emdash-desktop/src/main/core/project-setup/repository-setup.ts index 626fd33b4c..30b91d428e 100644 --- a/apps/emdash-desktop/src/main/core/project-setup/repository-setup.ts +++ b/apps/emdash-desktop/src/main/core/project-setup/repository-setup.ts @@ -1,12 +1,10 @@ import path from 'node:path'; +import type { FileError, IFileSystem } from '@emdash/core/files'; import type { CloneRepositoryError, GitHeadModel, IGitWorktree } from '@emdash/core/git'; -import { match, P } from 'ts-pattern'; -import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; -import type { FileSystemProvider } from '@main/core/fs/types'; +import { ensureAbsoluteDir, openFileSystem, statAbsolute } from '@main/core/runtime/files-helpers'; import { runtimeManager } from '@main/core/runtime/runtime-manager'; +import type { IFilesRuntime } from '@main/core/runtime/types'; import type { MachineRef } from '@main/core/runtime/types'; -import { sshConnectionManager } from '@main/core/ssh/lifecycle/production-ssh-connection-manager'; export type GitRepositorySetupResult = { success: true } | { success: false; error: string }; @@ -31,25 +29,19 @@ function parentPathForMachine(targetPath: string, machine: MachineRef): string { return machine.kind === 'ssh' ? path.posix.dirname(targetPath) : path.dirname(targetPath); } -async function createProjectFs(root: string, machine: MachineRef): Promise { - if (machine.kind === 'ssh') { - const proxy = await sshConnectionManager.connect(machine.connectionId); - return new SshFileSystem(proxy, root); +function cloneRepositoryErrorMessage(error: CloneRepositoryError): string { + switch (error.type) { + case 'target_exists': + return `Target directory already exists and is not empty: ${error.path}`; + case 'auth_failed': + case 'remote_not_found': + case 'git_error': + return error.message; } - return new LocalFileSystem(root); } -function cloneRepositoryErrorMessage(error: CloneRepositoryError): string { - return match(error) - .with( - { type: 'target_exists' }, - (e) => `Target directory already exists and is not empty: ${e.path}` - ) - .with( - P.union({ type: 'auth_failed' }, { type: 'remote_not_found' }, { type: 'git_error' }), - (e) => e.message - ) - .exhaustive(); +function fileErrorMessage(error: FileError): string { + return error.message; } function initialReadmeContent(name: string, description: string | undefined): string { @@ -78,11 +70,15 @@ export async function cloneProjectRepository( params: CloneProjectRepositoryParams ): Promise { const machine = machineForConnection(params.connectionId); - const parentFs = await createProjectFs(parentPathForMachine(params.targetPath, machine), machine); - await parentFs.mkdir('.', { recursive: true }); - const runtimeLease = await runtimeManager.acquire(machine); try { + const madeParentDir = await ensureAbsoluteDir( + runtimeLease.value.files, + parentPathForMachine(params.targetPath, machine) + ); + if (!madeParentDir.success) { + return { success: false, error: fileErrorMessage(madeParentDir.error) }; + } const result = await runtimeLease.value.git.cloneRepository( params.repositoryUrl, params.targetPath @@ -100,25 +96,23 @@ export async function initializeProjectRepository( params: InitializeProjectRepositoryParams ): Promise { const machine = machineForConnection(params.connectionId); - const projectFs = await createProjectFs(params.targetPath, machine); - - if (!(await projectFs.exists('.'))) { - return { success: false, error: 'Local path does not exist' }; - } - - const writeResult = await projectFs.write( - 'README.md', - initialReadmeContent(params.name, params.description) - ); - if (!writeResult.success) { - return { success: false, error: writeResult.error || 'Failed to write README.md' }; - } - const runtimeLease = await runtimeManager.acquire(machine); try { + const projectFs = await ensureProjectDirectory(runtimeLease.value.files, params.targetPath); + if (!projectFs.success) return { success: false, error: projectFs.error }; + + const readmePath = runtimeLease.value.files.path.join(params.targetPath, 'README.md'); + const writeResult = await projectFs.data.writeText( + readmePath, + initialReadmeContent(params.name, params.description) + ); + if (!writeResult.success) { + return { success: false, error: fileErrorMessage(writeResult.error) }; + } + const worktreeLease = await runtimeLease.value.git.openWorktree(params.targetPath); try { - const stageResult = await worktreeLease.value.stage(['README.md']); + const stageResult = await worktreeLease.value.stage([readmePath]); if (!stageResult.success) return { success: false, error: stageResult.error.message }; const commitResult = await worktreeLease.value.commit('Initial commit'); if (!commitResult.success) return { success: false, error: commitResult.error.message }; @@ -130,3 +124,17 @@ export async function initializeProjectRepository( await runtimeLease.release(); } } + +async function ensureProjectDirectory( + files: IFilesRuntime, + targetPath: string +): Promise<{ success: true; data: IFileSystem } | { success: false; error: string }> { + const stat = await statAbsolute(files, targetPath); + if (!stat.success) return { success: false, error: 'Local path does not exist' }; + if (stat.data.type !== 'directory') { + return { success: false, error: `Path is not a directory: ${targetPath}` }; + } + const opened = openFileSystem(files); + if (!opened.success) return { success: false, error: fileErrorMessage(opened.error) }; + return { success: true, data: opened.data }; +} diff --git a/apps/emdash-desktop/src/main/core/projects/create-project-provider.ts b/apps/emdash-desktop/src/main/core/projects/create-project-provider.ts index 0140fb1227..2ba18a75ec 100644 --- a/apps/emdash-desktop/src/main/core/projects/create-project-provider.ts +++ b/apps/emdash-desktop/src/main/core/projects/create-project-provider.ts @@ -1,19 +1,22 @@ -import fs from 'node:fs'; -import path from 'node:path'; +import type { IFileSystem } from '@emdash/core/files'; import type { IGitRepository, IGitRuntime } from '@emdash/core/git'; -import type { Lease } from '@emdash/shared'; +import { err, ok, type Lease, type Result } from '@emdash/shared'; import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context'; import { SshExecutionContext } from '@main/core/execution-context/ssh-execution-context'; -import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { GitRepositoryFetchService } from '@main/core/git/repository/fetch-service'; import { GitRepositoryService } from '@main/core/git/repository/service'; import { projectGitHubAccountBackfillService } from '@main/core/github/services/project-github-account-backfill-instance'; +import { + absoluteDirectoryFileSystem, + ensureAbsoluteDir, + openFileSystem, +} from '@main/core/runtime/files-helpers'; import { runtimeManager } from '@main/core/runtime/runtime-manager'; import type { MachineRef, MachineRuntime } from '@main/core/runtime/types'; import { sshConnectionManager } from '@main/core/ssh/lifecycle/production-ssh-connection-manager'; import type { SshConnectionManagerEvent } from '@main/core/ssh/lifecycle/ssh-connection-manager'; +import { LocalWorkspaceSetupExecutor } from '@main/core/workspaces/local-workspace-setup-executor'; +import { applyRecovery } from '@main/core/workspaces/recovery-strategy'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; import { gitRepoUpdateChannel } from '@shared/core/git/events'; @@ -23,38 +26,48 @@ import { ProjectProvider, type ProjectProviderTransport } from './project-provid import type { ProjectSettingsProvider } from './settings/provider'; import { LocalProjectSettingsProvider } from './settings/providers/local-project-settings-provider'; import { SshProjectSettingsProvider } from './settings/providers/ssh-project-settings-provider'; -import { LocalWorktreeHost } from './worktrees/hosts/local-worktree-host'; -import { SshWorktreeHost } from './worktrees/hosts/ssh-worktree-host'; -import type { WorktreeHost } from './worktrees/hosts/worktree-host'; import { WorktreeService } from './worktrees/worktree-service'; -export async function createProvider(project: LocalProject | SshProject): Promise { - if (project.type === 'ssh') { - return createSshProvider(project); - } - return createLocalProvider(project); +export type CreateProviderError = { message: string }; + +export async function createProvider( + project: LocalProject | SshProject +): Promise> { + return project.type === 'ssh' ? createSshProvider(project) : createLocalProvider(project); } -async function createLocalProvider(project: LocalProject): Promise { - const localFs = new LocalFileSystem(project.path); +async function createLocalProvider( + project: LocalProject +): Promise> { const ctx = new LocalExecutionContext({ root: project.path }); const projectMachine: MachineRef = { kind: 'local' }; const runtimeLease = await runtimeManager.acquire(projectMachine); - const settings = new LocalProjectSettingsProvider(project.id, project.path, project.baseRef); - try { + const projectFileSystem = openFileSystem(runtimeLease.value.files); + if (!projectFileSystem.success) { + await runtimeLease.release(); + return err({ message: projectFileSystem.error.message }); + } + const settings = new LocalProjectSettingsProvider( + project.id, + project.path, + project.baseRef, + projectFileSystem.data + ); await runLegacyProjectSettingsMigration(settings, runtimeLease.value.git, project.path); const worktreeDirectory = await settings.getWorktreeDirectory(); - await fs.promises.mkdir(worktreeDirectory, { recursive: true }); - const worktreeHost = await LocalWorktreeHost.create({ - allowedRoots: [project.path, worktreeDirectory], - }); + const madeWorktreeDir = await ensureAbsoluteDir(runtimeLease.value.files, worktreeDirectory); + if (!madeWorktreeDir.success) { + await runtimeLease.release(); + return err({ message: madeWorktreeDir.error.message }); + } const resolveWorktreePoolPath = async () => { const directory = await settings.getWorktreeDirectory(); - await fs.promises.mkdir(directory, { recursive: true }); - await worktreeHost.allowRoot(directory); - return path.join(directory, safePathSegment(project.name, project.id)); + return runtimeLease.value.files.path.join( + directory, + safePathSegment(project.name, project.id) + ); }; const repoLease = await runtimeLease.value.git.openRepository(project.path); @@ -69,42 +82,47 @@ async function createLocalProvider(project: LocalProject): Promise {}, runtimeLease, repoLease ); await backfillGitHubAccount(provider); - return provider; + return ok(provider); } catch (error) { await repoLease.release(); throw error; } } catch (error) { await runtimeLease.release(); - throw error; + return err(toCreateProviderError(error)); } } -async function createSshProvider(project: SshProject): Promise { +async function createSshProvider( + project: SshProject +): Promise> { try { const proxy = await sshConnectionManager.connect(project.connectionId); - const rootFs = new SshFileSystem(proxy, '/'); - const projectFs = new SshFileSystem(proxy, project.path); const baseCtx = new SshExecutionContext(proxy, { root: project.path }); const ctx = baseCtx; const projectMachine: MachineRef = { kind: 'ssh', connectionId: project.connectionId }; const runtimeLease = await runtimeManager.acquire(projectMachine); + const projectFileSystem = openFileSystem(runtimeLease.value.files); + if (!projectFileSystem.success) { + await runtimeLease.release(); + return err({ message: projectFileSystem.error.message }); + } const settings = new SshProjectSettingsProvider( project.id, - projectFs, + projectFileSystem.data, project.baseRef, - rootFs, + absoluteDirectoryFileSystem(runtimeLease.value.files), project.path, baseCtx ); @@ -112,11 +130,14 @@ async function createSshProvider(project: SshProject): Promise try { await runLegacyProjectSettingsMigration(settings, runtimeLease.value.git, project.path); const worktreeDirectory = await settings.getWorktreeDirectory(); - const worktreePoolPath = path.posix.join(worktreeDirectory, project.name); - const worktreeHost = new SshWorktreeHost(rootFs); - await worktreeHost.mkdirAbsolute(worktreePoolPath, { recursive: true }); + const worktreePoolPath = runtimeLease.value.files.path.join(worktreeDirectory, project.name); + const madeWorktreePool = await ensureAbsoluteDir(runtimeLease.value.files, worktreePoolPath); + if (!madeWorktreePool.success) { + await runtimeLease.release(); + return err({ message: madeWorktreePool.error.message }); + } const resolveWorktreePoolPath = async () => - path.posix.join(await settings.getWorktreeDirectory(), project.name); + runtimeLease.value.files.path.join(await settings.getWorktreeDirectory(), project.name); let provider: ProjectProvider | undefined; const handler = (evt: SshConnectionManagerEvent) => { @@ -140,9 +161,9 @@ async function createSshProvider(project: SshProject): Promise defaultWorkspaceMachine: projectMachine, ctx, }, - projectFs, + runtimeLease.value.files, + projectFileSystem.data, settings, - worktreeHost, resolveWorktreePoolPath, dispose, runtimeLease, @@ -153,7 +174,7 @@ async function createSshProvider(project: SshProject): Promise // Wire reconnect handler after provider is built so gitRepositoryFetchService is available. sshConnectionManager.on('connection-event', handler); - return provider; + return ok(provider); } catch (error) { await repoLease.release(); throw error; @@ -168,10 +189,14 @@ async function createSshProvider(project: SshProject): Promise error: error instanceof Error ? error.message : String(error), }); sshConnectionManager.reportChannelError(project.connectionId, error); - throw error; + return err(toCreateProviderError(error)); } } +function toCreateProviderError(error: unknown): CreateProviderError { + return { message: error instanceof Error ? error.message : String(error) }; +} + async function runLegacyProjectSettingsMigration( settings: LocalProjectSettingsProvider | SshProjectSettingsProvider, git: IGitRuntime, @@ -203,9 +228,9 @@ function buildProvider( ProjectProviderTransport, 'kind' | 'projectMachine' | 'defaultWorkspaceType' | 'defaultWorkspaceMachine' | 'ctx' >, - projectFs: FileSystemProvider, + files: MachineRuntime['files'], + projectFileSystem: IFileSystem, settings: ProjectSettingsProvider, - worktreeHost: WorktreeHost, resolveWorktreePoolPath: () => Promise, dispose: () => void | Promise, runtimeLease: Lease, @@ -213,21 +238,44 @@ function buildProvider( ): ProjectProvider { const { ctx } = transportMeta; - const transport: ProjectProviderTransport = { - ...transportMeta, - fs: projectFs, - settings, - worktreeHost, - }; - const gitRepository = new GitRepositoryService(repoLease.value, settings); const worktreeService = new WorktreeService({ repoPath, projectSettings: settings, ctx, - host: worktreeHost, + files, resolveWorktreePoolPath, }); + const transport: ProjectProviderTransport = { + ...transportMeta, + fileSystem: projectFileSystem, + projectConfigPath: files.path.join(repoPath, '.emdash.json'), + resolveProjectPath: (relativePath) => files.path.join(repoPath, relativePath), + configPathForDirectory: (directoryPath) => files.path.join(directoryPath, '.emdash.json'), + runWorkspaceSetup: async ({ spec, worktreePoolPath }) => { + const stepCtx = { + ctx, + repoPath, + worktreePoolPath, + files, + projectSettings: settings, + worktreeService, + }; + const executor = new LocalWorkspaceSetupExecutor(stepCtx); + let setupResult = await executor.execute(spec); + if (!setupResult.success) { + const recovery = await applyRecovery(setupResult.error, stepCtx); + + if (recovery.kind === 'resolved') { + setupResult = ok({ path: recovery.path, warnings: [] }); + } else if (recovery.kind === 'retry') { + setupResult = await executor.execute(spec); + } + } + return setupResult; + }, + settings, + }; const gitRepositoryFetchService = new GitRepositoryFetchService(gitRepository, () => gitRepository.getBaseRemote() ); @@ -242,7 +290,7 @@ function buildProvider( await runtimeLease.release(); }; - return new ProjectProvider( + const provider = new ProjectProvider( projectId, repoPath, transport, @@ -255,4 +303,5 @@ function buildProvider( await dispose(); } ); + return provider; } diff --git a/apps/emdash-desktop/src/main/core/projects/operations/create-local-project.ts b/apps/emdash-desktop/src/main/core/projects/operations/create-local-project.ts index fd85df02da..2f02f38db9 100644 --- a/apps/emdash-desktop/src/main/core/projects/operations/create-local-project.ts +++ b/apps/emdash-desktop/src/main/core/projects/operations/create-local-project.ts @@ -1,8 +1,10 @@ import { randomUUID } from 'node:crypto'; +import { isFileNotFoundCode } from '@emdash/core/files'; import { err, ok, withLease } from '@emdash/shared'; import { sql } from 'drizzle-orm'; import { projectEvents } from '@main/core/projects/project-events'; import { projectManager } from '@main/core/projects/project-manager'; +import { statAbsolute } from '@main/core/runtime/files-helpers'; import { runtimeManager } from '@main/core/runtime/runtime-manager'; import { db } from '@main/db/client'; import { projects } from '@main/db/schema'; @@ -84,20 +86,24 @@ export async function createLocalProject( } export async function getLocalProjectPathStatus(path: string): Promise { - const directoryStatus = getDirectoryStatus(path); - if (directoryStatus.kind === 'inspect-failed') { - return { - isDirectory: false, - isGitRepo: false, - error: { type: 'inspect-failed', path, message: directoryStatus.message }, - }; - } - if (directoryStatus.kind !== 'directory') { - return { isDirectory: false, isGitRepo: false }; - } - const runtimeLease = await runtimeManager.acquire({ kind: 'local' }); try { + const pathEntry = await statAbsolute(runtimeLease.value.files, path); + if (!pathEntry.success) { + const code = 'code' in pathEntry.error ? pathEntry.error.code : undefined; + if (isFileNotFoundCode(code)) { + return { isDirectory: false, isGitRepo: false }; + } + return { + isDirectory: false, + isGitRepo: false, + error: { type: 'inspect-failed', path, message: pathEntry.error.message }, + }; + } + if (pathEntry.data.type !== 'directory') { + return { isDirectory: false, isGitRepo: false }; + } + const inspection = await runtimeLease.value.git.inspectPath(path); if (inspection.kind === 'inspect-failed') { return { diff --git a/apps/emdash-desktop/src/main/core/projects/operations/create-ssh-project.ts b/apps/emdash-desktop/src/main/core/projects/operations/create-ssh-project.ts index 3a47ff2fc9..6769b74f99 100644 --- a/apps/emdash-desktop/src/main/core/projects/operations/create-ssh-project.ts +++ b/apps/emdash-desktop/src/main/core/projects/operations/create-ssh-project.ts @@ -1,11 +1,11 @@ import { randomUUID } from 'node:crypto'; -import { err, ok, withLease } from '@emdash/shared'; +import { isFileNotFoundCode } from '@emdash/core/files'; +import { err, ok } from '@emdash/shared'; import { sql } from 'drizzle-orm'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import { projectEvents } from '@main/core/projects/project-events'; import { projectManager } from '@main/core/projects/project-manager'; +import { statAbsolute } from '@main/core/runtime/files-helpers'; import { runtimeManager } from '@main/core/runtime/runtime-manager'; -import { sshConnectionManager } from '@main/core/ssh/lifecycle/production-ssh-connection-manager'; import { db } from '@main/db/client'; import { projects } from '@main/db/schema'; import { log } from '@main/lib/logger'; @@ -24,26 +24,39 @@ export type CreateSshProjectParams = { export async function createSshProject( params: CreateSshProjectParams ): Promise { - const sshProxy = await sshConnectionManager.connect(params.connectionId); + const runtimeLease = await runtimeManager.acquire({ + kind: 'ssh', + connectionId: params.connectionId, + }); - const sshFs = new SshFileSystem(sshProxy, params.path); - const pathEntry = await sshFs.stat(''); - if (!pathEntry || pathEntry.type !== 'dir') { - return err({ - type: 'invalid-directory', - path: params.path, - message: 'Invalid directory', - }); + let gitInfo; + try { + const pathEntry = await statAbsolute(runtimeLease.value.files, params.path); + if (!pathEntry.success) { + const code = 'code' in pathEntry.error ? pathEntry.error.code : undefined; + if (!isFileNotFoundCode(code)) { + return err({ type: 'inspect-failed', path: params.path, message: pathEntry.error.message }); + } + return err({ type: 'invalid-directory', path: params.path, message: 'Invalid directory' }); + } + if (pathEntry.data.type !== 'directory') { + return err({ + type: 'invalid-directory', + path: params.path, + message: 'Invalid directory', + }); + } + + const repositoryResult = await ensureProjectRepository( + runtimeLease.value.git, + params.path, + params.initGitRepository + ); + if (!repositoryResult.success) return repositoryResult; + gitInfo = repositoryResult.data; + } finally { + await runtimeLease.release(); } - const repositoryResult = await withLease( - runtimeManager.acquire({ - kind: 'ssh', - connectionId: params.connectionId, - }), - (runtime) => ensureProjectRepository(runtime.git, params.path, params.initGitRepository) - ); - if (!repositoryResult.success) return repositoryResult; - const gitInfo = repositoryResult.data; const [row] = await db .insert(projects) @@ -91,15 +104,24 @@ export async function getSshProjectPathStatus( connectionId: string ): Promise { try { - const sshProxy = await sshConnectionManager.connect(connectionId); - const sshFs = new SshFileSystem(sshProxy, path); - const pathEntry = await sshFs.stat(''); - if (!pathEntry || pathEntry.type !== 'dir') { - return { isDirectory: false, isGitRepo: false }; - } - const runtimeLease = await runtimeManager.acquire({ kind: 'ssh', connectionId }); try { + const pathEntry = await statAbsolute(runtimeLease.value.files, path); + if (!pathEntry.success) { + const code = 'code' in pathEntry.error ? pathEntry.error.code : undefined; + if (isFileNotFoundCode(code)) { + return { isDirectory: false, isGitRepo: false }; + } + return { + isDirectory: false, + isGitRepo: false, + error: { type: 'inspect-failed', path, message: pathEntry.error.message }, + }; + } + if (pathEntry.data.type !== 'directory') { + return { isDirectory: false, isGitRepo: false }; + } + const inspection = await runtimeLease.value.git.inspectPath(path); if (inspection.kind === 'inspect-failed') { return { diff --git a/apps/emdash-desktop/src/main/core/projects/operations/createProject.test.ts b/apps/emdash-desktop/src/main/core/projects/operations/createProject.test.ts index 71fb5a921f..62a5f0d70d 100644 --- a/apps/emdash-desktop/src/main/core/projects/operations/createProject.test.ts +++ b/apps/emdash-desktop/src/main/core/projects/operations/createProject.test.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import type { Result } from '@emdash/shared'; +import { ok, type Result } from '@emdash/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createLocalProject, getLocalProjectPathStatus } from './create-local-project'; import { createSshProject, getSshProjectPathStatus } from './create-ssh-project'; @@ -20,8 +20,8 @@ const mocks = vi.hoisted(() => ({ insertMock: vi.fn(), valuesMock: vi.fn(), returningMock: vi.fn(), - sshConnectMock: vi.fn(), - sshStatMock: vi.fn(), + fileSystemMock: vi.fn(), + statMock: vi.fn(), })); vi.mock('@main/core/runtime/runtime-manager', () => ({ @@ -30,20 +30,6 @@ vi.mock('@main/core/runtime/runtime-manager', () => ({ }, })); -vi.mock('@main/core/fs/impl/ssh-fs', () => ({ - SshFileSystem: vi.fn(function MockSshFileSystem() { - return { - stat: mocks.sshStatMock, - }; - }), -})); - -vi.mock('@main/core/ssh/lifecycle/production-ssh-connection-manager', () => ({ - sshConnectionManager: { - connect: mocks.sshConnectMock, - }, -})); - vi.mock('@main/core/projects/project-manager', () => ({ projectManager: { openProject: mocks.openProjectMock, @@ -63,6 +49,24 @@ function expectOk(result: Result): T { return result.data; } +function makeFilesRuntime() { + return { + path: { + join: (...parts: string[]) => path.posix.join(...parts), + dirname: (value: string) => path.posix.dirname(value), + basename: (value: string) => path.posix.basename(value), + isAbsolute: (value: string) => path.posix.isAbsolute(value), + relative: (from: string, to: string) => path.posix.relative(from, to), + contains: () => true, + }, + fileSystem: mocks.fileSystemMock.mockImplementation(() => + ok({ + stat: mocks.statMock, + }) + ), + }; +} + beforeEach(() => { vi.clearAllMocks(); @@ -72,6 +76,7 @@ beforeEach(() => { mocks.getProjectMock.mockReturnValue(undefined); mocks.acquireRuntimeMock.mockResolvedValue({ value: { + files: makeFilesRuntime(), git: { ensureRepository: mocks.ensureRepositoryMock, inspectPath: mocks.inspectPathMock, @@ -98,8 +103,7 @@ beforeEach(() => { }); mocks.repoGetRefsMock.mockResolvedValue({ branches: [] }); mocks.repoGetDefaultBranchMock.mockResolvedValue('main'); - mocks.sshConnectMock.mockResolvedValue({ id: 'ssh-proxy' }); - mocks.sshStatMock.mockResolvedValue({ path: '', type: 'dir' }); + mocks.statMock.mockResolvedValue(ok({ path: 'worktree', type: 'directory' })); }); describe('createLocalProject', () => { @@ -384,6 +388,40 @@ describe('getLocalProjectPathStatus', () => { }); expect(mocks.inspectPathMock).toHaveBeenCalledWith(projectPath); }); + + it('does not inspect git status for local paths that are not directories', async () => { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-project-')); + tempDirs.push(projectPath); + mocks.statMock.mockResolvedValueOnce(ok({ path: path.basename(projectPath), type: 'file' })); + + const status = await getLocalProjectPathStatus(projectPath); + + expect(status).toEqual({ isDirectory: false, isGitRepo: false }); + expect(mocks.inspectPathMock).not.toHaveBeenCalled(); + }); + + it('returns local stat failures as inspection failures', async () => { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-project-')); + tempDirs.push(projectPath); + mocks.statMock.mockResolvedValueOnce({ + success: false, + error: { + type: 'fs-error', + path: projectPath, + message: 'Permission denied', + code: 'EACCES', + }, + }); + + const status = await getLocalProjectPathStatus(projectPath); + + expect(status).toEqual({ + isDirectory: false, + isGitRepo: false, + error: { type: 'inspect-failed', path: projectPath, message: 'Permission denied' }, + }); + expect(mocks.inspectPathMock).not.toHaveBeenCalled(); + }); }); describe('createSshProject', () => { @@ -415,11 +453,12 @@ describe('createSshProject', () => { }) ); - expect(mocks.sshStatMock).toHaveBeenCalledWith(''); expect(mocks.acquireRuntimeMock).toHaveBeenCalledWith({ kind: 'ssh', connectionId: 'connection-id', }); + expect(mocks.fileSystemMock).toHaveBeenCalledWith(); + expect(mocks.statMock).toHaveBeenCalledWith(projectPath); expect(mocks.ensureRepositoryMock).toHaveBeenCalledWith(projectPath, { initIfMissing: true, }); @@ -466,7 +505,7 @@ describe('createSshProject', () => { }); it('rejects invalid remote directories', async () => { - mocks.sshStatMock.mockResolvedValueOnce(null); + mocks.statMock.mockResolvedValueOnce(ok({ path: 'worktree', type: 'file' })); await expect( createSshProject({ @@ -526,7 +565,7 @@ describe('getSshProjectPathStatus', () => { const projectPath = '/remote/worktree'; it('returns invalid status when remote directory does not exist', async () => { - mocks.sshStatMock.mockResolvedValueOnce(null); + mocks.statMock.mockResolvedValueOnce(ok({ path: 'worktree', type: 'file' })); const status = await getSshProjectPathStatus(projectPath, 'connection-id'); diff --git a/apps/emdash-desktop/src/main/core/projects/path-utils.ts b/apps/emdash-desktop/src/main/core/projects/path-utils.ts index b9d7ad80fa..d4d02bd057 100644 --- a/apps/emdash-desktop/src/main/core/projects/path-utils.ts +++ b/apps/emdash-desktop/src/main/core/projects/path-utils.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { isFileNotFoundException } from '@emdash/core/files'; export type DirectoryStatus = | { kind: 'directory' } @@ -9,7 +10,7 @@ export function getDirectoryStatus(path: string): DirectoryStatus { try { return fs.statSync(path).isDirectory() ? { kind: 'directory' } : { kind: 'not-directory' }; } catch (error) { - if (isMissingPathError(error)) return { kind: 'not-directory' }; + if (isFileNotFoundException(error)) return { kind: 'not-directory' }; return { kind: 'inspect-failed', message: error instanceof Error ? error.message : String(error), @@ -20,9 +21,3 @@ export function getDirectoryStatus(path: string): DirectoryStatus { export function checkIsValidDirectory(path: string): boolean { return getDirectoryStatus(path).kind === 'directory'; } - -function isMissingPathError(error: unknown): boolean { - if (!error || typeof error !== 'object' || !('code' in error)) return false; - const code = (error as { code?: unknown }).code; - return code === 'ENOENT' || code === 'ENOTDIR'; -} diff --git a/apps/emdash-desktop/src/main/core/projects/project-manager.ts b/apps/emdash-desktop/src/main/core/projects/project-manager.ts index 15ea3e2576..c05a12bd98 100644 --- a/apps/emdash-desktop/src/main/core/projects/project-manager.ts +++ b/apps/emdash-desktop/src/main/core/projects/project-manager.ts @@ -58,7 +58,8 @@ class ProjectSessionManager createProvider(project), project.type === 'ssh' ? SSH_PROVIDER_TIMEOUT_MS : LOCAL_PROVIDER_TIMEOUT_MS ); - return ok(provider); + if (!provider.success) return err({ type: 'error', message: provider.error.message }); + return ok(provider.data); } catch (e) { const initError = toInitError(e); log.error('ProjectManager: error during project initialization', { diff --git a/apps/emdash-desktop/src/main/core/projects/project-provider.ts b/apps/emdash-desktop/src/main/core/projects/project-provider.ts index a99e41eb38..83522aab8e 100644 --- a/apps/emdash-desktop/src/main/core/projects/project-provider.ts +++ b/apps/emdash-desktop/src/main/core/projects/project-provider.ts @@ -1,3 +1,4 @@ +import type { IFileSystem } from '@emdash/core/files'; import type { FetchError, GitBranchRef, @@ -7,20 +8,20 @@ import type { } from '@emdash/core/git'; import type { IDisposable, IReleasable, Result } from '@emdash/shared'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { FileSystemProvider } from '@main/core/fs/types'; import type { GitRepositoryFetchService } from '@main/core/git/repository/fetch-service'; import type { GitRepositoryService } from '@main/core/git/repository/service'; import { previewServerService } from '@main/core/preview-servers/preview-server-service-instance'; import type { MachineRef } from '@main/core/runtime/types'; import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; +import type { SetupResult } from '@main/core/workspaces/workspace-setup-executor'; import type { WorkspaceProviderData } from '@shared/core/workspaces/workspace-provider-data'; +import type { WorkspaceSetupSpec } from '@shared/core/workspaces/workspace-setup-spec'; import type { ProjectRemoteState } from '@shared/projects'; import type { ConversationProvider } from '../conversations/types'; import { taskSessionManager } from '../tasks/task-session-manager'; import type { TerminalProvider } from '../terminals/terminal-provider'; import type { WorkspaceType } from '../workspaces/workspace-factory'; import type { ProjectSettingsProvider } from './settings/provider'; -import type { WorktreeHost } from './worktrees/hosts/worktree-host'; import type { WorktreeService } from './worktrees/worktree-service'; export type { WorkspaceProviderData }; @@ -44,6 +45,11 @@ export interface TaskProvider { readonly terminals: TerminalProvider; } +type RunWorkspaceSetup = (args: { + spec: WorkspaceSetupSpec; + worktreePoolPath: string; +}) => Promise; + /** * Transport-specific dependencies: the only things that differ between local and SSH. * Pure data — no lifecycle methods. @@ -54,9 +60,25 @@ export type ProjectProviderTransport = { readonly defaultWorkspaceType: WorkspaceType; readonly defaultWorkspaceMachine: MachineRef; readonly ctx: IExecutionContext; - readonly fs: FileSystemProvider; + readonly fileSystem: IFileSystem; + readonly projectConfigPath: string; + /** + * Transitional desktop-owned path helper. Remove once project config reads/writes + * are served by the workspace server/core boundary instead of main-process adapters. + */ + readonly resolveProjectPath: (relativePath: string) => string; + /** + * Transitional desktop-owned path helper. Remove with resolveProjectPath when + * config target resolution moves behind the workspace server/core boundary. + */ + readonly configPathForDirectory: (directoryPath: string) => string; + /** + * Transitional provisioning hook. Workspace setup currently still runs in the + * desktop app with direct access to the machine runtime; this should move behind + * the workspace server/core boundary and disappear from ProjectProvider. + */ + readonly runWorkspaceSetup: RunWorkspaceSetup; readonly settings: ProjectSettingsProvider; - readonly worktreeHost: WorktreeHost; }; export class ProjectProvider implements IReleasable, IDisposable { @@ -66,15 +88,18 @@ export class ProjectProvider implements IReleasable, IDisposable { readonly projectMachine: MachineRef; readonly settings: ProjectSettingsProvider; readonly gitRepository: GitRepositoryService; - readonly fs: FileSystemProvider; + readonly fileSystem: IFileSystem; + readonly projectConfigPath: string; readonly worktreeService: WorktreeService; readonly gitRepositoryFetchService: GitRepositoryFetchService; /** Workspace type for standard worktree tasks. BYOI tasks use their own remote workspace type. */ readonly defaultWorkspaceType: WorkspaceType; readonly defaultWorkspaceMachine: MachineRef; - readonly worktreeHost: WorktreeHost; private readonly _ctx: IExecutionContext; + private readonly _resolveProjectPath: (relativePath: string) => string; + private readonly _configPathForDirectory: (directoryPath: string) => string; + private readonly _runWorkspaceSetup: RunWorkspaceSetup; constructor( projectId: string, @@ -92,19 +117,43 @@ export class ProjectProvider implements IReleasable, IDisposable { this.projectMachine = transport.projectMachine; this._ctx = transport.ctx; this.settings = transport.settings; - this.fs = transport.fs; + this.fileSystem = transport.fileSystem; + this.projectConfigPath = transport.projectConfigPath; + this._resolveProjectPath = transport.resolveProjectPath; + this._configPathForDirectory = transport.configPathForDirectory; + this._runWorkspaceSetup = transport.runWorkspaceSetup; this.gitRepository = gitRepository; this.worktreeService = worktreeService; this.gitRepositoryFetchService = gitRepositoryFetchService; this.defaultWorkspaceType = transport.defaultWorkspaceType; this.defaultWorkspaceMachine = transport.defaultWorkspaceMachine; - this.worktreeHost = transport.worktreeHost; } get ctx(): IExecutionContext { return this._ctx; } + /** + * Transitional desktop-owned path helper. See ProjectProviderTransport. + */ + resolveProjectPath(relativePath: string): string { + return this._resolveProjectPath(relativePath); + } + + /** + * Transitional desktop-owned path helper. See ProjectProviderTransport. + */ + configPathForDirectory(directoryPath: string): string { + return this._configPathForDirectory(directoryPath); + } + + /** + * Transitional provisioning hook. See ProjectProviderTransport. + */ + runWorkspaceSetup(spec: WorkspaceSetupSpec, worktreePoolPath: string): Promise { + return this._runWorkspaceSetup({ spec, worktreePoolPath }); + } + getRemoteState(): Promise { return this.gitRepository.getRemoteState(); } diff --git a/apps/emdash-desktop/src/main/core/projects/settings/effective-task-settings.test.ts b/apps/emdash-desktop/src/main/core/projects/settings/effective-task-settings.test.ts index 98591d0016..213278f03b 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/effective-task-settings.test.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/effective-task-settings.test.ts @@ -1,5 +1,6 @@ +import type { IFileSystem } from '@emdash/core/files'; +import { ok } from '@emdash/shared'; import { describe, expect, it, vi } from 'vitest'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { getEffectiveTaskSettings } from './effective-task-settings'; import type { ProjectSettingsProvider } from './provider'; @@ -9,32 +10,40 @@ function makeProjectSettings(settings: Awaited { return { - exists: vi.fn().mockResolvedValue(config !== null), - read: vi.fn().mockResolvedValue({ - content: JSON.stringify(config), - truncated: false, - totalSize: 0, - }), - } as unknown as FileSystemProvider; + exists: vi.fn(async () => ok(config !== null)), + readText: vi.fn(async () => + ok({ + content: JSON.stringify(config), + truncated: false, + totalSize: 0, + }) + ), + }; } describe('getEffectiveTaskSettings', () => { + const taskConfigPath = '/worktree/.emdash.json'; + it('merges shareable project settings by leaf with project settings winning', async () => { + const taskFs = makeTaskFs({ + scripts: { setup: 'pnpm install', run: 'npm run dev' }, + shellSetup: 'source .envrc', + tmux: true, + remote: 'upstream', + }); const settings = await getEffectiveTaskSettings({ projectSettings: makeProjectSettings({ preservePatterns: ['.env.local'], scripts: { run: 'pnpm dev' }, }), - taskFs: makeTaskFs({ - scripts: { setup: 'pnpm install', run: 'npm run dev' }, - shellSetup: 'source .envrc', - tmux: true, - remote: 'upstream', - }), + taskFs, + taskConfigPath, }); + expect(taskFs.exists).toHaveBeenCalledWith(taskConfigPath); + expect(taskFs.readText).toHaveBeenCalledWith(taskConfigPath); expect(settings).toMatchObject({ preservePatterns: ['.env.local'], shellSetup: 'source .envrc', @@ -52,9 +61,10 @@ describe('getEffectiveTaskSettings', () => { const settings = await getEffectiveTaskSettings({ projectSettings: makeProjectSettings({ shellSetup: 'nvm use' }), taskFs: { - exists: vi.fn().mockResolvedValue(true), - read: vi.fn().mockResolvedValue({ content: '{', truncated: false, totalSize: 1 }), - } as unknown as FileSystemProvider, + exists: vi.fn(async () => ok(true)), + readText: vi.fn(async () => ok({ content: '{', truncated: false, totalSize: 1 })), + }, + taskConfigPath, }); expect(settings.preservePatterns).toContain('.env'); @@ -62,12 +72,30 @@ describe('getEffectiveTaskSettings', () => { expect(settings.shellSetup).toBe('nvm use'); }); + it('falls back to project settings when the task config read is truncated', async () => { + const settings = await getEffectiveTaskSettings({ + projectSettings: makeProjectSettings({ + scripts: { run: 'pnpm dev' }, + }), + taskFs: { + exists: vi.fn(async () => ok(true)), + readText: vi.fn(async () => + ok({ content: '{"scripts":', truncated: true, totalSize: 204_801 }) + ), + }, + taskConfigPath, + }); + + expect(settings.scripts?.run).toBe('pnpm dev'); + }); + it('falls back to defaults when project settings are invalid', async () => { const settings = await getEffectiveTaskSettings({ projectSettings: makeProjectSettings({ preservePatterns: 'not-an-array', } as never), taskFs: makeTaskFs(null), + taskConfigPath, }); expect(settings.preservePatterns).toContain('.env'); diff --git a/apps/emdash-desktop/src/main/core/projects/settings/effective-task-settings.ts b/apps/emdash-desktop/src/main/core/projects/settings/effective-task-settings.ts index 8b68e57cce..9d8337f6eb 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/effective-task-settings.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/effective-task-settings.ts @@ -1,4 +1,4 @@ -import type { FileSystemProvider } from '@main/core/fs/types'; +import type { IFileSystem } from '@emdash/core/files'; import { log } from '@main/lib/logger'; import { defaultShareableProjectSettings, @@ -10,20 +10,38 @@ import type { ProjectSettingsProvider } from './provider'; export async function getEffectiveTaskSettings(args: { projectSettings: ProjectSettingsProvider; - taskFs: FileSystemProvider; + taskFs: Pick; + taskConfigPath: string; }): Promise { - const { projectSettings, taskFs } = args; + const { projectSettings, taskFs, taskConfigPath } = args; const parsedSettings = shareableProjectSettingsSchema.safeParse(await projectSettings.get()); const localShareableSettings = parsedSettings.success ? parsedSettings.data : {}; const defaults = defaultShareableProjectSettings(); - const exists = await taskFs.exists('.emdash.json'); - if (!exists) { + const exists = await taskFs.exists(taskConfigPath); + if (!exists.success) { + log.warn('Failed to check task .emdash.json, falling back to project settings', exists.error); + return mergeShareableProjectSettings(defaults, localShareableSettings); + } + if (!exists.data) { return mergeShareableProjectSettings(defaults, localShareableSettings); } try { - const { content } = await taskFs.read('.emdash.json'); - const projectFileSettings = shareableProjectSettingsSchema.parse(JSON.parse(content)); + const content = await taskFs.readText(taskConfigPath); + if (!content.success) { + log.warn('Failed to read task .emdash.json, falling back to project settings', content.error); + return mergeShareableProjectSettings(defaults, localShareableSettings); + } + if (content.data.truncated) { + log.warn('Task .emdash.json was truncated, falling back to project settings', { + path: taskConfigPath, + totalSize: content.data.totalSize, + }); + return mergeShareableProjectSettings(defaults, localShareableSettings); + } + const projectFileSettings = shareableProjectSettingsSchema.parse( + JSON.parse(content.data.content) + ); return mergeShareableProjectSettings(defaults, projectFileSettings, localShareableSettings); } catch (err) { log.warn('Failed to parse task .emdash.json, falling back to project settings', err); diff --git a/apps/emdash-desktop/src/main/core/projects/settings/legacy-project-settings-migration.ts b/apps/emdash-desktop/src/main/core/projects/settings/legacy-project-settings-migration.ts index 7a55263774..9657a043cf 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/legacy-project-settings-migration.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/legacy-project-settings-migration.ts @@ -1,5 +1,5 @@ +import type { IFileSystem } from '@emdash/core/files'; import type { Result } from '@emdash/shared'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { log } from '@main/lib/logger'; import { remoteNameFromQualifiedRef } from '@shared/core/git/utils'; import { @@ -22,7 +22,8 @@ import type { ProjectSettingsStorage, StoredProjectSettings } from './project-se export type LegacyProjectSettingsMigrationArgs = { projectId: string; row: StoredProjectSettings | undefined; - configReader: Pick | undefined; + configReader: Pick | undefined; + configPath: string; defaultBranchFallback: string; storage: ProjectSettingsStorage; git?: ProjectSettingsGitInspector; @@ -49,7 +50,8 @@ function normalizeLegacyDefaultBranch( } async function readLegacyProjectConfig( - configReader: Pick | undefined + configReader: Pick | undefined, + configPath: string ): Promise< | (BaseProjectSettings & { remote?: string; @@ -58,9 +60,25 @@ async function readLegacyProjectConfig( > { if (!configReader) return undefined; try { - if (!(await configReader.exists('.emdash.json'))) return undefined; - const { content } = await configReader.read('.emdash.json'); - const parsed = legacyProjectConfigSchema.safeParse(parseJsonObject(content)); + const exists = await configReader.exists(configPath); + if (!exists.success) { + log.warn('Failed to check legacy .emdash.json for migration', exists.error); + return undefined; + } + if (!exists.data) return undefined; + const content = await configReader.readText(configPath); + if (!content.success) { + log.warn('Failed to read legacy .emdash.json for migration', content.error); + return undefined; + } + if (content.data.truncated) { + log.warn('Legacy .emdash.json was truncated during migration', { + path: configPath, + totalSize: content.data.totalSize, + }); + return undefined; + } + const parsed = legacyProjectConfigSchema.safeParse(parseJsonObject(content.data.content)); if (!parsed.success) { log.warn('Failed to parse legacy .emdash.json for migration', parsed.error); return undefined; @@ -76,6 +94,7 @@ export async function migrateLegacyProjectSettingsIfNeeded({ projectId, row, configReader, + configPath, defaultBranchFallback, storage, git, @@ -100,7 +119,7 @@ export async function migrateLegacyProjectSettingsIfNeeded({ 'shareable project settings' ); const { remote, ...currentSettings } = current; - const legacy = await readLegacyProjectConfig(configReader); + const legacy = await readLegacyProjectConfig(configReader, configPath); const next: BaseProjectSettings = baseProjectSettingsSchema.parse({ ...currentSettings, baseRemote: currentSettings.baseRemote ?? remote, @@ -129,7 +148,7 @@ export async function migrateLegacyProjectSettingsIfNeeded({ } if (legacy && !shareableAlreadyMigrated) { - if ((await git?.isFileCleanlyTracked('.emdash.json')) === false) { + if ((await git?.isFileCleanlyTracked(configPath)) === false) { const legacyShareable = shareableProjectSettingsSchema.parse(legacy); nextShareable = mergeShareableProjectSettings(currentShareable, legacyShareable); } diff --git a/apps/emdash-desktop/src/main/core/projects/settings/preserve-pattern-safety.ts b/apps/emdash-desktop/src/main/core/projects/settings/preserve-pattern-safety.ts new file mode 100644 index 0000000000..581bd70ed3 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/projects/settings/preserve-pattern-safety.ts @@ -0,0 +1,34 @@ +import type { RuntimePath } from '@main/core/runtime/types'; + +export function isSafePreservePattern(machinePath: RuntimePath, pattern: string): boolean { + const trimmed = pattern.trim(); + if (!trimmed) return false; + if (looksAbsolute(machinePath, trimmed)) return false; + return !trimmed.replace(/\\/g, '/').split('/').includes('..'); +} + +export function preservedRepoRelativePath( + machinePath: RuntimePath, + repoPath: string, + absPath: string +): string | null { + if (!machinePath.contains(repoPath, absPath)) return null; + const relPath = machinePath.relative(repoPath, absPath).replace(/\\/g, '/'); + if (!relPath || relPath === '.emdash.json') return null; + if (relPath === '..' || relPath.startsWith('../') || looksAbsolute(machinePath, relPath)) + return null; + return relPath; +} + +export function preservedDestinationPath( + machinePath: RuntimePath, + targetPath: string, + relPath: string +): string | null { + const destPath = machinePath.join(targetPath, relPath); + return machinePath.contains(targetPath, destPath) ? destPath : null; +} + +function looksAbsolute(machinePath: RuntimePath, value: string): boolean { + return machinePath.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\'); +} diff --git a/apps/emdash-desktop/src/main/core/projects/settings/project-settings-service.ts b/apps/emdash-desktop/src/main/core/projects/settings/project-settings-service.ts index fd4f1a81f9..68b433db61 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/project-settings-service.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/project-settings-service.ts @@ -145,7 +145,7 @@ export class ProjectSettingsService implements Hookable, I const overrideState = await computeProjectSettingsOverrideState(resolvedTargets); const configMigrations = hasConfiguredShareableProjectSettings(settings) ? [] - : await inspectProjectConfigMigrations(project.fs); + : await inspectProjectConfigMigrations(project); return { settings, defaults, diff --git a/apps/emdash-desktop/src/main/core/projects/settings/project-settings.test.ts b/apps/emdash-desktop/src/main/core/projects/settings/project-settings.test.ts index 5fbf509968..6d6bbace5f 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/project-settings.test.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/project-settings.test.ts @@ -2,9 +2,10 @@ import { randomUUID } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import type { IFileSystem } from '@emdash/core/files'; +import { err, ok } from '@emdash/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import { DEFAULT_PRESERVE_PATTERNS } from '@shared/core/project-settings/project-settings'; import type { ProjectSettingsStorage } from './project-settings-storage'; import { LocalProjectSettingsProvider } from './providers/local-project-settings-provider'; @@ -20,6 +21,62 @@ function makeTrackingGit(isFileCleanlyTracked: boolean) { }; } +const projectId = () => `project-${randomUUID()}`; + +function makeLocalConfigReader(projectPath: string): Pick { + const resolvePath = (filePath: string) => + path.isAbsolute(filePath) ? filePath : path.join(projectPath, filePath); + return { + exists: vi.fn(async (filePath: string) => ok(fs.existsSync(resolvePath(filePath)))), + readText: vi.fn(async (filePath: string) => { + try { + const content = fs.readFileSync(resolvePath(filePath), 'utf8'); + return ok({ content, truncated: false, totalSize: Buffer.byteLength(content) }); + } catch { + return err({ + type: 'fs-error' as const, + path: filePath, + message: `File not found: ${filePath}`, + code: 'ENOENT', + }); + } + }), + }; +} + +function makeLocalProvider( + projectPath: string, + options?: ConstructorParameters[4] +): LocalProjectSettingsProvider { + return new LocalProjectSettingsProvider( + projectId(), + projectPath, + 'main', + makeLocalConfigReader(projectPath), + options + ); +} + +function makeSshConfigReader( + config: unknown | null = null +): Pick { + return { + exists: vi.fn(async () => ok(config !== null)), + readText: vi.fn(async (filePath: string) => { + if (config === null) { + return err({ + type: 'fs-error' as const, + path: filePath, + message: `File not found: ${filePath}`, + code: 'NOT_FOUND', + }); + } + const content = JSON.stringify(config); + return ok({ content, truncated: false, totalSize: Buffer.byteLength(content) }); + }), + }; +} + vi.mock('@main/core/settings/settings-service', () => ({ appSettingsService: { get: vi.fn().mockImplementation((key: string) => { @@ -72,8 +129,6 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }; }; - const projectId = () => `project-${randomUUID()}`; - beforeEach(() => { storageMockState.storage = createStorage(); }); @@ -89,7 +144,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); await expect(provider.get()).resolves.toMatchObject({ preservePatterns: [...DEFAULT_PRESERVE_PATTERNS], @@ -104,7 +159,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { JSON.stringify({ shellSetup: 'nvm use' }) ); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); await expect(provider.get()).resolves.toMatchObject({ preservePatterns: [...DEFAULT_PRESERVE_PATTERNS], @@ -119,7 +174,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { JSON.stringify({ preservePatterns: ['.env.shared'] }) ); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); await expect(provider.get()).resolves.not.toHaveProperty('preservePatterns'); }); @@ -140,9 +195,8 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }) ); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main', { - git: makeTrackingGit(false), - }); + const git = makeTrackingGit(false); + const provider = makeLocalProvider(projectPath, { git }); await expect(provider.get()).resolves.toMatchObject({ preservePatterns: ['.env.local'], @@ -153,6 +207,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { teardown: 'pnpm cleanup', }, }); + expect(git.isFileCleanlyTracked).toHaveBeenCalledWith(path.join(projectPath, '.emdash.json')); }); it('migrates local-only shareable settings for rows already base-migrated', async () => { @@ -181,9 +236,8 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }, }; storageMockState.storage = settingsStorage; - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main', { - git: makeTrackingGit(false), - }); + const git = makeTrackingGit(false); + const provider = makeLocalProvider(projectPath, { git }); await expect(provider.get()).resolves.toMatchObject({ shellSetup: 'nvm use', @@ -192,6 +246,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { run: 'pnpm dev', }, }); + expect(git.isFileCleanlyTracked).toHaveBeenCalledWith(path.join(projectPath, '.emdash.json')); const result = await provider.update({ preservePatterns: [] }); expect(result.success).toBe(true); @@ -213,22 +268,22 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }) ); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main', { - git: makeTrackingGit(true), - }); + const git = makeTrackingGit(true); + const provider = makeLocalProvider(projectPath, { git }); await expect(provider.get()).resolves.toMatchObject({ preservePatterns: [...DEFAULT_PRESERVE_PATTERNS], }); await expect(provider.get()).resolves.not.toHaveProperty('shellSetup'); await expect(provider.get()).resolves.not.toHaveProperty('scripts'); + expect(git.isFileCleanlyTracked).toHaveBeenCalledWith(path.join(projectPath, '.emdash.json')); }); it('does not seed computed worktreeDirectory into project settings', async () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); await expect(provider.get()).resolves.not.toHaveProperty('worktreeDirectory'); await expect(provider.getDefaultWorktreeDirectory()).resolves.toBe('/tmp/emdash/worktrees'); @@ -251,7 +306,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }, }; storageMockState.storage = settingsStorage; - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); await expect(provider.get()).resolves.toMatchObject({ baseRemote: 'upstream' }); expect(JSON.parse(row.baseProjectSettingsJson)).toEqual({ baseRemote: 'upstream' }); @@ -260,7 +315,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { it('keeps computed worktreeDirectory default separate from configured overrides', async () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const expectedOverridePath = path.resolve(projectPath, 'worktrees'); const result = await provider.update({ preservePatterns: [], @@ -277,7 +332,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { it('stores the selected GitHub account as base project settings', async () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const result = await provider.update({ preservePatterns: [], @@ -291,7 +346,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { it('stores null GitHub account selection as an explicit project override', async () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const result = await provider.update({ preservePatterns: [], @@ -324,7 +379,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }, }; storageMockState.storage = settingsStorage; - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const result = await provider.patch({ githubAccountId: 'github.com:42' }); @@ -363,7 +418,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }, }; storageMockState.storage = settingsStorage; - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); await expect(provider.ensure()).rejects.toThrow('db write failed'); await expect(provider.ensure()).resolves.toBeUndefined(); @@ -396,7 +451,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }, }; storageMockState.storage = settingsStorage; - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const result = await provider.patch({ clearShareableFields: ['preservePatterns', 'scripts.run'], @@ -414,7 +469,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const expectedPath = path.resolve(projectPath, 'worktrees'); const result = await provider.update({ preservePatterns: [], worktreeDirectory: expectedPath }); expect(result.success).toBe(true); @@ -430,7 +485,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const result = await provider.update({ preservePatterns: [], worktreeDirectory: 'worktrees' }); expect(result).toEqual({ @@ -443,7 +498,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const foreignPath = process.platform === 'win32' ? '/tmp/worktrees' : 'C:\\worktrees'; const result = await provider.update({ preservePatterns: [], worktreeDirectory: foreignPath }); @@ -458,7 +513,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { tempDirs.push(projectPath); fs.writeFileSync(path.join(projectPath, 'not-a-directory'), 'file'); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const result = await provider.update({ preservePatterns: [], worktreeDirectory: path.join(projectPath, 'not-a-directory', 'worktrees'), @@ -473,7 +528,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-settings-local-')); tempDirs.push(projectPath); - const provider = new LocalProjectSettingsProvider(projectId(), projectPath, 'main'); + const provider = makeLocalProvider(projectPath); const result = await provider.update({ preservePatterns: [], worktreeDirectory: ' ' }); expect(result.success).toBe(true); @@ -481,12 +536,10 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }); it('normalizes and canonicalizes ssh absolute worktreeDirectory on update', async () => { - const projectFs = { - exists: vi.fn().mockResolvedValue(false), - } as unknown as SshFileSystem; + const projectFs = makeSshConfigReader(); const rootFs = { - mkdir: vi.fn().mockResolvedValue(undefined), - realPath: vi.fn().mockResolvedValue('/canonical/ssh-worktrees'), + mkdir: vi.fn().mockResolvedValue(ok()), + realPath: vi.fn().mockResolvedValue(ok('/canonical/ssh-worktrees')), }; const provider = new SshProjectSettingsProvider( @@ -512,12 +565,10 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }); it('rejects ssh relative worktreeDirectory values', async () => { - const projectFs = { - exists: vi.fn().mockResolvedValue(false), - } as unknown as SshFileSystem; + const projectFs = makeSshConfigReader(); const rootFs = { - mkdir: vi.fn().mockResolvedValue(undefined), - realPath: vi.fn().mockResolvedValue('/canonical/ssh-worktrees'), + mkdir: vi.fn().mockResolvedValue(ok()), + realPath: vi.fn().mockResolvedValue(ok('/canonical/ssh-worktrees')), }; const provider = new SshProjectSettingsProvider( @@ -538,9 +589,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }); it('uses project-scoped ssh default worktree directory when not configured', async () => { - const projectFs = { - exists: vi.fn().mockResolvedValue(false), - } as unknown as SshFileSystem; + const projectFs = makeSshConfigReader(); const provider = new SshProjectSettingsProvider( projectId(), @@ -554,12 +603,10 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }); it('rejects tilde worktreeDirectory for ssh projects', async () => { - const projectFs = { - exists: vi.fn().mockResolvedValue(false), - } as unknown as SshFileSystem; + const projectFs = makeSshConfigReader(); const rootFs = { - mkdir: vi.fn().mockResolvedValue(undefined), - realPath: vi.fn().mockResolvedValue('/canonical/ssh-worktrees'), + mkdir: vi.fn().mockResolvedValue(ok()), + realPath: vi.fn().mockResolvedValue(ok('/canonical/ssh-worktrees')), }; const provider = new SshProjectSettingsProvider( @@ -581,12 +628,7 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }); it('falls back to project-scoped ssh default when configured directory is invalid', async () => { - const projectFs = { - exists: vi.fn().mockResolvedValue(true), - read: vi.fn().mockResolvedValue({ - content: JSON.stringify({ worktreeDirectory: '~/worktrees' }), - }), - } as unknown as SshFileSystem; + const projectFs = makeSshConfigReader({ worktreeDirectory: '~/worktrees' }); const provider = new SshProjectSettingsProvider( projectId(), @@ -600,12 +642,10 @@ describe('ProjectSettingsProvider worktreeDirectory validation', () => { }); it('expands and caches ssh home for tilde worktreeDirectory values', async () => { - const projectFs = { - exists: vi.fn().mockResolvedValue(false), - } as unknown as SshFileSystem; + const projectFs = makeSshConfigReader(); const rootFs = { - mkdir: vi.fn().mockResolvedValue(undefined), - realPath: vi.fn().mockResolvedValue('/canonical/ssh-worktrees'), + mkdir: vi.fn().mockResolvedValue(ok()), + realPath: vi.fn().mockResolvedValue(ok('/canonical/ssh-worktrees')), }; const ctx = { root: undefined, diff --git a/apps/emdash-desktop/src/main/core/projects/settings/providers/db-project-settings-provider.ts b/apps/emdash-desktop/src/main/core/projects/settings/providers/db-project-settings-provider.ts index d41cf276f6..a2be540c3c 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/providers/db-project-settings-provider.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/providers/db-project-settings-provider.ts @@ -1,5 +1,5 @@ +import type { IFileSystem } from '@emdash/core/files'; import { err, ok, type Result } from '@emdash/shared'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { appSettingsService } from '@main/core/settings/settings-service'; import { log } from '@main/lib/logger'; import { remoteNameFromQualifiedRef } from '@shared/core/git/utils'; @@ -37,7 +37,8 @@ export abstract class DbProjectSettingsProvider implements ProjectSettingsProvid private readonly projectId: string, protected readonly projectPath: string, protected readonly defaultBranchFallback: string = 'main', - private readonly configReader: Pick | undefined, + private readonly configReader: Pick | undefined, + private readonly joinProjectPath: (rootPath: string, relPath: string) => string, private readonly options: DbProjectSettingsProviderOptions = {} ) {} @@ -63,10 +64,22 @@ export abstract class DbProjectSettingsProvider implements ProjectSettingsProvid private async hasSharedPreservePatterns(): Promise { if (!this.configReader) return false; + const configPath = this.projectFilePath(CONFIG_FILE); try { - if (!(await this.configReader.exists(CONFIG_FILE))) return false; - const { content } = await this.configReader.read(CONFIG_FILE); - const parsed = shareableProjectSettingsSchema.safeParse(parseJsonObject(content)); + const exists = await this.configReader.exists(configPath); + if (!exists.success || !exists.data) return false; + const content = await this.configReader.readText(configPath); + if (!content.success) return false; + if (content.data.truncated) { + log.warn('Shared project settings were truncated during initialization', { + path: configPath, + totalSize: content.data.totalSize, + }); + return false; + } + const parsed = shareableProjectSettingsSchema.safeParse( + parseJsonObject(content.data.content) + ); if (!parsed.success) { log.warn('Failed to inspect shared project settings during initialization', parsed.error); return false; @@ -78,6 +91,10 @@ export abstract class DbProjectSettingsProvider implements ProjectSettingsProvid } } + private projectFilePath(relPath: string): string { + return this.joinProjectPath(this.projectPath, relPath); + } + private async ensureRow(): Promise { if (await this.storage.get(this.projectId)) return; @@ -140,6 +157,7 @@ export abstract class DbProjectSettingsProvider implements ProjectSettingsProvid projectId: this.projectId, row, configReader: this.configReader, + configPath: this.projectFilePath(CONFIG_FILE), defaultBranchFallback: this.defaultBranchFallback, storage: this.storage, git, diff --git a/apps/emdash-desktop/src/main/core/projects/settings/providers/local-project-settings-provider.ts b/apps/emdash-desktop/src/main/core/projects/settings/providers/local-project-settings-provider.ts index 0cc1129f7c..2574a56f21 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/providers/local-project-settings-provider.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/providers/local-project-settings-provider.ts @@ -1,7 +1,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import type { Result } from '@emdash/shared'; +import type { IFileSystem } from '@emdash/core/files'; +import { err, ok, type Result } from '@emdash/shared'; import { appSettingsService } from '@main/core/settings/settings-service'; import type { UpdateProjectSettingsError } from '@shared/projects'; import { @@ -24,21 +25,10 @@ export class LocalProjectSettingsProvider extends DbProjectSettingsProvider { projectId: string, projectPath: string, defaultBranchFallback: string = 'main', + configReader: Pick, options: DbProjectSettingsProviderOptions = {} ) { - super( - projectId, - projectPath, - defaultBranchFallback, - { - exists: async (filePath) => fs.existsSync(path.join(projectPath, filePath)), - read: async (filePath) => { - const content = await fs.promises.readFile(path.join(projectPath, filePath), 'utf8'); - return { content, truncated: false, totalSize: Buffer.byteLength(content) }; - }, - }, - options - ); + super(projectId, projectPath, defaultBranchFallback, configReader, path.join, options); } protected defaultWorktreeDirectory(): Promise { @@ -53,9 +43,20 @@ export class LocalProjectSettingsProvider extends DbProjectSettingsProvider { pathPlatform: localPathPlatform, fs: { mkdir: async (p, options) => { - await fs.promises.mkdir(p, options); + try { + await fs.promises.mkdir(p, options); + return ok(); + } catch (error: unknown) { + return err({ message: error instanceof Error ? error.message : String(error) }); + } + }, + realPath: async (p) => { + try { + return ok(await fs.promises.realpath(p)); + } catch (error: unknown) { + return err({ message: error instanceof Error ? error.message : String(error) }); + } }, - realPath: async (p) => fs.promises.realpath(p), }, homeDirectory: os.homedir(), }); diff --git a/apps/emdash-desktop/src/main/core/projects/settings/providers/ssh-project-settings-provider.ts b/apps/emdash-desktop/src/main/core/projects/settings/providers/ssh-project-settings-provider.ts index 4ceabbe097..76a551daf0 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/providers/ssh-project-settings-provider.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/providers/ssh-project-settings-provider.ts @@ -1,8 +1,7 @@ import path from 'node:path'; +import type { IFileSystem } from '@emdash/core/files'; import { err, ok, type Result } from '@emdash/shared'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { getDefaultSshWorktreeDirectory } from '@main/core/settings/worktree-defaults'; import { resolveRemoteHome } from '@main/core/ssh/lifecycle/remote-shell-profile'; import type { UpdateProjectSettingsError } from '@shared/projects'; @@ -21,14 +20,20 @@ export class SshProjectSettingsProvider extends DbProjectSettingsProvider { constructor( projectId: string, - private readonly fs: SshFileSystem, + fs: Pick, defaultBranchFallback: string = 'main', - private readonly rootFs?: Pick, + private readonly rootFs?: { + mkdir( + path: string, + options?: { recursive?: boolean } + ): Promise>; + realPath(path: string): Promise>; + }, projectPath: string = '/', private readonly ctx?: IExecutionContext, options: DbProjectSettingsProviderOptions = {} ) { - super(projectId, projectPath, defaultBranchFallback, fs, options); + super(projectId, projectPath, defaultBranchFallback, fs, path.posix.join, options); } private async getHomeDirectory(): Promise> { diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/codex-config-migration.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/codex-config-migration.ts index 194fab0d1e..72c0edfb53 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/codex-config-migration.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/codex-config-migration.ts @@ -1,7 +1,7 @@ -import { err, ok, type Result } from '@emdash/shared'; +import type { IFileSystem } from '@emdash/core/files'; +import type { Result } from '@emdash/shared'; import * as toml from 'smol-toml'; import z from 'zod'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { log } from '@main/lib/logger'; import { type MigrateProjectConfigRequest, @@ -9,11 +9,18 @@ import { type ShareableProjectSettings, type ShareableProjectSettingsWriteField, } from '@shared/core/project-settings/project-settings'; -import { mergeShareableProjectSettings } from '@shared/core/project-settings/project-settings-fields'; import type { UpdateProjectSettingsError } from '@shared/projects'; import type { ProjectProvider } from '../../project-provider'; import type { ProjectConfigMigrator } from './config-migration'; -import { CONFIG_FILE } from './workspace-config-file'; +import { + addScript, + applyProjectConfigMigration, + errorMessage, + openProjectFileSystem, + projectPath, + trimmedText, + writeConfigFailed, +} from './config-migration-utils'; const CODEX_ENVIRONMENT_FILE = '.codex/environments/environment.toml'; @@ -46,35 +53,6 @@ type CodexMigrationData = { unsupportedFields: string[]; }; -function writeConfigFailed(message: string): Result { - return err({ type: 'write-config-failed', message }); -} - -function trimmedText(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} - -function setScript( - settings: ShareableProjectSettings, - field: ShareableProjectSettingsWriteField, - value: string -): void { - settings.scripts ??= {}; - if (field === 'scripts.setup') settings.scripts.setup = value; - if (field === 'scripts.teardown') settings.scripts.teardown = value; -} - -function addScript( - data: CodexMigrationData, - field: ShareableProjectSettingsWriteField, - value: string | undefined -): void { - if (!value) return; - setScript(data.settings, field, value); - data.fields.push(field); -} - function actionLabel(action: z.infer, index: number): string { const name = action.name?.trim(); return name ? name : String(index); @@ -105,7 +83,8 @@ function toCodexMigration(data: CodexMigrationData): ProjectConfigMigration | nu } async function readCodexMigrationData( - fs: Pick + project: ProjectProvider, + fileSystem: IFileSystem ): Promise { const data: CodexMigrationData = { settings: {}, @@ -114,10 +93,27 @@ async function readCodexMigrationData( unsupportedFields: [], }; - if (!(await fs.exists(CODEX_ENVIRONMENT_FILE))) return data; + const environmentPath = projectPath(project, CODEX_ENVIRONMENT_FILE); + const exists = await fileSystem.exists(environmentPath); + if (!exists.success) { + log.warn('Failed to inspect Codex environment file for migration', exists.error); + return data; + } + if (!exists.data) return data; - const { content } = await fs.read(CODEX_ENVIRONMENT_FILE); - const codexEnvironment = codexEnvironmentSchema.parse(toml.parse(content)); + const content = await fileSystem.readText(environmentPath); + if (!content.success) { + log.warn('Failed to read Codex environment file for migration', content.error); + return data; + } + if (content.data.truncated) { + log.warn('Codex environment file was truncated during migration', { + path: environmentPath, + totalSize: content.data.totalSize, + }); + return data; + } + const codexEnvironment = codexEnvironmentSchema.parse(toml.parse(content.data.content)); data.files.push(CODEX_ENVIRONMENT_FILE); addScript(data, 'scripts.setup', trimmedText(codexEnvironment.setup?.script)); @@ -132,47 +128,25 @@ async function migrateCodexConfig( request: MigrateProjectConfigRequest ): Promise> { try { - const data = await readCodexMigrationData(project.fs); + const fileSystem = openProjectFileSystem(project); + if (!fileSystem.success) return fileSystem; + + const data = await readCodexMigrationData(project, fileSystem.data); const migration = toCodexMigration(data); if (!migration) { return writeConfigFailed('No supported Codex settings were found.'); } - if (request.destination === 'local') { - const currentSettings = await project.settings.get(); - const shareableSettings = mergeShareableProjectSettings(currentSettings, data.settings); - const updateResult = await project.settings.update({ - ...currentSettings, - ...shareableSettings, - }); - if (!updateResult.success) return updateResult; - return ok(migration); - } - - const writeResult = await project.fs.write( - CONFIG_FILE, - `${JSON.stringify(data.settings, null, 2)}\n` - ); - if (!writeResult.success) { - log.warn('Failed to write migrated project config file', writeResult.error); - return writeConfigFailed(writeResult.error ?? `Failed to write ${CONFIG_FILE}.`); - } - - const clearResult = await project.settings.patch({ clearShareableFields: data.fields }); - if (!clearResult.success) { - log.warn('Failed to clear imported local project settings', clearResult.error); - return writeConfigFailed(`Wrote ${CONFIG_FILE}, but failed to clear local project settings.`); - } - - return ok(migration); + return await applyProjectConfigMigration(project, request, data, migration); } catch (error) { log.warn('Failed to migrate Codex config to project config', error); - return writeConfigFailed(error instanceof Error ? error.message : String(error)); + return writeConfigFailed(errorMessage(error)); } } export const codexConfigMigrator: ProjectConfigMigrator = { provider: 'codex', - inspect: async (fs) => toCodexMigration(await readCodexMigrationData(fs)), + inspect: async (project, fileSystem) => + toCodexMigration(await readCodexMigrationData(project, fileSystem)), migrate: migrateCodexConfig, }; diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/conductor-config-migration.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/conductor-config-migration.ts index 312f20144c..95f34436f1 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/conductor-config-migration.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/conductor-config-migration.ts @@ -1,6 +1,6 @@ -import { err, ok, type Result } from '@emdash/shared'; +import type { IFileSystem } from '@emdash/core/files'; +import type { Result } from '@emdash/shared'; import z from 'zod'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { log } from '@main/lib/logger'; import { type MigrateProjectConfigRequest, @@ -8,12 +8,19 @@ import { type ShareableProjectSettings, type ShareableProjectSettingsWriteField, } from '@shared/core/project-settings/project-settings'; -import { mergeShareableProjectSettings } from '@shared/core/project-settings/project-settings-fields'; import type { UpdateProjectSettingsError } from '@shared/projects'; import type { ProjectProvider } from '../../project-provider'; import { parseJsonObject } from '../project-settings-json'; import type { ProjectConfigMigrator } from './config-migration'; -import { CONFIG_FILE } from './workspace-config-file'; +import { + addScript, + applyProjectConfigMigration, + errorMessage, + openProjectFileSystem, + projectPath, + trimmedText, + writeConfigFailed, +} from './config-migration-utils'; const CONDUCTOR_CONFIG_FILE = 'conductor.json'; const CONDUCTOR_WORKTREE_INCLUDE_FILE = '.worktreeinclude'; @@ -39,15 +46,6 @@ type ConductorMigrationData = { unsupportedFields: string[]; }; -function writeConfigFailed(message: string): Result { - return err({ type: 'write-config-failed', message }); -} - -function trimmedText(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} - function parseWorktreeInclude(content: string): string[] { return content .split(/\r?\n/) @@ -67,7 +65,8 @@ function toConductorMigration(data: ConductorMigrationData): ProjectConfigMigrat } async function readConductorMigrationData( - fs: Pick + project: ProjectProvider, + fileSystem: IFileSystem ): Promise { const data: ConductorMigrationData = { settings: {}, @@ -76,41 +75,61 @@ async function readConductorMigrationData( unsupportedFields: [], }; - const hasConductorConfig = await fs.exists(CONDUCTOR_CONFIG_FILE); - if (hasConductorConfig) { - const { content } = await fs.read(CONDUCTOR_CONFIG_FILE); - const conductorConfig = conductorConfigSchema.parse(parseJsonObject(content)); - data.files.push(CONDUCTOR_CONFIG_FILE); - - const setup = trimmedText(conductorConfig.scripts?.setup); - const run = trimmedText(conductorConfig.scripts?.run); - const archive = trimmedText(conductorConfig.scripts?.archive); - - if (setup) { - data.settings.scripts ??= {}; - data.settings.scripts.setup = setup; - data.fields.push('scripts.setup'); - } - if (run) { - data.settings.scripts ??= {}; - data.settings.scripts.run = run; - data.fields.push('scripts.run'); - } - if (archive) { - data.settings.scripts ??= {}; - data.settings.scripts.teardown = archive; - data.fields.push('scripts.teardown'); - } - - if (conductorConfig.runScriptMode !== undefined) data.unsupportedFields.push('runScriptMode'); - if (conductorConfig.enterpriseDataPrivacy !== undefined) { - data.unsupportedFields.push('enterpriseDataPrivacy'); + const conductorConfigPath = projectPath(project, CONDUCTOR_CONFIG_FILE); + const hasConductorConfig = await fileSystem.exists(conductorConfigPath); + if (!hasConductorConfig.success) { + log.warn('Failed to inspect Conductor config for migration', hasConductorConfig.error); + } + if (hasConductorConfig.success && hasConductorConfig.data) { + const content = await fileSystem.readText(conductorConfigPath); + if (!content.success) { + log.warn('Failed to read Conductor config for migration', content.error); + } else if (content.data.truncated) { + log.warn('Conductor config was truncated during migration', { + path: conductorConfigPath, + totalSize: content.data.totalSize, + }); + } else { + const conductorConfig = conductorConfigSchema.parse(parseJsonObject(content.data.content)); + data.files.push(CONDUCTOR_CONFIG_FILE); + + const setup = trimmedText(conductorConfig.scripts?.setup); + const run = trimmedText(conductorConfig.scripts?.run); + const archive = trimmedText(conductorConfig.scripts?.archive); + + addScript(data, 'scripts.setup', setup); + addScript(data, 'scripts.run', run); + addScript(data, 'scripts.teardown', archive); + + if (conductorConfig.runScriptMode !== undefined) data.unsupportedFields.push('runScriptMode'); + if (conductorConfig.enterpriseDataPrivacy !== undefined) { + data.unsupportedFields.push('enterpriseDataPrivacy'); + } } } - if (await fs.exists(CONDUCTOR_WORKTREE_INCLUDE_FILE)) { - const { content } = await fs.read(CONDUCTOR_WORKTREE_INCLUDE_FILE); - const patterns = parseWorktreeInclude(content); + const worktreeIncludePath = projectPath(project, CONDUCTOR_WORKTREE_INCLUDE_FILE); + const hasWorktreeInclude = await fileSystem.exists(worktreeIncludePath); + if (!hasWorktreeInclude.success) { + log.warn( + 'Failed to inspect Conductor worktree include for migration', + hasWorktreeInclude.error + ); + } + if (hasWorktreeInclude.success && hasWorktreeInclude.data) { + const content = await fileSystem.readText(worktreeIncludePath); + if (!content.success) { + log.warn('Failed to read Conductor worktree include for migration', content.error); + return data; + } + if (content.data.truncated) { + log.warn('Conductor worktree include was truncated during migration', { + path: worktreeIncludePath, + totalSize: content.data.totalSize, + }); + return data; + } + const patterns = parseWorktreeInclude(content.data.content); if (patterns.length > 0) { data.files.push(CONDUCTOR_WORKTREE_INCLUDE_FILE); data.settings.preservePatterns = patterns; @@ -126,47 +145,25 @@ async function migrateConductorConfig( request: MigrateProjectConfigRequest ): Promise> { try { - const data = await readConductorMigrationData(project.fs); + const fileSystem = openProjectFileSystem(project); + if (!fileSystem.success) return fileSystem; + + const data = await readConductorMigrationData(project, fileSystem.data); const migration = toConductorMigration(data); if (!migration) { return writeConfigFailed('No supported Conductor settings were found.'); } - if (request.destination === 'local') { - const currentSettings = await project.settings.get(); - const shareableSettings = mergeShareableProjectSettings(currentSettings, data.settings); - const updateResult = await project.settings.update({ - ...currentSettings, - ...shareableSettings, - }); - if (!updateResult.success) return updateResult; - return ok(migration); - } - - const writeResult = await project.fs.write( - CONFIG_FILE, - `${JSON.stringify(data.settings, null, 2)}\n` - ); - if (!writeResult.success) { - log.warn('Failed to write migrated project config file', writeResult.error); - return writeConfigFailed(writeResult.error ?? `Failed to write ${CONFIG_FILE}.`); - } - - const clearResult = await project.settings.patch({ clearShareableFields: data.fields }); - if (!clearResult.success) { - log.warn('Failed to clear imported local project settings', clearResult.error); - return writeConfigFailed(`Wrote ${CONFIG_FILE}, but failed to clear local project settings.`); - } - - return ok(migration); + return await applyProjectConfigMigration(project, request, data, migration); } catch (error) { log.warn('Failed to migrate Conductor config to project config', error); - return writeConfigFailed(error instanceof Error ? error.message : String(error)); + return writeConfigFailed(errorMessage(error)); } } export const conductorConfigMigrator: ProjectConfigMigrator = { provider: 'conductor', - inspect: async (fs) => toConductorMigration(await readConductorMigrationData(fs)), + inspect: async (project, fileSystem) => + toConductorMigration(await readConductorMigrationData(project, fileSystem)), migrate: migrateConductorConfig, }; diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration-utils.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration-utils.ts new file mode 100644 index 0000000000..7ece83f4d0 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration-utils.ts @@ -0,0 +1,108 @@ +import type { IFileSystem } from '@emdash/core/files'; +import { err, ok, type Result } from '@emdash/shared'; +import { log } from '@main/lib/logger'; +import type { + MigrateProjectConfigRequest, + ProjectConfigMigration, + ShareableProjectSettings, + ShareableProjectSettingsWriteField, +} from '@shared/core/project-settings/project-settings'; +import { mergeShareableProjectSettings } from '@shared/core/project-settings/project-settings-fields'; +import type { UpdateProjectSettingsError } from '@shared/projects'; +import type { ProjectProvider } from '../../project-provider'; +import { CONFIG_FILE } from './workspace-config-file'; + +type ScriptField = Extract; + +export type MigrationSettingsData = { + settings: ShareableProjectSettings; + fields: ShareableProjectSettingsWriteField[]; +}; + +export function writeConfigFailed( + message: string +): Result { + return err({ type: 'write-config-failed', message }); +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function projectPath(project: ProjectProvider, relPath: string): string { + return project.resolveProjectPath(relPath); +} + +export function openProjectFileSystem( + project: ProjectProvider +): Result { + return ok(project.fileSystem); +} + +export function trimmedText(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function normalizedCommandLines(commands: string[]): string | undefined { + const normalized = commands.map((command) => command.trim()).filter(Boolean); + return normalized.length > 0 ? normalized.join('\n') : undefined; +} + +export function setScript( + settings: ShareableProjectSettings, + field: ScriptField, + value: string +): void { + settings.scripts ??= {}; + if (field === 'scripts.setup') settings.scripts.setup = value; + if (field === 'scripts.run') settings.scripts.run = value; + if (field === 'scripts.teardown') settings.scripts.teardown = value; +} + +export function addScript( + data: MigrationSettingsData, + field: ScriptField, + value: string | undefined +): void { + if (!value) return; + setScript(data.settings, field, value); + data.fields.push(field); +} + +export async function applyProjectConfigMigration( + project: ProjectProvider, + request: MigrateProjectConfigRequest, + data: MigrationSettingsData, + migration: ProjectConfigMigration +): Promise> { + if (request.destination === 'local') { + const currentSettings = await project.settings.get(); + const shareableSettings = mergeShareableProjectSettings(currentSettings, data.settings); + const updateResult = await project.settings.update({ + ...currentSettings, + ...shareableSettings, + }); + if (!updateResult.success) return updateResult; + return ok(migration); + } + + const fileSystem = openProjectFileSystem(project); + if (!fileSystem.success) return fileSystem; + + const written = await fileSystem.data.writeText( + projectPath(project, CONFIG_FILE), + `${JSON.stringify(data.settings, null, 2)}\n` + ); + if (!written.success) { + return writeConfigFailed(`Could not write ${CONFIG_FILE}: ${written.error.message}`); + } + + const clearResult = await project.settings.patch({ clearShareableFields: data.fields }); + if (!clearResult.success) { + log.warn('Failed to clear imported local project settings', clearResult.error); + return writeConfigFailed(`Wrote ${CONFIG_FILE}, but failed to clear local project settings.`); + } + + return ok(migration); +} diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration.test.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration.test.ts index df4af9bd31..fff66a3e1a 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration.test.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration.test.ts @@ -1,3 +1,4 @@ +import { err, ok } from '@emdash/shared'; import { describe, expect, it, vi } from 'vitest'; import { inspectProjectConfigMigrations, @@ -10,32 +11,62 @@ vi.mock('@main/lib/logger', () => ({ }, })); +const repoPath = '/repo'; + function createFs(initialFiles: Record) { - const files = new Map(Object.entries(initialFiles)); + const files = new Map( + Object.entries(initialFiles).map(([filePath, content]) => [ + filePath.startsWith('/') ? filePath : `${repoPath}/${filePath}`, + content, + ]) + ); return { - exists: vi.fn((filePath: string) => Promise.resolve(files.has(filePath))), - read: vi.fn((filePath: string) => { + exists: vi.fn((filePath: string) => Promise.resolve(ok(files.has(filePath)))), + readText: vi.fn((filePath: string) => { const content = files.get(filePath); - if (content === undefined) throw new Error(`Missing file: ${filePath}`); - return Promise.resolve({ - content, - truncated: false, - totalSize: Buffer.byteLength(content), - }); + if (content === undefined) { + return Promise.resolve( + err({ + type: 'fs-error' as const, + path: filePath, + message: `Missing file: ${filePath}`, + code: 'ENOENT', + }) + ); + } + return Promise.resolve( + ok({ + content, + truncated: false, + totalSize: Buffer.byteLength(content), + }) + ); }), - write: vi.fn((filePath: string, content: string) => { + writeText: vi.fn((filePath: string, content: string) => { files.set(filePath, content); - return Promise.resolve({ - success: true, - bytesWritten: Buffer.byteLength(content), - }); + return Promise.resolve(ok({ bytesWritten: Buffer.byteLength(content) })); }), content(filePath: string) { - return files.get(filePath); + return files.get(filePath) ?? files.get(`${repoPath}/${filePath}`); }, }; } +function createProject( + fileSystem: ReturnType, + settings: Record = {} +) { + const join = (...parts: string[]) => parts.join('/').replace(/\/+/g, '/'); + return { + repoPath, + fileSystem, + projectConfigPath: join(repoPath, '.emdash.json'), + resolveProjectPath: (relativePath: string) => join(repoPath, relativePath), + configPathForDirectory: (directoryPath: string) => join(directoryPath, '.emdash.json'), + settings, + } as never; +} + describe('config migration', () => { it('detects importable Conductor settings', async () => { const fs = createFs({ @@ -56,7 +87,7 @@ describe('config migration', () => { `, }); - await expect(inspectProjectConfigMigrations(fs)).resolves.toEqual([ + await expect(inspectProjectConfigMigrations(createProject(fs))).resolves.toEqual([ { provider: 'conductor', label: 'Conductor', @@ -80,15 +111,10 @@ describe('config migration', () => { }); const patch = vi.fn().mockResolvedValue({ success: true }); - const result = await migrateProjectConfigFromProvider( - { - fs, - settings: { - patch, - }, - } as never, - { provider: 'conductor', destination: 'shared' } - ); + const result = await migrateProjectConfigFromProvider(createProject(fs, { patch }), { + provider: 'conductor', + destination: 'shared', + }); expect(result.success).toBe(true); expect(JSON.parse(fs.content('.emdash.json') ?? '{}')).toEqual({ @@ -118,7 +144,7 @@ describe('config migration', () => { }), }); - await expect(inspectProjectConfigMigrations(fs)).resolves.toEqual([ + await expect(inspectProjectConfigMigrations(createProject(fs))).resolves.toEqual([ { provider: 'conductor', label: 'Conductor', @@ -129,15 +155,10 @@ describe('config migration', () => { ]); const patch = vi.fn().mockResolvedValue({ success: true }); - const result = await migrateProjectConfigFromProvider( - { - fs, - settings: { - patch, - }, - } as never, - { provider: 'conductor', destination: 'shared' } - ); + const result = await migrateProjectConfigFromProvider(createProject(fs, { patch }), { + provider: 'conductor', + destination: 'shared', + }); expect(result.success).toBe(true); expect(JSON.parse(fs.content('.emdash.json') ?? '{}')).toEqual({ @@ -162,23 +183,20 @@ describe('config migration', () => { const update = vi.fn().mockResolvedValue({ success: true }); const result = await migrateProjectConfigFromProvider( - { - fs, - settings: { - get: vi.fn().mockResolvedValue({ - shellSetup: 'source .envrc', - scripts: { - setup: 'pnpm install', - }, - }), - update, - }, - } as never, + createProject(fs, { + get: vi.fn().mockResolvedValue({ + shellSetup: 'source .envrc', + scripts: { + setup: 'pnpm install', + }, + }), + update, + }), { provider: 'conductor', destination: 'local' } ); expect(result.success).toBe(true); - expect(fs.write).not.toHaveBeenCalled(); + expect(fs.writeText).not.toHaveBeenCalled(); expect(update).toHaveBeenCalledWith({ shellSetup: 'source .envrc', scripts: { @@ -198,7 +216,7 @@ describe('config migration', () => { }), }); - await expect(inspectProjectConfigMigrations(fs)).resolves.toEqual([ + await expect(inspectProjectConfigMigrations(createProject(fs))).resolves.toEqual([ { provider: 'superset', label: 'Superset', @@ -219,15 +237,10 @@ describe('config migration', () => { }); const patch = vi.fn().mockResolvedValue({ success: true }); - const result = await migrateProjectConfigFromProvider( - { - fs, - settings: { - patch, - }, - } as never, - { provider: 'superset', destination: 'shared' } - ); + const result = await migrateProjectConfigFromProvider(createProject(fs, { patch }), { + provider: 'superset', + destination: 'shared', + }); expect(result.success).toBe(true); expect(JSON.parse(fs.content('.emdash.json') ?? '{}')).toEqual({ @@ -251,22 +264,19 @@ describe('config migration', () => { const update = vi.fn().mockResolvedValue({ success: true }); const result = await migrateProjectConfigFromProvider( - { - fs, - settings: { - get: vi.fn().mockResolvedValue({ - scripts: { - setup: 'bun install', - }, - }), - update, - }, - } as never, + createProject(fs, { + get: vi.fn().mockResolvedValue({ + scripts: { + setup: 'bun install', + }, + }), + update, + }), { provider: 'superset', destination: 'local' } ); expect(result.success).toBe(true); - expect(fs.write).not.toHaveBeenCalled(); + expect(fs.writeText).not.toHaveBeenCalled(); expect(update).toHaveBeenCalledWith({ scripts: { setup: 'bun install', @@ -288,9 +298,9 @@ describe('config migration', () => { }), }); - await expect(inspectProjectConfigMigrations(fs)).resolves.toEqual([]); + await expect(inspectProjectConfigMigrations(createProject(fs))).resolves.toEqual([]); await expect( - migrateProjectConfigFromProvider({ fs } as never, { + migrateProjectConfigFromProvider(createProject(fs), { provider: 'superset', destination: 'shared', }) @@ -318,7 +328,7 @@ describe('config migration', () => { }), }); - await expect(inspectProjectConfigMigrations(fs)).resolves.toEqual([ + await expect(inspectProjectConfigMigrations(createProject(fs))).resolves.toEqual([ { provider: 'paseo', label: 'Paseo', @@ -350,15 +360,10 @@ describe('config migration', () => { }); const patch = vi.fn().mockResolvedValue({ success: true }); - const result = await migrateProjectConfigFromProvider( - { - fs, - settings: { - patch, - }, - } as never, - { provider: 'paseo', destination: 'shared' } - ); + const result = await migrateProjectConfigFromProvider(createProject(fs, { patch }), { + provider: 'paseo', + destination: 'shared', + }); expect(result.success).toBe(true); expect(JSON.parse(fs.content('.emdash.json') ?? '{}')).toEqual({ @@ -386,22 +391,19 @@ describe('config migration', () => { const update = vi.fn().mockResolvedValue({ success: true }); const result = await migrateProjectConfigFromProvider( - { - fs, - settings: { - get: vi.fn().mockResolvedValue({ - scripts: { - teardown: 'docker compose down', - }, - }), - update, - }, - } as never, + createProject(fs, { + get: vi.fn().mockResolvedValue({ + scripts: { + teardown: 'docker compose down', + }, + }), + update, + }), { provider: 'paseo', destination: 'local' } ); expect(result.success).toBe(true); - expect(fs.write).not.toHaveBeenCalled(); + expect(fs.writeText).not.toHaveBeenCalled(); expect(update).toHaveBeenCalledWith({ scripts: { setup: 'npm ci', @@ -420,9 +422,9 @@ describe('config migration', () => { }), }); - await expect(inspectProjectConfigMigrations(fs)).resolves.toEqual([]); + await expect(inspectProjectConfigMigrations(createProject(fs))).resolves.toEqual([]); await expect( - migrateProjectConfigFromProvider({ fs } as never, { + migrateProjectConfigFromProvider(createProject(fs), { provider: 'paseo', destination: 'shared', }) @@ -457,7 +459,7 @@ describe('config migration', () => { `, }); - await expect(inspectProjectConfigMigrations(fs)).resolves.toEqual([ + await expect(inspectProjectConfigMigrations(createProject(fs))).resolves.toEqual([ { provider: 'codex', label: 'Codex', @@ -484,15 +486,10 @@ describe('config migration', () => { }); const patch = vi.fn().mockResolvedValue({ success: true }); - const result = await migrateProjectConfigFromProvider( - { - fs, - settings: { - patch, - }, - } as never, - { provider: 'codex', destination: 'shared' } - ); + const result = await migrateProjectConfigFromProvider(createProject(fs, { patch }), { + provider: 'codex', + destination: 'shared', + }); expect(result.success).toBe(true); expect(JSON.parse(fs.content('.emdash.json') ?? '{}')).toEqual({ @@ -516,22 +513,19 @@ describe('config migration', () => { const update = vi.fn().mockResolvedValue({ success: true }); const result = await migrateProjectConfigFromProvider( - { - fs, - settings: { - get: vi.fn().mockResolvedValue({ - scripts: { - teardown: 'docker compose down', - }, - }), - update, - }, - } as never, + createProject(fs, { + get: vi.fn().mockResolvedValue({ + scripts: { + teardown: 'docker compose down', + }, + }), + update, + }), { provider: 'codex', destination: 'local' } ); expect(result.success).toBe(true); - expect(fs.write).not.toHaveBeenCalled(); + expect(fs.writeText).not.toHaveBeenCalled(); expect(update).toHaveBeenCalledWith({ scripts: { setup: 'npm ci', @@ -550,9 +544,9 @@ describe('config migration', () => { `, }); - await expect(inspectProjectConfigMigrations(fs)).resolves.toEqual([]); + await expect(inspectProjectConfigMigrations(createProject(fs))).resolves.toEqual([]); await expect( - migrateProjectConfigFromProvider({ fs } as never, { + migrateProjectConfigFromProvider(createProject(fs), { provider: 'codex', destination: 'shared', }) @@ -574,7 +568,7 @@ describe('config migration', () => { }); await expect( - migrateProjectConfigFromProvider({ fs } as never, { + migrateProjectConfigFromProvider(createProject(fs), { provider: 'conductor', destination: 'shared', }) @@ -597,7 +591,7 @@ describe('config migration', () => { '.emdash.json': JSON.stringify({ scripts: { run: 'pnpm dev' } }), }); - const result = await migrateProjectConfigFromProvider({ fs } as never, { + const result = await migrateProjectConfigFromProvider(createProject(fs), { provider: 'conductor', destination: 'shared', }); @@ -610,14 +604,14 @@ describe('config migration', () => { message: '.emdash.json already exists.', }, }); - expect(fs.write).not.toHaveBeenCalled(); + expect(fs.writeText).not.toHaveBeenCalled(); }); it('returns an error for unknown providers', async () => { const fs = createFs({}); await expect( - migrateProjectConfigFromProvider({ fs } as never, { + migrateProjectConfigFromProvider(createProject(fs), { provider: 'unknown' as never, destination: 'shared', }) diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration.ts index 1d798c8c37..da1c3d36bf 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/config-migration.ts @@ -1,5 +1,5 @@ -import { err, type Result } from '@emdash/shared'; -import type { FileSystemProvider } from '@main/core/fs/types'; +import type { IFileSystem } from '@emdash/core/files'; +import type { Result } from '@emdash/shared'; import { log } from '@main/lib/logger'; import type { MigrateProjectConfigRequest, @@ -9,6 +9,12 @@ import type { UpdateProjectSettingsError } from '@shared/projects'; import type { ProjectProvider } from '../../project-provider'; import { codexConfigMigrator } from './codex-config-migration'; import { conductorConfigMigrator } from './conductor-config-migration'; +import { + errorMessage, + openProjectFileSystem, + projectPath, + writeConfigFailed, +} from './config-migration-utils'; import { paseoConfigMigrator } from './paseo-config-migration'; import { supersetConfigMigrator } from './superset-config-migration'; import { CONFIG_FILE } from './workspace-config-file'; @@ -16,7 +22,8 @@ import { CONFIG_FILE } from './workspace-config-file'; export type ProjectConfigMigrator = { provider: ProjectConfigMigration['provider']; inspect: ( - fs: Pick + project: ProjectProvider, + fileSystem: IFileSystem ) => Promise; migrate: ( project: ProjectProvider, @@ -31,24 +38,30 @@ const PROJECT_CONFIG_MIGRATORS = [ codexConfigMigrator, ] as const; -function writeConfigFailed(message: string): Result { - return err({ type: 'write-config-failed', message }); +function projectConfigPath(project: ProjectProvider): string { + return projectPath(project, CONFIG_FILE); } export async function inspectProjectConfigMigrations( - fs: Pick + project: ProjectProvider ): Promise { - try { - if (await fs.exists(CONFIG_FILE)) return []; - } catch (error) { - log.warn(`Failed to inspect ${CONFIG_FILE} before config migration`, error); + const fileSystem = openProjectFileSystem(project); + if (!fileSystem.success) { + log.warn('Failed to open project file system before config migration', fileSystem.error); + return []; + } + + const existingConfig = await fileSystem.data.exists(projectConfigPath(project)); + if (!existingConfig.success) { + log.warn(`Failed to inspect ${CONFIG_FILE} before config migration`, existingConfig.error); return []; } + if (existingConfig.data) return []; const migrations = await Promise.all( PROJECT_CONFIG_MIGRATORS.map(async (migrator) => { try { - return await migrator.inspect(fs); + return await migrator.inspect(project, fileSystem.data); } catch (error) { log.warn(`Failed to inspect ${migrator.provider} config for migration`, error); return null; @@ -64,7 +77,16 @@ export async function migrateProjectConfigFromProvider( request: MigrateProjectConfigRequest ): Promise> { try { - if (await project.fs.exists(CONFIG_FILE)) { + const fileSystem = openProjectFileSystem(project); + if (!fileSystem.success) return fileSystem; + + const existingConfig = await fileSystem.data.exists(projectConfigPath(project)); + if (!existingConfig.success) { + return writeConfigFailed( + `Could not check existing ${CONFIG_FILE}: ${existingConfig.error.message}` + ); + } + if (existingConfig.data) { return writeConfigFailed(`${CONFIG_FILE} already exists.`); } @@ -76,6 +98,6 @@ export async function migrateProjectConfigFromProvider( return await migrator.migrate(project, request); } catch (error) { log.warn(`Failed to migrate ${request.provider} config to project config`, error); - return writeConfigFailed(error instanceof Error ? error.message : String(error)); + return writeConfigFailed(errorMessage(error)); } } diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/paseo-config-migration.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/paseo-config-migration.ts index 31e3bb0de7..2a0365edb0 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/paseo-config-migration.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/paseo-config-migration.ts @@ -1,6 +1,6 @@ -import { err, ok, type Result } from '@emdash/shared'; +import type { IFileSystem } from '@emdash/core/files'; +import type { Result } from '@emdash/shared'; import z from 'zod'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { log } from '@main/lib/logger'; import { type MigrateProjectConfigRequest, @@ -8,12 +8,19 @@ import { type ShareableProjectSettings, type ShareableProjectSettingsWriteField, } from '@shared/core/project-settings/project-settings'; -import { mergeShareableProjectSettings } from '@shared/core/project-settings/project-settings-fields'; import type { UpdateProjectSettingsError } from '@shared/projects'; import type { ProjectProvider } from '../../project-provider'; import { parseJsonObject } from '../project-settings-json'; import type { ProjectConfigMigrator } from './config-migration'; -import { CONFIG_FILE } from './workspace-config-file'; +import { + addScript, + applyProjectConfigMigration, + errorMessage, + normalizedCommandLines, + openProjectFileSystem, + projectPath, + writeConfigFailed, +} from './config-migration-utils'; const PASEO_CONFIG_FILE = 'paseo.json'; @@ -48,36 +55,11 @@ type PaseoMigrationData = { unsupportedFields: string[]; }; -function writeConfigFailed(message: string): Result { - return err({ type: 'write-config-failed', message }); -} - function normalizeCommand(value: string | string[] | undefined): string | undefined { if (value === undefined) return undefined; const commands = Array.isArray(value) ? value : [value]; - const normalized = commands.map((command) => command.trim()).filter(Boolean); - return normalized.length > 0 ? normalized.join('\n') : undefined; -} - -function setScript( - settings: ShareableProjectSettings, - field: ShareableProjectSettingsWriteField, - value: string -): void { - settings.scripts ??= {}; - if (field === 'scripts.setup') settings.scripts.setup = value; - if (field === 'scripts.teardown') settings.scripts.teardown = value; -} - -function addScript( - data: PaseoMigrationData, - field: ShareableProjectSettingsWriteField, - value: string | undefined -): void { - if (!value) return; - setScript(data.settings, field, value); - data.fields.push(field); + return normalizedCommandLines(commands); } function toPaseoMigration(data: PaseoMigrationData): ProjectConfigMigration | null { @@ -105,7 +87,8 @@ function addUnsupportedScripts( } async function readPaseoMigrationData( - fs: Pick + project: ProjectProvider, + fileSystem: IFileSystem ): Promise { const data: PaseoMigrationData = { settings: {}, @@ -114,10 +97,27 @@ async function readPaseoMigrationData( unsupportedFields: [], }; - if (!(await fs.exists(PASEO_CONFIG_FILE))) return data; + const paseoConfigPath = projectPath(project, PASEO_CONFIG_FILE); + const exists = await fileSystem.exists(paseoConfigPath); + if (!exists.success) { + log.warn('Failed to inspect Paseo config for migration', exists.error); + return data; + } + if (!exists.data) return data; - const { content } = await fs.read(PASEO_CONFIG_FILE); - const paseoConfig = paseoConfigSchema.parse(parseJsonObject(content)); + const content = await fileSystem.readText(paseoConfigPath); + if (!content.success) { + log.warn('Failed to read Paseo config for migration', content.error); + return data; + } + if (content.data.truncated) { + log.warn('Paseo config was truncated during migration', { + path: paseoConfigPath, + totalSize: content.data.totalSize, + }); + return data; + } + const paseoConfig = paseoConfigSchema.parse(parseJsonObject(content.data.content)); data.files.push(PASEO_CONFIG_FILE); addScript(data, 'scripts.setup', normalizeCommand(paseoConfig.worktree?.setup)); @@ -136,47 +136,25 @@ async function migratePaseoConfig( request: MigrateProjectConfigRequest ): Promise> { try { - const data = await readPaseoMigrationData(project.fs); + const fileSystem = openProjectFileSystem(project); + if (!fileSystem.success) return fileSystem; + + const data = await readPaseoMigrationData(project, fileSystem.data); const migration = toPaseoMigration(data); if (!migration) { return writeConfigFailed('No supported Paseo settings were found.'); } - if (request.destination === 'local') { - const currentSettings = await project.settings.get(); - const shareableSettings = mergeShareableProjectSettings(currentSettings, data.settings); - const updateResult = await project.settings.update({ - ...currentSettings, - ...shareableSettings, - }); - if (!updateResult.success) return updateResult; - return ok(migration); - } - - const writeResult = await project.fs.write( - CONFIG_FILE, - `${JSON.stringify(data.settings, null, 2)}\n` - ); - if (!writeResult.success) { - log.warn('Failed to write migrated project config file', writeResult.error); - return writeConfigFailed(writeResult.error ?? `Failed to write ${CONFIG_FILE}.`); - } - - const clearResult = await project.settings.patch({ clearShareableFields: data.fields }); - if (!clearResult.success) { - log.warn('Failed to clear imported local project settings', clearResult.error); - return writeConfigFailed(`Wrote ${CONFIG_FILE}, but failed to clear local project settings.`); - } - - return ok(migration); + return await applyProjectConfigMigration(project, request, data, migration); } catch (error) { log.warn('Failed to migrate Paseo config to project config', error); - return writeConfigFailed(error instanceof Error ? error.message : String(error)); + return writeConfigFailed(errorMessage(error)); } } export const paseoConfigMigrator: ProjectConfigMigrator = { provider: 'paseo', - inspect: async (fs) => toPaseoMigration(await readPaseoMigrationData(fs)), + inspect: async (project, fileSystem) => + toPaseoMigration(await readPaseoMigrationData(project, fileSystem)), migrate: migratePaseoConfig, }; diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/project-settings-override-state.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/project-settings-override-state.ts index ce7c8a68bc..4a123fa5a2 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/project-settings-override-state.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/project-settings-override-state.ts @@ -7,7 +7,6 @@ import { } from '@shared/core/project-settings/project-settings'; import { SHAREABLE_FIELD_ACCESSORS } from '@shared/core/project-settings/project-settings-fields'; import type { ProjectSettingsResolvedTarget } from './project-settings-target-resolver'; -import { CONFIG_FILE } from './workspace-config-file'; export async function computeProjectSettingsOverrideState( targets: ProjectSettingsResolvedTarget[] @@ -16,10 +15,19 @@ export async function computeProjectSettingsOverrideState( for (const resolved of targets) { try { - if (!(await resolved.fs.exists(CONFIG_FILE))) continue; + const exists = await resolved.fileSystem.exists(resolved.configPath); + if (!exists.success || !exists.data) continue; - const { content } = await resolved.fs.read(CONFIG_FILE); - const parsed = shareableProjectSettingsSchema.safeParse(JSON.parse(content)); + const content = await resolved.fileSystem.readText(resolved.configPath); + if (!content.success) continue; + if (content.data.truncated) { + log.warn('Project settings override source was truncated', { + path: resolved.configPath, + totalSize: content.data.totalSize, + }); + continue; + } + const parsed = shareableProjectSettingsSchema.safeParse(JSON.parse(content.data.content)); if (!parsed.success) continue; for (const field of SHAREABLE_PROJECT_SETTINGS_WRITE_FIELDS) { diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/project-settings-target-resolver.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/project-settings-target-resolver.ts index aa3cc72ec6..5a8ad5a24e 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/project-settings-target-resolver.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/project-settings-target-resolver.ts @@ -1,7 +1,5 @@ +import type { IFileSystem } from '@emdash/core/files'; import { eq } from 'drizzle-orm'; -import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { getProvisionedWorkspaceBranch } from '@main/core/workspaces/workspace-branch'; import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; import { db } from '@main/db/client'; @@ -20,7 +18,8 @@ import type { ProjectProvider } from '../../project-provider'; import { resolveWorkspace } from '../../utils'; export type ProjectSettingsResolvedTarget = ProjectSettingsWriteTargetOption & { - fs: FileSystemProvider; + fileSystem: IFileSystem; + configPath: string; }; function stripTarget(target: ProjectSettingsWriteTargetOption): ProjectSettingsWriteTarget { @@ -32,7 +31,7 @@ function stripTarget(target: ProjectSettingsWriteTargetOption): ProjectSettingsW export function stripResolvedTarget( target: ProjectSettingsResolvedTarget ): ProjectSettingsWriteTargetOption { - const { fs: _fs, ...option } = target; + const { configPath: _configPath, fileSystem: _fileSystem, ...option } = target; return option; } @@ -56,13 +55,15 @@ async function resolveTaskTarget( task: TaskTargetRow ): Promise { let targetPath: string | null = null; - let fs: FileSystemProvider | null = null; + let fileSystem: IFileSystem | null = null; + let configPath: string | null = null; if (task.workspaceId) { const activeWorkspace = workspaceRegistry.get(task.workspaceId); if (activeWorkspace) { targetPath = activeWorkspace.path; - fs = activeWorkspace.fs; + fileSystem = activeWorkspace.fileSystem; + configPath = activeWorkspace.configPath; } } @@ -76,23 +77,25 @@ async function resolveTaskTarget( } if (!targetPath) return null; if (targetPath === project.repoPath) return null; + const resolvedFileSystem = fileSystem ?? resolveProjectFileSystem(project); + if (!resolvedFileSystem) return null; return { type: 'task', taskId: task.id, label: task.name, path: targetPath, - fs: - fs ?? - (project.defaultWorkspaceType.kind === 'ssh' - ? new SshFileSystem(project.defaultWorkspaceType.proxy, targetPath) - : new LocalFileSystem(targetPath)), + fileSystem: resolvedFileSystem, + configPath: configPath ?? project.configPathForDirectory(targetPath), }; } export async function resolveAllProjectSettingsTargets( project: ProjectProvider ): Promise { + const projectFileSystem = resolveProjectFileSystem(project); + if (!projectFileSystem) return []; + const [projectRow] = await db .select({ name: projectsTable.name }) .from(projectsTable) @@ -103,7 +106,8 @@ export async function resolveAllProjectSettingsTargets( type: 'project', label: projectRow?.name ?? 'Project repository', path: project.repoPath, - fs: project.fs, + fileSystem: projectFileSystem, + configPath: project.projectConfigPath, }; if (!projectRow) return [projectTarget]; @@ -145,16 +149,20 @@ export async function resolveProjectSettingsTarget( if (request.target.type === 'workspace') { const workspace = resolveWorkspace(project.projectId, request.target.workspaceId); - return workspace - ? { - type: 'workspace', - workspaceId: request.target.workspaceId, - label: 'Workspace', - path: workspace.path, - fs: workspace.fs, - } - : null; + if (!workspace) return null; + return { + type: 'workspace', + workspaceId: request.target.workspaceId, + label: 'Workspace', + path: workspace.path, + fileSystem: workspace.fileSystem, + configPath: workspace.configPath, + }; } return null; } + +function resolveProjectFileSystem(project: ProjectProvider): IFileSystem | null { + return project.fileSystem; +} diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/share-project-settings-to-config.test.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/share-project-settings-to-config.test.ts index dae89fbaf9..df5504c374 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/share-project-settings-to-config.test.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/share-project-settings-to-config.test.ts @@ -1,3 +1,5 @@ +import type { IFileSystem } from '@emdash/core/files'; +import { err, ok } from '@emdash/shared'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ShareableProjectSettings } from '@shared/core/project-settings/project-settings'; import { computeProjectSettingsOverrideState } from './project-settings-override-state'; @@ -36,6 +38,83 @@ vi.mock('@main/lib/logger', () => ({ }, })); +const repoPath = '/repo'; +const configPath = `${repoPath}/.emdash.json`; + +function createMemoryFileSystem(initialFiles: Record = {}) { + const files = new Map( + Object.entries(initialFiles).map(([filePath, content]) => [ + filePath.startsWith('/') ? filePath : `${repoPath}/${filePath}`, + content, + ]) + ); + const fileSystem = { + exists: vi.fn(async (filePath: string) => ok(files.has(filePath))), + readText: vi.fn(async (filePath: string) => { + const content = files.get(filePath); + if (content === undefined) { + return err({ + type: 'fs-error' as const, + path: filePath, + message: `Missing file: ${filePath}`, + code: 'ENOENT', + }); + } + return ok({ content, truncated: false, totalSize: Buffer.byteLength(content) }); + }), + writeText: vi.fn(async (filePath: string, content: string) => { + files.set(filePath, content); + return ok({ bytesWritten: Buffer.byteLength(content) }); + }), + readBytes: vi.fn(), + writeBytes: vi.fn(), + stat: vi.fn(), + mkdir: vi.fn(), + remove: vi.fn(), + realPath: vi.fn(), + copyFile: vi.fn(), + glob: vi.fn(), + enumerate: vi.fn(), + content(filePath: string) { + return files.get(filePath) ?? files.get(`${repoPath}/${filePath}`); + }, + }; + return fileSystem as unknown as IFileSystem & typeof fileSystem; +} + +function joinPath(...parts: string[]): string { + return parts.join('/').replace(/\/+/g, '/'); +} + +function configPathForDirectory(directoryPath: string): string { + return joinPath(directoryPath, '.emdash.json'); +} + +function projectFixture(fileSystem: IFileSystem, overrides: Record = {}) { + return { + projectId: 'project-1', + repoPath, + fileSystem, + projectConfigPath: configPath, + resolveProjectPath: (relativePath: string) => joinPath(repoPath, relativePath), + configPathForDirectory, + defaultWorkspaceType: { kind: 'local' }, + ...overrides, + }; +} + +function workspaceFixture(workspacePath: string, fileSystem: IFileSystem) { + return { + path: workspacePath, + fileSystem, + configPath: configPathForDirectory(workspacePath), + }; +} + +function projectTarget(fileSystem: IFileSystem) { + return { type: 'project' as const, label: 'Repo Name', path: repoPath, fileSystem, configPath }; +} + describe('shareProjectSettingsToConfig', () => { beforeEach(() => { vi.clearAllMocks(); @@ -44,13 +123,10 @@ describe('shareProjectSettingsToConfig', () => { }); it('writes selected shareable project settings to .emdash.json', async () => { - const write = vi.fn().mockResolvedValue({ success: true, bytesWritten: 100 }); + const fileSystem = createMemoryFileSystem(); + const write = fileSystem.writeText; const patch = vi.fn().mockResolvedValue({ success: true }); const project = { - fs: { - exists: vi.fn().mockResolvedValue(false), - write, - }, settings: { get: vi.fn().mockResolvedValue({ defaultBranch: 'origin/main', @@ -73,12 +149,12 @@ describe('shareProjectSettingsToConfig', () => { target: { type: 'project' }, fields: ['preservePatterns', 'shellSetup', 'scripts.setup', 'scripts.run'], }, - [{ type: 'project', label: 'Repo Name', path: '/repo', fs: project.fs as never }] + [projectTarget(fileSystem)] ); expect(result.success).toBe(true); expect(write).toHaveBeenCalledWith( - '.emdash.json', + configPath, `${JSON.stringify( { preservePatterns: ['.env', '.env.local'], @@ -98,20 +174,11 @@ describe('shareProjectSettingsToConfig', () => { }); it('preserves existing config fields when sharing a later script field to the same target', async () => { - let configContent = ''; + const fileSystem = createMemoryFileSystem(); let shareableSettings: ShareableProjectSettings = { preservePatterns: ['.env', '.env.local'], }; - const fs = { - exists: vi.fn().mockImplementation(() => Promise.resolve(configContent !== '')), - read: vi.fn().mockImplementation(() => Promise.resolve({ content: configContent })), - write: vi.fn().mockImplementation((_path: string, content: string) => { - configContent = content; - return Promise.resolve({ success: true, bytesWritten: content.length }); - }), - }; const project = { - fs, settings: { get: vi.fn().mockImplementation(() => Promise.resolve(shareableSettings)), patch: vi.fn().mockImplementation(({ clearShareableFields }) => { @@ -125,7 +192,7 @@ describe('shareProjectSettingsToConfig', () => { }), }, }; - const targets = [{ type: 'project' as const, label: 'Repo Name', path: '/repo', fs }]; + const targets = [projectTarget(fileSystem)]; await shareProjectSettingsToConfig( project as never, @@ -152,7 +219,7 @@ describe('shareProjectSettingsToConfig', () => { ); expect(result.success).toBe(true); - expect(JSON.parse(configContent)).toEqual({ + expect(JSON.parse(fileSystem.content('.emdash.json') ?? '{}')).toEqual({ preservePatterns: ['.env', '.env.local'], scripts: { run: 'pnpm dev', @@ -161,16 +228,12 @@ describe('shareProjectSettingsToConfig', () => { }); it('only clears fields that were actually written to .emdash.json', async () => { - const write = vi.fn().mockResolvedValue({ success: true, bytesWritten: 100 }); + const fileSystem = createMemoryFileSystem({ + '.emdash.json': JSON.stringify({ preservePatterns: ['.env'] }), + }); + const write = fileSystem.writeText; const patch = vi.fn().mockResolvedValue({ success: true }); const project = { - fs: { - exists: vi.fn().mockResolvedValue(true), - read: vi.fn().mockResolvedValue({ - content: JSON.stringify({ preservePatterns: ['.env'] }), - }), - write, - }, settings: { get: vi.fn().mockResolvedValue({ preservePatterns: ['.env.local'], @@ -185,12 +248,12 @@ describe('shareProjectSettingsToConfig', () => { target: { type: 'project' }, fields: ['preservePatterns', 'scripts.run'], }, - [{ type: 'project', label: 'Repo Name', path: '/repo', fs: project.fs as never }] + [projectTarget(fileSystem)] ); expect(result.success).toBe(true); expect(write).toHaveBeenCalledWith( - '.emdash.json', + configPath, `${JSON.stringify({ preservePatterns: ['.env.local'] }, null, 2)}\n` ); expect(patch).toHaveBeenCalledWith({ @@ -200,15 +263,17 @@ describe('shareProjectSettingsToConfig', () => { it('returns an error when the filesystem reports an unsuccessful write', async () => { const patch = vi.fn(); + const fileSystem = { + ...createMemoryFileSystem(), + writeText: vi.fn(async (filePath: string) => + err({ + type: 'fs-error' as const, + path: filePath, + message: 'permission denied', + }) + ), + }; const project = { - fs: { - exists: vi.fn().mockResolvedValue(false), - write: vi.fn().mockResolvedValue({ - success: false, - bytesWritten: 0, - error: 'permission denied', - }), - }, settings: { get: vi.fn().mockResolvedValue({ preservePatterns: ['.env'], @@ -223,30 +288,29 @@ describe('shareProjectSettingsToConfig', () => { target: { type: 'project' }, fields: ['preservePatterns'], }, - [{ type: 'project', label: 'Repo Name', path: '/repo', fs: project.fs as never }] + [projectTarget(fileSystem as never)] ); expect(result).toEqual({ success: false, - error: { type: 'write-config-failed', message: 'permission denied' }, + error: { + type: 'write-config-failed', + message: 'Could not write .emdash.json: permission denied', + }, }); expect(patch).not.toHaveBeenCalled(); }); it('returns an error when clearing shared fields fails after writing config', async () => { - const write = vi.fn().mockResolvedValue({ success: true, bytesWritten: 100 }); + const fileSystem = createMemoryFileSystem({ + '.emdash.json': `${JSON.stringify({ shellSetup: 'old setup' }, null, 2)}\n`, + }); + const write = fileSystem.writeText; const patch = vi.fn().mockResolvedValue({ success: false, error: { type: 'error' }, }); const project = { - fs: { - exists: vi.fn().mockResolvedValue(true), - read: vi.fn().mockResolvedValue({ - content: `${JSON.stringify({ shellSetup: 'old setup' }, null, 2)}\n`, - }), - write, - }, settings: { get: vi.fn().mockResolvedValue({ preservePatterns: ['.env'], @@ -261,7 +325,7 @@ describe('shareProjectSettingsToConfig', () => { target: { type: 'project' }, fields: ['preservePatterns'], }, - [{ type: 'project', label: 'Repo Name', path: '/repo', fs: project.fs as never }] + [projectTarget(fileSystem)] ); expect(result).toEqual({ @@ -275,11 +339,8 @@ describe('shareProjectSettingsToConfig', () => { }); it('returns the read/parse failure when existing .emdash.json cannot be parsed', async () => { + const fileSystem = createMemoryFileSystem({ '.emdash.json': '{ invalid json' }); const project = { - fs: { - exists: vi.fn().mockResolvedValue(true), - read: vi.fn().mockResolvedValue({ content: '{ invalid json' }), - }, settings: { get: vi.fn().mockResolvedValue({ preservePatterns: ['.env'], @@ -293,7 +354,7 @@ describe('shareProjectSettingsToConfig', () => { target: { type: 'project' }, fields: ['preservePatterns'], }, - [{ type: 'project', label: 'Repo Name', path: '/repo', fs: project.fs as never }] + [projectTarget(fileSystem)] ); if (result.success) { @@ -308,6 +369,41 @@ describe('shareProjectSettingsToConfig', () => { expect(result.error.message).toContain('Could not read existing .emdash.json'); }); + it('does not overwrite an existing .emdash.json when the read is truncated', async () => { + const fileSystem = { + ...createMemoryFileSystem({ '.emdash.json': '{"shellSetup":' }), + readText: vi.fn(async () => + ok({ content: '{"shellSetup":', truncated: true, totalSize: 204_801 }) + ), + }; + const project = { + settings: { + get: vi.fn().mockResolvedValue({ + preservePatterns: ['.env'], + }), + patch: vi.fn(), + }, + }; + + const result = await shareProjectSettingsToConfig( + project as never, + { + target: { type: 'project' }, + fields: ['preservePatterns'], + }, + [projectTarget(fileSystem as never)] + ); + + expect(result).toEqual({ + success: false, + error: { + type: 'write-config-failed', + message: 'Could not read existing .emdash.json: file was truncated.', + }, + }); + expect(fileSystem.writeText).not.toHaveBeenCalled(); + }); + it('returns target resolution failures instead of rejecting the RPC', async () => { await expect( shareProjectSettingsToConfig( @@ -333,15 +429,12 @@ describe('shareProjectSettingsToConfig', () => { it('includes task worktrees from git branch discovery, not only active workspaces', async () => { const findBranchAnywhere = vi.fn().mockResolvedValue('/external/worktrees/task-one'); - const project = { - projectId: 'project-1', - repoPath: '/repo', - fs: {}, - defaultWorkspaceType: { kind: 'local' }, + const projectFs = createMemoryFileSystem(); + const project = projectFixture(projectFs, { worktreeService: { findBranchAnywhere, }, - }; + }); mocks.select .mockReturnValueOnce({ from: () => ({ @@ -381,32 +474,26 @@ describe('shareProjectSettingsToConfig', () => { }); it('excludes task targets that use the project root working directory', async () => { - const projectRootFs = { - exists: vi.fn().mockResolvedValue(true), - read: vi.fn().mockResolvedValue({ - content: JSON.stringify({ shellSetup: 'root setup' }), - }), - }; - const worktreeFs = { - exists: vi.fn().mockResolvedValue(true), - read: vi.fn().mockResolvedValue({ - content: JSON.stringify({ shellSetup: 'worktree setup' }), + const projectRootFs = createMemoryFileSystem({ + '.emdash.json': JSON.stringify({ shellSetup: 'root setup' }), + }); + const worktreeFs = createMemoryFileSystem({ + '/repo/.emdash/worktrees/task-two/.emdash.json': JSON.stringify({ + shellSetup: 'worktree setup', }), - }; + }); const findBranchAnywhere = vi.fn(); - const project = { - projectId: 'project-1', - repoPath: '/repo', - fs: projectRootFs, - defaultWorkspaceType: { kind: 'local' }, + const project = projectFixture(projectRootFs, { worktreeService: { findBranchAnywhere, }, - }; + }); mocks.workspaceGet.mockImplementation((workspaceId: string) => { - if (workspaceId === 'root-workspace') return { path: '/repo', fs: projectRootFs }; + if (workspaceId === 'root-workspace') { + return workspaceFixture('/repo', projectRootFs); + } if (workspaceId === 'worktree-workspace') { - return { path: '/repo/.emdash/worktrees/task-two', fs: worktreeFs }; + return workspaceFixture('/repo/.emdash/worktrees/task-two', worktreeFs); } return undefined; }); @@ -465,15 +552,12 @@ describe('shareProjectSettingsToConfig', () => { it('skips task target resolution when the project row no longer exists', async () => { const findBranchAnywhere = vi.fn(); - const project = { - projectId: 'project-1', - repoPath: '/repo', - fs: {}, - defaultWorkspaceType: { kind: 'local' }, + const projectFs = createMemoryFileSystem(); + const project = projectFixture(projectFs, { worktreeService: { findBranchAnywhere, }, - }; + }); mocks.select.mockReturnValueOnce({ from: () => ({ where: () => ({ @@ -492,28 +576,22 @@ describe('shareProjectSettingsToConfig', () => { }); it('detects workspace setting overrides from .emdash.json files', async () => { - const project = { - projectId: 'project-1', - repoPath: '/repo', - fs: { - exists: vi.fn().mockResolvedValue(true), - read: vi.fn().mockResolvedValue({ - content: JSON.stringify({ - preservePatterns: ['.env', '.env.local'], - shellSetup: 'nvm use', - scripts: { - setup: 'pnpm install', - run: 'pnpm dev', - teardown: 'docker compose down', - }, - }), - }), - }, - defaultWorkspaceType: { kind: 'local' }, + const projectFs = createMemoryFileSystem({ + '.emdash.json': JSON.stringify({ + preservePatterns: ['.env', '.env.local'], + shellSetup: 'nvm use', + scripts: { + setup: 'pnpm install', + run: 'pnpm dev', + teardown: 'docker compose down', + }, + }), + }); + const project = projectFixture(projectFs, { worktreeService: { findBranchAnywhere: vi.fn(), }, - }; + }); mocks.select .mockReturnValueOnce({ from: () => ({ diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/share-project-settings-to-config.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/share-project-settings-to-config.ts index 5728d19c3b..93880cb284 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/share-project-settings-to-config.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/share-project-settings-to-config.ts @@ -1,8 +1,9 @@ -import { err, ok, type Result } from '@emdash/shared'; +import { ok, type Result } from '@emdash/shared'; import { log } from '@main/lib/logger'; import type { WriteProjectConfigRequest } from '@shared/core/project-settings/project-settings'; import type { UpdateProjectSettingsError } from '@shared/projects'; import type { ProjectProvider } from '../../project-provider'; +import { errorMessage, writeConfigFailed } from './config-migration-utils'; import { resolveProjectSettingsTarget, type ProjectSettingsResolvedTarget, @@ -13,14 +14,6 @@ import { patchShareableProjectSettingsFields, } from './workspace-config-file'; -function writeConfigFailed(message: string): Result { - return err({ type: 'write-config-failed', message }); -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - export async function shareProjectSettingsToConfig( project: ProjectProvider, request: WriteProjectConfigRequest, @@ -35,9 +28,28 @@ export async function shareProjectSettingsToConfig( const localSettings = await project.settings.get(); let config: Record; try { - if (await target.fs.exists(CONFIG_FILE)) { - const { content } = await target.fs.read(CONFIG_FILE); - config = parseWorkspaceConfigObject(content); + const exists = await target.fileSystem.exists(target.configPath); + if (!exists.success) { + const message = `Could not check existing ${CONFIG_FILE}: ${exists.error.message}`; + log.warn('Failed to check project config before writing', exists.error); + return writeConfigFailed(message); + } + if (exists.data) { + const content = await target.fileSystem.readText(target.configPath); + if (!content.success) { + const message = `Could not read existing ${CONFIG_FILE}: ${content.error.message}`; + log.warn('Failed to read project config before writing', content.error); + return writeConfigFailed(message); + } + if (content.data.truncated) { + const message = `Could not read existing ${CONFIG_FILE}: file was truncated.`; + log.warn('Project config was truncated before writing', { + path: target.configPath, + totalSize: content.data.totalSize, + }); + return writeConfigFailed(message); + } + config = parseWorkspaceConfigObject(content.data.content); } else { config = {}; } @@ -53,10 +65,13 @@ export async function shareProjectSettingsToConfig( request.fields ); - const writeResult = await target.fs.write(CONFIG_FILE, `${JSON.stringify(config, null, 2)}\n`); - if (!writeResult.success) { - log.warn('Failed to write project config file', writeResult.error); - return writeConfigFailed(writeResult.error ?? `Failed to write ${CONFIG_FILE}.`); + const written = await target.fileSystem.writeText( + target.configPath, + `${JSON.stringify(config, null, 2)}\n` + ); + if (!written.success) { + log.warn('Failed to write project config to repo', written.error); + return writeConfigFailed(`Could not write ${CONFIG_FILE}: ${written.error.message}`); } const clearResult = await project.settings.patch({ clearShareableFields: writtenFields }); diff --git a/apps/emdash-desktop/src/main/core/projects/settings/sharing/superset-config-migration.ts b/apps/emdash-desktop/src/main/core/projects/settings/sharing/superset-config-migration.ts index 6a0d6795bd..6a0dacd8c5 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/sharing/superset-config-migration.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/sharing/superset-config-migration.ts @@ -1,6 +1,6 @@ -import { err, ok, type Result } from '@emdash/shared'; +import type { IFileSystem } from '@emdash/core/files'; +import type { Result } from '@emdash/shared'; import z from 'zod'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { log } from '@main/lib/logger'; import { type MigrateProjectConfigRequest, @@ -8,12 +8,19 @@ import { type ShareableProjectSettings, type ShareableProjectSettingsWriteField, } from '@shared/core/project-settings/project-settings'; -import { mergeShareableProjectSettings } from '@shared/core/project-settings/project-settings-fields'; import type { UpdateProjectSettingsError } from '@shared/projects'; import type { ProjectProvider } from '../../project-provider'; import { parseJsonObject } from '../project-settings-json'; import type { ProjectConfigMigrator } from './config-migration'; -import { CONFIG_FILE } from './workspace-config-file'; +import { + applyProjectConfigMigration, + errorMessage, + normalizedCommandLines, + openProjectFileSystem, + projectPath, + setScript, + writeConfigFailed, +} from './config-migration-utils'; const SUPERSET_CONFIG_FILE = '.superset/config.json'; @@ -52,15 +59,6 @@ const SUPERSET_SCRIPT_FIELDS = [ target: ShareableProjectSettingsWriteField; }>; -function writeConfigFailed(message: string): Result { - return err({ type: 'write-config-failed', message }); -} - -function normalizeCommands(commands: string[]): string | undefined { - const normalized = commands.map((command) => command.trim()).filter(Boolean); - return normalized.length > 0 ? normalized.join('\n') : undefined; -} - function addUnsupportedOverrideFields( data: SupersetMigrationData, source: 'setup' | 'run' | 'teardown', @@ -71,17 +69,6 @@ function addUnsupportedOverrideFields( if (script.after !== undefined) data.unsupportedFields.push(`${source}.after`); } -function setScript( - settings: ShareableProjectSettings, - field: ShareableProjectSettingsWriteField, - value: string -): void { - settings.scripts ??= {}; - if (field === 'scripts.setup') settings.scripts.setup = value; - if (field === 'scripts.run') settings.scripts.run = value; - if (field === 'scripts.teardown') settings.scripts.teardown = value; -} - function toSupersetMigration(data: SupersetMigrationData): ProjectConfigMigration | null { if (data.fields.length === 0) return null; return { @@ -94,7 +81,8 @@ function toSupersetMigration(data: SupersetMigrationData): ProjectConfigMigratio } async function readSupersetMigrationData( - fs: Pick + project: ProjectProvider, + fileSystem: IFileSystem ): Promise { const data: SupersetMigrationData = { settings: {}, @@ -103,10 +91,27 @@ async function readSupersetMigrationData( unsupportedFields: [], }; - if (!(await fs.exists(SUPERSET_CONFIG_FILE))) return data; + const supersetConfigPath = projectPath(project, SUPERSET_CONFIG_FILE); + const exists = await fileSystem.exists(supersetConfigPath); + if (!exists.success) { + log.warn('Failed to inspect Superset config for migration', exists.error); + return data; + } + if (!exists.data) return data; - const { content } = await fs.read(SUPERSET_CONFIG_FILE); - const config = supersetConfigSchema.parse(parseJsonObject(content)); + const content = await fileSystem.readText(supersetConfigPath); + if (!content.success) { + log.warn('Failed to read Superset config for migration', content.error); + return data; + } + if (content.data.truncated) { + log.warn('Superset config was truncated during migration', { + path: supersetConfigPath, + totalSize: content.data.totalSize, + }); + return data; + } + const config = supersetConfigSchema.parse(parseJsonObject(content.data.content)); data.files.push(SUPERSET_CONFIG_FILE); for (const { source, target } of SUPERSET_SCRIPT_FIELDS) { @@ -114,7 +119,7 @@ async function readSupersetMigrationData( if (script === undefined) continue; if (Array.isArray(script)) { - const value = normalizeCommands(script); + const value = normalizedCommandLines(script); if (!value) continue; setScript(data.settings, target, value); data.fields.push(target); @@ -132,47 +137,25 @@ async function migrateSupersetConfig( request: MigrateProjectConfigRequest ): Promise> { try { - const data = await readSupersetMigrationData(project.fs); + const fileSystem = openProjectFileSystem(project); + if (!fileSystem.success) return fileSystem; + + const data = await readSupersetMigrationData(project, fileSystem.data); const migration = toSupersetMigration(data); if (!migration) { return writeConfigFailed('No supported Superset settings were found.'); } - if (request.destination === 'local') { - const currentSettings = await project.settings.get(); - const shareableSettings = mergeShareableProjectSettings(currentSettings, data.settings); - const updateResult = await project.settings.update({ - ...currentSettings, - ...shareableSettings, - }); - if (!updateResult.success) return updateResult; - return ok(migration); - } - - const writeResult = await project.fs.write( - CONFIG_FILE, - `${JSON.stringify(data.settings, null, 2)}\n` - ); - if (!writeResult.success) { - log.warn('Failed to write migrated project config file', writeResult.error); - return writeConfigFailed(writeResult.error ?? `Failed to write ${CONFIG_FILE}.`); - } - - const clearResult = await project.settings.patch({ clearShareableFields: data.fields }); - if (!clearResult.success) { - log.warn('Failed to clear imported local project settings', clearResult.error); - return writeConfigFailed(`Wrote ${CONFIG_FILE}, but failed to clear local project settings.`); - } - - return ok(migration); + return await applyProjectConfigMigration(project, request, data, migration); } catch (error) { log.warn('Failed to migrate Superset config to project config', error); - return writeConfigFailed(error instanceof Error ? error.message : String(error)); + return writeConfigFailed(errorMessage(error)); } } export const supersetConfigMigrator: ProjectConfigMigrator = { provider: 'superset', - inspect: async (fs) => toSupersetMigration(await readSupersetMigrationData(fs)), + inspect: async (project, fileSystem) => + toSupersetMigration(await readSupersetMigrationData(project, fileSystem)), migrate: migrateSupersetConfig, }; diff --git a/apps/emdash-desktop/src/main/core/projects/settings/worktree-directory.test.ts b/apps/emdash-desktop/src/main/core/projects/settings/worktree-directory.test.ts index 5f09dd4f46..f29a86f444 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/worktree-directory.test.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/worktree-directory.test.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { err, ok } from '@emdash/shared'; import { describe, expect, it, vi } from 'vitest'; import { canonicalizeWorktreeDirectory, normalizeWorktreeDirectory } from './worktree-directory'; @@ -150,8 +151,8 @@ describe('worktree-directory', () => { describe('canonicalizeWorktreeDirectory', () => { it('creates and canonicalizes directory through fs provider', async () => { const fs = { - mkdir: vi.fn().mockResolvedValue(undefined), - realPath: vi.fn().mockResolvedValue('/canonical/path'), + mkdir: vi.fn().mockResolvedValue(ok()), + realPath: vi.fn().mockResolvedValue(ok('/canonical/path')), }; const resolved = await canonicalizeWorktreeDirectory('/input/path', fs); @@ -165,7 +166,7 @@ describe('worktree-directory', () => { it('rejects inaccessible directories', async () => { const fs = { - mkdir: vi.fn().mockRejectedValue(new Error('permission denied')), + mkdir: vi.fn().mockResolvedValue(err({ message: 'permission denied' })), realPath: vi.fn(), }; diff --git a/apps/emdash-desktop/src/main/core/projects/settings/worktree-directory.ts b/apps/emdash-desktop/src/main/core/projects/settings/worktree-directory.ts index db27b3fd27..16c18d39e9 100644 --- a/apps/emdash-desktop/src/main/core/projects/settings/worktree-directory.ts +++ b/apps/emdash-desktop/src/main/core/projects/settings/worktree-directory.ts @@ -1,12 +1,19 @@ import type path from 'node:path'; import { err, ok, type Result } from '@emdash/shared'; -import type { FileSystemProvider } from '@main/core/fs/types'; import type { UpdateProjectSettingsError } from '@shared/projects'; export type PathPlatform = 'posix' | 'win32'; type PathApi = Pick; +export type WorktreeDirectoryFileSystem = { + mkdir( + path: string, + options?: { recursive?: boolean } + ): Promise>; + realPath(path: string): Promise>; +}; + function isWindowsDriveAbsolute(input: string): boolean { return /^[A-Za-z]:[\\/]/.test(input); } @@ -60,14 +67,14 @@ export async function normalizeWorktreeDirectory( export async function canonicalizeWorktreeDirectory( directory: string, - fs: Pick + fs: WorktreeDirectoryFileSystem ): Promise> { - try { - await fs.mkdir(directory, { recursive: true }); - return ok(await fs.realPath(directory)); - } catch { - return err({ type: 'invalid-worktree-directory' }); - } + const madeDir = await fs.mkdir(directory, { recursive: true }); + if (!madeDir.success) return err({ type: 'invalid-worktree-directory' }); + + const realPath = await fs.realPath(directory); + if (!realPath.success) return err({ type: 'invalid-worktree-directory' }); + return ok(realPath.data); } export async function resolveAndValidateWorktreeDirectory( @@ -75,7 +82,7 @@ export async function resolveAndValidateWorktreeDirectory( options: { pathApi: PathApi; pathPlatform: PathPlatform; - fs: Pick; + fs: WorktreeDirectoryFileSystem; homeDirectory?: string; resolveHomeDirectory?: () => Promise; } diff --git a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/local-worktree-host.test.ts b/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/local-worktree-host.test.ts deleted file mode 100644 index 2fcd74c68e..0000000000 --- a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/local-worktree-host.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { FileSystemErrorCodes } from '@main/core/fs/types'; -import { isPathInsideRoot, LocalWorktreeHost } from './local-worktree-host'; - -describe('LocalWorktreeHost', () => { - let repoDir: string; - let worktreeDir: string; - let outsideDir: string; - - beforeEach(() => { - repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-wtfs-repo-')); - worktreeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-wtfs-worktrees-')); - outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'emdash-wtfs-outside-')); - }); - - afterEach(() => { - fs.rmSync(repoDir, { recursive: true, force: true }); - fs.rmSync(worktreeDir, { recursive: true, force: true }); - fs.rmSync(outsideDir, { recursive: true, force: true }); - }); - - async function makeHost(): Promise { - return LocalWorktreeHost.create({ - allowedRoots: [repoDir, worktreeDir], - }); - } - - it('copies files between separate allowed roots using absolute paths', async () => { - const host = await makeHost(); - const src = path.join(repoDir, '.env'); - const dest = path.join(worktreeDir, 'task-1', '.env'); - fs.writeFileSync(src, 'SECRET=abc'); - - await host.mkdirAbsolute(path.dirname(dest), { recursive: true }); - await host.copyFileAbsolute(src, dest); - - expect(fs.readFileSync(dest, 'utf8')).toBe('SECRET=abc'); - }); - - it('rejects relative paths', async () => { - const host = await makeHost(); - - await expect(host.mkdirAbsolute('relative/path', { recursive: true })).rejects.toMatchObject({ - code: FileSystemErrorCodes.INVALID_PATH, - }); - }); - - it('rejects paths outside the allowed roots', async () => { - const host = await makeHost(); - const src = path.join(outsideDir, 'secret.txt'); - const dest = path.join(worktreeDir, 'secret.txt'); - fs.writeFileSync(src, 'outside'); - - await expect(host.copyFileAbsolute(src, dest)).rejects.toMatchObject({ - code: FileSystemErrorCodes.PATH_ESCAPE, - }); - }); - - it('allows adding a new trusted worktree root', async () => { - const host = await makeHost(); - const nextWorktreeDir = path.join(outsideDir, 'next-worktrees'); - fs.mkdirSync(nextWorktreeDir); - const target = path.join(nextWorktreeDir, 'task-1'); - - await expect(host.mkdirAbsolute(target, { recursive: true })).rejects.toMatchObject({ - code: FileSystemErrorCodes.PATH_ESCAPE, - }); - - await host.allowRoot(nextWorktreeDir); - await host.mkdirAbsolute(target, { recursive: true }); - - expect(fs.existsSync(target)).toBe(true); - }); - - it('rejects symlink escapes outside the allowed roots', async () => { - if (process.platform === 'win32') { - return; - } - - const host = await makeHost(); - const secret = path.join(outsideDir, 'passwords.txt'); - const escape = path.join(worktreeDir, 'escape'); - fs.writeFileSync(secret, 'outside'); - fs.symlinkSync(outsideDir, escape); - - await expect(host.realPathAbsolute(path.join(escape, 'passwords.txt'))).rejects.toMatchObject({ - code: FileSystemErrorCodes.PATH_ESCAPE, - }); - }); - - it('returns false/null for out-of-scope existence checks', async () => { - const host = await makeHost(); - const outside = path.join(outsideDir, 'file.txt'); - fs.writeFileSync(outside, 'outside'); - - await expect(host.existsAbsolute(outside)).resolves.toBe(false); - await expect(host.statAbsolute(outside)).resolves.toBeNull(); - }); - - it('matches Windows paths by drive-aware containment rules', () => { - expect( - isPathInsideRoot(String.raw`C:\repo\.env`, String.raw`C:\repo`, { - pathApi: path.win32, - }) - ).toBe(true); - expect( - isPathInsideRoot(String.raw`C:\repo2\.env`, String.raw`C:\repo`, { - pathApi: path.win32, - }) - ).toBe(false); - expect( - isPathInsideRoot(String.raw`D:\repo\.env`, String.raw`C:\repo`, { - pathApi: path.win32, - }) - ).toBe(false); - expect( - isPathInsideRoot(String.raw`c:\repo\.env`, String.raw`C:\Repo`, { - pathApi: path.win32, - }) - ).toBe(true); - }); -}); diff --git a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/local-worktree-host.ts b/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/local-worktree-host.ts deleted file mode 100644 index d46d766087..0000000000 --- a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/local-worktree-host.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { glob } from 'glob'; -import { FileSystemError, FileSystemErrorCodes, type FileEntry } from '@main/core/fs/types'; -import type { WorktreeHost } from './worktree-host'; - -type PathApi = Pick; - -export function isPathInsideRoot( - child: string, - parent: string, - options: { pathApi?: PathApi } = {} -): boolean { - const pathApi = options.pathApi ?? path; - const rel = pathApi.relative(parent, child); - return rel === '' || (!rel.startsWith('..') && !pathApi.isAbsolute(rel)); -} - -function isNotFound(error: unknown): boolean { - const code = (error as NodeJS.ErrnoException).code; - return code === 'ENOENT' || code === 'ENOTDIR'; -} - -export class LocalWorktreeHost implements WorktreeHost { - readonly pathApi = path; - - private constructor(private readonly roots: string[]) {} - - private static async resolveAllowedRoot(root: string): Promise { - const resolved = path.resolve(root); - if (!path.isAbsolute(resolved)) { - throw new FileSystemError( - `Expected absolute allowed root: ${root}`, - FileSystemErrorCodes.INVALID_PATH, - root - ); - } - return fs.realpath(resolved); - } - - static async create(args: { allowedRoots: string[] }): Promise { - if (args.allowedRoots.length === 0) { - throw new FileSystemError( - 'At least one allowed root is required', - FileSystemErrorCodes.INVALID_PATH - ); - } - - const roots = await Promise.all( - args.allowedRoots.map((root) => LocalWorktreeHost.resolveAllowedRoot(root)) - ); - - return new LocalWorktreeHost(roots); - } - - async allowRoot(root: string): Promise { - const resolved = await LocalWorktreeHost.resolveAllowedRoot(root); - if (!this.roots.some((existing) => existing === resolved)) { - this.roots.push(resolved); - } - } - - private assertAbsolute(input: string): string { - const resolved = path.resolve(input); - if (!path.isAbsolute(input)) { - throw new FileSystemError( - `Expected absolute path: ${input}`, - FileSystemErrorCodes.INVALID_PATH, - input - ); - } - return resolved; - } - - private assertInsideAllowedRoots(resolved: string, originalPath: string): void { - if (!this.roots.some((root) => isPathInsideRoot(resolved, root))) { - throw new FileSystemError( - `Path outside allowed roots: ${originalPath}`, - FileSystemErrorCodes.PATH_ESCAPE, - originalPath - ); - } - } - - private async validateExisting(input: string): Promise { - const resolved = this.assertAbsolute(input); - const real = await fs.realpath(resolved); - this.assertInsideAllowedRoots(real, input); - return real; - } - - private async nearestExistingPath(resolved: string): Promise<{ - realAncestor: string; - unresolvedSegments: string[]; - }> { - const unresolvedSegments: string[] = []; - let current = resolved; - - while (true) { - try { - return { - realAncestor: await fs.realpath(current), - unresolvedSegments: unresolvedSegments.reverse(), - }; - } catch (error) { - if (!isNotFound(error)) throw error; - const parent = path.dirname(current); - if (parent === current) throw error; - unresolvedSegments.push(path.basename(current)); - current = parent; - } - } - } - - private async validateTarget(input: string): Promise { - const resolved = this.assertAbsolute(input); - try { - return await this.validateExisting(resolved); - } catch (error) { - if (!isNotFound(error)) throw error; - } - - const { realAncestor, unresolvedSegments } = await this.nearestExistingPath(resolved); - this.assertInsideAllowedRoots(realAncestor, input); - const target = path.join(realAncestor, ...unresolvedSegments); - this.assertInsideAllowedRoots(target, input); - return target; - } - - async existsAbsolute(filePath: string): Promise { - try { - await this.validateExisting(filePath); - return true; - } catch { - return false; - } - } - - async mkdirAbsolute(dirPath: string, options?: { recursive?: boolean }): Promise { - const target = await this.validateTarget(dirPath); - await fs.mkdir(target, { recursive: options?.recursive ?? false }); - } - - async removeAbsolute( - filePath: string, - options?: { recursive?: boolean } - ): Promise<{ success: boolean; error?: string }> { - try { - const target = await this.validateExisting(filePath); - await fs.rm(target, { - recursive: options?.recursive ?? false, - force: true, - maxRetries: options?.recursive ? 3 : 0, - retryDelay: 100, - }); - return { success: true }; - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; - } - } - - async realPathAbsolute(filePath: string): Promise { - return this.validateExisting(filePath); - } - - async globAbsolute(pattern: string, options: { cwd: string; dot?: boolean }): Promise { - const cwd = await this.validateExisting(options.cwd); - return glob(pattern, { cwd, dot: options.dot ?? false, absolute: false }); - } - - async readFileAbsolute(filePath: string): Promise { - const safePath = await this.validateExisting(filePath); - return fs.readFile(safePath, 'utf8'); - } - - async copyFileAbsolute(src: string, dest: string): Promise { - const safeSrc = await this.validateExisting(src); - const safeDest = await this.validateTarget(dest); - await fs.copyFile(safeSrc, safeDest); - } - - async statAbsolute(filePath: string): Promise { - try { - const fullPath = await this.validateExisting(filePath); - const stat = await fs.stat(fullPath); - return { - path: fullPath, - type: stat.isDirectory() ? 'dir' : 'file', - size: stat.size, - mtime: stat.mtime, - ctime: stat.ctime, - mode: stat.mode, - }; - } catch { - return null; - } - } -} diff --git a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/ssh-worktree-host.test.ts b/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/ssh-worktree-host.test.ts deleted file mode 100644 index d70452b609..0000000000 --- a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/ssh-worktree-host.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { FileSystemErrorCodes, type FileSystemProvider } from '@main/core/fs/types'; -import { SshWorktreeHost } from './ssh-worktree-host'; - -function makeFs(): Pick< - FileSystemProvider, - 'exists' | 'mkdir' | 'remove' | 'realPath' | 'glob' | 'read' | 'copyFile' | 'stat' -> { - return { - exists: vi.fn().mockResolvedValue(true), - mkdir: vi.fn().mockResolvedValue(undefined), - remove: vi.fn().mockResolvedValue({ success: true }), - realPath: vi.fn().mockResolvedValue('/real/path'), - glob: vi.fn().mockResolvedValue(['.env']), - read: vi.fn().mockResolvedValue({ content: 'hello', truncated: false, totalSize: 5 }), - copyFile: vi.fn().mockResolvedValue(undefined), - stat: vi.fn().mockResolvedValue(null), - }; -} - -describe('SshWorktreeHost', () => { - it('delegates absolute POSIX paths to the wrapped filesystem', async () => { - const fs = makeFs(); - const host = new SshWorktreeHost(fs); - - await host.mkdirAbsolute('/remote/worktrees/project', { recursive: true }); - await host.copyFileAbsolute('/remote/repo/.env', '/remote/worktrees/project/task/.env'); - await host.globAbsolute('.env', { cwd: '/remote/repo', dot: true }); - - expect(fs.mkdir).toHaveBeenCalledWith('/remote/worktrees/project', { recursive: true }); - expect(fs.copyFile).toHaveBeenCalledWith( - '/remote/repo/.env', - '/remote/worktrees/project/task/.env' - ); - expect(fs.glob).toHaveBeenCalledWith('.env', { cwd: '/remote/repo', dot: true }); - }); - - it('rejects relative paths before delegating', async () => { - const fs = makeFs(); - const host = new SshWorktreeHost(fs); - - await expect(host.existsAbsolute('relative/path')).rejects.toMatchObject({ - code: FileSystemErrorCodes.INVALID_PATH, - }); - expect(fs.exists).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/ssh-worktree-host.ts b/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/ssh-worktree-host.ts deleted file mode 100644 index 9751e4a8c1..0000000000 --- a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/ssh-worktree-host.ts +++ /dev/null @@ -1,68 +0,0 @@ -import path from 'node:path'; -import { - FileSystemError, - FileSystemErrorCodes, - type FileEntry, - type FileSystemProvider, -} from '@main/core/fs/types'; -import type { WorktreeHost } from './worktree-host'; - -type SshWorktreeFs = Pick< - FileSystemProvider, - 'exists' | 'mkdir' | 'remove' | 'realPath' | 'glob' | 'read' | 'copyFile' | 'stat' ->; - -export class SshWorktreeHost implements WorktreeHost { - readonly pathApi = path.posix; - - constructor(private readonly fs: SshWorktreeFs) {} - - private validateAbsolute(input: string): string { - if (!path.posix.isAbsolute(input)) { - throw new FileSystemError( - `Expected absolute POSIX path: ${input}`, - FileSystemErrorCodes.INVALID_PATH, - input - ); - } - return input; - } - - async existsAbsolute(filePath: string): Promise { - return this.fs.exists(this.validateAbsolute(filePath)); - } - - async mkdirAbsolute(dirPath: string, options?: { recursive?: boolean }): Promise { - return this.fs.mkdir(this.validateAbsolute(dirPath), options); - } - - async removeAbsolute( - filePath: string, - options?: { recursive?: boolean } - ): Promise<{ success: boolean; error?: string }> { - return this.fs.remove(this.validateAbsolute(filePath), options); - } - - async realPathAbsolute(filePath: string): Promise { - return this.fs.realPath(this.validateAbsolute(filePath)); - } - - async globAbsolute(pattern: string, options: { cwd: string; dot?: boolean }): Promise { - return this.fs.glob(pattern, { - ...options, - cwd: this.validateAbsolute(options.cwd), - }); - } - - async readFileAbsolute(filePath: string): Promise { - return (await this.fs.read(this.validateAbsolute(filePath))).content; - } - - async copyFileAbsolute(src: string, dest: string): Promise { - return this.fs.copyFile(this.validateAbsolute(src), this.validateAbsolute(dest)); - } - - async statAbsolute(filePath: string): Promise { - return this.fs.stat(this.validateAbsolute(filePath)); - } -} diff --git a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/worktree-host.ts b/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/worktree-host.ts deleted file mode 100644 index ab1f388d0a..0000000000 --- a/apps/emdash-desktop/src/main/core/projects/worktrees/hosts/worktree-host.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type path from 'node:path'; -import type { FileEntry } from '@main/core/fs/types'; - -export type WorktreeHostPathApi = Pick; - -export interface WorktreeHost { - readonly pathApi: WorktreeHostPathApi; - existsAbsolute(path: string): Promise; - mkdirAbsolute(path: string, options?: { recursive?: boolean }): Promise; - removeAbsolute( - path: string, - options?: { recursive?: boolean } - ): Promise<{ success: boolean; error?: string }>; - realPathAbsolute(path: string): Promise; - globAbsolute(pattern: string, options: { cwd: string; dot?: boolean }): Promise; - readFileAbsolute(path: string): Promise; - copyFileAbsolute(src: string, dest: string): Promise; - statAbsolute(path: string): Promise; -} diff --git a/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.test.ts b/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.test.ts index 1cd9350211..bc16abf94d 100644 --- a/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.test.ts +++ b/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.test.ts @@ -1,14 +1,15 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import nodePath from 'node:path'; +import { contains, FilesRuntime, type IFileSystem } from '@emdash/core/files'; import type { GitRemote } from '@emdash/core/git'; -import { ok } from '@emdash/shared'; +import { err, ok, type Result } from '@emdash/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context'; import type { IExecutionContext } from '@main/core/execution-context/types'; +import type { IFilesRuntime, RuntimePath } from '@main/core/runtime/types'; import type { ProjectSettingsProvider } from '../settings/provider'; -import { LocalWorktreeHost } from './hosts/local-worktree-host'; -import type { WorktreeHost } from './hosts/worktree-host'; import { WorktreeService } from './worktree-service'; async function git( @@ -43,18 +44,78 @@ function makeSettings(preservePatterns: string[] = []): ProjectSettingsProvider const originRemote = (url = 'ssh://example.com/repo.git'): GitRemote => ({ name: 'origin', url }); +type FakeFilesRuntimeOptions = { + pathApi?: RuntimePath; + existsAbsolute?: (absPath: string) => Promise; + mkdirAbsolute?: (absPath: string, options?: { recursive?: boolean }) => Promise; + removeAbsolute?: ( + absPath: string, + options?: { recursive?: boolean } + ) => Promise>; + realPathAbsolute?: (absPath: string) => Promise; +}; + +function makeFakeFilesRuntime(options: FakeFilesRuntimeOptions = {}): IFilesRuntime { + const pathApi = options.pathApi ?? nativeMachinePath; + return { + path: pathApi, + openTree: vi.fn(), + watchChanges: vi.fn(), + fileSystem: vi.fn(() => + ok({ + exists: async (absPath: string) => ok(await (options.existsAbsolute?.(absPath) ?? false)), + mkdir: async (absPath: string, mkdirOptions?: { recursive?: boolean }) => { + await options.mkdirAbsolute?.(absPath, mkdirOptions); + return ok(); + }, + remove: async (absPath: string, removeOptions?: { recursive?: boolean }) => { + const result = (await options.removeAbsolute?.(absPath, removeOptions)) ?? ok(); + return result.success + ? ok() + : err({ + type: 'fs-error' as const, + path: absPath, + message: result.error.message, + }); + }, + realPath: async (absPath: string) => + ok(await (options.realPathAbsolute?.(absPath) ?? absPath)), + stat: async () => + err({ + type: 'fs-error' as const, + path: '', + message: 'stat is not implemented by test fake', + code: 'ENOENT', + }), + glob: () => + ok( + (async function* () { + // No preserved files in fake-runtime unit cases. + })() + ), + } as unknown as IFileSystem) + ), + dispose: vi.fn(), + } as unknown as IFilesRuntime; +} + +const nativeMachinePath: RuntimePath = { + join: (...parts: string[]) => nodePath.join(...parts), + dirname: (value: string) => nodePath.dirname(value), + basename: (value: string) => nodePath.basename(value), + isAbsolute: (value: string) => nodePath.isAbsolute(value), + relative: (from: string, to: string) => nodePath.relative(from, to), + contains, +}; + describe('WorktreeService', () => { let repoDir: string; let poolDir: string; - let host: WorktreeHost; beforeEach(async () => { repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-repo-')); poolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-pool-')); await initRepo(repoDir); - host = await LocalWorktreeHost.create({ - allowedRoots: [repoDir, poolDir], - }); }); afterEach(() => { @@ -75,27 +136,36 @@ describe('WorktreeService', () => { return new WorktreeService({ repoPath, ctx: new LocalExecutionContext({ root: repoPath }), - host, + files: Object.assign(new FilesRuntime(), { path: nativeMachinePath }), projectSettings: overrides.projectSettings ?? makeSettings(), resolveWorktreePoolPath: overrides.resolveWorktreePoolPath ?? (async () => worktreePoolPath), }); } - it('uses the host path API for worktree paths', async () => { - const remoteHost: WorktreeHost = { - existsAbsolute: vi.fn().mockResolvedValue(false), - mkdirAbsolute: vi.fn().mockResolvedValue(undefined), - removeAbsolute: vi.fn().mockResolvedValue({ success: true }), - realPathAbsolute: vi.fn().mockResolvedValue('/remote/worktrees/project'), - globAbsolute: vi.fn().mockResolvedValue([]), - readFileAbsolute: vi.fn().mockResolvedValue(''), - copyFileAbsolute: vi.fn().mockResolvedValue(undefined), - statAbsolute: vi.fn().mockResolvedValue(null), - pathApi: { - join: (...segments: string[]) => `host:${path.posix.join(...segments)}`, - dirname: (input: string) => `host-dir:${path.posix.dirname(input.replace(/^host:/, ''))}`, + it('uses the runtime path API for worktree paths', async () => { + const stripHost = (value: string) => value.replace(/^host:/, ''); + const remotePathApi: RuntimePath = { + join: (...segments: string[]) => + `host:${path.posix.join(...segments.map((segment) => stripHost(segment)))}`, + dirname: (input: string) => `host:${path.posix.dirname(stripHost(input))}`, + basename: (input: string) => path.posix.basename(stripHost(input)), + isAbsolute: (input: string) => input.startsWith('host:/') || path.posix.isAbsolute(input), + relative: (from: string, to: string) => path.posix.relative(stripHost(from), stripHost(to)), + contains: (parent: string, child: string) => { + const rel = path.posix.relative(stripHost(parent), stripHost(child)); + return ( + rel === '' || (rel !== '..' && !rel.startsWith('../') && !path.posix.isAbsolute(rel)) + ); }, }; + const existsAbsolute = vi.fn().mockResolvedValue(false); + const mkdirAbsolute = vi.fn().mockResolvedValue(undefined); + const files = makeFakeFilesRuntime({ + pathApi: remotePathApi, + existsAbsolute, + mkdirAbsolute, + realPathAbsolute: async (absPath) => absPath, + }); const remoteCtx = { root: '/remote/repo', supportsLocalSpawn: false, @@ -106,16 +176,14 @@ describe('WorktreeService', () => { const svc = new WorktreeService({ repoPath: '/remote/repo', ctx: remoteCtx, - host: remoteHost, + files, projectSettings: makeSettings(), resolveWorktreePoolPath: async () => '/remote/worktrees/project', }); await expect(svc.getWorktree('emdash/task-abc')).resolves.toBeUndefined(); - expect(remoteHost.existsAbsolute).toHaveBeenCalledWith( - 'host:/remote/worktrees/project/emdash/task-abc' - ); + expect(existsAbsolute).toHaveBeenCalledWith('host:/remote/worktrees/project/emdash/task-abc'); const checkoutResult = await svc.checkoutBranchWorktree( { type: 'local', branch: 'main' }, @@ -123,10 +191,9 @@ describe('WorktreeService', () => { ); expect(checkoutResult.success).toBe(true); - expect(remoteHost.mkdirAbsolute).toHaveBeenCalledWith( - 'host-dir:/remote/worktrees/project/emdash', - { recursive: true } - ); + expect(mkdirAbsolute).toHaveBeenCalledWith('host:/remote/worktrees/project/emdash', { + recursive: true, + }); }); describe('checkoutBranchWorktree', () => { @@ -153,28 +220,23 @@ describe('WorktreeService', () => { execStreaming: async () => {}, dispose: () => {}, }; - const fakeHost: WorktreeHost = { - pathApi: path, - existsAbsolute: vi.fn(async (absPath: string) => absPath === targetPath), - mkdirAbsolute: vi.fn(async () => {}), - removeAbsolute: vi.fn(async () => ({ success: false, error: 'permission denied' })), - realPathAbsolute: vi.fn(async (absPath: string) => absPath), - globAbsolute: vi.fn(async () => []), - readFileAbsolute: vi.fn(async () => ''), - copyFileAbsolute: vi.fn(async () => {}), - statAbsolute: vi.fn(async () => null), - }; + const removeAbsolute = vi.fn(async () => err({ message: 'permission denied' })); + const files = makeFakeFilesRuntime({ + existsAbsolute: async (absPath) => absPath === targetPath, + removeAbsolute, + realPathAbsolute: async (absPath) => absPath, + }); const svc = new WorktreeService({ repoPath: repoDir, ctx, - host: fakeHost, + files, projectSettings: makeSettings(), resolveWorktreePoolPath: async () => poolDir, }); await expect(svc.getWorktree(branchName)).resolves.toBeUndefined(); - expect(fakeHost.removeAbsolute).toHaveBeenCalledWith(targetPath, { recursive: true }); + expect(removeAbsolute).toHaveBeenCalledWith(targetPath, { recursive: true }); }); it('creates a worktree from an existing local source branch', async () => { @@ -232,21 +294,15 @@ describe('WorktreeService', () => { execStreaming: async () => {}, dispose: () => {}, }; - const fakeHost: WorktreeHost = { - pathApi: path, - existsAbsolute: vi.fn(async (absPath: string) => absPath === targetPath), - mkdirAbsolute: vi.fn(async () => {}), - removeAbsolute: vi.fn(async () => ({ success: false, error: 'permission denied' })), - realPathAbsolute: vi.fn(async (absPath: string) => absPath), - globAbsolute: vi.fn(async () => []), - readFileAbsolute: vi.fn(async () => ''), - copyFileAbsolute: vi.fn(async () => {}), - statAbsolute: vi.fn(async () => null), - }; + const files = makeFakeFilesRuntime({ + existsAbsolute: async (absPath) => absPath === targetPath, + removeAbsolute: async () => err({ message: 'permission denied' }), + realPathAbsolute: async (absPath) => absPath, + }); const svc = new WorktreeService({ repoPath: repoDir, ctx, - host: fakeHost, + files, projectSettings: makeSettings(), resolveWorktreePoolPath: async () => poolDir, }); @@ -310,23 +366,17 @@ describe('WorktreeService', () => { execStreaming: async () => {}, dispose: () => {}, }; - const fakeHost: WorktreeHost = { - pathApi: path, - existsAbsolute: vi.fn(async (absPath: string) => { + const files = makeFakeFilesRuntime({ + existsAbsolute: async (absPath) => { return absPath === targetPath || absPath === path.join(targetPath, '.git'); - }), - mkdirAbsolute: vi.fn(async () => {}), - removeAbsolute: vi.fn(async () => ({ success: true })), - realPathAbsolute: vi.fn(async (absPath: string) => absPath), - globAbsolute: vi.fn(async () => []), - readFileAbsolute: vi.fn(async () => ''), - copyFileAbsolute: vi.fn(async () => {}), - statAbsolute: vi.fn(async () => null), - }; + }, + removeAbsolute: async () => ok(), + realPathAbsolute: async (absPath) => absPath, + }); const svc = new WorktreeService({ repoPath: repoDir, ctx, - host: fakeHost, + files, projectSettings: makeSettings(), resolveWorktreePoolPath: async () => poolDir, }); @@ -422,6 +472,36 @@ describe('WorktreeService', () => { if (!result.success) throw new Error('expected success'); expect(fs.readFileSync(path.join(result.data, '.env'), 'utf8')).toBe('SECRET=abc'); }); + + it('skips preserve patterns that can escape the source repo or target worktree', async () => { + fs.writeFileSync(path.join(repoDir, '.env'), 'SECRET=abc'); + const parentSecret = path.join(path.dirname(repoDir), 'preserve-secret.txt'); + const absoluteSecret = path.join(os.tmpdir(), `preserve-secret-${Date.now()}.txt`); + fs.writeFileSync(parentSecret, 'parent-secret'); + fs.writeFileSync(absoluteSecret, 'absolute-secret'); + await git(['branch', 'task/safe-preserve'], { cwd: repoDir }); + const svc = makeService({ + projectSettings: makeSettings(['.env', '../preserve-secret.txt', absoluteSecret]), + }); + + try { + const result = await svc.checkoutBranchWorktree( + { type: 'local', branch: 'main' }, + 'task/safe-preserve' + ); + + expect(result.success).toBe(true); + if (!result.success) throw new Error('expected success'); + expect(fs.readFileSync(path.join(result.data, '.env'), 'utf8')).toBe('SECRET=abc'); + expect(fs.existsSync(path.join(path.dirname(result.data), 'preserve-secret.txt'))).toBe( + false + ); + expect(fs.existsSync(path.join(result.data, path.basename(absoluteSecret)))).toBe(false); + } finally { + fs.rmSync(parentSecret, { force: true }); + fs.rmSync(absoluteSecret, { force: true }); + } + }); }); describe('removeWorktree', () => { @@ -435,21 +515,15 @@ describe('WorktreeService', () => { execStreaming: async () => {}, dispose: () => {}, }; - const fakeHost: WorktreeHost = { - pathApi: path, - existsAbsolute: vi.fn(async () => false), - mkdirAbsolute: vi.fn(async () => {}), - removeAbsolute: vi.fn(async () => ({ success: false, error: 'permission denied' })), - realPathAbsolute: vi.fn(async (absPath: string) => absPath), - globAbsolute: vi.fn(async () => []), - readFileAbsolute: vi.fn(async () => ''), - copyFileAbsolute: vi.fn(async () => {}), - statAbsolute: vi.fn(async () => null), - }; + const files = makeFakeFilesRuntime({ + existsAbsolute: async () => false, + removeAbsolute: async () => err({ message: 'permission denied' }), + realPathAbsolute: async (absPath) => absPath, + }); const svc = new WorktreeService({ repoPath: repoDir, ctx, - host: fakeHost, + files, projectSettings: makeSettings(), resolveWorktreePoolPath: async () => poolDir, }); diff --git a/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.ts b/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.ts index 30376f6ff6..36ca9c9494 100644 --- a/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.ts +++ b/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.ts @@ -1,30 +1,44 @@ -import { promises as fsPromises } from 'node:fs'; +import type { IFileSystem } from '@emdash/core/files'; import type { GitBranchRef } from '@emdash/core/git'; import { err, ok, toSerializedError, type Result, type SerializedError } from '@emdash/shared'; import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { FileSystemProvider } from '@main/core/fs/types'; +import { + ensureAbsoluteDir, + isRealPathContained, + openFileSystem, + realPathAbsolute, +} from '@main/core/runtime/files-helpers'; +import type { IFilesRuntime } from '@main/core/runtime/types'; import { log } from '@main/lib/logger'; import { DEFAULT_REMOTE_NAME } from '@shared/core/git/types'; import { getEffectiveTaskSettings } from '../settings/effective-task-settings'; +import { + isSafePreservePattern, + preservedDestinationPath, + preservedRepoRelativePath, +} from '../settings/preserve-pattern-safety'; import type { ProjectSettingsProvider } from '../settings/provider'; -import type { WorktreeHost } from './hosts/worktree-host'; export type ServeWorktreeError = | { type: 'worktree-setup-failed'; cause: SerializedError } | { type: 'branch-not-found'; branch: string }; +function fileErrorCause(error: { type?: string; message: string }): SerializedError { + return { name: error.type ?? 'FileError', message: error.message }; +} + export class WorktreeService { private gitOpQueue: Promise = Promise.resolve(); private readonly resolveWorktreePoolPath: () => Promise; private readonly repoPath: string; private readonly ctx: IExecutionContext; - private readonly host: WorktreeHost; + private readonly files: IFilesRuntime; private readonly projectSettings: ProjectSettingsProvider; constructor(args: { repoPath: string; ctx: IExecutionContext; - host: WorktreeHost; + files: IFilesRuntime; projectSettings: ProjectSettingsProvider; resolveWorktreePoolPath: () => Promise; }) { @@ -32,7 +46,7 @@ export class WorktreeService { this.repoPath = args.repoPath; this.projectSettings = args.projectSettings; this.ctx = args.ctx; - this.host = args.host; + this.files = args.files; this.ctx.exec('git', ['worktree', 'prune']).catch(() => {}); } @@ -45,20 +59,8 @@ export class WorktreeService { private async isValidWorktree(worktreePath: string): Promise { // A linked worktree contains a .git FILE pointing to the main repo's worktrees - // directory. For local execution we bypass host path-restriction checks and use - // fs directly so external worktrees (outside allowedRoots) are still detected. - // For SSH we rely on the host (SshWorktreeHost has no root restrictions). - let hasGitFile = false; - if (this.ctx.supportsLocalSpawn) { - try { - await fsPromises.access(this.host.pathApi.join(worktreePath, '.git')); - hasGitFile = true; - } catch { - return false; - } - } else { - hasGitFile = await this.host.existsAbsolute(this.host.pathApi.join(worktreePath, '.git')); - } + // directory. + const hasGitFile = await this.existsAbsolute(this.files.path.join(worktreePath, '.git')); if (!hasGitFile) return false; try { @@ -79,21 +81,30 @@ export class WorktreeService { return this.resolveWorktreePoolPath(); } - private async ensureWorktreePoolDirExists(): Promise { - await this.host.mkdirAbsolute(await this.resolveWorktreePoolPath(), { recursive: true }); + private async ensureWorktreePoolDirExists(): Promise> { + const result = await ensureAbsoluteDir(this.files, await this.resolveWorktreePoolPath()); + return result.success + ? ok() + : err({ type: 'worktree-setup-failed', cause: fileErrorCause(result.error) }); } private async removePathForReuse(targetPath: string): Promise { - const result = await this.host.removeAbsolute(targetPath, { recursive: true }); - if (!result.success) { + const poolPath = await this.resolveWorktreePoolPath(); + const contained = await isRealPathContained(this.files, poolPath, targetPath, { + candidateMustExist: true, + }); + if (!contained.success || !contained.data) { + throw new Error(`Refusing to remove worktree path outside pool: "${targetPath}"`); + } + + const removed = await this.removeAbsolute(targetPath, { recursive: true }); + if (!removed.success) { throw new Error( - result.error - ? `Failed to remove stale worktree directory "${targetPath}": ${result.error}` - : `Failed to remove stale worktree directory "${targetPath}"` + `Failed to remove stale worktree directory "${targetPath}": ${removed.error.message}` ); } - if (await this.host.existsAbsolute(targetPath)) { + if (await this.existsAbsolute(targetPath)) { throw new Error( `Failed to remove stale worktree directory "${targetPath}": path still exists` ); @@ -109,15 +120,29 @@ export class WorktreeService { } async existsAtAbsolutePath(absPath: string): Promise { - if (this.ctx.supportsLocalSpawn) { - try { - await fsPromises.access(absPath); - return true; - } catch { - return false; - } + return this.existsAbsolute(absPath); + } + + private async existsAbsolute(absPath: string): Promise { + if (!this.files.path.isAbsolute(absPath)) return false; + const opened = this.files.fileSystem(); + if (!opened.success) return false; + const exists = await opened.data.exists(absPath); + return exists.success ? exists.data : false; + } + + private async removeAbsolute( + absPath: string, + options?: { recursive?: boolean } + ): Promise> { + if (!this.files.path.isAbsolute(absPath)) { + return err({ message: `Expected absolute path: ${absPath}` }); } - return this.host.existsAbsolute(absPath); + const fs = openFileSystem(this.files); + if (!fs.success) return err({ message: fs.error.message }); + const removed = await fs.data.remove(absPath, options); + if (!removed.success) return err({ message: removed.error.message }); + return ok(); } async findBranchAnywhere(branchName: string): Promise { @@ -196,8 +221,8 @@ export class WorktreeService { async getWorktree(branchName: string): Promise { const worktreePoolPath = await this.resolveWorktreePoolPath(); - const worktreePath = this.host.pathApi.join(worktreePoolPath, branchName); - if (await this.host.existsAbsolute(worktreePath)) { + const worktreePath = this.files.path.join(worktreePoolPath, branchName); + if (await this.existsAbsolute(worktreePath)) { if (await this.isValidWorktree(worktreePath)) return worktreePath; try { await this.removePathForReuse(worktreePath); @@ -207,14 +232,16 @@ export class WorktreeService { } try { - const realPoolPath = await this.host.realPathAbsolute(worktreePoolPath); + const realPoolPath = await realPathAbsolute(this.files, worktreePoolPath); + if (!realPoolPath.success) return undefined; const { stdout } = await this.ctx.exec('git', ['worktree', 'list', '--porcelain']); const branchLine = `branch refs/heads/${branchName}`; for (const block of stdout.split('\n\n')) { if (block.split('\n').some((line) => line === branchLine)) { const match = /^worktree (.+)$/m.exec(block); const candidatePath = match?.[1]; - if (!candidatePath?.startsWith(realPoolPath)) continue; + if (!candidatePath || !this.files.path.contains(realPoolPath.data, candidatePath)) + continue; if (await this.isValidWorktree(candidatePath)) return candidatePath; await this.ctx.exec('git', ['worktree', 'prune']).catch(() => {}); } @@ -228,7 +255,8 @@ export class WorktreeService { branchName: string, options: { copyPreservedFiles?: boolean } = {} ): Promise> { - await this.ensureWorktreePoolDirExists(); + const poolDir = await this.ensureWorktreePoolDirExists(); + if (!poolDir.success) return poolDir; return this.enqueueGitOp(() => this.doCheckoutBranchWorktree(sourceBranch, branchName, options) ); @@ -246,8 +274,8 @@ export class WorktreeService { return ok(checkedOutPath); } - const targetPath = this.host.pathApi.join(await this.resolveWorktreePoolPath(), branchName); - if (await this.host.existsAbsolute(targetPath)) { + const targetPath = this.files.path.join(await this.resolveWorktreePoolPath(), branchName); + if (await this.existsAbsolute(targetPath)) { if (await this.isValidWorktree(targetPath)) { await this.ensureBranchBaseConfig(branchName, baseConfigValue); return ok(targetPath); @@ -276,7 +304,10 @@ export class WorktreeService { } await this.ensureBranchBaseConfig(branchName, baseConfigValue); - await this.host.mkdirAbsolute(this.host.pathApi.dirname(targetPath), { recursive: true }); + const parentDir = await ensureAbsoluteDir(this.files, this.files.path.dirname(targetPath)); + if (!parentDir.success) { + return err({ type: 'worktree-setup-failed', cause: fileErrorCause(parentDir.error) }); + } await this.ctx.exec('git', ['worktree', 'prune']).catch(() => {}); await this.ctx.exec('git', ['worktree', 'add', targetPath, branchName]); } catch (cause) { @@ -299,7 +330,8 @@ export class WorktreeService { branchName: string, options: { copyPreservedFiles?: boolean } = {} ): Promise> { - await this.ensureWorktreePoolDirExists(); + const poolDir = await this.ensureWorktreePoolDirExists(); + if (!poolDir.success) return poolDir; return this.enqueueGitOp(() => this.doCheckoutExistingBranch(branchName, options)); } @@ -327,10 +359,10 @@ export class WorktreeService { return ok(checkedOutPath); } - const targetPath = this.host.pathApi.join(await this.resolveWorktreePoolPath(), branchName); + const targetPath = this.files.path.join(await this.resolveWorktreePoolPath(), branchName); const remoteCandidates = await this.getRemoteCandidates(); - if (await this.host.existsAbsolute(targetPath)) { + if (await this.existsAbsolute(targetPath)) { if (await this.isValidWorktree(targetPath)) return ok(targetPath); try { await this.removePathForReuse(targetPath); @@ -341,7 +373,10 @@ export class WorktreeService { } try { - await this.host.mkdirAbsolute(this.host.pathApi.dirname(targetPath), { recursive: true }); + const parentDir = await ensureAbsoluteDir(this.files, this.files.path.dirname(targetPath)); + if (!parentDir.success) { + return err({ type: 'worktree-setup-failed', cause: fileErrorCause(parentDir.error) }); + } for (const remoteName of remoteCandidates) { await this.ctx.exec('git', ['fetch', remoteName]).catch(() => {}); } @@ -403,24 +438,16 @@ export class WorktreeService { }); } - private taskConfigFs(targetPath: string): Pick { - return { - exists: (filePath) => this.host.existsAbsolute(this.host.pathApi.join(targetPath, filePath)), - read: async (filePath) => { - const content = await this.host.readFileAbsolute( - this.host.pathApi.join(targetPath, filePath) - ); - return { - content, - truncated: false, - totalSize: Buffer.byteLength(content), - }; - }, - }; + private taskConfigFs(): IFileSystem | null { + const opened = this.files.fileSystem(); + if (opened.success) return opened.data; + log.warn('WorktreeService: failed to open task config filesystem', opened.error); + return null; } - private async isTrackedSourcePath(relPath: string): Promise { + private async isTrackedSourcePath(absPath: string): Promise { try { + const relPath = this.files.path.relative(this.repoPath, absPath); await this.ctx.exec('git', ['ls-files', '--error-unmatch', '--', relPath]); return true; } catch { @@ -429,24 +456,55 @@ export class WorktreeService { } private async copyPreservedFiles(targetPath: string): Promise { + const taskFs = this.taskConfigFs(); + if (!taskFs) return; + const settings = await getEffectiveTaskSettings({ projectSettings: this.projectSettings, - taskFs: this.taskConfigFs(targetPath) as FileSystemProvider, + taskFs, + taskConfigPath: this.files.path.join(targetPath, '.emdash.json'), }); const patterns = settings.preservePatterns ?? []; + const repoFs = this.files.fileSystem(); + if (!repoFs.success) { + log.warn('WorktreeService: failed to open repo filesystem for preserved files', repoFs.error); + return; + } for (const pattern of patterns) { - const matches = await this.host.globAbsolute(pattern, { - cwd: this.repoPath, - dot: true, - }); - for (const relPath of matches) { - if (relPath === '.emdash.json' || (await this.isTrackedSourcePath(relPath))) continue; - const src = this.host.pathApi.join(this.repoPath, relPath); - const stat = await this.host.statAbsolute(src).catch(() => null); - if (!stat || stat.type !== 'file') continue; - const dest = this.host.pathApi.join(targetPath, relPath); - await this.host.mkdirAbsolute(this.host.pathApi.dirname(dest), { recursive: true }); - await this.host.copyFileAbsolute(src, dest); + if (!isSafePreservePattern(this.files.path, pattern)) { + log.warn('WorktreeService: skipping unsafe preserve pattern', { pattern }); + continue; + } + const matches = repoFs.data.glob([pattern], { cwd: this.repoPath, dot: true }); + if (!matches.success) { + log.warn('WorktreeService: failed to match preserve pattern', { + pattern, + error: matches.error, + }); + continue; + } + for await (const absPath of matches.data) { + const relPath = preservedRepoRelativePath(this.files.path, this.repoPath, absPath); + if (!relPath || (await this.isTrackedSourcePath(absPath))) continue; + const stat = await repoFs.data.stat(absPath); + if (!stat.success || stat.data.type !== 'file') continue; + const destPath = preservedDestinationPath(this.files.path, targetPath, relPath); + if (!destPath) continue; + const contained = await isRealPathContained(this.files, targetPath, destPath); + if (!contained.success || !contained.data) { + log.warn('WorktreeService: skipping preserved file with out-of-worktree destination', { + destPath, + }); + continue; + } + const copied = await repoFs.data.copyFile(absPath, destPath); + if (!copied.success) { + log.warn('WorktreeService: failed to copy preserved file', { + sourcePath: absPath, + destPath, + error: copied.error, + }); + } } } } diff --git a/apps/emdash-desktop/src/main/core/pty/controller.test.ts b/apps/emdash-desktop/src/main/core/pty/controller.test.ts index bc0e91a9d4..29ce07b4c7 100644 --- a/apps/emdash-desktop/src/main/core/pty/controller.test.ts +++ b/apps/emdash-desktop/src/main/core/pty/controller.test.ts @@ -1,9 +1,27 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as nodeCrypto from 'node:crypto'; +import type * as fsPromises from 'node:fs/promises'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { conversationEvents } from '@main/core/conversations/conversation-events'; -import { taskSessionManager } from '../tasks/task-session-manager'; -import { workspaceRegistry } from '../workspaces/workspace-registry'; import { ptySessionRegistry } from './pty-session-registry'; +const mocks = vi.hoisted(() => ({ + getTask: vi.fn(), + getWorkspace: vi.fn(), + getWorkspaceId: vi.fn(), + randomUUID: vi.fn(), + readFile: vi.fn(), +})); + +vi.mock('node:crypto', async (importActual) => { + const actual = await importActual(); + return { ...actual, randomUUID: mocks.randomUUID }; +}); + +vi.mock('node:fs/promises', async (importActual) => { + const actual = await importActual(); + return { ...actual, readFile: mocks.readFile }; +}); + vi.mock('./persist-dropped-blob', () => ({ cleanupExpiredDroppedBlobs: vi.fn().mockResolvedValue(undefined), persistClipboardImagePath: vi.fn(), @@ -22,11 +40,16 @@ vi.mock('@main/lib/events', () => ({ })); vi.mock('../tasks/task-session-manager', () => ({ - taskSessionManager: {}, + taskSessionManager: { + getTask: mocks.getTask, + getWorkspaceId: mocks.getWorkspaceId, + }, })); vi.mock('../workspaces/workspace-registry', () => ({ - workspaceRegistry: {}, + workspaceRegistry: { + get: mocks.getWorkspace, + }, })); const emitSpy = vi.spyOn(conversationEvents, '_emit'); @@ -46,15 +69,7 @@ function makePty(write = vi.fn()) { describe('ptyController', () => { beforeEach(() => { emitSpy.mockClear(); - }); - - afterEach(() => { - // Tests below mutate the shared mock singletons; reset them so a later test - // still sees the empty `{}` the vi.mock factories return. - const taskMgr = taskSessionManager as unknown as Record; - delete taskMgr.getTask; - delete taskMgr.getWorkspaceId; - delete (workspaceRegistry as unknown as Record).get; + vi.clearAllMocks(); }); it('emits input-submitted for remote agent PTYs on enter', () => { @@ -79,37 +94,33 @@ describe('ptyController', () => { }); it('uploads remote attachments into the git-ignored .emdash dir, not the worktree root (#2680)', async () => { - const mkdir = vi.fn().mockResolvedValue(undefined); - const copyLocalFile = vi.fn().mockResolvedValue(undefined); - const workspace = { path: '/remote/worktree', fs: { mkdir, copyLocalFile } }; - - const taskMgr = taskSessionManager as unknown as { - getTask: (id: string) => unknown; - getWorkspaceId: (id: string) => string; - }; - taskMgr.getTask = vi.fn(() => ({})); - taskMgr.getWorkspaceId = vi.fn(() => 'ws-1'); - const wsReg = workspaceRegistry as unknown as { get: (id: string) => unknown }; - wsReg.get = vi.fn(() => workspace); + const bytes = Buffer.from('content'); + const mkdir = vi.fn().mockResolvedValue({ success: true }); + const writeBytes = vi.fn().mockResolvedValue({ success: true }); + mocks.randomUUID.mockReturnValue('upload-id'); + mocks.readFile.mockResolvedValue(bytes); + mocks.getTask.mockReturnValue({}); + mocks.getWorkspaceId.mockReturnValue('workspace-1'); + mocks.getWorkspace.mockReturnValue({ + path: '/remote/worktree', + fileSystem: { mkdir, writeBytes }, + }); const result = await ptyController.uploadFiles({ sessionId: 'proj-1:task-1:conv-1', localPaths: ['/local/tmp/emdash-drop-abc-image.png'], }); - expect(result.success).toBe(true); - expect(mkdir).toHaveBeenCalledWith('.emdash/uploads', { recursive: true }); - expect(copyLocalFile).toHaveBeenCalledTimes(1); - - const [src, destRel] = copyLocalFile.mock.calls[0]!; - expect(src).toBe('/local/tmp/emdash-drop-abc-image.png'); - expect(destRel).toMatch(/^\.emdash\/uploads\/[0-9a-f-]+-emdash-drop-abc-image\.png$/); - - if (result.success) { - // Lands under the git-ignored .emdash dir, never directly in the worktree root. - expect(result.data.remotePaths[0]).toMatch( - /^\/remote\/worktree\/\.emdash\/uploads\/[0-9a-f-]+-emdash-drop-abc-image\.png$/ - ); - } + expect(result).toEqual({ + success: true, + data: { + remotePaths: ['/remote/worktree/.emdash/uploads/upload-id-emdash-drop-abc-image.png'], + }, + }); + expect(mkdir).toHaveBeenCalledWith('/remote/worktree/.emdash/uploads', { recursive: true }); + expect(writeBytes).toHaveBeenCalledWith( + '/remote/worktree/.emdash/uploads/upload-id-emdash-drop-abc-image.png', + bytes + ); }); }); diff --git a/apps/emdash-desktop/src/main/core/pty/controller.ts b/apps/emdash-desktop/src/main/core/pty/controller.ts index 302833e7e9..ee5213314c 100644 --- a/apps/emdash-desktop/src/main/core/pty/controller.ts +++ b/apps/emdash-desktop/src/main/core/pty/controller.ts @@ -1,7 +1,9 @@ import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; import { basename } from 'node:path'; import { err, ok } from '@emdash/shared'; import { conversationEvents } from '@main/core/conversations/conversation-events'; +import { joinMachinePath } from '@main/core/files/path-utils'; import { log } from '@main/lib/logger'; import { parsePtySessionId } from '@shared/core/pty/ptySessionId'; import { createRPCController } from '@shared/lib/ipc/rpc'; @@ -154,19 +156,30 @@ export const ptyController = createRPCController({ const workspaceId = taskSessionManager.getWorkspaceId(scopeId) ?? ''; const workspace = workspaceRegistry.get(workspaceId); - if (!workspace?.fs.copyLocalFile) return err({ type: 'not_ssh' as const }); + if (!workspace) return err({ type: 'not_ssh' as const }); // Upload into the git-ignored .emdash runtime dir, not the worktree root. // Writing to the root left every attached image behind as an untracked file // that dirtied `git status` and never got cleaned up (#2680). const uploadDir = `${SSH_PROJECT_STATE_DIR_NAME}/uploads`; - await workspace.fs.mkdir(uploadDir, { recursive: true }); + const uploadDirPath = joinMachinePath(workspace.path, uploadDir); + const madeUploadDir = await workspace.fileSystem.mkdir(uploadDirPath, { recursive: true }); + if (!madeUploadDir.success) { + return err({ type: 'upload_failed' as const, message: madeUploadDir.error.message }); + } const remotePaths = await Promise.all( args.localPaths.map(async (localPath) => { - const remoteRelPath = `${uploadDir}/${randomUUID()}-${basename(localPath)}`; - await workspace.fs.copyLocalFile!(localPath, remoteRelPath); - return `${workspace.path}/${remoteRelPath}`; + const remotePath = joinMachinePath( + uploadDirPath, + `${randomUUID()}-${basename(localPath)}` + ); + const bytes = await readFile(localPath); + const written = await workspace.fileSystem.writeBytes(remotePath, bytes); + if (!written.success) { + throw new Error(written.error.message); + } + return remotePath; }) ); return ok({ remotePaths }); diff --git a/apps/emdash-desktop/src/main/core/runtime/files-helpers.test.ts b/apps/emdash-desktop/src/main/core/runtime/files-helpers.test.ts new file mode 100644 index 0000000000..aefe09d452 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/files-helpers.test.ts @@ -0,0 +1,73 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import nodePath from 'node:path'; +import { contains, FilesRuntime } from '@emdash/core/files'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { isRealPathContained, realPathNearestExisting } from './files-helpers'; +import type { IFilesRuntime, RuntimePath } from './types'; + +const nativeMachinePath: RuntimePath = { + join: (...parts: string[]) => nodePath.join(...parts), + dirname: (value: string) => nodePath.dirname(value), + basename: (value: string) => nodePath.basename(value), + isAbsolute: (value: string) => nodePath.isAbsolute(value), + relative: (from: string, to: string) => nodePath.relative(from, to), + contains, +}; + +function makeFilesRuntime(): IFilesRuntime { + return Object.assign(new FilesRuntime(), { path: nativeMachinePath }) as IFilesRuntime; +} + +describe('files-helpers realpath containment', () => { + let root: string; + let outside: string; + const files = makeFilesRuntime(); + + beforeEach(() => { + root = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'fh-root-')); + outside = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'fh-out-')); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + }); + + it('treats a real path inside the root as contained', async () => { + fs.mkdirSync(nodePath.join(root, 'inside')); + const result = await isRealPathContained( + files, + root, + nodePath.join(root, 'inside', 'file.txt') + ); + expect(result.success && result.data).toBe(true); + }); + + it('rejects a write whose destination parent is a symlink escaping the root', async () => { + // A symlinked subdirectory inside the root pointing outside it must not let a + // write/copy land outside the worktree. + fs.symlinkSync(outside, nodePath.join(root, 'escape'), 'dir'); + const result = await isRealPathContained( + files, + root, + nodePath.join(root, 'escape', 'file.txt') + ); + expect(result.success && result.data).toBe(false); + }); + + it('rejects removing an existing symlink that resolves outside the root', async () => { + fs.symlinkSync(outside, nodePath.join(root, 'escape'), 'dir'); + const result = await isRealPathContained(files, root, nodePath.join(root, 'escape'), { + candidateMustExist: true, + }); + expect(result.success && result.data).toBe(false); + }); + + it('resolves the nearest existing ancestor for a non-existent path', async () => { + fs.mkdirSync(nodePath.join(root, 'a')); + const realRoot = fs.realpathSync(root); + const resolved = await realPathNearestExisting(files, nodePath.join(root, 'a', 'b', 'c.txt')); + expect(resolved.success && resolved.data).toBe(nodePath.join(realRoot, 'a', 'b', 'c.txt')); + }); +}); diff --git a/apps/emdash-desktop/src/main/core/runtime/files-helpers.ts b/apps/emdash-desktop/src/main/core/runtime/files-helpers.ts new file mode 100644 index 0000000000..a82246ca2c --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/files-helpers.ts @@ -0,0 +1,137 @@ +import { + isFileNotFoundError, + type FileError, + type FileStat, + type IFileSystem, +} from '@emdash/core/files'; +import { err, ok, type Result } from '@emdash/shared'; +import type { IFilesRuntime } from './types'; + +export type AbsoluteDirectoryFileSystem = { + mkdir(path: string, options?: { recursive?: boolean }): Promise>; + realPath(path: string): Promise>; +}; + +export function openFileSystem(files: IFilesRuntime): Result { + return files.fileSystem(); +} + +export function absoluteDirectoryFileSystem(files: IFilesRuntime): AbsoluteDirectoryFileSystem { + return { + mkdir: (absPath, options) => ensureAbsoluteDir(files, absPath, options), + realPath: (absPath) => realPathAbsolute(files, absPath), + }; +} + +export async function ensureAbsoluteDir( + files: IFilesRuntime, + absPath: string, + options: { recursive?: boolean } = {} +): Promise> { + if (!files.path.isAbsolute(absPath)) { + return err({ + type: 'invalid-path', + path: absPath, + message: `Expected absolute path: ${absPath}`, + }); + } + + const opened = openFileSystem(files); + if (!opened.success) return opened; + return opened.data.mkdir(absPath, { + recursive: options.recursive ?? true, + }); +} + +export async function realPathAbsolute( + files: IFilesRuntime, + absPath: string +): Promise> { + if (!files.path.isAbsolute(absPath)) { + return err({ + type: 'invalid-path', + path: absPath, + message: `Expected absolute path: ${absPath}`, + }); + } + + const opened = openFileSystem(files); + if (!opened.success) return opened; + return opened.data.realPath(absPath); +} + +export async function statAbsolute( + files: IFilesRuntime, + absPath: string +): Promise<{ success: true; data: FileStat } | { success: false; error: FileError }> { + if (!files.path.isAbsolute(absPath)) { + return { + success: false, + error: { type: 'invalid-path', path: absPath, message: `Expected absolute path: ${absPath}` }, + }; + } + + const opened = openFileSystem(files); + if (!opened.success) return opened; + return opened.data.stat(absPath); +} + +export async function realPathNearestExisting( + files: IFilesRuntime, + absPath: string +): Promise> { + if (!files.path.isAbsolute(absPath)) { + return err({ + type: 'invalid-path', + path: absPath, + message: `Expected absolute path: ${absPath}`, + }); + } + + const opened = openFileSystem(files); + if (!opened.success) return opened; + const fs = opened.data; + + let current = absPath; + const tail: string[] = []; + for (;;) { + const real = await fs.realPath(current); + if (real.success) { + const resolved = tail.length + ? files.path.join(real.data, ...tail.slice().reverse()) + : real.data; + return ok(resolved); + } + if (!isFileNotFoundError(real.error)) return real; + + const parent = files.path.dirname(current); + if (parent === current) { + return err({ + type: 'invalid-path', + path: absPath, + message: `No existing ancestor for path: ${absPath}`, + }); + } + tail.push(files.path.basename(current)); + current = parent; + } +} + +export async function isRealPathContained( + files: IFilesRuntime, + rootPath: string, + candidatePath: string, + options: { candidateMustExist?: boolean } = {} +): Promise> { + const rootReal = await realPathAbsolute(files, rootPath); + if (!rootReal.success) return rootReal; + + const candidateReal = options.candidateMustExist + ? await realPathAbsolute(files, candidatePath) + : await realPathNearestExisting(files, candidatePath); + if (!candidateReal.success) return ok(false); + + return ok( + candidateReal.data === rootReal.data || files.path.contains(rootReal.data, candidateReal.data) + ); +} diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-system.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-system.ts new file mode 100644 index 0000000000..9f2bf53109 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-system.ts @@ -0,0 +1,499 @@ +import path from 'node:path'; +import { + type FileEnumeration, + type FileError, + type FileGlob, + type FileGlobOptions, + type FileStat, + type IFileSystem, + type ReadBytesResult, + type ReadFileOptions, + type ReadTextResult, + type WriteFileResult, +} from '@emdash/core/files'; +import { err, ok, type Result } from '@emdash/shared'; +import type { SFTPWrapper } from 'ssh2'; +import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; +import { SshFileSystem } from './ssh-legacy-fs'; +import { FileSystemError, FileSystemErrorCodes, type FileEntry } from './ssh-legacy-fs-types'; +import { normalizeRemoteAbsolutePath, normalizeRemoteRootPath } from './ssh-paths'; +import { enumerateRemoteWorkspace } from './ssh-remote-enumerate'; + +const DEFAULT_MAX_BYTES = 200 * 1024; +const MAX_READ_BYTES = 100 * 1024 * 1024; + +const SFTP_STATUS = { + NO_SUCH_FILE: 2, + PERMISSION_DENIED: 3, + FAILURE: 4, +} as const; + +type SftpError = Error & { code?: number }; + +export class LegacySshFileSystem implements IFileSystem { + private readonly legacy: SshFileSystem; + private cachedSftp: SFTPWrapper | undefined; + + constructor(private readonly proxy: SshClientProxy) { + this.legacy = new SshFileSystem(proxy, '/'); + } + + async readText( + absPath: string, + options?: ReadFileOptions + ): Promise> { + const normalized = normalizeRemoteAbsolutePath(absPath); + if (!normalized.success) return normalized; + + try { + return ok(await this.legacy.read(normalized.data, options?.maxBytes)); + } catch (error) { + return err(toFileError(error, absPath)); + } + } + + async readBytes( + absPath: string, + options: ReadFileOptions = {} + ): Promise> { + const resolved = normalizeRemoteAbsolutePath(absPath); + if (!resolved.success) return resolved; + + try { + const sftp = await this.getSftp(); + return await this.readRemoteBytes(sftp, absPath, resolved.data, options.maxBytes); + } catch (error) { + return err(toFileError(error, absPath)); + } + } + + async writeText(absPath: string, content: string): Promise> { + const normalized = normalizeRemoteAbsolutePath(absPath); + if (!normalized.success) return normalized; + + try { + const result = await this.legacy.write(normalized.data, content); + if (!result.success) { + return err({ + type: 'fs-error', + path: absPath, + message: result.error ?? `Failed to write file: ${absPath}`, + }); + } + return ok({ bytesWritten: result.bytesWritten }); + } catch (error) { + return err(toFileError(error, absPath)); + } + } + + async writeBytes( + absPath: string, + bytes: Uint8Array + ): Promise> { + const resolved = normalizeRemoteAbsolutePath(absPath); + if (!resolved.success) return resolved; + + try { + const sftp = await this.getSftp(); + const parentDir = path.posix.dirname(resolved.data); + await this.ensureRemoteDir(sftp, parentDir); + return await this.writeRemoteBytes(sftp, absPath, resolved.data, Buffer.from(bytes)); + } catch (error) { + return err(toFileError(error, absPath)); + } + } + + async stat(absPath: string): Promise> { + const normalized = normalizeRemoteAbsolutePath(absPath); + if (!normalized.success) return normalized; + + try { + const entry = await this.legacy.stat(normalized.data); + if (!entry) { + return err({ + type: 'fs-error', + path: absPath, + message: `File or directory not found: ${absPath}`, + code: FileSystemErrorCodes.NOT_FOUND, + }); + } + return ok(toFileStat(entry)); + } catch (error) { + return err(toFileError(error, absPath)); + } + } + + async exists(absPath: string): Promise> { + const normalized = normalizeRemoteAbsolutePath(absPath); + if (!normalized.success) return normalized; + + try { + return ok(await this.legacy.exists(normalized.data)); + } catch (error) { + return err(toFileError(error, absPath)); + } + } + + async mkdir( + absPath: string, + options: { recursive?: boolean } = {} + ): Promise> { + const normalized = normalizeRemoteAbsolutePath(absPath); + if (!normalized.success) return normalized; + if (normalized.data === '/') return ok(); + + try { + await this.legacy.mkdir(normalized.data, options); + return ok(); + } catch (error) { + return err(toFileError(error, absPath)); + } + } + + async remove( + absPath: string, + options: { recursive?: boolean } = {} + ): Promise> { + const normalized = normalizeRemoteAbsolutePath(absPath); + if (!normalized.success) return normalized; + + try { + const result = await this.legacy.remove(normalized.data, options); + if (!result.success) { + return err({ + type: 'fs-error', + path: absPath, + message: result.error ?? `Failed to remove file: ${absPath}`, + }); + } + return ok(); + } catch (error) { + return err(toFileError(error, absPath)); + } + } + + async realPath(absPath: string): Promise> { + const normalized = normalizeRemoteAbsolutePath(absPath); + if (!normalized.success) return normalized; + + try { + return ok(await this.legacy.realPath(normalized.data)); + } catch (error) { + return err(toFileError(error, absPath)); + } + } + + async copyFile(src: string, dest: string): Promise> { + const normalizedSrc = normalizeRemoteAbsolutePath(src); + if (!normalizedSrc.success) return normalizedSrc; + const normalizedDest = normalizeRemoteAbsolutePath(dest); + if (!normalizedDest.success) return normalizedDest; + + try { + await this.legacy.copyFile(normalizedSrc.data, normalizedDest.data); + return ok(); + } catch (error) { + return err(toFileError(error, dest)); + } + } + + glob(patterns: string[], options: FileGlobOptions): Result { + const validated = validateGlobPatterns(patterns); + if (!validated.success) return validated; + const cwd = normalizeRemoteAbsolutePath(options.cwd); + if (!cwd.success) return cwd; + return ok(this.globPaths(validated.data, options)); + } + + enumerate(rootPath: string): Result { + const normalizedRoot = normalizeRemoteAbsolutePath(rootPath); + if (!normalizedRoot.success) return normalizedRoot; + return ok(enumerateRemoteWorkspace(this.proxy, normalizedRoot.data)); + } + + private getSftp(): Promise { + if (this.cachedSftp) return Promise.resolve(this.cachedSftp); + return new Promise((resolve, reject) => { + this.proxy.sftp((error, sftp) => { + if (error) { + reject(error); + return; + } + this.cachedSftp = sftp; + sftp.on('close', () => { + this.cachedSftp = undefined; + }); + resolve(sftp); + }); + }); + } + + private async *globPaths(patterns: string[], options: FileGlobOptions): FileGlob { + const cwd = normalizeRemoteAbsolutePath(options.cwd); + if (!cwd.success) return; + + const seen = new Set(); + for (const pattern of patterns) { + const matches = await this.legacy.glob(pattern, { + cwd: cwd.data, + dot: options.dot ?? false, + }); + for (const match of matches) { + const normalized = normalizeRemoteAbsolutePath(path.posix.resolve(cwd.data, match)); + if (!normalized.success || seen.has(normalized.data)) continue; + seen.add(normalized.data); + yield normalized.data; + } + } + } + + private async readRemoteBytes( + sftp: SFTPWrapper, + relPath: string, + fullPath: string, + maxBytes: number | undefined + ): Promise> { + return new Promise((resolve) => { + sftp.open(fullPath, 'r', (openError, handle) => { + if (openError) { + resolve(err(toFileError(openError, relPath))); + return; + } + + sftp.fstat(handle, (statError, stats) => { + if (statError) { + closeRemoteHandle(sftp, handle); + resolve(err(toFileError(statError, relPath))); + return; + } + + if (stats.isDirectory()) { + closeRemoteHandle(sftp, handle); + resolve( + err({ + type: 'fs-error', + path: relPath, + message: `Path is a directory: ${relPath}`, + code: FileSystemErrorCodes.IS_DIRECTORY, + }) + ); + return; + } + + const readSize = Math.min(stats.size, normalizeMaxBytes(maxBytes)); + if (readSize === 0) { + closeRemoteHandle(sftp, handle); + resolve( + ok({ + bytes: new Uint8Array(), + truncated: stats.size > readSize, + totalSize: stats.size, + }) + ); + return; + } + + const buffer = Buffer.alloc(readSize); + sftp.read(handle, buffer, 0, readSize, 0, (readError, bytesRead) => { + closeRemoteHandle(sftp, handle); + if (readError) { + resolve(err(toFileError(readError, relPath))); + return; + } + resolve( + ok({ + bytes: buffer.subarray(0, bytesRead), + truncated: stats.size > readSize, + totalSize: stats.size, + }) + ); + }); + }); + }); + }); + } + + private async writeRemoteBytes( + sftp: SFTPWrapper, + relPath: string, + fullPath: string, + buffer: Buffer + ): Promise> { + return new Promise((resolve) => { + sftp.open(fullPath, 'w', (openError, handle) => { + if (openError) { + resolve(err(toFileError(openError, relPath))); + return; + } + + sftp.write(handle, buffer, 0, buffer.length, 0, (writeError) => { + closeRemoteHandle(sftp, handle); + if (writeError) { + resolve(err(toFileError(writeError, relPath))); + return; + } + resolve(ok({ bytesWritten: buffer.byteLength })); + }); + }); + }); + } + + private async ensureRemoteDir(sftp: SFTPWrapper, dirPath: string): Promise { + const normalizedDir = normalizeRemoteRootPath(dirPath); + if (normalizedDir === '/') return; + + const kind = await this.remoteEntryKind(sftp, normalizedDir); + if (kind === 'directory') return; + if (kind === 'file') { + throw new FileSystemError( + `Path is not a directory: ${normalizedDir}`, + FileSystemErrorCodes.NOT_DIRECTORY, + normalizedDir + ); + } + + const parentDir = path.posix.dirname(normalizedDir); + if (parentDir && parentDir !== normalizedDir) await this.ensureRemoteDir(sftp, parentDir); + await this.mkdirRemote(sftp, normalizedDir); + } + + private remoteEntryKind( + sftp: SFTPWrapper, + fullPath: string + ): Promise<'file' | 'directory' | undefined> { + return new Promise((resolve, reject) => { + sftp.stat(fullPath, (error, stats) => { + if (!error) { + resolve(stats.isDirectory() ? 'directory' : 'file'); + return; + } + if (isNoSuchFile(error)) { + resolve(undefined); + return; + } + reject(error); + }); + }); + } + + private mkdirRemote(sftp: SFTPWrapper, fullPath: string): Promise { + return new Promise((resolve, reject) => { + sftp.mkdir(fullPath, (error) => { + if (!error || isAlreadyExists(error)) { + resolve(); + return; + } + reject(error); + }); + }); + } +} + +function toFileStat(entry: FileEntry): FileStat { + return { + path: entry.path, + type: entry.type === 'dir' ? 'directory' : 'file', + size: entry.size ?? 0, + mtime: entry.mtime ?? new Date(0), + ctime: entry.ctime ?? new Date(0), + mode: entry.mode ?? 0, + }; +} + +function toFileError(error: unknown, absPath: string): FileError { + if (error instanceof FileSystemError) { + return { type: 'fs-error', path: absPath, message: error.message, code: error.code }; + } + + const sftpError = error as SftpError | undefined; + const message = typeof sftpError?.message === 'string' ? sftpError.message : String(error); + const code = mapSftpErrorCode(sftpError); + return { + type: 'fs-error', + path: absPath, + message, + ...(code ? { code } : {}), + }; +} + +function mapSftpErrorCode(error: SftpError | undefined): string | undefined { + if (!error) return undefined; + if (error.code === SFTP_STATUS.NO_SUCH_FILE) return FileSystemErrorCodes.NOT_FOUND; + if (error.code === SFTP_STATUS.PERMISSION_DENIED) return FileSystemErrorCodes.PERMISSION_DENIED; + const message = error.message ?? ''; + if (message.includes('No such file')) return FileSystemErrorCodes.NOT_FOUND; + if (message.includes('Permission denied')) return FileSystemErrorCodes.PERMISSION_DENIED; + if (message.includes('is a directory')) return FileSystemErrorCodes.IS_DIRECTORY; + if (message.includes('Not a directory')) return FileSystemErrorCodes.NOT_DIRECTORY; + return undefined; +} + +function normalizeMaxBytes(maxBytes: number | undefined): number { + if (maxBytes === undefined) return DEFAULT_MAX_BYTES; + if (!Number.isFinite(maxBytes) || maxBytes < 0) return 0; + return Math.min(Math.floor(maxBytes), MAX_READ_BYTES); +} + +function validateGlobPatterns(patterns: string[]): Result { + if (patterns.length === 0) { + return err({ + type: 'invalid-path', + path: '', + message: 'At least one glob pattern is required', + }); + } + + const normalizedPatterns: string[] = []; + for (const pattern of patterns) { + if (!pattern) { + return err({ + type: 'invalid-path', + path: pattern, + message: 'Glob pattern must not be empty', + }); + } + if (pattern.includes('\0')) { + return err({ type: 'invalid-path', path: pattern, message: 'Path contains a null byte' }); + } + if (path.posix.isAbsolute(pattern) || path.win32.isAbsolute(pattern)) { + return err({ + type: 'invalid-path', + path: pattern, + message: 'Absolute paths are not allowed', + }); + } + + const parts = pattern.replace(/\\/g, '/').split('/').filter(Boolean); + if (parts.includes('..')) { + return err({ + type: 'invalid-path', + path: pattern, + message: 'Parent path segments are not allowed', + }); + } + normalizedPatterns.push(pattern.replace(/\\/g, '/')); + } + return ok(normalizedPatterns); +} + +function isNoSuchFile(error: unknown): boolean { + const sftpError = error as SftpError | undefined; + return ( + sftpError?.code === SFTP_STATUS.NO_SUCH_FILE || + (sftpError?.message ?? '').includes('No such file') + ); +} + +function isAlreadyExists(error: unknown): boolean { + const sftpError = error as SftpError | undefined; + const message = sftpError?.message ?? ''; + return ( + message.includes('already exists') || + message.includes('File exists') || + sftpError?.code === SFTP_STATUS.FAILURE + ); +} + +function closeRemoteHandle(sftp: SFTPWrapper, handle: Buffer): void { + sftp.close(handle, () => {}); +} diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.test.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.test.ts new file mode 100644 index 0000000000..ce4f6d8888 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-file-tree.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { LegacySshFilesRuntime } from './ssh-files'; +import { SshFileSystem } from './ssh-legacy-fs'; +import type { FileEntry, FileListResult } from './ssh-legacy-fs-types'; + +function listResult(entries: FileEntry[]): FileListResult { + return { entries, total: entries.length }; +} + +function fileEntry(path: string): FileEntry { + return { + path, + type: 'file', + size: 1, + mtime: new Date(1_000), + mode: 0o100644, + }; +} + +function dirEntry(path: string): FileEntry { + return { + path, + type: 'dir', + size: 0, + mtime: new Date(1_000), + mode: 0o040755, + }; +} + +describe('LegacySshFilesRuntime file tree', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('loads children for expanded remote directory scopes', async () => { + vi.spyOn(SshFileSystem.prototype, 'list').mockImplementation(async (dirPath = '/repo') => { + if (dirPath === '/repo') return listResult([dirEntry('/repo/src')]); + if (dirPath === '/repo/src') return listResult([fileEntry('/repo/src/index.ts')]); + return listResult([]); + }); + + const runtime = new LegacySshFilesRuntime({} as never); + const opened = await runtime.openTree('/repo'); + expect(opened.success).toBe(true); + if (!opened.success) return; + + const tree = opened.data.value; + const rootSnapshot = await tree.getSnapshot(); + expect(rootSnapshot.success).toBe(true); + if (!rootSnapshot.success) return; + + const src = rootSnapshot.data.entries.find(([, node]) => node.path === '/repo/src')?.[1]; + expect(src).toMatchObject({ path: '/repo/src', type: 'directory', parentId: null }); + expect(src).toBeDefined(); + if (!src) return; + + const expanded = await tree.expandDir(src.id); + expect(expanded.success).toBe(true); + + const expandedSnapshot = await tree.getSnapshot(); + expect(expandedSnapshot.success).toBe(true); + if (!expandedSnapshot.success) return; + + expect(expandedSnapshot.data.entries.map(([, node]) => node.path).sort()).toEqual([ + '/repo/src', + '/repo/src/index.ts', + ]); + expect( + expandedSnapshot.data.entries.find(([, node]) => node.path === '/repo/src/index.ts')?.[1] + ).toMatchObject({ parentId: src.id }); + + await opened.data.release(); + await runtime.dispose(); + }); +}); diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.test.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.test.ts new file mode 100644 index 0000000000..8628483bd0 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.test.ts @@ -0,0 +1,187 @@ +import { EventEmitter } from 'node:events'; +import type { ClientChannel } from 'ssh2'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; +import type { FileWatchEvent } from '@shared/core/fs/fs'; +import { LegacySshFilesRuntime } from './ssh-files'; +import { SshFileSystem } from './ssh-legacy-fs'; + +type SnapshotRecord = { + kind: 'file' | 'directory'; + path: string; + size?: string; + mtime?: string; +}; + +class FakeExecChannel extends EventEmitter { + readonly stderr = new EventEmitter(); +} + +describe('LegacySshFilesRuntime', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('keeps scoped watches on the existing SSH polling watcher', async () => { + let emitLegacyEvents: ((events: FileWatchEvent[]) => void) | undefined; + const update = vi.fn(); + const close = vi.fn(); + vi.spyOn(SshFileSystem.prototype, 'watch').mockImplementation((cb) => { + emitLegacyEvents = cb; + return { update, close }; + }); + + const runtime = new LegacySshFilesRuntime({} as never); + const updates: unknown[] = []; + const subscription = runtime.watchChanges('/repo', (update) => updates.push(update), { + paths: ['/repo/src'], + }); + expect(subscription.success).toBe(true); + expect(update).toHaveBeenCalledWith(['/repo/src']); + + emitLegacyEvents?.([ + { type: 'modify', entryType: 'file', path: '/repo/src/notes.md' }, + { type: 'modify', entryType: 'file', path: '/repo/src/node_modules/pkg/index.js' }, + ]); + + expect(updates).toEqual([ + { + kind: 'changes', + changes: [{ kind: 'update', entryType: 'file', path: '/repo/src/notes.md' }], + }, + ]); + + if (subscription.success) subscription.data.unsubscribe(); + expect(close).toHaveBeenCalledTimes(1); + + await runtime.dispose(); + }); + + it('uses recursive snapshot polling for root watches', async () => { + vi.useFakeTimers(); + const watchSpy = vi.spyOn(SshFileSystem.prototype, 'watch'); + const { proxy, exec } = makeSnapshotProxy([ + snapshot([ + { kind: 'file', path: 'README.md', size: '1', mtime: '1' }, + { kind: 'file', path: 'src/a.ts', size: '1', mtime: '1' }, + ]), + snapshot([ + { kind: 'file', path: 'src/a.ts', size: '2', mtime: '2' }, + { kind: 'file', path: 'src/b.ts', size: '1', mtime: '1' }, + { kind: 'file', path: 'node_modules/pkg/index.js', size: '1', mtime: '1' }, + ]), + ]); + + const runtime = new LegacySshFilesRuntime(proxy); + const updates: unknown[] = []; + const subscription = runtime.watchChanges('/repo', (update) => updates.push(update), { + debounceMs: 100, + }); + + expect(subscription.success).toBe(true); + if (!subscription.success) return; + + await expect(subscription.data.ready()).resolves.toEqual({ success: true, data: undefined }); + expect(updates).toEqual([]); + expect(watchSpy).not.toHaveBeenCalled(); + expect(exec).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(100); + + expect(exec).toHaveBeenCalledTimes(2); + expect(updates).toEqual([ + { + kind: 'changes', + changes: [ + { kind: 'update', path: '/repo/src/a.ts', entryType: 'file' }, + { kind: 'create', path: '/repo/src/b.ts', entryType: 'file' }, + { kind: 'delete', path: '/repo/README.md', entryType: 'file' }, + ], + }, + ]); + + subscription.data.unsubscribe(); + await runtime.dispose(); + }); + + it('enumerates remote files with one streamed command', async () => { + const { proxy, exec } = makeSnapshotProxy([ + enumeration(['README.md', 'src/a.ts', 'node_modules/pkg/index.js']), + ]); + const runtime = new LegacySshFilesRuntime(proxy); + + const fileSystem = runtime.fileSystem(); + expect(fileSystem.success).toBe(true); + if (!fileSystem.success) return; + + const result = fileSystem.data.enumerate('/repo'); + expect(result.success).toBe(true); + if (!result.success) return; + + await expect(collect(result.data)).resolves.toEqual(['/repo/README.md', '/repo/src/a.ts']); + expect(exec).toHaveBeenCalledTimes(1); + + await runtime.dispose(); + }); + + it('returns a disposed error when watched after disposal', async () => { + const runtime = new LegacySshFilesRuntime({} as never); + await runtime.dispose(); + + const subscription = runtime.watchChanges('/repo', () => {}); + + expect(subscription.success).toBe(false); + if (!subscription.success) { + expect(subscription.error).toMatchObject({ + type: 'fs-error', + message: 'LegacySshFilesRuntime disposed', + }); + } + }); +}); + +async function collect(iterable: AsyncIterable): Promise { + const paths: string[] = []; + for await (const relPath of iterable) paths.push(relPath); + return paths; +} + +function snapshot(records: SnapshotRecord[]): Buffer { + const fields = records.flatMap((record) => [ + record.kind, + record.size ?? '1', + record.mtime ?? '1', + record.path, + ]); + return Buffer.from(`${fields.join('\0')}\0`); +} + +function enumeration(paths: string[]): Buffer { + return Buffer.from(`${paths.join('\0')}\0`); +} + +function makeSnapshotProxy(snapshots: Buffer[]): { + proxy: SshClientProxy; + exec: ReturnType; +} { + const exec = vi.fn( + (command: string, cb: (err: Error | undefined, stream: ClientChannel) => void) => { + const stream = new FakeExecChannel(); + const stdout = snapshots.shift() ?? Buffer.alloc(0); + cb(undefined, stream as unknown as ClientChannel); + queueMicrotask(() => { + stream.emit('data', stdout); + stream.emit('close', 0); + }); + } + ); + + return { + proxy: { + getRemoteShellProfile: vi.fn().mockResolvedValue({ shell: '/bin/sh', env: {} }), + exec, + } as unknown as SshClientProxy, + exec, + }; +} diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.ts new file mode 100644 index 0000000000..32561e0ab7 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-files.ts @@ -0,0 +1,931 @@ +import path from 'node:path'; +import { + type FileChange, + type FileChangeSubscription, + type FileChangeUpdate, + type FileChangeWatchOptions, + type FileEntryType, + type FileError, + type FileNode, + type FileTreeError, + type FileTreeLease, + type FileTreeSequences, + type FileTreeSnapshot, + type FileTreeUpdate, + type IFileSystem, + type IFileTree, + type NodeId, + type SubscribedSnapshot, +} from '@emdash/core/files'; +import { LiveCollection, ResourceMap, type KeyedOp } from '@emdash/core/lib'; +import { err, ok, type Result, type Unsubscribe } from '@emdash/shared'; +import type { ClientChannel } from 'ssh2'; +import type { IFilesRuntime } from '@main/core/runtime/types'; +import { buildRemoteShellCommand } from '@main/core/ssh/lifecycle/remote-shell-profile'; +import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; +import { log } from '@main/lib/logger'; +import { quoteShellArg } from '@main/utils/shellEscape'; +import type { FileWatchEvent } from '@shared/core/fs/fs'; +import { LegacySshFileSystem } from './ssh-file-system'; +import { SshFileSystem } from './ssh-legacy-fs'; +import { FileSystemError, FileSystemErrorCodes } from './ssh-legacy-fs-types'; +import type { FileEntry } from './ssh-legacy-fs-types'; +import { + containsRemotePath, + isIgnoredRemotePath, + normalizeRemoteAbsolutePath, + normalizeRemoteRootPath, + toRemoteAbsolutePath, +} from './ssh-paths'; +import { buildFindPruneExpression } from './ssh-remote-enumerate'; + +const SSH_FILE_TREE_POLL_MS = 4_000; +const SSH_FILE_CHANGE_POLL_MS = 4_000; + +type LegacyListedEntry = { + path: string; + name: string; + type: 'file' | 'directory'; +}; + +type ChangeFeedHandle = { + close(): void; +}; + +type LegacySshSnapshotEntry = { + entryType: Exclude; + size: string; + mtime: string; +}; + +/** + * Transitional SSH file-domain adapter. + * + * It preserves the existing SFTP-backed tree listing and path-scoped polling + * behavior until the core runtime can run inside the remote workspace server. + */ +export class LegacySshFilesRuntime implements IFilesRuntime { + readonly path: IFilesRuntime['path'] = posixMachinePath; + + private readonly trees: ResourceMap; + private readonly changeFeeds = new Set(); + private disposeRequested = false; + + constructor(private readonly proxy: SshClientProxy) { + this.trees = new ResourceMap({ + teardown: (_rootPath, tree) => tree.dispose(), + onError: (context, error) => + log.warn('LegacySshFilesRuntime: teardown failed', { + context, + error: String(error), + }), + }); + } + + async openTree(rootPath: string): Promise> { + const normalizedRoot = normalizeRemoteAbsolutePath(rootPath); + if (!normalizedRoot.success) return err(normalizedRoot.error); + if (this.disposeRequested) { + return err({ + type: 'fs-error', + path: normalizedRoot.data, + message: 'LegacySshFilesRuntime disposed', + }); + } + + const lease = await this.trees.acquire(normalizedRoot.data, async () => { + return new LegacySshFileTree(this.proxy, normalizedRoot.data, (context, error) => + log.warn('LegacySshFilesRuntime: background error', { + context, + error: String(error), + }) + ); + }); + + try { + const ready = await lease.value.ready(); + if (!ready.success) { + await lease.release(); + return err(ready.error); + } + return ok(lease); + } catch (error) { + await lease.release(); + throw error; + } + } + + fileSystem(): Result { + if (this.disposeRequested) { + return err({ + type: 'fs-error', + path: '', + message: 'LegacySshFilesRuntime disposed', + }); + } + return ok(new LegacySshFileSystem(this.proxy)); + } + + async copyFile(src: string, dest: string): Promise> { + const sourcePath = normalizeRemoteAbsolutePath(src); + if (!sourcePath.success) return sourcePath; + const destPath = normalizeRemoteAbsolutePath(dest); + if (!destPath.success) return destPath; + + if (this.disposeRequested) { + return err({ + type: 'fs-error', + path: destPath.data, + message: 'LegacySshFilesRuntime disposed', + }); + } + + try { + const destParent = path.posix.dirname(destPath.data); + const result = await execRemoteBuffer( + this.proxy, + [ + `mkdir -p ${quoteShellArg(destParent)}`, + `cp -p ${quoteShellArg(sourcePath.data)} ${quoteShellArg(destPath.data)}`, + ].join(' && ') + ); + if (result.exitCode !== 0) { + return err({ + type: 'fs-error', + path: destPath.data, + message: result.stderr || `Remote copy exited with code ${result.exitCode}`, + }); + } + return ok(); + } catch (error) { + return err(toFileError(error, destPath.data)); + } + } + + watchChanges( + rootPath: string, + cb: (update: FileChangeUpdate) => void, + options: FileChangeWatchOptions = {} + ): Result { + const normalizedRoot = normalizeRemoteAbsolutePath(rootPath); + if (!normalizedRoot.success) return normalizedRoot; + if (this.disposeRequested) { + return err({ + type: 'fs-error', + path: normalizedRoot.data, + message: 'LegacySshFilesRuntime disposed', + }); + } + + const paths = normalizeWatchedPaths(normalizedRoot.data, options.paths); + if (!paths.success) return paths; + + if (watchesWholeRoot(normalizedRoot.data, paths.data)) { + const feed = new LegacySshRecursiveChangeFeed( + this.proxy, + normalizedRoot.data, + cb, + (context, error) => + log.warn('LegacySshFilesRuntime: background error', { + context, + error: String(error), + }), + options.debounceMs ?? SSH_FILE_CHANGE_POLL_MS + ); + this.changeFeeds.add(feed); + + let unsubscribed = false; + const unsubscribe = () => { + if (unsubscribed) return; + unsubscribed = true; + this.changeFeeds.delete(feed); + feed.close(); + }; + + return ok({ + ready: () => feed.ready(), + unsubscribe, + }); + } + + const fs = new SshFileSystem(this.proxy, '/'); + const watcher = fs.watch( + (events) => { + const changes = eventsToChanges('/', events); + if (changes.length > 0) cb({ kind: 'changes', changes }); + }, + { debounceMs: options.debounceMs } + ); + watcher.update(paths.data); + this.changeFeeds.add(watcher); + + let unsubscribed = false; + const unsubscribe = () => { + if (unsubscribed) return; + unsubscribed = true; + this.changeFeeds.delete(watcher); + watcher.close(); + }; + + return ok({ + ready: async () => ok(), + unsubscribe, + }); + } + + async dispose(): Promise { + if (this.disposeRequested) return; + this.disposeRequested = true; + for (const feed of this.changeFeeds) feed.close(); + this.changeFeeds.clear(); + await this.trees.dispose(); + } +} + +const posixMachinePath: IFilesRuntime['path'] = { + join: (...parts) => path.posix.join(...parts), + dirname: (value) => path.posix.dirname(value), + basename: (value) => path.posix.basename(value), + isAbsolute: (value) => path.posix.isAbsolute(value), + relative: (from, to) => path.posix.relative(from, to), + contains: (parent, child) => { + const rel = path.posix.relative(parent, child); + return rel === '' || (rel !== '..' && !rel.startsWith('../') && !path.posix.isAbsolute(rel)); + }, +}; + +class LegacySshRecursiveChangeFeed implements ChangeFeedHandle { + private snapshot: Map | null = null; + private timer: ReturnType | undefined; + private pollInFlight = false; + private closed = false; + private readonly readyPromise: Promise>; + + constructor( + private readonly proxy: SshClientProxy, + private readonly rootPath: string, + private readonly cb: (update: FileChangeUpdate) => void, + private readonly onError: (context: string, error: unknown) => void, + private readonly pollIntervalMs: number + ) { + this.readyPromise = this.initialize(); + } + + ready(): Promise> { + return this.readyPromise; + } + + close(): void { + if (this.closed) return; + this.closed = true; + if (this.timer) clearInterval(this.timer); + } + + private async initialize(): Promise> { + const scanned = await this.scan(); + if (!scanned.success) return err(scanned.error); + if (this.closed) return ok(); + + this.snapshot = scanned.data; + this.timer = setInterval(() => { + void this.poll(); + }, this.pollIntervalMs); + return ok(); + } + + private async poll(): Promise { + if (this.closed || this.pollInFlight) return; + + this.pollInFlight = true; + try { + const scanned = await this.scan(); + if (this.closed) return; + if (!scanned.success) { + this.onError(`ssh file changes scan ${this.rootPath}`, scanned.error); + this.cb({ kind: 'resync' }); + return; + } + + if (!this.snapshot) { + this.snapshot = scanned.data; + return; + } + + const changes = diffRecursiveSnapshots(this.snapshot, scanned.data); + this.snapshot = scanned.data; + if (changes.length > 0) this.cb({ kind: 'changes', changes }); + } finally { + this.pollInFlight = false; + } + } + + private async scan(): Promise, FileError>> { + try { + const result = await execRemoteBuffer( + this.proxy, + buildRecursiveSnapshotCommand(this.rootPath) + ); + if (result.exitCode !== 0) { + return err({ + type: 'fs-error', + path: this.rootPath, + message: result.stderr || `Remote file snapshot exited with code ${result.exitCode}`, + }); + } + return ok(parseRecursiveSnapshot(this.rootPath, result.stdout)); + } catch (error) { + return err(toFileError(error, this.rootPath)); + } + } +} + +class LegacySshFileTree implements IFileTree { + readonly rootPath: string; + private readonly collection = new LiveCollection({ + scopeOf: (node) => node.parentId, + }); + private readonly fs: SshFileSystem; + private readonly pathToId = new Map(); + private readonly nodes = new Map(); + private readonly childrenByParent = new Map>(); + private readonly scopeLoads = new Map< + NodeId | null, + Promise> + >(); + private readonly pollTimer: ReturnType; + private nextId = 1; + private disposed = false; + private readyPromise: Promise> | null = null; + + constructor( + proxy: SshClientProxy, + rootPath: string, + private readonly onError: (context: string, error: unknown) => void + ) { + this.rootPath = rootPath; + this.fs = new SshFileSystem(proxy, rootPath); + this.pollTimer = setInterval(() => { + if (this.collection.subscriberCount === 0) return; + void this.refreshLoadedScopes().then( + (result) => { + if (!result.success) this.onError(`ssh file-tree refresh ${this.rootPath}`, result.error); + }, + (error) => this.onError(`ssh file-tree refresh ${this.rootPath}`, error) + ); + }, SSH_FILE_TREE_POLL_MS); + } + + async ready(): Promise> { + if (this.readyPromise) return this.readyPromise; + + const readyPromise = (async (): Promise> => { + const loaded = await this.loadDirectoryScope(null); + if (!loaded.success) return err(loaded.error); + return ok(); + })().catch((error): Result => { + if (this.readyPromise === readyPromise) { + this.readyPromise = null; + } + throw error; + }); + this.readyPromise = readyPromise; + return readyPromise; + } + + async getSnapshot(): Promise> { + const ready = await this.ready(); + if (!ready.success) return err(ready.error); + return ok(this.collection.getCached()); + } + + subscribe(cb: (update: FileTreeUpdate) => void): Unsubscribe { + return this.collection.subscribe(cb); + } + + async subscribeWithSnapshot( + cb: (update: FileTreeUpdate) => void + ): Promise, FileTreeError>> { + const unsubscribe = this.subscribe(cb); + const snapshot = await this.getSnapshot(); + if (!snapshot.success) { + unsubscribe(); + return err(snapshot.error); + } + return ok({ snapshot: snapshot.data, unsubscribe }); + } + + async expandDir(dirId: NodeId | null): Promise> { + const ready = await this.ready(); + if (!ready.success) return err(ready.error); + return this.loadDirectoryScope(dirId); + } + + async revealPath(pathToReveal: string): Promise> { + const ready = await this.ready(); + if (!ready.success) return err(ready.error); + const normalized = normalizeRemoteAbsolutePath(pathToReveal); + if (!normalized.success) return normalized; + if (!containsRemotePath(this.rootPath, normalized.data)) { + return err({ + type: 'invalid-path', + path: pathToReveal, + message: 'Path is outside the file-tree root', + }); + } + + const relPath = path.posix.relative(this.rootPath, normalized.data); + const parts = relPath.split('/').filter(Boolean); + let sequences: FileTreeSequences = {}; + for (let index = 0; index < parts.length; index += 1) { + const absPath = normalizeRemoteRootPath( + path.posix.join(this.rootPath, ...parts.slice(0, index + 1)) + ); + const node = this.getByPath(absPath); + if (!node) return err({ type: 'not-found', path: absPath }); + const shouldExpand = index < parts.length - 1 || node.type === 'directory'; + if (!shouldExpand) continue; + if (node.type !== 'directory') { + return err({ type: 'not-directory', id: node.id, path: node.path }); + } + const expanded = await this.loadDirectoryScope(node.id); + if (!expanded.success) return expanded; + sequences = mergeSequences(sequences, expanded.data); + } + return ok(sequences); + } + + async refresh(): Promise> { + const refreshed = await this.refreshLoadedScopes(); + if (!refreshed.success) return err(refreshed.error); + return ok(this.collection.getCached()); + } + + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + clearInterval(this.pollTimer); + this.collection.dispose(); + } + + private async refreshLoadedScopes(): Promise> { + const scopes = this.collection.loadedScopes(); + let sequences: FileTreeSequences = {}; + for (const scope of scopes) { + if (scope !== null && !this.nodes.has(scope)) continue; + const refreshed = await this.loadDirectoryScope(scope); + if (!refreshed.success) { + const recovered = this.recoverMissingLoadedScope(scope, refreshed.error); + if (!recovered.success) return err(recovered.error); + sequences = mergeSequences(sequences, recovered.data); + continue; + } + sequences = mergeSequences(sequences, refreshed.data); + } + return ok(sequences); + } + + private async loadDirectoryScope( + scope: NodeId | null + ): Promise> { + const existing = this.scopeLoads.get(scope); + if (existing) return existing; + + const loading = this.loadDirectoryScopeInternal(scope); + this.scopeLoads.set(scope, loading); + void loading.finally(() => { + if (this.scopeLoads.get(scope) === loading) this.scopeLoads.delete(scope); + }); + return loading; + } + + private async loadDirectoryScopeInternal( + scope: NodeId | null + ): Promise> { + const dirNode = scope === null ? null : this.nodes.get(scope); + if (scope !== null && !dirNode) return err({ type: 'not-found', id: scope }); + if (dirNode && dirNode.type !== 'directory') { + return err({ type: 'not-directory', id: dirNode.id, path: dirNode.path }); + } + + const dirPath = dirNode?.path ?? this.rootPath; + const listed = await this.listChildren(dirPath); + if (!listed.success) return listed; + + const listedPaths = new Set(listed.data.map((entry) => entry.path)); + let sequence = this.removeMissingChildren(scope, listedPaths); + const nodes = listed.data.map((entry) => + this.upsertNode(entry, scope, this.getByPath(entry.path)?.childrenLoaded) + ); + const loaded = await this.collection.loadScope(scope, async () => + ok(nodes.map((node) => [node.id, node] as const)) + ); + if (!loaded.success) return loaded; + sequence = Math.max(sequence, loaded.data); + + if (dirNode && !dirNode.childrenLoaded) { + const updated = { ...dirNode, childrenLoaded: true }; + this.setNode(updated); + sequence = Math.max(sequence, this.collection.put(updated.id, updated)); + } + + return ok(sequence === 0 ? {} : { tree: sequence }); + } + + private async listChildren(dirPath: string): Promise> { + const normalized = normalizeRemoteAbsolutePath(dirPath); + if (!normalized.success) return normalized; + if (!containsRemotePath(this.rootPath, normalized.data)) { + return err({ + type: 'invalid-path', + path: dirPath, + message: 'Path is outside the file-tree root', + }); + } + + try { + const result = await this.fs.list(normalized.data, { includeHidden: true }); + const entries: LegacyListedEntry[] = []; + for (const entry of result.entries) { + const absPath = toRemoteAbsolutePath(this.rootPath, entry.path); + if (isIgnoredRemotePath(this.rootPath, absPath)) continue; + if (entry.type !== 'dir' && entry.type !== 'file') continue; + entries.push(toListedEntry(this.rootPath, entry)); + } + entries.sort((a, b) => { + if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return ok(entries); + } catch (error) { + return err(toFileTreeError(error, normalized.data)); + } + } + + private removeMissingChildren(parentId: NodeId | null, listedPaths: Set): number { + const missing = this.childrenOf(parentId) + .filter((node) => !listedPaths.has(node.path)) + .map((node) => node.id); + return this.removeSubtrees(missing); + } + + private removeSubtrees(rootIds: NodeId[]): number { + const ops: Array> = []; + const removedScopes: NodeId[] = []; + for (const rootId of rootIds) { + const removed = this.removeSubtree(rootId); + for (const node of removed) { + ops.push({ op: 'del', key: node.id }); + if (node.type === 'directory') removedScopes.push(node.id); + } + } + + let sequence = this.collection.apply(ops); + for (const scope of removedScopes) + sequence = Math.max(sequence, this.collection.unloadScope(scope)); + return sequence; + } + + private recoverMissingLoadedScope( + scope: NodeId | null, + error: FileTreeError + ): Result { + if (scope === null || (error.type !== 'not-found' && error.type !== 'not-directory')) { + return err(error); + } + + const sequence = this.removeSubtrees([scope]); + return ok(sequence === 0 ? {} : { tree: sequence }); + } + + private getByPath(path: string): FileNode | undefined { + const id = this.pathToId.get(path); + return id === undefined ? undefined : this.nodes.get(id); + } + + private upsertNode( + entry: LegacyListedEntry, + parentId: NodeId | null, + childrenLoaded?: boolean + ): FileNode { + const existingId = this.pathToId.get(entry.path); + const id = existingId ?? this.nextId++; + const previous = this.nodes.get(id); + const node: FileNode = { + id, + path: entry.path, + name: entry.name, + parentId, + type: entry.type, + childrenLoaded: + entry.type === 'directory' ? (childrenLoaded ?? previous?.childrenLoaded ?? false) : false, + }; + this.setNode(node); + return node; + } + + private setNode(node: FileNode): void { + const previous = this.nodes.get(node.id); + if (previous) { + this.pathToId.delete(previous.path); + this.removeChild(previous.parentId, node.id); + } + this.pathToId.set(node.path, node.id); + this.addChild(node.parentId, node.id); + this.nodes.set(node.id, node); + } + + private removeSubtree(rootId: NodeId): FileNode[] { + const removed: FileNode[] = []; + const visit = (id: NodeId) => { + const node = this.nodes.get(id); + if (!node) return; + for (const child of this.childrenOf(id)) visit(child.id); + this.removeNode(id); + removed.push(node); + }; + visit(rootId); + return removed; + } + + private removeNode(id: NodeId): void { + const node = this.nodes.get(id); + if (!node) return; + this.pathToId.delete(node.path); + this.removeChild(node.parentId, id); + this.nodes.delete(id); + } + + private childrenOf(parentId: NodeId | null): FileNode[] { + const ids = this.childrenByParent.get(parentId); + if (!ids) return []; + const children: FileNode[] = []; + for (const id of ids) { + const node = this.nodes.get(id); + if (node) children.push(node); + } + return children; + } + + private addChild(parentId: NodeId | null, id: NodeId): void { + let children = this.childrenByParent.get(parentId); + if (!children) { + children = new Set(); + this.childrenByParent.set(parentId, children); + } + children.add(id); + } + + private removeChild(parentId: NodeId | null, id: NodeId): void { + const children = this.childrenByParent.get(parentId); + if (!children) return; + children.delete(id); + if (children.size === 0) this.childrenByParent.delete(parentId); + } +} + +function normalizeWatchedPaths( + rootPath: string, + paths: string[] | undefined +): Result { + if (!paths || paths.length === 0) return ok([rootPath]); + + const normalizedPaths: string[] = []; + for (const pathValue of paths) { + if (pathValue.includes('\0')) { + return err({ type: 'invalid-path', path: pathValue, message: 'Path contains a null byte' }); + } + + const normalized = normalizeRemoteAbsolutePath(pathValue); + if (!normalized.success) return normalized; + + if (!containsRemotePath(rootPath, normalized.data)) { + return err({ + type: 'invalid-path', + path: pathValue, + message: 'Path is outside the watch root', + }); + } + + normalizedPaths.push(normalized.data); + } + + return ok(normalizedPaths); +} + +function watchesWholeRoot(rootPath: string, paths: string[]): boolean { + return paths.includes(rootPath); +} + +function eventsToChanges(rootPath: string, events: FileWatchEvent[]): FileChange[] { + const changes: FileChange[] = []; + for (const event of events) { + const eventPath = toRemoteAbsolutePath(rootPath, event.path); + if (isIgnoredRemotePath(rootPath, eventPath)) continue; + if (event.type === 'rename') { + if (event.oldPath) { + const oldPath = toRemoteAbsolutePath(rootPath, event.oldPath); + if (!isIgnoredRemotePath(rootPath, oldPath)) { + changes.push({ kind: 'delete', path: oldPath, entryType: event.entryType }); + } + } + changes.push({ kind: 'create', path: eventPath, entryType: event.entryType }); + continue; + } + changes.push({ + kind: event.type === 'modify' ? 'update' : event.type, + path: eventPath, + entryType: event.entryType, + }); + } + return changes; +} + +function diffRecursiveSnapshots( + previous: Map, + next: Map +): FileChange[] { + const changes: FileChange[] = []; + + for (const [entryPath, entry] of next) { + const previousEntry = previous.get(entryPath); + if (!previousEntry) { + changes.push({ kind: 'create', path: entryPath, entryType: entry.entryType }); + continue; + } + if (snapshotEntryChanged(previousEntry, entry)) { + changes.push({ kind: 'update', path: entryPath, entryType: entry.entryType }); + } + } + + for (const [entryPath, entry] of previous) { + if (!next.has(entryPath)) { + changes.push({ kind: 'delete', path: entryPath, entryType: entry.entryType }); + } + } + + return changes; +} + +function snapshotEntryChanged( + previous: LegacySshSnapshotEntry, + next: LegacySshSnapshotEntry +): boolean { + return ( + previous.entryType !== next.entryType || + previous.size !== next.size || + previous.mtime !== next.mtime + ); +} + +async function execRemoteBuffer( + proxy: SshClientProxy, + command: string +): Promise<{ stdout: Buffer; stderr: string; exitCode: number }> { + const profile = await proxy.getRemoteShellProfile(); + const fullCommand = buildRemoteShellCommand(profile, command); + + return new Promise((resolve, reject) => { + proxy.exec(fullCommand, (err, stream) => { + if (err) { + reject(err); + return; + } + + readExecStream(stream, resolve, reject); + }); + }); +} + +function readExecStream( + stream: ClientChannel, + resolve: (value: { stdout: Buffer; stderr: string; exitCode: number }) => void, + reject: (reason?: unknown) => void +): void { + const stdout: Buffer[] = []; + let stderr = ''; + let settled = false; + + stream.on('data', (chunk: Buffer) => { + stdout.push(Buffer.from(chunk)); + }); + stream.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + }); + stream.on('close', (exitCode: number | null) => { + if (settled) return; + settled = true; + resolve({ + stdout: Buffer.concat(stdout), + stderr: stderr.trim(), + exitCode: exitCode ?? -1, + }); + }); + stream.on('error', (error: Error) => { + if (settled) return; + settled = true; + reject(error); + }); +} + +function buildRecursiveSnapshotCommand(rootPath: string): string { + const pruneExpression = buildFindPruneExpression(); + const snapshotScript = ` +stat_style=$1 +shift +for p do + rel=\${p#./} + [ "$rel" = "." ] && continue + if [ -d "$p" ]; then kind=directory; else kind=file; fi + if [ "$stat_style" = gnu ]; then + meta=$(stat -c '%s %Y' -- "$p" 2>/dev/null) || continue + else + meta=$(stat -f '%z %m' -- "$p" 2>/dev/null) || continue + fi + size=\${meta%% *} + mtime=\${meta#* } + printf '%s\\0%s\\0%s\\0%s\\0' "$kind" "$size" "$mtime" "$rel" +done +`.trim(); + + return [ + `cd ${quoteShellArg(rootPath)} || exit 1`, + "if stat -c '%s %Y' . >/dev/null 2>&1; then stat_style=gnu; elif stat -f '%z %m' . >/dev/null 2>&1; then stat_style=bsd; else exit 2; fi", + `find . ${pruneExpression}\\( -type f -o -type d \\) -exec sh -c ${quoteShellArg( + snapshotScript + )} sh "$stat_style" {} +`, + ].join('\n'); +} + +function parseRecursiveSnapshot( + rootPath: string, + stdout: Buffer +): Map { + const entries = new Map(); + const fields = stdout.toString('utf8').split('\0'); + + for (let index = 0; index + 3 < fields.length; index += 4) { + const entryType = parseSnapshotEntryType(fields[index]); + if (!entryType) continue; + + const size = fields[index + 1]; + const mtime = fields[index + 2]; + const absPath = toRemoteAbsolutePath(rootPath, fields[index + 3]); + if (isIgnoredRemotePath(rootPath, absPath)) continue; + + entries.set(absPath, { + entryType, + size, + mtime, + }); + } + + return entries; +} + +function parseSnapshotEntryType(raw: string): Exclude | null { + if (raw === 'file' || raw === 'directory') return raw; + return null; +} + +function toListedEntry(rootPath: string, entry: FileEntry): LegacyListedEntry { + const absPath = toRemoteAbsolutePath(rootPath, entry.path); + return { + path: absPath, + name: path.posix.basename(absPath), + type: entry.type === 'dir' ? 'directory' : 'file', + }; +} + +function toFileTreeError(error: unknown, relPath: string): FileTreeError { + if (error instanceof FileSystemError) { + if (error.code === FileSystemErrorCodes.NOT_FOUND) return { type: 'not-found', path: relPath }; + if (error.code === FileSystemErrorCodes.NOT_DIRECTORY) { + return { type: 'not-directory', path: relPath }; + } + if ( + error.code === FileSystemErrorCodes.INVALID_PATH || + error.code === FileSystemErrorCodes.PATH_ESCAPE + ) { + return { type: 'invalid-path', path: relPath, message: error.message }; + } + return { type: 'fs-error', path: relPath, message: error.message }; + } + return { type: 'fs-error', path: relPath, message: String(error) }; +} + +function toFileError(error: unknown, path: string): FileError { + if (error instanceof FileSystemError) { + if ( + error.code === FileSystemErrorCodes.INVALID_PATH || + error.code === FileSystemErrorCodes.PATH_ESCAPE + ) { + return { type: 'invalid-path', path, message: error.message }; + } + return { type: 'fs-error', path, message: error.message }; + } + return { type: 'fs-error', path, message: String(error) }; +} + +function mergeSequences(left: FileTreeSequences, right: FileTreeSequences): FileTreeSequences { + return { tree: Math.max(left.tree ?? 0, right.tree ?? 0) || undefined }; +} diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.test.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.test.ts new file mode 100644 index 0000000000..83a32ca1ac --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.test.ts @@ -0,0 +1,145 @@ +import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import type { GitWorktreeUpdate } from '@emdash/core/git'; +import { afterEach, describe, expect, it } from 'vitest'; +import { FALLBACK_REMOTE_SHELL_PROFILE } from '@main/core/ssh/lifecycle/remote-shell-profile'; +import { invalidateLegacySshGitWorktreeStatus, LegacySshGitRuntime } from './ssh-git'; + +const execFileAsync = promisify(execFile); + +class LocalChannel extends EventEmitter { + readonly stderr = new EventEmitter(); + private child: ChildProcessWithoutNullStreams | null = null; + + attach(child: ChildProcessWithoutNullStreams): void { + this.child = child; + child.stdout.on('data', (chunk) => this.emit('data', chunk)); + child.stderr.on('data', (chunk) => this.stderr.emit('data', chunk)); + child.on('close', (code) => this.emit('close', code)); + child.on('error', (error) => this.emit('error', error)); + } + + setEncoding(_encoding: BufferEncoding): void {} + + destroy(): void { + this.child?.kill(); + this.emit('close', 1); + } +} + +const localSshProxy = { + getRemoteShellProfile: async () => FALLBACK_REMOTE_SHELL_PROFILE, + refreshRemoteShellProfile: async () => FALLBACK_REMOTE_SHELL_PROFILE, + exec(command: string, callback: (error: Error | undefined, channel: LocalChannel) => void) { + const channel = new LocalChannel(); + const child = spawn('/bin/sh', ['-lc', command]); + channel.attach(child); + callback(undefined, channel); + }, +}; + +async function eventually( + read: () => T | undefined, + timeoutMs = 2_000, + intervalMs = 25 +): Promise { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + const value = read(); + if (value !== undefined) return value; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error('Timed out waiting for condition'); +} + +async function createRepo(): Promise { + const repo = await mkdtemp(path.join(tmpdir(), 'emdash-ssh-git-')); + await execFileAsync('git', ['init', '-b', 'main'], { cwd: repo }); + await execFileAsync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo }); + await execFileAsync('git', ['config', 'user.name', 'Test User'], { cwd: repo }); + await writeFile(path.join(repo, 'tracked.ts'), 'tracked\n', 'utf8'); + await execFileAsync('git', ['add', 'tracked.ts'], { cwd: repo }); + await execFileAsync('git', ['commit', '-m', 'init'], { cwd: repo }); + return repo; +} + +describe('LegacySshGitRuntime', () => { + const repos: string[] = []; + + afterEach(async () => { + await Promise.all(repos.map((repo) => rm(repo, { recursive: true, force: true }))); + repos.length = 0; + }); + + it('refreshes status when the SSH file-change feed invalidates it', async () => { + const repo = await createRepo(); + repos.push(repo); + const runtime = new LegacySshGitRuntime(localSshProxy as never); + const lease = await runtime.openWorktree(repo); + const worktree = lease.value; + const updates: GitWorktreeUpdate[] = []; + + try { + await expect(worktree.getStatus()).resolves.toMatchObject({ kind: 'ok', unstaged: [] }); + const unsubscribe = worktree.subscribe((update) => updates.push(update)); + + await writeFile(path.join(repo, 'test.ts'), 'test\n', 'utf8'); + expect(invalidateLegacySshGitWorktreeStatus(worktree)).toBe(true); + + await eventually(() => + updates.some( + (update) => + update.kind === 'status' && + update.model.kind === 'ok' && + update.model.unstaged.some((change) => change.path === path.posix.join(repo, 'test.ts')) + ) + ? true + : undefined + ); + + unsubscribe(); + } finally { + await lease.release(); + await runtime.dispose(); + } + }); + + it('does not accept the first untracked fingerprint poll as a stale baseline', async () => { + const repo = await createRepo(); + repos.push(repo); + const runtime = new LegacySshGitRuntime(localSshProxy as never); + const lease = await runtime.openWorktree(repo); + const worktree = lease.value; + const updates: GitWorktreeUpdate[] = []; + + try { + await expect(worktree.getStatus()).resolves.toMatchObject({ kind: 'ok', unstaged: [] }); + const unsubscribe = worktree.subscribe((update) => updates.push(update)); + + await writeFile(path.join(repo, 'test.ts'), 'test\n', 'utf8'); + await ( + worktree as unknown as { pollStatus(untracked: 'normal' | 'no'): Promise } + ).pollStatus('normal'); + + await eventually(() => + updates.some( + (update) => + update.kind === 'status' && + update.model.kind === 'ok' && + update.model.unstaged.some((change) => change.path === path.posix.join(repo, 'test.ts')) + ) + ? true + : undefined + ); + + unsubscribe(); + } finally { + await lease.release(); + await runtime.dispose(); + } + }); +}); diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts index beca7439f1..dbed39745c 100644 --- a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.ts @@ -49,11 +49,11 @@ import { LiveModel, ResourceMap } from '@emdash/core/lib'; import { err, ok, type Lease, type Result, type Unsubscribe } from '@emdash/shared'; import { Result as ResultUtil } from '@emdash/shared/result'; import { SshExecutionContext } from '@main/core/execution-context/ssh-execution-context'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import { GitService } from '@main/core/git/legacy/git-service'; import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; import { log } from '@main/lib/logger'; import type { ImageReadResult as LegacyImageReadResult } from '@shared/core/git/types'; +import { LegacySshFileSystem } from './ssh-file-system'; const STATUS_POLL_MS = 10_000; const UNTRACKED_STATUS_POLL_MS = 30_000; @@ -225,7 +225,7 @@ export class LegacySshGitRuntime implements IGitRuntime { } private createGit(root: string): GitService { - const fs = new SshFileSystem(this.proxy, root); + const fs = new LegacySshFileSystem(this.proxy); const ctx = new SshExecutionContext(this.proxy, { root }); return new GitService(ctx, fs); } @@ -456,6 +456,10 @@ class LegacySshGitWorktree implements IGitWorktree { return { status, head }; } + invalidateStatus(): void { + this.statusModel.invalidate(); + } + subscribe(cb: (update: GitWorktreeUpdate) => void): Unsubscribe { const status = this.statusModel.subscribe(({ value, sequence, generation }) => cb({ kind: 'status', model: value, sequence, generation }) @@ -486,27 +490,28 @@ class LegacySshGitWorktree implements IGitWorktree { } isFileCleanlyTracked(filePath: string): Promise { - return this.git.isFileCleanlyTracked(filePath); + return this.git.isFileCleanlyTracked(this.toGitPath(filePath)); } - getChangedFiles(base: DiffTarget): Promise { - return this.git.getChangedFiles(base) as Promise; + async getChangedFiles(base: DiffTarget): Promise { + return (await this.git.getChangedFiles(base)).map((change) => this.toAbsChange(change)); } getFileAtRef(filePath: string, ref: string): Promise { - return this.git.getFileAtRef(filePath, ref); + return this.git.getFileAtRef(this.toGitPath(filePath), ref); } getFileAtIndex(filePath: string): Promise { - return this.git.getFileAtIndex(filePath); + return this.git.getFileAtIndex(this.toGitPath(filePath)); } async getImageAtRef(filePath: string, ref: string): Promise { - return mapImageReadResult(await this.git.getImageAtRef(filePath, ref)); + const gitPath = this.toGitPath(filePath); + return mapImageReadResult(await this.git.getImageAtRef(gitPath, ref)); } async getImageAtIndex(filePath: string): Promise { - return mapImageReadResult(await this.git.getImageAtIndex(filePath)); + return mapImageReadResult(await this.git.getImageAtIndex(this.toGitPath(filePath))); } getLog(options?: GitLogOptions): Promise { @@ -519,7 +524,7 @@ class LegacySshGitWorktree implements IGitWorktree { async stage(paths: string[]): Promise> { try { - await this.git.stageFiles(paths); + await this.git.stageFiles(this.toGitPaths(paths)); return ok(await this.refreshStatus()); } catch (error) { return err(toGitCommandError(error)); @@ -537,7 +542,7 @@ class LegacySshGitWorktree implements IGitWorktree { async unstage(paths: string[]): Promise> { try { - await this.git.unstageFiles(paths); + await this.git.unstageFiles(this.toGitPaths(paths)); return ok(await this.refreshStatus()); } catch (error) { return err(toGitCommandError(error)); @@ -555,7 +560,7 @@ class LegacySshGitWorktree implements IGitWorktree { async revert(paths: string[]): Promise> { try { - await this.git.revertFiles(paths); + await this.git.revertFiles(this.toGitPaths(paths)); return ok(await this.refreshStatus()); } catch (error) { return err(toGitCommandError(error)); @@ -608,8 +613,8 @@ class LegacySshGitWorktree implements IGitWorktree { const status = await this.git.getFullStatus(); return { kind: 'ok', - staged: status.staged, - unstaged: status.unstaged, + staged: status.staged.map((change) => this.toAbsChange(change)), + unstaged: status.unstaged.map((change) => this.toAbsChange(change)), stagedAdded: status.totalAdded, stagedDeleted: status.totalDeleted, }; @@ -625,6 +630,24 @@ class LegacySshGitWorktree implements IGitWorktree { return this.git.getHeadInfo(); } + private toAbsChange(change: GitChange): GitChange { + return { ...change, path: this.toAbsPath(change.path) }; + } + + private toAbsPath(filePath: string): string { + if (path.posix.isAbsolute(filePath)) return path.posix.normalize(filePath); + return path.posix.join(this.worktree, filePath); + } + + private toGitPath(filePath: string): string { + if (!path.posix.isAbsolute(filePath)) return filePath; + return path.posix.relative(this.worktree, filePath); + } + + private toGitPaths(paths: string[]): string[] { + return paths.map((filePath) => this.toGitPath(filePath)); + } + private async refreshStatus(): Promise { const value = await this.statusModel.refresh(); return { status: value.sequence }; @@ -644,12 +667,32 @@ class LegacySshGitWorktree implements IGitWorktree { if (!fingerprint) return; const previous = this.fingerprints[untracked]; this.fingerprints[untracked] = fingerprint.hash; + if (previous === undefined) { + if (fingerprint.byteLength > 0 && this.statusModel.getCached()) this.statusModel.invalidate(); + return; + } if (previous !== undefined && previous !== fingerprint.hash) { this.statusModel.invalidate(); } } } +type LegacySshGitStatusInvalidatable = IGitWorktree & { + invalidateStatus(): void; +}; + +export function invalidateLegacySshGitWorktreeStatus(worktree: IGitWorktree): boolean { + if (!isLegacySshGitStatusInvalidatable(worktree)) return false; + worktree.invalidateStatus(); + return true; +} + +function isLegacySshGitStatusInvalidatable( + worktree: IGitWorktree +): worktree is LegacySshGitStatusInvalidatable { + return worktree instanceof LegacySshGitWorktree; +} + function mapImageReadResult(result: LegacyImageReadResult): ImageReadResult { if (result.kind !== 'unavailable') return result; if (result.reason === 'ssh') return { kind: 'unavailable', reason: 'git-error' }; diff --git a/apps/emdash-desktop/src/main/core/fs/types.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs-types.ts similarity index 89% rename from apps/emdash-desktop/src/main/core/fs/types.ts rename to apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs-types.ts index 2870a19bf1..8307040f60 100644 --- a/apps/emdash-desktop/src/main/core/fs/types.ts +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs-types.ts @@ -3,11 +3,11 @@ * Provides unified interface for local and remote (SSH/SFTP) filesystem operations */ -import type { FileWatchEvent } from '@shared/core/fs/fs'; - /** - * Handle returned by FileSystemProvider.watch(). - * Call update() to change the set of watched paths, close() to stop. + * Transitional SSH polling watcher handle. + * + * Runtime-owned file change feeds use this internally for the temporary SSH + * adapter; the renderer-facing legacy watch RPC has been removed. */ export interface FileWatcher { update(paths: string[]): void; @@ -137,10 +137,20 @@ export interface SearchMatch { } /** - * Filesystem interface abstraction - * Implementations: LocalFileSystem (local disk), RemoteFileSystem (SFTP over SSH) + * Legacy workspace filesystem abstraction. + * + * This provider remains active for non-tree workspace file operations + * (read/write/image/copy/search/config watches/project setup). Do not extend it + * for the editor file tree; file-tree reads, scopes, and deltas live in + * `@emdash/core/files` and are exposed through `workspace.fileTree`. + * + * Longer term this desktop-side provider should disappear behind filesystem APIs + * owned by `@emdash/core`. Those APIs should run where the workspace lives and + * call `node:fs` directly: desktop imports core directly for local projects, + * while the workspace server imports the same core API and exposes it to + * desktop for remote projects. */ -export interface FileSystemProvider { +export interface LegacySshFileOperations { /** * List directory contents * @param path - Directory path relative to project root @@ -262,20 +272,6 @@ export interface FileSystemProvider { * @param destRelPath - Destination path relative to this filesystem's root */ copyLocalFile?(localAbsPath: string, destRelPath: string): Promise; - - /** - * Watch the worktree for filesystem changes. Returns a FileWatcher handle; - * call update() to hint which paths matter (SSH uses this for polling), - * call close() to stop. Batches events and delivers them via callback. - * Optional — not all implementations support watching. - * - * Local: uses @parcel/watcher for a single recursive native-OS subscription. - * SSH: polls directories passed to update() at a fixed interval. - */ - watch?( - callback: (events: FileWatchEvent[]) => void, - options?: { debounceMs?: number } - ): FileWatcher; } /** diff --git a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.test.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs.test.ts similarity index 58% rename from apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.test.ts rename to apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs.test.ts index db4299bbe0..561e8faefc 100644 --- a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.test.ts +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs.test.ts @@ -1,8 +1,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { FileEntry, FileListResult } from '../types'; -import { SshFileSystem } from './ssh-fs'; +import { SshFileSystem } from './ssh-legacy-fs'; +import type { FileEntry, FileListResult } from './ssh-legacy-fs-types'; type SftpMkdirError = Error & { code?: number }; +type SftpItem = { + filename: string; + attrs: { + isDirectory: () => boolean; + size: number; + mtime: number; + atime: number; + mode: number; + }; +}; function listResult(entries: FileEntry[]): FileListResult { return { entries, total: entries.length }; @@ -39,6 +49,40 @@ function makeMkdirFs(errors: Array) { }; } +function makeListFs(rootPath: string, entriesByPath: Record) { + const sftp = { + on: vi.fn(), + readdir: vi.fn( + (dirPath: string, callback: (error: Error | null, items: SftpItem[]) => void) => { + callback(null, entriesByPath[dirPath] ?? []); + } + ), + }; + const proxy = { + sftp: vi.fn((callback: (error: Error | undefined, sftp: unknown) => void) => { + callback(undefined, sftp); + }), + }; + + return { + fs: new SshFileSystem(proxy as never, rootPath), + readdir: sftp.readdir, + }; +} + +function sftpItem(filename: string, type: 'file' | 'dir'): SftpItem { + return { + filename, + attrs: { + isDirectory: () => type === 'dir', + size: type === 'dir' ? 0 : 1, + mtime: 1, + atime: 1, + mode: type === 'dir' ? 0o040755 : 0o100644, + }, + }; +} + describe('SshFileSystem.mkdir', () => { afterEach(() => { vi.restoreAllMocks(); @@ -70,6 +114,42 @@ describe('SshFileSystem.mkdir', () => { }); }); +describe('SshFileSystem.list', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns relative paths when the remote root is /', async () => { + const { fs } = makeListFs('/', { + '/': [sftpItem('repo', 'dir')], + }); + + await expect(fs.list('', { includeHidden: true })).resolves.toMatchObject({ + entries: [{ path: 'repo', type: 'dir' }], + }); + }); + + it('returns relative nested paths when the remote root is /', async () => { + const { fs } = makeListFs('/', { + '/repo': [sftpItem('src', 'dir')], + }); + + await expect(fs.list('repo', { includeHidden: true })).resolves.toMatchObject({ + entries: [{ path: 'repo/src', type: 'dir' }], + }); + }); + + it('returns relative paths under a trailing-slash remote root', async () => { + const { fs } = makeListFs('/repo/', { + '/repo/src': [sftpItem('index.ts', 'file')], + }); + + await expect(fs.list('src', { includeHidden: true })).resolves.toMatchObject({ + entries: [{ path: 'src/index.ts', type: 'file' }], + }); + }); +}); + describe('SshFileSystem.watch', () => { afterEach(() => { vi.useRealTimers(); diff --git a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs.ts similarity index 97% rename from apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts rename to apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs.ts index 3a3f42529a..d9cef454e2 100644 --- a/apps/emdash-desktop/src/main/core/fs/impl/ssh-fs.ts +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-legacy-fs.ts @@ -14,7 +14,7 @@ import { FileSystemErrorCodes, type FileEntry, type FileListResult, - type FileSystemProvider, + type LegacySshFileOperations, type FileWatcher, type ListOptions, type ReadResult, @@ -22,7 +22,7 @@ import { type SearchOptions, type SearchResult, type WriteResult, -} from '../types'; +} from './ssh-legacy-fs-types'; const SFTP_STATUS = { NO_SUCH_FILE: 2, @@ -59,10 +59,14 @@ function fileEntryMetadataChanged(prev: FileEntry, next: FileEntry): boolean { } /** - * SshFileSystem implements IFileSystem using SFTP over SSH. - * Provides path traversal protection and proper error handling. + * Legacy SSH `LegacySshFileOperations` implementation using SFTP/SSH exec. + * + * This remains active for non-tree file operations and transitional SSH + * adapters. The editor file tree uses `LegacySshFilesRuntime` only as a + * temporary bridge until the `@emdash/core` file-tree runtime can run where the + * remote workspace lives. */ -export class SshFileSystem implements FileSystemProvider { +export class SshFileSystem implements LegacySshFileOperations { private cachedSftp: SFTPWrapper | undefined; constructor( @@ -849,14 +853,14 @@ export class SshFileSystem implements FileSystemProvider { * Get relative path from full remote path */ private relativePath(fullPath: string): string { - const normalized = fullPath.replace(/\\/g, '/'); - const normalizedBase = this.remotePath.replace(/\\/g, '/'); + const normalized = fullPath.replace(/\\/g, '/').replace(/\/+/g, '/'); + const normalizedBase = normalizeRemoteBasePath(this.remotePath); if (normalized === normalizedBase) { return ''; } - const prefix = `${normalizedBase}/`; + const prefix = normalizedBase === '/' ? '/' : `${normalizedBase}/`; if (normalized.startsWith(prefix)) { return normalized.substring(prefix.length); } @@ -1032,3 +1036,9 @@ export class SshFileSystem implements FileSystemProvider { }; } } + +function normalizeRemoteBasePath(path: string): string { + const normalized = path.replace(/\\/g, '/').replace(/\/+/g, '/'); + if (normalized === '/') return '/'; + return normalized.replace(/\/$/, ''); +} diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-paths.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-paths.ts new file mode 100644 index 0000000000..d49d407c9d --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-paths.ts @@ -0,0 +1,39 @@ +import path from 'node:path'; +import { isIgnoredRelativePath, type FileError } from '@emdash/core/files'; +import { err, ok, type Result } from '@emdash/shared'; + +export function normalizeRemoteRootPath(rootPath: string): string { + const normalized = path.posix.normalize(rootPath.replace(/\\/g, '/')); + return path.posix.isAbsolute(normalized) ? normalized : path.posix.resolve('/', normalized); +} + +export function normalizeRemoteAbsolutePath(value: string | undefined): Result { + const raw = value ?? ''; + if (raw.includes('\0')) { + return err({ type: 'invalid-path', path: raw, message: 'Path contains a null byte' }); + } + const normalized = path.posix.normalize(raw.replace(/\\/g, '/')); + if (!path.posix.isAbsolute(normalized)) { + return err({ type: 'invalid-path', path: raw, message: 'Path must be absolute' }); + } + return ok(normalized); +} + +export function toRemoteAbsolutePath(rootPath: string, value: string): string { + const normalized = value.replace(/\\/g, '/'); + if (path.posix.isAbsolute(normalized)) return normalizeRemoteRootPath(normalized); + return normalizeRemoteRootPath(path.posix.join(rootPath, normalized)); +} + +export function containsRemotePath(parent: string, child: string): boolean { + const rel = path.posix.relative(parent, child); + return rel === '' || (rel !== '..' && !rel.startsWith('../') && !path.posix.isAbsolute(rel)); +} + +export function isIgnoredRemotePath(rootPath: string, absPath: string): boolean { + const rel = path.posix.relative(normalizeRemoteRootPath(rootPath), absPath); + if (!rel || rel === '..' || rel.startsWith('../') || path.posix.isAbsolute(rel)) { + return false; + } + return isIgnoredRelativePath(rel); +} diff --git a/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-remote-enumerate.ts b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-remote-enumerate.ts new file mode 100644 index 0000000000..358685e53b --- /dev/null +++ b/apps/emdash-desktop/src/main/core/runtime/legacy/ssh-remote-enumerate.ts @@ -0,0 +1,114 @@ +import { StringDecoder } from 'node:string_decoder'; +import { IGNORED_PATH_SEGMENTS } from '@emdash/core/files'; +import type { ClientChannel } from 'ssh2'; +import { buildRemoteShellCommand } from '@main/core/ssh/lifecycle/remote-shell-profile'; +import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; +import { quoteShellArg } from '@main/utils/shellEscape'; +import { isIgnoredRemotePath, toRemoteAbsolutePath } from './ssh-paths'; + +export async function* enumerateRemoteWorkspace( + proxy: SshClientProxy, + rootPath: string +): AsyncIterable { + for await (const rawPath of execRemoteNulFields(proxy, buildRemoteEnumerationCommand(rootPath))) { + const absPath = toRemoteAbsolutePath(rootPath, rawPath); + if (isIgnoredRemotePath(rootPath, absPath)) continue; + yield absPath; + } +} + +export function buildFindPruneExpression(): string { + const ignoredNames = IGNORED_PATH_SEGMENTS.map((name) => `-name ${quoteShellArg(name)}`).join( + ' -o ' + ); + return ignoredNames ? `\\( ${ignoredNames} \\) -prune -o ` : ''; +} + +function buildRemoteEnumerationCommand(rootPath: string): string { + const pruneExpression = buildFindPruneExpression(); + const enumerateScript = ` +for p do + rel=\${p#./} + [ "$rel" = "." ] && continue + printf '%s\\0' "$rel" +done +`.trim(); + + return [ + `cd ${quoteShellArg(rootPath)} || exit 1`, + `find . ${pruneExpression}-type f -exec sh -c ${quoteShellArg(enumerateScript)} sh {} +`, + ].join('\n'); +} + +async function* execRemoteNulFields(proxy: SshClientProxy, command: string): AsyncIterable { + const profile = await proxy.getRemoteShellProfile(); + const fullCommand = buildRemoteShellCommand(profile, command); + const decoder = new StringDecoder('utf8'); + const queue: string[] = []; + let stream: ClientChannel | undefined; + let pending = ''; + let stderr = ''; + let done = false; + let error: unknown; + let notify: (() => void) | undefined; + + const wake = () => { + notify?.(); + notify = undefined; + }; + const waitForEvent = () => + new Promise((resolve) => { + notify = resolve; + }); + + await new Promise((resolve, reject) => { + proxy.exec(fullCommand, (err, channel) => { + if (err) { + reject(err); + return; + } + + stream = channel; + channel.on('data', (chunk: Buffer) => { + const text = pending + decoder.write(chunk); + const parts = text.split('\0'); + pending = parts.pop() ?? ''; + queue.push(...parts); + wake(); + }); + channel.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + }); + channel.on('close', (exitCode: number | null) => { + const tail = pending + decoder.end(); + if (tail) queue.push(tail); + pending = ''; + if ((exitCode ?? 0) !== 0) { + error = new Error(stderr.trim() || `Remote command exited with code ${exitCode}`); + } + done = true; + wake(); + }); + channel.on('error', (streamError: Error) => { + error = streamError; + done = true; + wake(); + }); + resolve(); + }); + }); + + try { + while (!done || queue.length > 0) { + while (queue.length > 0) { + const item = queue.shift(); + if (item) yield item; + } + if (error) throw error; + if (!done) await waitForEvent(); + } + if (error) throw error; + } finally { + if (!done) stream?.destroy(); + } +} diff --git a/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts b/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts index 9391f1f076..5c55170dd1 100644 --- a/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts +++ b/apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts @@ -1,14 +1,39 @@ +import nodePath from 'node:path'; +import { contains, FilesRuntime } from '@emdash/core/files'; import { GitRuntime } from '@emdash/core/git'; import { ResourceMap } from '@emdash/core/lib'; import type { Lease } from '@emdash/shared'; import { sshConnectionManager } from '@main/core/ssh/lifecycle/production-ssh-connection-manager'; import { log } from '@main/lib/logger'; import { ConstantHealthSource } from './health'; +import { LegacySshFilesRuntime } from './legacy/ssh-files'; import { LegacySshGitRuntime } from './legacy/ssh-git'; -import { machineKey, type MachineRef, type MachineRuntime, type RuntimeManager } from './types'; +import { + machineKey, + type MachineRef, + type MachineRuntime, + type RuntimeManager, + type RuntimePath, +} from './types'; + +const nativeRuntimePath: RuntimePath = { + join: (...parts) => nodePath.join(...parts), + dirname: (p) => nodePath.dirname(p), + basename: (p) => nodePath.basename(p), + isAbsolute: (p) => nodePath.isAbsolute(p), + relative: (from, to) => nodePath.relative(from, to), + contains, +}; class LocalMachineRuntime implements MachineRuntime { readonly machine: MachineRef = { kind: 'local' }; + readonly files = Object.assign( + new FilesRuntime({ + onError: (context, error) => + log.warn('Local file runtime background error', { context, error: String(error) }), + }), + { path: nativeRuntimePath } + ); readonly git = new GitRuntime({ onError: (context, error) => log.warn('Local GitRuntime background error', { context, error: String(error) }), @@ -16,12 +41,14 @@ class LocalMachineRuntime implements MachineRuntime { readonly health = new ConstantHealthSource(); async dispose(): Promise { + await this.files.dispose(); await this.git.dispose(); } } class SshMachineRuntime implements MachineRuntime { readonly machine: MachineRef; + readonly files: LegacySshFilesRuntime; readonly git: LegacySshGitRuntime; readonly health = new ConstantHealthSource(); @@ -30,10 +57,12 @@ class SshMachineRuntime implements MachineRuntime { proxy: Awaited> ) { this.machine = { kind: 'ssh', connectionId }; + this.files = new LegacySshFilesRuntime(proxy); this.git = new LegacySshGitRuntime(proxy); } async dispose(): Promise { + await this.files.dispose(); await this.git.dispose(); } } diff --git a/apps/emdash-desktop/src/main/core/runtime/types.ts b/apps/emdash-desktop/src/main/core/runtime/types.ts index 9fc7ddc92c..08b3ccdeea 100644 --- a/apps/emdash-desktop/src/main/core/runtime/types.ts +++ b/apps/emdash-desktop/src/main/core/runtime/types.ts @@ -1,3 +1,5 @@ +/** Transitional named import, remove when workspace server runs */ +import type { IFilesRuntime as CoreFilesRuntime } from '@emdash/core/files'; import type { IGitRuntime } from '@emdash/core/git'; import type { IDisposable, Lease, Unsubscribe } from '@emdash/shared'; @@ -13,8 +15,25 @@ export interface HealthSource { subscribe(cb: (health: RuntimeHealth) => void): Unsubscribe; } +/** + * Transitional: per-runtime path algebra bound to the machine that owns the files. + * Removed when the workspace server moves remote path math into core. + */ +export type RuntimePath = { + join(...parts: string[]): string; + dirname(path: string): string; + basename(path: string): string; + isAbsolute(path: string): boolean; + relative(from: string, to: string): string; + contains(parent: string, child: string): boolean; +}; + +/** Transitional named import, remove when workspace server runs */ +export type IFilesRuntime = CoreFilesRuntime & { readonly path: RuntimePath }; + export interface MachineRuntime extends IDisposable { readonly machine: MachineRef; + readonly files: IFilesRuntime; readonly git: IGitRuntime; readonly health: HealthSource; } diff --git a/apps/emdash-desktop/src/main/core/search/collect-with-budget.test.ts b/apps/emdash-desktop/src/main/core/search/collect-with-budget.test.ts new file mode 100644 index 0000000000..9da9bdf99b --- /dev/null +++ b/apps/emdash-desktop/src/main/core/search/collect-with-budget.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { collectWithBudget } from './collect-with-budget'; + +describe('collectWithBudget', () => { + it('collects all paths when no budget is exceeded', async () => { + await expect( + collectWithBudget(paths(['a.ts', 'b.ts']), { + maxFiles: 10, + timeoutMs: 1_000, + now: () => 0, + }) + ).resolves.toEqual({ + paths: ['a.ts', 'b.ts'], + truncated: false, + truncateReason: undefined, + }); + }); + + it('truncates at maxFiles', async () => { + await expect( + collectWithBudget(paths(['a.ts', 'b.ts', 'c.ts']), { + maxFiles: 2, + timeoutMs: 1_000, + now: () => 0, + }) + ).resolves.toEqual({ + paths: ['a.ts', 'b.ts'], + truncated: true, + truncateReason: 'maxEntries', + }); + }); + + it('truncates when the injected clock exceeds the time budget', async () => { + const ticks = [0, 0, 31]; + + await expect( + collectWithBudget(paths(['a.ts', 'b.ts']), { + maxFiles: 10, + timeoutMs: 30, + now: () => ticks.shift() ?? 31, + }) + ).resolves.toEqual({ + paths: ['a.ts'], + truncated: true, + truncateReason: 'timeBudget', + }); + }); +}); + +async function* paths(values: string[]): AsyncIterable { + for (const value of values) { + yield value; + } +} diff --git a/apps/emdash-desktop/src/main/core/search/collect-with-budget.ts b/apps/emdash-desktop/src/main/core/search/collect-with-budget.ts new file mode 100644 index 0000000000..d720ad4386 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/search/collect-with-budget.ts @@ -0,0 +1,40 @@ +import type { FileIndexTruncateReason } from './workspace-file-index-store'; + +export type CollectWithBudgetOptions = { + maxFiles: number; + timeoutMs: number; + now?: () => number; +}; + +export type BudgetedFileCollection = { + paths: string[]; + truncated: boolean; + truncateReason?: FileIndexTruncateReason; +}; + +export async function collectWithBudget( + paths: AsyncIterable, + options: CollectWithBudgetOptions +): Promise { + const now = options.now ?? Date.now; + const startTime = now(); + const collected: string[] = []; + let truncated = false; + let truncateReason: FileIndexTruncateReason | undefined; + + for await (const filePath of paths) { + if (now() - startTime > options.timeoutMs) { + truncated = true; + truncateReason = 'timeBudget'; + break; + } + if (collected.length >= options.maxFiles) { + truncated = true; + truncateReason = 'maxEntries'; + break; + } + collected.push(filePath); + } + + return { paths: collected, truncated, truncateReason }; +} diff --git a/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.db.test.ts b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.db.test.ts new file mode 100644 index 0000000000..c64ad5d4b8 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.db.test.ts @@ -0,0 +1,227 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type BetterSqlite3 from 'better-sqlite3'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { + WorkspaceFileEnumerator, + WorkspaceFileIndexServiceOptions, +} from './workspace-file-index-service'; + +type LoadedService = Awaited>; +type FileIndexMetaRow = { + root_path: string; + status: string; + file_count: number; + truncate_reason: string | null; +}; + +let loadedService: LoadedService | undefined; + +describe('WorkspaceFileIndexService', () => { + afterEach(async () => { + vi.useRealTimers(); + loadedService?.service.onWorkspaceDeactivated('ws-1'); + loadedService?.sqlite.close(); + if (loadedService) { + await rm(loadedService.tempDir, { recursive: true, force: true }); + } + loadedService = undefined; + vi.resetModules(); + delete process.env.EMDASH_DB_FILE; + }); + + it('indexes files from core enumeration when a workspace is activated', async () => { + loadedService = await loadService(); + const { service, sqlite } = loadedService; + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + enumerate: enumerator(() => ['/repo/README.md', '/repo/src/index.ts']), + }); + + expect(indexedPaths(sqlite)).toEqual(['/repo/README.md', '/repo/src/index.ts']); + expect(indexMeta(sqlite)).toEqual({ + root_path: '/repo', + status: 'complete', + file_count: 2, + truncate_reason: null, + }); + expect(service.search('ws-1', 'index')).toEqual([ + { path: '/repo/src/index.ts', filename: 'index.ts' }, + ]); + }); + + it('applies path-changing file events incrementally', async () => { + loadedService = await loadService(); + const { service, sqlite } = loadedService; + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + enumerate: enumerator(() => ['/repo/src/changed.ts', '/repo/src/old.ts']), + }); + + service.onWorkspaceFileChange('ws-1', { + kind: 'changes', + changes: [ + { kind: 'create', path: '/repo/src/new.ts', entryType: 'file' }, + { kind: 'update', path: '/repo/src/changed.ts', entryType: 'file' }, + { kind: 'update', path: '/repo/src/missing.ts', entryType: 'file' }, + { kind: 'delete', path: '/repo/src/old.ts', entryType: 'file' }, + ], + }); + + expect(indexedPaths(sqlite)).toEqual(['/repo/src/changed.ts', '/repo/src/new.ts']); + expect(indexMeta(sqlite)).toMatchObject({ status: 'complete', file_count: 2 }); + }); + + it('removes descendants when a directory-like path is deleted', async () => { + loadedService = await loadService(); + const { service, sqlite } = loadedService; + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + enumerate: enumerator(() => ['/repo/other.ts', '/repo/src/a.ts', '/repo/src/nested/b.ts']), + }); + + service.onWorkspaceFileChange('ws-1', { + kind: 'changes', + changes: [{ kind: 'delete', path: '/repo/src', entryType: 'unknown' }], + }); + + expect(indexedPaths(sqlite)).toEqual(['/repo/other.ts']); + expect(indexMeta(sqlite)).toMatchObject({ status: 'complete', file_count: 1 }); + }); + + it('reindexes from core enumeration on resync', async () => { + vi.useFakeTimers(); + loadedService = await loadService({ reindexDebounceMs: 1 }); + const { service, sqlite } = loadedService; + let paths = ['/repo/stale.ts']; + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + enumerate: enumerator(() => paths), + }); + expect(indexedPaths(sqlite)).toEqual(['/repo/stale.ts']); + + paths = ['/repo/fresh.ts']; + service.onWorkspaceFileChange('ws-1', { kind: 'resync' }); + await vi.advanceTimersByTimeAsync(1); + + expect(indexedPaths(sqlite)).toEqual(['/repo/fresh.ts']); + expect(indexMeta(sqlite)).toMatchObject({ status: 'complete', file_count: 1 }); + }); + + it('records truncated full indexes as incomplete', async () => { + loadedService = await loadService({ maxFiles: 2 }); + const { service, sqlite } = loadedService; + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + enumerate: enumerator(() => ['/repo/a.ts', '/repo/b.ts', '/repo/c.ts']), + }); + + expect(indexedPaths(sqlite)).toEqual(['/repo/a.ts', '/repo/b.ts']); + expect(indexMeta(sqlite)).toEqual({ + root_path: '/repo', + status: 'truncated', + file_count: 2, + truncate_reason: 'maxEntries', + }); + + service.onWorkspaceFileChange('ws-1', { + kind: 'changes', + changes: [{ kind: 'create', path: '/repo/d.ts', entryType: 'file' }], + }); + + expect(indexedPaths(sqlite)).toEqual(['/repo/a.ts', '/repo/b.ts']); + expect(indexMeta(sqlite)).toMatchObject({ status: 'truncated', file_count: 2 }); + }); + + it('does not grow a complete index past the file cap', async () => { + loadedService = await loadService({ maxFiles: 2 }); + const { service, sqlite } = loadedService; + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + enumerate: enumerator(() => ['/repo/a.ts', '/repo/b.ts']), + }); + + service.onWorkspaceFileChange('ws-1', { + kind: 'changes', + changes: [{ kind: 'create', path: '/repo/c.ts', entryType: 'file' }], + }); + + expect(indexedPaths(sqlite)).toEqual(['/repo/a.ts', '/repo/b.ts']); + expect(indexMeta(sqlite)).toMatchObject({ status: 'stale', file_count: 2 }); + }); +}); + +async function loadService(options: WorkspaceFileIndexServiceOptions = {}) { + vi.resetModules(); + const tempDir = await mkdtemp(join(tmpdir(), 'emdash-file-index-')); + process.env.EMDASH_DB_FILE = join(tempDir, 'test.db'); + + const [{ WorkspaceFileIndexService }, { sqlite }] = await Promise.all([ + import('./workspace-file-index-service'), + import('@main/db/client'), + ]); + createFileIndexTables(sqlite); + + return { + service: new WorkspaceFileIndexService(options), + sqlite, + tempDir, + }; +} + +function createFileIndexTables(sqlite: BetterSqlite3.Database): void { + sqlite.exec(` + CREATE VIRTUAL TABLE workspace_file_index USING fts5( + workspace_id UNINDEXED, + path, + filename, + tokenize = 'trigram case_sensitive 0' + ); + CREATE TABLE workspace_file_index_meta ( + workspace_id TEXT PRIMARY KEY, + indexed_at INTEGER NOT NULL, + root_path TEXT NOT NULL, + status TEXT NOT NULL + CHECK (status IN ('complete', 'stale', 'truncated')), + file_count INTEGER NOT NULL, + truncate_reason TEXT + CHECK (truncate_reason IS NULL OR truncate_reason IN ('maxEntries', 'timeBudget')) + ); + `); +} + +function enumerator(readPaths: () => readonly string[]): WorkspaceFileEnumerator { + return () => ({ + success: true as const, + data: (async function* () { + for (const path of readPaths()) { + yield path; + } + })(), + }); +} + +function indexedPaths(sqlite: BetterSqlite3.Database): string[] { + return ( + sqlite.prepare(`SELECT path FROM workspace_file_index ORDER BY path`).all() as Array<{ + path: string; + }> + ).map((row) => row.path); +} + +function indexMeta(sqlite: BetterSqlite3.Database): FileIndexMetaRow | undefined { + return sqlite + .prepare( + `SELECT root_path, status, file_count, truncate_reason + FROM workspace_file_index_meta + WHERE workspace_id = 'ws-1'` + ) + .get() as FileIndexMetaRow | undefined; +} diff --git a/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.test.ts b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.test.ts new file mode 100644 index 0000000000..4eb2573148 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.test.ts @@ -0,0 +1,305 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WorkspaceFileEnumerator } from './workspace-file-index-service'; +import type { + FileHit, + FileIndexMeta, + IWorkspaceFileIndexStore, +} from './workspace-file-index-store'; + +vi.mock('./workspace-file-index-store', () => ({ + WorkspaceFileIndexStore: class {}, +})); + +describe('WorkspaceFileIndexService', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('delegates initialize and search to the store', async () => { + const store = new FakeStore(); + store.searchResults = [{ path: '/repo/src/index.ts', filename: 'index.ts' }]; + const service = await createService(store); + + service.initialize(); + + expect(store.evictedDays).toBe(14); + expect(service.search('ws-1', 'index')).toEqual([ + { path: '/repo/src/index.ts', filename: 'index.ts' }, + ]); + expect(store.operations).toContain('search:index'); + }); + + it('refreshes complete metadata on activation without enumerating', async () => { + const store = new FakeStore(); + store.meta.set('ws-1', { + rootPath: '/repo', + status: 'complete', + fileCount: 1, + truncateReason: null, + }); + const service = await createService(store); + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + enumerate: enumerator(() => { + throw new Error('should not enumerate'); + }), + }); + + expect(store.operations).toEqual(['refresh:ws-1']); + }); + + it('reindexes when a complete index belongs to an old workspace root', async () => { + const store = new FakeStore(); + store.meta.set('ws-1', { + rootPath: '/old-repo', + status: 'complete', + fileCount: 1, + truncateReason: null, + }); + store.paths.set('ws-1', new Set(['/old-repo/stale.ts'])); + const service = await createService(store); + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + enumerate: enumerator(() => ['/repo/fresh.ts']), + }); + + expect([...store.pathSet('ws-1')]).toEqual(['/repo/fresh.ts']); + expect(store.meta.get('ws-1')).toEqual({ + rootPath: '/repo', + status: 'complete', + fileCount: 1, + truncateReason: null, + }); + expect(store.operations).toContain('deleteIndex:ws-1'); + }); + + it('indexes from enumeration when metadata is missing', async () => { + const store = new FakeStore(); + const service = await createService(store); + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + enumerate: enumerator(() => ['/repo/README.md', '/repo/src/index.ts']), + }); + + expect([...store.pathSet('ws-1')].sort()).toEqual(['/repo/README.md', '/repo/src/index.ts']); + expect(store.meta.get('ws-1')).toEqual({ + rootPath: '/repo', + status: 'complete', + fileCount: 2, + truncateReason: null, + }); + }); + + it('debounces and coalesces resync requests', async () => { + vi.useFakeTimers(); + const store = new FakeStore(); + store.meta.set('ws-1', { + rootPath: '/repo', + status: 'complete', + fileCount: 1, + truncateReason: null, + }); + const service = await createService(store, { reindexDebounceMs: 5 }); + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + enumerate: enumerator(() => ['/repo/fresh.ts']), + }); + service.onWorkspaceFileChange('ws-1', { kind: 'resync' }); + service.onWorkspaceFileChange('ws-1', { kind: 'resync' }); + + await vi.advanceTimersByTimeAsync(5); + + expect(store.operations.filter((op) => op.startsWith('sync:'))).toEqual([ + 'sync:/repo/fresh.ts', + ]); + expect(store.meta.get('ws-1')).toMatchObject({ status: 'complete', fileCount: 1 }); + }); + + it('applies deletes before creates, ignores updates, and recounts once for subtree deletes', async () => { + const store = new FakeStore(); + store.meta.set('ws-1', { + rootPath: '/repo', + status: 'complete', + fileCount: 3, + truncateReason: null, + }); + store.paths.set('ws-1', new Set(['/repo/changed.ts', '/repo/dir/a.ts', '/repo/old.ts'])); + const service = await createService(store); + + service.onWorkspaceFileChange('ws-1', { + kind: 'changes', + changes: [ + { kind: 'create', path: '/repo/new.ts', entryType: 'file' }, + { kind: 'update', path: '/repo/missing.ts', entryType: 'file' }, + { kind: 'delete', path: '/repo/old.ts', entryType: 'file' }, + { kind: 'delete', path: '/repo/dir', entryType: 'unknown' }, + ], + }); + + expect([...store.pathSet('ws-1')].sort()).toEqual(['/repo/changed.ts', '/repo/new.ts']); + expect(store.operations).toEqual([ + 'transaction', + 'count:ws-1', + 'deletePath:/repo/old.ts', + 'deleteSubtree:/repo/dir', + 'count:ws-1', + 'insert:/repo/new.ts', + 'count:ws-1', + 'record:complete:2', + ]); + }); + + it('marks the index stale when creates would exceed the cap', async () => { + vi.useFakeTimers(); + const store = new FakeStore(); + store.meta.set('ws-1', { + rootPath: '/repo', + status: 'complete', + fileCount: 2, + truncateReason: null, + }); + store.paths.set('ws-1', new Set(['/repo/a.ts', '/repo/b.ts'])); + const service = await createService(store, { maxFiles: 2, reindexDebounceMs: 1_000 }); + + await service.onWorkspaceActivated('ws-1', { + rootPath: '/repo', + enumerate: enumerator(() => ['/repo/a.ts', '/repo/b.ts', '/repo/c.ts']), + }); + service.onWorkspaceFileChange('ws-1', { + kind: 'changes', + changes: [ + { kind: 'delete', path: '/repo/missing.ts', entryType: 'file' }, + { kind: 'create', path: '/repo/c.ts', entryType: 'file' }, + ], + }); + + expect([...store.pathSet('ws-1')].sort()).toEqual(['/repo/a.ts', '/repo/b.ts']); + expect(store.meta.get('ws-1')).toMatchObject({ status: 'stale', fileCount: 2 }); + }); + + it('ignores incremental changes while the current index is truncated', async () => { + const store = new FakeStore(); + store.meta.set('ws-1', { + rootPath: '/repo', + status: 'truncated', + fileCount: 2, + truncateReason: 'maxEntries', + }); + store.paths.set('ws-1', new Set(['/repo/a.ts', '/repo/b.ts'])); + const service = await createService(store); + + service.onWorkspaceFileChange('ws-1', { + kind: 'changes', + changes: [{ kind: 'create', path: '/repo/c.ts', entryType: 'file' }], + }); + + expect([...store.pathSet('ws-1')].sort()).toEqual(['/repo/a.ts', '/repo/b.ts']); + expect(store.operations).toEqual([]); + }); +}); + +async function createService( + store: FakeStore, + options: { maxFiles?: number; reindexDebounceMs?: number } = {} +) { + const { WorkspaceFileIndexService } = await import('./workspace-file-index-service'); + return new WorkspaceFileIndexService({ store, ...options }); +} + +function enumerator(readPaths: () => readonly string[]): WorkspaceFileEnumerator { + return () => ({ + success: true as const, + data: (async function* () { + for (const path of readPaths()) { + yield path; + } + })(), + }); +} + +class FakeStore implements IWorkspaceFileIndexStore { + meta = new Map(); + paths = new Map>(); + operations: string[] = []; + evictedDays: number | undefined; + searchResults: FileHit[] = []; + + transaction(fn: () => T): T { + this.operations.push('transaction'); + return fn(); + } + + getMeta(workspaceId: string): FileIndexMeta | null { + return this.meta.get(workspaceId) ?? null; + } + + recordMeta(workspaceId: string, meta: FileIndexMeta): void { + this.operations.push(`record:${meta.status}:${meta.fileCount}`); + this.meta.set(workspaceId, meta); + } + + refreshMetaTimestamp(workspaceId: string): void { + this.operations.push(`refresh:${workspaceId}`); + } + + syncRows(workspaceId: string, paths: string[]): void { + this.operations.push(`sync:${paths.join(',')}`); + this.paths.set(workspaceId, new Set(paths)); + } + + insertPath(workspaceId: string, path: string): boolean { + this.operations.push(`insert:${path}`); + const paths = this.pathSet(workspaceId); + const alreadyIndexed = paths.has(path); + paths.add(path); + return !alreadyIndexed; + } + + deletePath(workspaceId: string, path: string): boolean { + this.operations.push(`deletePath:${path}`); + return this.pathSet(workspaceId).delete(path); + } + + deleteSubtree(workspaceId: string, path: string): void { + this.operations.push(`deleteSubtree:${path}`); + const paths = this.pathSet(workspaceId); + for (const indexedPath of [...paths]) { + if (indexedPath === path || indexedPath.startsWith(`${path}/`)) { + paths.delete(indexedPath); + } + } + } + + countIndexedFiles(workspaceId: string): number { + this.operations.push(`count:${workspaceId}`); + return this.pathSet(workspaceId).size; + } + + search(_workspaceId: string, query: string): FileHit[] { + this.operations.push(`search:${query}`); + return this.searchResults; + } + + deleteIndex(workspaceId: string): void { + this.operations.push(`deleteIndex:${workspaceId}`); + this.paths.delete(workspaceId); + this.meta.delete(workspaceId); + } + + evict(staleDays: number): void { + this.evictedDays = staleDays; + } + + pathSet(workspaceId: string): Set { + let paths = this.paths.get(workspaceId); + if (!paths) { + paths = new Set(); + this.paths.set(workspaceId, paths); + } + return paths; + } +} diff --git a/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts index f2912c4ed7..294992a3ff 100644 --- a/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts +++ b/apps/emdash-desktop/src/main/core/search/workspace-file-index-service.ts @@ -1,244 +1,293 @@ -import { basename } from 'node:path'; -import { Result } from '@emdash/shared/result'; -import { fsEvents } from '@main/core/fs/fs-events'; -import type { Workspace } from '@main/core/workspaces/workspace'; -import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; -import { sqlite } from '@main/db/client'; +import { + isIgnoredInsideRoot, + type FileChange, + type FileChangeUpdate, + type FileError, +} from '@emdash/core/files'; +import type { Result } from '@emdash/shared'; import { log } from '@main/lib/logger'; +import { collectWithBudget } from './collect-with-budget'; +import { + WorkspaceFileIndexStore, + type FileHit, + type IWorkspaceFileIndexStore, +} from './workspace-file-index-store'; const STALE_DAYS = 14; -const MAX_FILES = 50_000; -const CRAWL_TIMEOUT_MS = 30_000; -const REINDEX_DEBOUNCE_MS = 3_000; - -const CRAWL_IGNORED_DIRS = new Set([ - 'node_modules', - '.git', - '.svn', - '.hg', - 'dist', - 'build', - '.next', - '.nuxt', - 'coverage', - '.cache', - '.parcel-cache', - '__pycache__', - '.pytest_cache', - 'venv', - '.venv', - 'target', - '.terraform', - '.serverless', - 'worktrees', - '.emdash', - '.conductor', - '.cursor', - '.claude', - '.amp', - '.codex', - '.aider', - '.continue', - '.cody', - '.windsurf', -]); - -type FileHit = { path: string; filename: string }; - -class WorkspaceFileIndexService { - private crawling = new Set(); +const DEFAULT_MAX_FILES = 50_000; +const DEFAULT_REINDEX_TIMEOUT_MS = 30_000; +const DEFAULT_REINDEX_DEBOUNCE_MS = 3_000; + +export type WorkspaceFileEnumerator = ( + rootPath: string +) => Result, FileError>; + +export type WorkspaceFileIndexSource = { + rootPath: string; + enumerate: WorkspaceFileEnumerator; +}; + +export type WorkspaceFileIndexServiceOptions = { + store?: IWorkspaceFileIndexStore; + maxFiles?: number; + reindexTimeoutMs?: number; + reindexDebounceMs?: number; + now?: () => number; +}; + +export class WorkspaceFileIndexService { + private readonly store: IWorkspaceFileIndexStore; + private reindexing = new Set(); + private pendingReindex = new Set(); private debounceTimers = new Map>(); + private activeSources = new Map(); + + constructor(private readonly options: WorkspaceFileIndexServiceOptions = {}) { + this.store = options.store ?? new WorkspaceFileIndexStore(); + } initialize(): void { - this.evictStale(); + this.store.evict(STALE_DAYS); + } - fsEvents.on('watch:event', ({ workspaceId }) => { + onWorkspaceFileChange(workspaceId: string, update: FileChangeUpdate): void { + if (update.kind === 'resync') { this.scheduleReindex(workspaceId); - }); + return; + } + if (update.changes.length === 0) return; + + const meta = this.store.getMeta(workspaceId); + if (this.reindexing.has(workspaceId) || !meta) { + this.scheduleReindex(workspaceId); + return; + } + if (meta.status === 'stale') { + this.scheduleReindex(workspaceId); + return; + } + if (meta.status === 'truncated') return; + + this.applyChanges(workspaceId, update.changes); } - async onWorkspaceCreated(workspaceId: string, workspace: Workspace): Promise { - const alreadyIndexed = sqlite - .prepare(`SELECT 1 FROM workspace_file_index_meta WHERE workspace_id = ?`) - .get(workspaceId); + async onWorkspaceActivated(workspaceId: string, source: WorkspaceFileIndexSource): Promise { + this.activeSources.set(workspaceId, source); + const meta = this.store.getMeta(workspaceId); + + if (meta && meta.rootPath !== source.rootPath) { + this.store.deleteIndex(workspaceId); + await this.reindex(workspaceId); + return; + } - if (alreadyIndexed) { - this.touchMeta(workspaceId); + if (meta?.status === 'complete') { + this.store.refreshMetaTimestamp(workspaceId); return; } - await this.crawl(workspaceId, workspace); + await this.reindex(workspaceId); } - onWorkspaceDestroyed(_workspaceId: string): void { - // Intentionally a no-op: the index ages out 14 days after the last provision. - // Calling touchMeta here would reset the staleness clock on every destroy, - // preventing eviction of stale entries for frequently-cycled workspaces. + onWorkspaceDeactivated(workspaceId: string): void { + // Do not touch meta here: that would reset the staleness clock on every destroy + // and prevent eviction of stale entries for frequently-cycled workspaces. + this.activeSources.delete(workspaceId); + this.pendingReindex.delete(workspaceId); + this.clearDebounceTimer(workspaceId); } deleteIndex(workspaceId: string): void { + this.store.deleteIndex(workspaceId); + } + + search(workspaceId: string, query: string): FileHit[] { + return this.store.search(workspaceId, query); + } + + private async reindex(workspaceId: string): Promise { + if (this.reindexing.has(workspaceId)) { + this.pendingReindex.add(workspaceId); + return; + } + + this.reindexing.add(workspaceId); + try { - sqlite.transaction(() => { - sqlite.prepare(`DELETE FROM workspace_file_index WHERE workspace_id = ?`).run(workspaceId); - sqlite - .prepare(`DELETE FROM workspace_file_index_meta WHERE workspace_id = ?`) - .run(workspaceId); - })(); - log.info('WorkspaceFileIndexService: deleted index', { workspaceId }); + do { + this.pendingReindex.delete(workspaceId); + const source = this.activeSources.get(workspaceId); + if (!source) return; + + const enumeration = source.enumerate(source.rootPath); + if (!enumeration.success) { + log.warn('WorkspaceFileIndexService: enumerate failed to start', { + workspaceId, + error: enumeration.error, + }); + return; + } + + const result = await collectWithBudget(enumeration.data, { + maxFiles: this.maxFiles, + timeoutMs: this.reindexTimeoutMs, + now: this.options.now, + }); + if (this.activeSources.get(workspaceId) !== source) return; + + this.store.transaction(() => { + this.store.syncRows(workspaceId, result.paths); + this.store.recordMeta(workspaceId, { + rootPath: source.rootPath, + status: result.truncated ? 'truncated' : 'complete', + fileCount: result.paths.length, + truncateReason: result.truncateReason ?? null, + }); + }); + + const logPayload = { + workspaceId, + count: result.paths.length, + truncated: result.truncated, + truncateReason: result.truncateReason, + }; + if (result.truncated) { + log.warn('WorkspaceFileIndexService: indexed partial workspace', logPayload); + } else { + log.info('WorkspaceFileIndexService: indexed workspace', logPayload); + } + } while (this.pendingReindex.has(workspaceId)); } catch (e) { - log.warn('WorkspaceFileIndexService: deleteIndex failed', { workspaceId, error: String(e) }); + log.warn('WorkspaceFileIndexService: reindex failed', { workspaceId, error: String(e) }); + } finally { + this.reindexing.delete(workspaceId); } } - search(workspaceId: string, query: string): FileHit[] { - const terms = query - .trim() - .split(/[\s\-_/]+/) - .filter((t) => t.length >= 3); - - if (terms.length === 0) return []; - - const ftsQuery = terms.map((t) => `"${t}"`).join(' AND '); - return Result.from( - Result.try( - () => - sqlite - .prepare( - `SELECT path, filename - FROM workspace_file_index - WHERE workspace_file_index MATCH ? - AND workspace_id = ? - ORDER BY bm25(workspace_file_index, 1.0, 2.0) - LIMIT 20` - ) - .all(ftsQuery, workspaceId) as FileHit[] - ) - ) - .tapErr((e) => - log.warn('WorkspaceFileIndexService: search failed', { workspaceId, error: e.message }) - ) - .unwrapOr([]); - } - - private async crawl(workspaceId: string, workspace: Workspace): Promise { - if (this.crawling.has(workspaceId)) return; - this.crawling.add(workspaceId); - + private applyChanges(workspaceId: string, changes: FileChange[]): void { + let needsReindex = false; + const rootPath = this.metaRootPath(workspaceId); try { - const result = await workspace.fs.list('', { - recursive: true, - maxEntries: MAX_FILES, - timeBudgetMs: CRAWL_TIMEOUT_MS, - }); + this.store.transaction(() => { + let indexedFileCount = this.store.countIndexedFiles(workspaceId); + let needsCountRefresh = false; + const creates: string[] = []; + + for (const change of changes) { + if (isIgnoredInsideRoot(rootPath, change.path)) continue; + + if (change.kind === 'delete') { + if (change.entryType === 'file') { + if (this.store.deletePath(workspaceId, change.path)) { + indexedFileCount = Math.max(0, indexedFileCount - 1); + } + } else { + this.store.deleteSubtree(workspaceId, change.path); + needsCountRefresh = true; + } + continue; + } + + if (change.entryType === 'directory') { + needsReindex = true; + continue; + } - const files = result.entries.filter( - (e) => e.type === 'file' && !e.path.split('/').some((seg) => CRAWL_IGNORED_DIRS.has(seg)) - ); - - sqlite.transaction(() => { - sqlite.prepare(`DELETE FROM workspace_file_index WHERE workspace_id = ?`).run(workspaceId); - const stmt = sqlite.prepare( - `INSERT INTO workspace_file_index(workspace_id, path, filename) VALUES (?, ?, ?)` - ); - for (const f of files) { - stmt.run(workspaceId, f.path, basename(f.path)); + if (change.kind === 'create') { + creates.push(change.path); + } } - })(); - this.touchMeta(workspaceId); - log.info('WorkspaceFileIndexService: indexed workspace', { - workspaceId, - count: files.length, - truncated: result.truncated ?? false, + if (needsCountRefresh) { + indexedFileCount = this.store.countIndexedFiles(workspaceId); + } + + for (const path of creates) { + if (indexedFileCount >= this.maxFiles) { + needsReindex = true; + continue; + } + + const added = this.store.insertPath(workspaceId, path); + if (added) indexedFileCount += 1; + } + }); + + if (needsReindex) { + this.markStale(workspaceId); + this.scheduleReindex(workspaceId); + return; + } + + this.store.recordMeta(workspaceId, { + rootPath: this.metaRootPath(workspaceId), + status: 'complete', + fileCount: this.store.countIndexedFiles(workspaceId), + truncateReason: null, }); } catch (e) { - log.warn('WorkspaceFileIndexService: crawl failed', { workspaceId, error: String(e) }); - } finally { - this.crawling.delete(workspaceId); + log.warn('WorkspaceFileIndexService: incremental update failed', { + workspaceId, + error: String(e), + }); + this.markStale(workspaceId); + this.scheduleReindex(workspaceId); } } private scheduleReindex(workspaceId: string): void { - const existing = this.debounceTimers.get(workspaceId); - if (existing) clearTimeout(existing); + if (!this.activeSources.has(workspaceId)) return; + this.clearDebounceTimer(workspaceId); this.debounceTimers.set( workspaceId, setTimeout(() => { this.debounceTimers.delete(workspaceId); - const ws = workspaceRegistry.get(workspaceId); - if (ws) void this.crawl(workspaceId, ws); - }, REINDEX_DEBOUNCE_MS) + void this.reindex(workspaceId); + }, this.reindexDebounceMs) ); } - private touchMeta(workspaceId: string): void { - try { - sqlite - .prepare( - `INSERT OR REPLACE INTO workspace_file_index_meta (workspace_id, indexed_at) - VALUES (?, unixepoch())` - ) - .run(workspaceId); - } catch (e) { - log.warn('WorkspaceFileIndexService: touchMeta failed', { workspaceId, error: String(e) }); - } + private clearDebounceTimer(workspaceId: string): void { + const timer = this.debounceTimers.get(workspaceId); + if (timer) clearTimeout(timer); + this.debounceTimers.delete(workspaceId); } - private evictStale(): void { + private markStale(workspaceId: string): void { try { - const cutoff = Math.floor(Date.now() / 1000) - STALE_DAYS * 86400; - const stale = sqlite - .prepare(`SELECT workspace_id FROM workspace_file_index_meta WHERE indexed_at < ?`) - .all(cutoff) as Array<{ workspace_id: string }>; - - if (stale.length > 0) { - sqlite.transaction(() => { - const delIndex = sqlite.prepare( - `DELETE FROM workspace_file_index WHERE workspace_id = ?` - ); - const delMeta = sqlite.prepare( - `DELETE FROM workspace_file_index_meta WHERE workspace_id = ?` - ); - for (const row of stale) { - delIndex.run(row.workspace_id); - delMeta.run(row.workspace_id); - } - })(); - log.info('WorkspaceFileIndexService: evicted stale indexes', { count: stale.length }); - } + this.store.recordMeta(workspaceId, { + rootPath: this.metaRootPath(workspaceId), + status: 'stale', + fileCount: this.store.countIndexedFiles(workspaceId), + truncateReason: null, + }); } catch (e) { - log.warn('WorkspaceFileIndexService: evictStale failed', { error: String(e) }); + log.warn('WorkspaceFileIndexService: markStale failed', { workspaceId, error: String(e) }); } + } - try { - const orphans = sqlite - .prepare( - `SELECT m.workspace_id - FROM workspace_file_index_meta m - LEFT JOIN workspaces w ON w.id = m.workspace_id - WHERE w.id IS NULL` - ) - .all() as Array<{ workspace_id: string }>; - - if (orphans.length === 0) return; - - sqlite.transaction(() => { - const delIndex = sqlite.prepare(`DELETE FROM workspace_file_index WHERE workspace_id = ?`); - const delMeta = sqlite.prepare( - `DELETE FROM workspace_file_index_meta WHERE workspace_id = ?` - ); - for (const row of orphans) { - delIndex.run(row.workspace_id); - delMeta.run(row.workspace_id); - } - })(); + private get maxFiles(): number { + return this.options.maxFiles ?? DEFAULT_MAX_FILES; + } - log.info('WorkspaceFileIndexService: evicted orphan indexes', { count: orphans.length }); - } catch (e) { - log.warn('WorkspaceFileIndexService: evictOrphans failed', { error: String(e) }); - } + private get reindexTimeoutMs(): number { + return this.options.reindexTimeoutMs ?? DEFAULT_REINDEX_TIMEOUT_MS; + } + + private get reindexDebounceMs(): number { + return this.options.reindexDebounceMs ?? DEFAULT_REINDEX_DEBOUNCE_MS; + } + + private metaRootPath(workspaceId: string): string { + return ( + this.activeSources.get(workspaceId)?.rootPath ?? + this.store.getMeta(workspaceId)?.rootPath ?? + '' + ); } } -export const workspaceFileIndexService = new WorkspaceFileIndexService(); +export const workspaceFileIndexService = new WorkspaceFileIndexService({ + store: new WorkspaceFileIndexStore(), +}); diff --git a/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.db.test.ts b/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.db.test.ts new file mode 100644 index 0000000000..1b691f12c3 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.db.test.ts @@ -0,0 +1,203 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type BetterSqlite3 from 'better-sqlite3'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +type LoadedStore = Awaited>; + +let loadedStore: LoadedStore | undefined; + +describe('WorkspaceFileIndexStore', () => { + afterEach(async () => { + loadedStore?.sqlite.close(); + if (loadedStore) { + await rm(loadedStore.tempDir, { recursive: true, force: true }); + } + loadedStore = undefined; + vi.resetModules(); + delete process.env.EMDASH_DB_FILE; + }); + + it('stores and reads file index metadata', async () => { + loadedStore = await loadStore(); + const { store } = loadedStore; + + store.recordMeta('ws-1', { + rootPath: '/repo', + status: 'truncated', + fileCount: 50_000, + truncateReason: 'maxEntries', + }); + + expect(store.getMeta('ws-1')).toEqual({ + rootPath: '/repo', + status: 'truncated', + fileCount: 50_000, + truncateReason: 'maxEntries', + }); + }); + + it('syncs rows by diffing existing paths', async () => { + loadedStore = await loadStore(); + const { store, sqlite } = loadedStore; + + store.syncRows('ws-1', paths(['/repo/a.ts', '/repo/b.ts'])); + store.syncRows('ws-1', paths(['/repo/b.ts', '/repo/c.ts'])); + + expect(indexedPaths(sqlite, 'ws-1')).toEqual(['/repo/b.ts', '/repo/c.ts']); + expect(store.countIndexedFiles('ws-1')).toBe(2); + }); + + it('returns whether insertPath added a new row', async () => { + loadedStore = await loadStore(); + const { store, sqlite } = loadedStore; + + expect(store.insertPath('ws-1', '/repo/src/index.ts')).toBe(true); + expect(store.insertPath('ws-1', '/repo/src/index.ts')).toBe(false); + + expect(indexedPaths(sqlite, 'ws-1')).toEqual(['/repo/src/index.ts']); + expect(store.countIndexedFiles('ws-1')).toBe(1); + }); + + it('searches with the FTS query dialect', async () => { + loadedStore = await loadStore(); + const { store } = loadedStore; + + store.syncRows('ws-1', paths(['/repo/README.md', '/repo/src/index.ts', '/repo/src/router.ts'])); + + expect(store.search('ws-1', 'index')).toEqual([ + { path: '/repo/src/index.ts', filename: 'index.ts' }, + ]); + expect(store.search('ws-1', 'in')).toEqual([]); + }); + + it('deletes exact paths and escaped subtrees', async () => { + loadedStore = await loadStore(); + const { store, sqlite } = loadedStore; + store.syncRows( + 'ws-1', + paths(['/repo/foo_%', '/repo/foo_%/a.ts', '/repo/foo_%/nested/b.ts', '/repo/foo-x/a.ts']) + ); + + store.deletePath('ws-1', '/repo/foo_%/a.ts'); + store.deleteSubtree('ws-1', '/repo/foo_%'); + + expect(indexedPaths(sqlite, 'ws-1')).toEqual(['/repo/foo-x/a.ts']); + }); + + it('deletes an entire workspace index', async () => { + loadedStore = await loadStore(); + const { store, sqlite } = loadedStore; + store.syncRows('ws-1', paths(['/repo/a.ts'])); + store.recordMeta('ws-1', { + rootPath: '/repo', + status: 'complete', + fileCount: 1, + truncateReason: null, + }); + + store.deleteIndex('ws-1'); + + expect(indexedPaths(sqlite, 'ws-1')).toEqual([]); + expect(store.getMeta('ws-1')).toBeNull(); + }); + + it('evicts stale and orphaned indexes', async () => { + loadedStore = await loadStore(); + const { store, sqlite } = loadedStore; + sqlite.prepare(`INSERT INTO workspaces (id) VALUES (?)`).run('fresh'); + + store.syncRows('stale', paths(['/repo/stale.ts'])); + store.recordMeta('stale', { + rootPath: '/repo', + status: 'complete', + fileCount: 1, + truncateReason: null, + }); + store.syncRows('orphan', paths(['/repo/orphan.ts'])); + store.recordMeta('orphan', { + rootPath: '/repo', + status: 'complete', + fileCount: 1, + truncateReason: null, + }); + store.syncRows('fresh', paths(['/repo/fresh.ts'])); + store.recordMeta('fresh', { + rootPath: '/repo', + status: 'complete', + fileCount: 1, + truncateReason: null, + }); + sqlite + .prepare(`UPDATE workspace_file_index_meta SET indexed_at = ? WHERE workspace_id = ?`) + .run(Math.floor(Date.now() / 1000) - 15 * 86400, 'stale'); + + store.evict(14); + + expect(allIndexedWorkspaces(sqlite)).toEqual(['fresh']); + expect(indexedPaths(sqlite, 'fresh')).toEqual(['/repo/fresh.ts']); + }); +}); + +async function loadStore() { + vi.resetModules(); + const tempDir = await mkdtemp(join(tmpdir(), 'emdash-file-index-store-')); + process.env.EMDASH_DB_FILE = join(tempDir, 'test.db'); + + const [{ WorkspaceFileIndexStore }, { sqlite }] = await Promise.all([ + import('./workspace-file-index-store'), + import('@main/db/client'), + ]); + createTables(sqlite); + + return { + store: new WorkspaceFileIndexStore(), + sqlite, + tempDir, + }; +} + +function createTables(sqlite: BetterSqlite3.Database): void { + sqlite.exec(` + CREATE VIRTUAL TABLE workspace_file_index USING fts5( + workspace_id UNINDEXED, + path, + filename, + tokenize = 'trigram case_sensitive 0' + ); + CREATE TABLE workspace_file_index_meta ( + workspace_id TEXT PRIMARY KEY, + indexed_at INTEGER NOT NULL, + root_path TEXT NOT NULL, + status TEXT NOT NULL + CHECK (status IN ('complete', 'stale', 'truncated')), + file_count INTEGER NOT NULL, + truncate_reason TEXT + CHECK (truncate_reason IS NULL OR truncate_reason IN ('maxEntries', 'timeBudget')) + ); + CREATE TABLE workspaces ( + id TEXT PRIMARY KEY + ); + `); +} + +function paths(values: string[]): string[] { + return values; +} + +function indexedPaths(sqlite: BetterSqlite3.Database, workspaceId: string): string[] { + return ( + sqlite + .prepare(`SELECT path FROM workspace_file_index WHERE workspace_id = ? ORDER BY path`) + .all(workspaceId) as Array<{ path: string }> + ).map((row) => row.path); +} + +function allIndexedWorkspaces(sqlite: BetterSqlite3.Database): string[] { + return ( + sqlite + .prepare(`SELECT DISTINCT workspace_id FROM workspace_file_index ORDER BY workspace_id`) + .all() as Array<{ workspace_id: string }> + ).map((row) => row.workspace_id); +} diff --git a/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.ts b/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.ts new file mode 100644 index 0000000000..12062a4822 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/search/workspace-file-index-store.ts @@ -0,0 +1,270 @@ +import { basename } from 'node:path'; +import { sqlite } from '@main/db/client'; +import { log } from '@main/lib/logger'; + +export type FileHit = { path: string; filename: string }; +export type FileIndexStatus = 'complete' | 'stale' | 'truncated'; +export type FileIndexTruncateReason = 'maxEntries' | 'timeBudget'; +export type FileIndexMeta = { + rootPath: string; + status: FileIndexStatus; + fileCount: number; + truncateReason: FileIndexTruncateReason | null; +}; + +export interface IWorkspaceFileIndexStore { + transaction(fn: () => T): T; + getMeta(workspaceId: string): FileIndexMeta | null; + recordMeta(workspaceId: string, meta: FileIndexMeta): void; + refreshMetaTimestamp(workspaceId: string): void; + syncRows(workspaceId: string, paths: string[]): void; + insertPath(workspaceId: string, path: string): boolean; + deletePath(workspaceId: string, path: string): boolean; + deleteSubtree(workspaceId: string, path: string): void; + countIndexedFiles(workspaceId: string): number; + search(workspaceId: string, query: string): FileHit[]; + deleteIndex(workspaceId: string): void; + evict(staleDays: number): void; +} + +export class WorkspaceFileIndexStore implements IWorkspaceFileIndexStore { + transaction(fn: () => T): T { + return sqlite.transaction(fn)(); + } + + getMeta(workspaceId: string): FileIndexMeta | null { + try { + const row = sqlite + .prepare( + `SELECT root_path, status, file_count, truncate_reason + FROM workspace_file_index_meta + WHERE workspace_id = ?` + ) + .get(workspaceId) as + | { root_path: string; status: string; file_count: number; truncate_reason: string | null } + | undefined; + + if (!row || !isFileIndexStatus(row.status)) return null; + return { + rootPath: row.root_path, + status: row.status, + fileCount: row.file_count, + truncateReason: isFileIndexTruncateReason(row.truncate_reason) ? row.truncate_reason : null, + }; + } catch (e) { + log.warn('WorkspaceFileIndexStore: getMeta failed', { workspaceId, error: String(e) }); + return null; + } + } + + recordMeta(workspaceId: string, meta: FileIndexMeta): void { + sqlite + .prepare( + `INSERT OR REPLACE INTO workspace_file_index_meta ( + workspace_id, + indexed_at, + root_path, + status, + file_count, + truncate_reason + ) + VALUES (?, unixepoch(), ?, ?, ?, ?)` + ) + .run(workspaceId, meta.rootPath, meta.status, meta.fileCount, meta.truncateReason); + } + + refreshMetaTimestamp(workspaceId: string): void { + try { + sqlite + .prepare( + `UPDATE workspace_file_index_meta + SET indexed_at = unixepoch() + WHERE workspace_id = ?` + ) + .run(workspaceId); + } catch (e) { + log.warn('WorkspaceFileIndexStore: refreshMetaTimestamp failed', { + workspaceId, + error: String(e), + }); + } + } + + syncRows(workspaceId: string, paths: string[]): void { + const existingPaths = this.indexedPathSet(workspaceId); + const desiredPaths = new Set(paths); + const deletePath = sqlite.prepare( + `DELETE FROM workspace_file_index WHERE workspace_id = ? AND path = ?` + ); + const insertPath = sqlite.prepare( + `INSERT INTO workspace_file_index(workspace_id, path, filename) VALUES (?, ?, ?)` + ); + + for (const path of existingPaths) { + if (!desiredPaths.has(path)) deletePath.run(workspaceId, path); + } + + for (const path of paths) { + if (!existingPaths.has(path)) insertPath.run(workspaceId, path, basename(path)); + } + } + + insertPath(workspaceId: string, path: string): boolean { + if (this.hasIndexedPath(workspaceId, path)) return false; + sqlite + .prepare(`INSERT INTO workspace_file_index(workspace_id, path, filename) VALUES (?, ?, ?)`) + .run(workspaceId, path, basename(path)); + return true; + } + + deletePath(workspaceId: string, path: string): boolean { + const result = sqlite + .prepare(`DELETE FROM workspace_file_index WHERE workspace_id = ? AND path = ?`) + .run(workspaceId, path); + return result.changes > 0; + } + + deleteSubtree(workspaceId: string, path: string): void { + sqlite + .prepare( + `DELETE FROM workspace_file_index + WHERE workspace_id = ? + AND (path = ? OR path LIKE ? ESCAPE '\\')` + ) + .run(workspaceId, path, `${escapeSqliteLike(path)}/%`); + } + + countIndexedFiles(workspaceId: string): number { + const row = sqlite + .prepare(`SELECT COUNT(*) AS count FROM workspace_file_index WHERE workspace_id = ?`) + .get(workspaceId) as { count: number }; + return row.count; + } + + search(workspaceId: string, query: string): FileHit[] { + const terms = query + .trim() + .split(/[\s\-_/]+/) + .filter((t) => t.length >= 3); + + if (terms.length === 0) return []; + + const ftsQuery = terms.map((t) => `"${t}"`).join(' AND '); + try { + return sqlite + .prepare( + `SELECT path, filename + FROM workspace_file_index + WHERE workspace_file_index MATCH ? + AND workspace_id = ? + ORDER BY bm25(workspace_file_index, 1.0, 2.0) + LIMIT 20` + ) + .all(ftsQuery, workspaceId) as FileHit[]; + } catch (e) { + log.warn('WorkspaceFileIndexStore: search failed', { workspaceId, error: String(e) }); + return []; + } + } + + deleteIndex(workspaceId: string): void { + try { + sqlite.transaction(() => { + sqlite.prepare(`DELETE FROM workspace_file_index WHERE workspace_id = ?`).run(workspaceId); + sqlite + .prepare(`DELETE FROM workspace_file_index_meta WHERE workspace_id = ?`) + .run(workspaceId); + })(); + log.info('WorkspaceFileIndexStore: deleted index', { workspaceId }); + } catch (e) { + log.warn('WorkspaceFileIndexStore: deleteIndex failed', { workspaceId, error: String(e) }); + } + } + + evict(staleDays: number): void { + this.evictStale(staleDays); + this.evictOrphans(); + } + + private indexedPathSet(workspaceId: string): Set { + const rows = sqlite + .prepare(`SELECT path FROM workspace_file_index WHERE workspace_id = ?`) + .all(workspaceId) as Array<{ path: string }>; + return new Set(rows.map((row) => row.path)); + } + + private hasIndexedPath(workspaceId: string, path: string): boolean { + return Boolean( + sqlite + .prepare(`SELECT 1 FROM workspace_file_index WHERE workspace_id = ? AND path = ? LIMIT 1`) + .get(workspaceId, path) + ); + } + + private evictStale(staleDays: number): void { + try { + const cutoff = Math.floor(Date.now() / 1000) - staleDays * 86400; + const stale = sqlite + .prepare(`SELECT workspace_id FROM workspace_file_index_meta WHERE indexed_at < ?`) + .all(cutoff) as Array<{ workspace_id: string }>; + + if (stale.length === 0) return; + + sqlite.transaction(() => { + const delIndex = sqlite.prepare(`DELETE FROM workspace_file_index WHERE workspace_id = ?`); + const delMeta = sqlite.prepare( + `DELETE FROM workspace_file_index_meta WHERE workspace_id = ?` + ); + for (const row of stale) { + delIndex.run(row.workspace_id); + delMeta.run(row.workspace_id); + } + })(); + log.info('WorkspaceFileIndexStore: evicted stale indexes', { count: stale.length }); + } catch (e) { + log.warn('WorkspaceFileIndexStore: evictStale failed', { error: String(e) }); + } + } + + private evictOrphans(): void { + try { + const orphans = sqlite + .prepare( + `SELECT m.workspace_id + FROM workspace_file_index_meta m + LEFT JOIN workspaces w ON w.id = m.workspace_id + WHERE w.id IS NULL` + ) + .all() as Array<{ workspace_id: string }>; + + if (orphans.length === 0) return; + + sqlite.transaction(() => { + const delIndex = sqlite.prepare(`DELETE FROM workspace_file_index WHERE workspace_id = ?`); + const delMeta = sqlite.prepare( + `DELETE FROM workspace_file_index_meta WHERE workspace_id = ?` + ); + for (const row of orphans) { + delIndex.run(row.workspace_id); + delMeta.run(row.workspace_id); + } + })(); + + log.info('WorkspaceFileIndexStore: evicted orphan indexes', { count: orphans.length }); + } catch (e) { + log.warn('WorkspaceFileIndexStore: evictOrphans failed', { error: String(e) }); + } + } +} + +function escapeSqliteLike(value: string): string { + return value.replace(/[\\%_]/g, (match) => `\\${match}`); +} + +function isFileIndexStatus(value: string): value is FileIndexStatus { + return value === 'complete' || value === 'stale' || value === 'truncated'; +} + +function isFileIndexTruncateReason(value: string | null): value is FileIndexTruncateReason { + return value === 'maxEntries' || value === 'timeBudget'; +} diff --git a/apps/emdash-desktop/src/main/core/ssh/controller.ts b/apps/emdash-desktop/src/main/core/ssh/controller.ts index d13da26460..3be1f4cc52 100644 --- a/apps/emdash-desktop/src/main/core/ssh/controller.ts +++ b/apps/emdash-desktop/src/main/core/ssh/controller.ts @@ -11,7 +11,6 @@ import { telemetryService } from '@main/lib/telemetry'; import type { ConnectionState, ConnectionTestResult, - FileEntry, SshConfig, SshConfigHost, SshConnectionUsage, @@ -224,60 +223,4 @@ export const sshController = createRPCController({ .set({ name, updatedAt: new Date().toISOString() }) .where(eq(sshConnectionsTable.id, id)); }, - - /** List files/directories at a remote path via SFTP. */ - listFiles: async ({ - connectionId, - path: remotePath, - }: { - connectionId: string; - path: string; - }): Promise => { - let proxy = sshConnectionManager.getProxy(connectionId); - - if (!proxy || !proxy.isConnected) { - proxy = await sshConnectionManager.connect(connectionId); - } - - return new Promise((resolve, reject) => { - proxy!.sftp((err, sftp) => { - if (err) { - reject(new Error(`SFTP error: ${err.message}`)); - return; - } - sftp.readdir(remotePath, (readdirErr, list) => { - sftp.end(); - if (readdirErr) { - reject(new Error(`readdir error: ${readdirErr.message}`)); - return; - } - const entries: FileEntry[] = list - .map((item) => { - const mode = item.attrs.mode ?? 0; - const isDir = (mode & 0o170000) === 0o040000; - const isLink = (mode & 0o170000) === 0o120000; - const entryType: FileEntry['type'] = isLink - ? 'symlink' - : isDir - ? 'directory' - : 'file'; - const fullPath = `${remotePath.replace(/\/$/, '')}/${item.filename}`; - return { - path: fullPath, - name: item.filename, - type: entryType, - size: item.attrs.size ?? 0, - modifiedAt: new Date((item.attrs.mtime ?? 0) * 1000), - }; - }) - .sort((a, b) => { - if (a.type === 'directory' && b.type !== 'directory') return -1; - if (a.type !== 'directory' && b.type === 'directory') return 1; - return a.name.localeCompare(b.name); - }); - resolve(entries); - }); - }); - }); - }, }); diff --git a/apps/emdash-desktop/src/main/core/tasks/task-builder.ts b/apps/emdash-desktop/src/main/core/tasks/task-builder.ts index df821a6695..2852533bfc 100644 --- a/apps/emdash-desktop/src/main/core/tasks/task-builder.ts +++ b/apps/emdash-desktop/src/main/core/tasks/task-builder.ts @@ -1,5 +1,6 @@ import type { GitBranchRef } from '@emdash/core/git'; import type { ConversationProvider } from '@main/core/conversations/types'; +import type { IFilesRuntime } from '@main/core/runtime/types'; import type { TerminalProvider } from '@main/core/terminals/terminal-provider'; import type { Workspace } from '@main/core/workspaces/workspace'; import { events } from '@main/lib/events'; @@ -49,7 +50,8 @@ export async function buildTaskFromWorkspace( projectPath: string, settings: ProjectSettingsProvider, workspaceBranchName?: string, - workspaceSourceBranch?: GitBranchRef + workspaceSourceBranch?: GitBranchRef, + sshFilesRuntime?: IFilesRuntime ): Promise { const { taskEnvVars, tmuxEnabled, shellSetup } = await resolveTaskEnv( task, @@ -67,6 +69,7 @@ export async function buildTaskFromWorkspace( tmuxEnabled, shellSetup, taskEnvVars, + filesRuntime: sshFilesRuntime, }); const taskProvider: TaskProvider = { diff --git a/apps/emdash-desktop/src/main/core/terminals/lifecycle-script-settings.ts b/apps/emdash-desktop/src/main/core/terminals/lifecycle-script-settings.ts index 56c51c1506..a925ac8a3e 100644 --- a/apps/emdash-desktop/src/main/core/terminals/lifecycle-script-settings.ts +++ b/apps/emdash-desktop/src/main/core/terminals/lifecycle-script-settings.ts @@ -1,8 +1,13 @@ +import { err, ok, type Result } from '@emdash/shared'; import type { Workspace } from '@main/core/workspaces/workspace'; import type { LifecycleScriptType } from '@shared/core/tasks/taskEvents'; import { getEffectiveTaskSettings } from '../projects/settings/effective-task-settings'; import { resolveWorkspace } from '../projects/utils'; +export type LifecycleScriptSettingsError = + | { type: 'not_found'; entity: 'workspace'; workspaceId: string } + | { type: 'fs_error'; message: string }; + /** * Reads the effective lifecycle script config for an already-resolved workspace. * This is used by callers that already have a Workspace, such as workspace setup/teardown hooks. @@ -10,15 +15,16 @@ import { resolveWorkspace } from '../projects/utils'; export async function resolveLifecycleScriptForWorkspace( workspace: Workspace, type: LifecycleScriptType -): Promise<{ script?: string; shellSetup?: string }> { +): Promise> { const settings = await getEffectiveTaskSettings({ projectSettings: workspace.settings, - taskFs: workspace.fs, + taskFs: workspace.fileSystem, + taskConfigPath: workspace.configPath, }); - return { + return ok({ script: settings.scripts?.[type], shellSetup: settings.shellSetup, - }; + }); } /** @@ -33,10 +39,16 @@ export async function resolveLifecycleScript({ projectId: string; workspaceId: string; type: LifecycleScriptType; -}): Promise<{ workspace: Workspace; script?: string; shellSetup?: string }> { +}): Promise< + Result< + { workspace: Workspace; script?: string; shellSetup?: string }, + LifecycleScriptSettingsError + > +> { const workspace = resolveWorkspace(projectId, workspaceId); - if (!workspace) throw new Error('Workspace not found'); + if (!workspace) return err({ type: 'not_found', entity: 'workspace', workspaceId }); const settings = await resolveLifecycleScriptForWorkspace(workspace, type); - return { workspace, ...settings }; + if (!settings.success) return settings; + return ok({ workspace, ...settings.data }); } diff --git a/apps/emdash-desktop/src/main/core/terminals/prepareLifecycleScript.ts b/apps/emdash-desktop/src/main/core/terminals/prepareLifecycleScript.ts index e4e2a68ea0..1b4d146fc5 100644 --- a/apps/emdash-desktop/src/main/core/terminals/prepareLifecycleScript.ts +++ b/apps/emdash-desktop/src/main/core/terminals/prepareLifecycleScript.ts @@ -1,4 +1,6 @@ +import { ok, type Result } from '@emdash/shared'; import { resolveLifecycleScript } from './lifecycle-script-settings'; +import type { LifecycleScriptSettingsError } from './lifecycle-script-settings'; export async function prepareLifecycleScript({ projectId, @@ -8,17 +10,20 @@ export async function prepareLifecycleScript({ projectId: string; workspaceId: string; type: 'setup' | 'run' | 'teardown'; -}): Promise { - const { workspace, script, shellSetup } = await resolveLifecycleScript({ +}): Promise> { + const resolved = await resolveLifecycleScript({ projectId, workspaceId, type, }); - if (!script) return; + if (!resolved.success) return resolved; + const { workspace, script, shellSetup } = resolved.data; + if (!script) return ok(); await workspace.lifecycleService.prepareLifecycleScript({ type, script, shellSetup, }); + return ok(); } diff --git a/apps/emdash-desktop/src/main/core/terminals/runLifecycleScript.test.ts b/apps/emdash-desktop/src/main/core/terminals/runLifecycleScript.test.ts index 8a8dfde1f9..7ed19ade3f 100644 --- a/apps/emdash-desktop/src/main/core/terminals/runLifecycleScript.test.ts +++ b/apps/emdash-desktop/src/main/core/terminals/runLifecycleScript.test.ts @@ -36,8 +36,12 @@ describe('runLifecycleScript', () => { it('runs manual lifecycle scripts with exit and restores the prompt afterward', async () => { const lifecycleRun = vi.fn(async () => {}); vi.mocked(resolveWorkspace).mockReturnValue({ + path: '/workspace', settings: {}, - fs: {}, + files: { + fileSystem: () => ({ success: true, data: {} }), + path: { join: (...parts: string[]) => parts.join('/') }, + }, lifecycleService: { runLifecycleScript: lifecycleRun, }, @@ -49,13 +53,14 @@ describe('runLifecycleScript', () => { }, } as never); - await runLifecycleScript({ + const result = await runLifecycleScript({ projectId: 'project-1', taskId: 'task-1', workspaceId: 'branch:feature', type: 'run', }); + expect(result).toEqual({ success: true, data: undefined }); expect(lifecycleRun).toHaveBeenCalledWith( { type: 'run', script: 'pnpm dev', shellSetup: 'source .envrc' }, { exit: true, waitForExit: true, respawnAfterExit: true } diff --git a/apps/emdash-desktop/src/main/core/terminals/runLifecycleScript.ts b/apps/emdash-desktop/src/main/core/terminals/runLifecycleScript.ts index 9bddde20b0..1ba1c72647 100644 --- a/apps/emdash-desktop/src/main/core/terminals/runLifecycleScript.ts +++ b/apps/emdash-desktop/src/main/core/terminals/runLifecycleScript.ts @@ -1,5 +1,9 @@ +import { ok, type Result } from '@emdash/shared'; import { runLifecycleScriptWithPolicy } from './lifecycle-script-coordinator'; -import { resolveLifecycleScript } from './lifecycle-script-settings'; +import { + resolveLifecycleScript, + type LifecycleScriptSettingsError, +} from './lifecycle-script-settings'; export async function runLifecycleScript({ projectId, @@ -11,13 +15,15 @@ export async function runLifecycleScript({ taskId: string; workspaceId: string; type: 'setup' | 'run' | 'teardown'; -}) { - const { workspace, script, shellSetup } = await resolveLifecycleScript({ +}): Promise> { + const resolved = await resolveLifecycleScript({ projectId, workspaceId, type, }); - if (!script) return; + if (!resolved.success) return resolved; + const { workspace, script, shellSetup } = resolved.data; + if (!script) return ok(); await runLifecycleScriptWithPolicy({ workspace, projectId, @@ -35,4 +41,5 @@ export async function runLifecycleScript({ }, logPrefix: 'TerminalsController', }); + return ok(); } diff --git a/apps/emdash-desktop/src/main/core/workspaces/byoi/provision-byoi-task.ts b/apps/emdash-desktop/src/main/core/workspaces/byoi/provision-byoi-task.ts index 85bf28e437..dbab60e5e7 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/byoi/provision-byoi-task.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/byoi/provision-byoi-task.ts @@ -83,7 +83,7 @@ export async function provisionBYOITask( const workspaceType: WorkspaceType = { kind: 'ssh', proxy, connectionId }; const workspaceMachine: MachineRef = { kind: 'ssh', connectionId }; - const workspace = await workspaceRegistry.acquire( + const acquired = await workspaceRegistry.acquire( workspaceId, projectId, createWorkspaceFactory(workspaceId, workspaceType, { @@ -126,24 +126,27 @@ export async function provisionBYOITask( }); const { taskProvider } = await buildTaskFromWorkspace( task, - workspace, + acquired.workspace, workspaceType, projectId, projectPath, - settings + settings, + undefined, + undefined, + acquired.sshFilesRuntime ); log.debug(`${logPrefix}: provisionBYOITask DONE`, { taskId: task.id }); provisionSucceeded = true; return { path: workDir, - workspaceId: workspace.id, + workspaceId: acquired.workspace.id, sshConnectionId: connectionId, taskProvider, workspaceProviderData: { ...wpConfig, remoteWorkspaceId: output.id }, }; } finally { if (!provisionSucceeded) { - await workspaceRegistry.teardown(workspace.id, 'terminate').catch(() => {}); + await workspaceRegistry.teardown(acquired.workspace.id, 'terminate').catch(() => {}); } } } diff --git a/apps/emdash-desktop/src/main/core/workspaces/project-settings-controller.ts b/apps/emdash-desktop/src/main/core/workspaces/project-settings-controller.ts index 304a7cca6a..7f56e91376 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/project-settings-controller.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/project-settings-controller.ts @@ -1,18 +1,22 @@ +import { err, ok } from '@emdash/shared'; import { getEffectiveTaskSettings } from '@main/core/projects/settings/effective-task-settings'; import { workspaceRegistry } from '@main/core/workspaces/workspace-registry'; -import type { ProjectSettings } from '@shared/core/project-settings/project-settings'; +import type { ProjectSettingsLoadResult } from '@shared/core/project-settings/project-settings'; import { createRPCController } from '@shared/lib/ipc/rpc'; -async function getSettings(workspaceId: string): Promise { +async function getSettings(workspaceId: string): Promise { const workspace = workspaceRegistry.get(workspaceId); if (!workspace) { - throw new Error(`Workspace ${workspaceId} not found`); + return err({ type: 'not_found', entity: 'workspace', workspaceId }); } - return getEffectiveTaskSettings({ - projectSettings: workspace.settings, - taskFs: workspace.fs, - }); + return ok( + await getEffectiveTaskSettings({ + projectSettings: workspace.settings, + taskFs: workspace.fileSystem, + taskConfigPath: workspace.configPath, + }) + ); } export const projectSettingsController = createRPCController({ diff --git a/apps/emdash-desktop/src/main/core/workspaces/recovery-strategy.ts b/apps/emdash-desktop/src/main/core/workspaces/recovery-strategy.ts index 49fc972174..d244df4a82 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/recovery-strategy.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/recovery-strategy.ts @@ -22,7 +22,7 @@ export async function applyRecovery( const { branchName, candidatePath } = error; // Try the hint path first, then search all worktrees. - const path = candidatePath ?? (await findBranchAnywhere(branchName, ctx)); + const path = candidatePath ?? (await ctx.worktreeService.findBranchAnywhere(branchName)); if (path) { log.info('recovery-strategy: branch already checked out elsewhere — adopting path', { @@ -42,8 +42,7 @@ export async function applyRecovery( log.info('recovery-strategy: stale directory — removing and pruning', { path }); try { - await ctx.host.removeAbsolute(path, { recursive: true }); - await ctx.ctx.exec('git', ['worktree', 'prune']).catch(() => {}); + await ctx.worktreeService.removeWorktree(path); return { kind: 'retry' }; } catch (removeError) { log.warn('recovery-strategy: failed to remove stale directory', { @@ -56,22 +55,3 @@ export async function applyRecovery( return { kind: 'failed', error }; } - -async function findBranchAnywhere( - branchName: string, - ctx: StepContext -): Promise { - try { - const { stdout } = await ctx.ctx.exec('git', ['worktree', 'list', '--porcelain']); - const branchLine = `branch refs/heads/${branchName}`; - for (const block of stdout.split('\n\n')) { - if (!block.split('\n').some((line) => line === branchLine)) continue; - const match = /^worktree (.+)$/m.exec(block); - const candidatePath = match?.[1]; - if (!candidatePath) continue; - const gitFile = ctx.host.pathApi.join(candidatePath, '.git'); - if (await ctx.host.existsAbsolute(gitFile)) return candidatePath; - } - } catch {} - return undefined; -} diff --git a/apps/emdash-desktop/src/main/core/workspaces/setup-steps/copy-preserved-files.ts b/apps/emdash-desktop/src/main/core/workspaces/setup-steps/copy-preserved-files.ts index 333f654825..446660443e 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/setup-steps/copy-preserved-files.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/setup-steps/copy-preserved-files.ts @@ -1,25 +1,26 @@ +import type { IFileSystem } from '@emdash/core/files'; import { ok, type Result } from '@emdash/shared'; -import type { FileSystemProvider } from '@main/core/fs/types'; import { getEffectiveTaskSettings } from '@main/core/projects/settings/effective-task-settings'; +import { + isSafePreservePattern, + preservedDestinationPath, + preservedRepoRelativePath, +} from '@main/core/projects/settings/preserve-pattern-safety'; +import { isRealPathContained } from '@main/core/runtime/files-helpers'; import { log } from '@main/lib/logger'; import type * as Step from '@shared/core/workspaces/workspace-setup-steps/copy-preserved-files'; import type { StepContext } from './step-context'; -function makeTaskFs( - targetPath: string, - ctx: StepContext -): Pick { - return { - exists: (filePath) => ctx.host.existsAbsolute(ctx.host.pathApi.join(targetPath, filePath)), - read: async (filePath) => { - const content = await ctx.host.readFileAbsolute(ctx.host.pathApi.join(targetPath, filePath)); - return { content, truncated: false, totalSize: Buffer.byteLength(content) }; - }, - }; +function makeTaskFs(ctx: StepContext): IFileSystem | null { + const opened = ctx.files.fileSystem(); + if (opened.success) return opened.data; + log.warn('setup-steps/copy-preserved-files: failed to open task filesystem', opened.error); + return null; } -async function isTrackedSourcePath(relPath: string, ctx: StepContext): Promise { +async function isTrackedSourcePath(absPath: string, ctx: StepContext): Promise { try { + const relPath = ctx.files.path.relative(ctx.repoPath, absPath); await ctx.ctx.exec('git', ['ls-files', '--error-unmatch', '--', relPath]); return true; } catch { @@ -38,25 +39,57 @@ export async function execute( } try { + const taskFs = makeTaskFs(ctx); + if (!taskFs) return ok({}); + const settings = await getEffectiveTaskSettings({ projectSettings: ctx.projectSettings, - taskFs: makeTaskFs(targetPath, ctx) as unknown as FileSystemProvider, + taskFs, + taskConfigPath: ctx.files.path.join(targetPath, '.emdash.json'), }); const patterns = settings.preservePatterns ?? []; + const repoFs = ctx.files.fileSystem(); + if (!repoFs.success) { + log.warn('setup-steps/copy-preserved-files: failed to open repo filesystem', repoFs.error); + return ok({}); + } for (const pattern of patterns) { - const matches = await ctx.host.globAbsolute(pattern, { - cwd: ctx.repoPath, - dot: true, - }); - for (const relPath of matches) { - if (relPath === '.emdash.json' || (await isTrackedSourcePath(relPath, ctx))) continue; - const src = ctx.host.pathApi.join(ctx.repoPath, relPath); - const stat = await ctx.host.statAbsolute(src).catch(() => null); - if (!stat || stat.type !== 'file') continue; - const dest = ctx.host.pathApi.join(targetPath, relPath); - await ctx.host.mkdirAbsolute(ctx.host.pathApi.dirname(dest), { recursive: true }); - await ctx.host.copyFileAbsolute(src, dest); + if (!isSafePreservePattern(ctx.files.path, pattern)) { + log.warn('setup-steps/copy-preserved-files: skipping unsafe preserve pattern', { pattern }); + continue; + } + const matches = repoFs.data.glob([pattern], { cwd: ctx.repoPath, dot: true }); + if (!matches.success) { + log.warn('setup-steps/copy-preserved-files: failed to match preserve pattern', { + pattern, + error: matches.error, + }); + continue; + } + for await (const absPath of matches.data) { + const relPath = preservedRepoRelativePath(ctx.files.path, ctx.repoPath, absPath); + if (!relPath || (await isTrackedSourcePath(absPath, ctx))) continue; + const stat = await repoFs.data.stat(absPath); + if (!stat.success || stat.data.type !== 'file') continue; + const destPath = preservedDestinationPath(ctx.files.path, targetPath, relPath); + if (!destPath) continue; + const contained = await isRealPathContained(ctx.files, targetPath, destPath); + if (!contained.success || !contained.data) { + log.warn( + 'setup-steps/copy-preserved-files: skipping preserved file with out-of-worktree destination', + { destPath } + ); + continue; + } + const copied = await repoFs.data.copyFile(absPath, destPath); + if (!copied.success) { + log.warn('setup-steps/copy-preserved-files: failed to copy preserved file', { + sourcePath: absPath, + destPath, + error: copied.error, + }); + } } } } catch (error: unknown) { diff --git a/apps/emdash-desktop/src/main/core/workspaces/setup-steps/git-fetch.test.ts b/apps/emdash-desktop/src/main/core/workspaces/setup-steps/git-fetch.test.ts index b5dcfa5184..f00afe6efc 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/setup-steps/git-fetch.test.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/setup-steps/git-fetch.test.ts @@ -7,9 +7,13 @@ function makeCtx(exec: StepContext['ctx']['exec']): StepContext { ctx: { exec } as StepContext['ctx'], repoPath: '/repo', worktreePoolPath: '/repo/.emdash/worktrees', - host: {} as StepContext['host'], + files: {} as StepContext['files'], projectSettings: {} as StepContext['projectSettings'], - worktreeService: {} as StepContext['worktreeService'], + worktreeService: { + findBranchAnywhere: vi.fn(), + removeWorktree: vi.fn(), + serveBranchWorktree: vi.fn(), + } as StepContext['worktreeService'], }; } diff --git a/apps/emdash-desktop/src/main/core/workspaces/setup-steps/step-context.ts b/apps/emdash-desktop/src/main/core/workspaces/setup-steps/step-context.ts index d8d7185e20..2e0e049ee2 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/setup-steps/step-context.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/setup-steps/step-context.ts @@ -1,7 +1,7 @@ import type { IExecutionContext } from '@main/core/execution-context/types'; import type { ProjectSettingsProvider } from '@main/core/projects/settings/provider'; -import type { WorktreeHost } from '@main/core/projects/worktrees/hosts/worktree-host'; import type { WorktreeService } from '@main/core/projects/worktrees/worktree-service'; +import type { IFilesRuntime } from '@main/core/runtime/types'; /** * Context passed to every workspace setup step executor. @@ -15,12 +15,15 @@ export type StepContext = { repoPath: string; /** Absolute path to the worktree pool directory where worktrees are created. */ worktreePoolPath: string; - /** Filesystem host (local or SSH). */ - host: WorktreeHost; + /** Runtime-owned files capability for the project machine. */ + files: IFilesRuntime; /** Project settings provider (used by copy-preserved-files). */ projectSettings: ProjectSettingsProvider; /** Worktree service that owns checkout validation, stale cleanup, and checkout creation. */ - worktreeService: Pick; + worktreeService: Pick< + WorktreeService, + 'findBranchAnywhere' | 'removeWorktree' | 'serveBranchWorktree' + >; /** * Resolved worktree path from a preceding `add-worktree` step. * Populated by the executor after a successful add-worktree step so that diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace-bootstrap-service.db.test.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace-bootstrap-service.db.test.ts index 649c65498d..7fc2654d89 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace-bootstrap-service.db.test.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace-bootstrap-service.db.test.ts @@ -150,7 +150,7 @@ describe('WorkspaceBootstrapService', () => { describe('ensureWorkspaceSetup', () => { it('repairs persisted branch worktree paths before acquiring the workspace', async () => { const serveBranchWorktree = vi.fn().mockResolvedValue(ok('/worktrees/task-branch')); - const existsAbsolute = vi.fn().mockResolvedValue(true); + const existsAtAbsolutePath = vi.fn().mockResolvedValue(true); const project = { projectId: 'proj-1', type: 'local', @@ -163,10 +163,8 @@ describe('WorkspaceBootstrapService', () => { getConfiguredRemotes: vi.fn(), }, gitRepositoryFetchService: {}, - worktreeHost: { - existsAbsolute, - }, worktreeService: { + existsAtAbsolutePath, serveBranchWorktree, }, } as unknown as ProjectProvider; @@ -200,7 +198,7 @@ describe('WorkspaceBootstrapService', () => { type: 'local', branch: 'main', }); - expect(existsAbsolute).not.toHaveBeenCalledWith('/worktrees/broken-task-branch'); + expect(existsAtAbsolutePath).not.toHaveBeenCalledWith('/worktrees/broken-task-branch'); expect(mocks.acquireWorkspace).toHaveBeenCalled(); const [ws] = await fixture.db.select().from(workspaces).where(eq(workspaces.id, WS_ID)); diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace-bootstrap-service.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace-bootstrap-service.ts index 220a6e3fda..33801f7185 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace-bootstrap-service.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace-bootstrap-service.ts @@ -20,8 +20,6 @@ import { compileSetupSpec } from '@shared/core/workspaces/workspace-setup-spec'; import type { WorkspaceType } from '@shared/core/workspaces/workspaces'; import { deriveBranchName, resolveWorkspaceIntent } from '../tasks/resolve-workspace-intent'; import { provisionBYOITask } from './byoi/provision-byoi-task'; -import { LocalWorkspaceSetupExecutor } from './local-workspace-setup-executor'; -import { applyRecovery } from './recovery-strategy'; import { getProvisionedWorkspaceBranch } from './workspace-branch'; import { createWorkspaceFactory } from './workspace-factory'; import { computeWorkspaceKey } from './workspace-key'; @@ -147,7 +145,7 @@ export class WorkspaceBootstrapService { // Fast path: non-worktree path already persisted and still exists on disk. if (workspaceRow.path && !isByoi) { - const exists = await project.worktreeHost.existsAbsolute(workspaceRow.path); + const exists = await project.worktreeService.existsAtAbsolutePath(workspaceRow.path); if (exists) { return this._acquireAndBuild( workspaceRow.id, @@ -201,28 +199,7 @@ export class WorkspaceBootstrapService { } const worktreePoolPath = await project.worktreeService.getWorktreePoolPath(); - const stepCtx = { - ctx: project.ctx, - repoPath: project.repoPath, - worktreePoolPath, - host: project.worktreeHost, - projectSettings: project.settings, - worktreeService: project.worktreeService, - }; - - const executor = new LocalWorkspaceSetupExecutor(stepCtx); - let setupResult = await executor.execute(spec); - - if (!setupResult.success) { - const recovery = await applyRecovery(setupResult.error, stepCtx); - - if (recovery.kind === 'resolved') { - setupResult = ok({ path: recovery.path, warnings: [] }); - } else if (recovery.kind === 'retry') { - setupResult = await executor.execute(spec); - } - // 'failed' falls through to the error check below - } + const setupResult = await project.runWorkspaceSetup(spec, worktreePoolPath); if (!setupResult.success) { const { kind, type } = setupResult.error; @@ -333,9 +310,9 @@ export class WorkspaceBootstrapService { message: 'Initialising workspace…', }); - let workspace; + let acquired; try { - workspace = await workspaceRegistry.acquire( + acquired = await workspaceRegistry.acquire( workspaceId, project.projectId, createWorkspaceFactory(workspaceId, type, { @@ -373,13 +350,14 @@ export class WorkspaceBootstrapService { try { const buildResult = await buildTaskFromWorkspace( task, - workspace, + acquired.workspace, type, project.projectId, project.repoPath, project.settings, workspaceBranchName, - workspaceSourceBranch + workspaceSourceBranch, + acquired.sshFilesRuntime ); buildSucceeded = true; return ok({ diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts index b392818076..49bed7a694 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace-factory.ts @@ -3,11 +3,11 @@ import { SshConversationProvider } from '@main/core/conversations/impl/ssh-conve import type { ConversationProvider } from '@main/core/conversations/types'; import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context'; import { SshExecutionContext } from '@main/core/execution-context/ssh-execution-context'; -import { LocalFileSystem } from '@main/core/fs/impl/local-fs'; -import { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import { GitRepositoryFetchService } from '@main/core/git/repository/fetch-service'; import { GitRepositoryService } from '@main/core/git/repository/service'; import { previewServerService } from '@main/core/preview-servers/preview-server-service-instance'; +import { invalidateLegacySshGitWorktreeStatus } from '@main/core/runtime/legacy/ssh-git'; +import type { IFilesRuntime } from '@main/core/runtime/types'; import type { MachineRef, RuntimeManager } from '@main/core/runtime/types'; import { workspaceFileIndexService } from '@main/core/search/workspace-file-index-service'; import { appSettingsService } from '@main/core/settings/settings-service'; @@ -24,6 +24,7 @@ import { type WorkspaceFactoryResult } from '@main/core/workspaces/workspace-reg import { handleGitWorktreeUpdate } from '@main/core/workspaces/workspace-worktree-update'; import { events } from '@main/lib/events'; import { log } from '@main/lib/logger'; +import { fileChangesChannel, fileTreeUpdateChannel } from '@shared/core/fs/fsEvents'; import { gitWorktreeUpdateChannel } from '@shared/core/git/events'; import type { Task } from '@shared/core/tasks/tasks'; import { getEffectiveTaskSettings } from '../projects/settings/effective-task-settings'; @@ -71,13 +72,19 @@ export function createWorkspaceFactory( return async () => { const workDir = context.workDir; - // Transport-specific FS and exec - const workspaceFs = - type.kind === 'ssh' ? new SshFileSystem(type.proxy, workDir) : new LocalFileSystem(workDir); - const ctx = type.kind === 'ssh' ? new SshExecutionContext(type.proxy) : new LocalExecutionContext(); + const runtime = await acquireWorkspaceRuntime(context.workspaceRuntime, workDir); + const { gitWorktree, fileTree, filesRuntime } = runtime; + const openedFileSystem = filesRuntime.fileSystem(); + if (!openedFileSystem.success) { + await runtime.release(); + throw new Error(`Failed to open file system: ${openedFileSystem.error.message}`); + } + const fileSystem = openedFileSystem.data; + const configPath = filesRuntime.path.join(workDir, '.emdash.json'); + // Settings (shared) const projectSettings = await context.settings.get(); const defaultBranch = await context.settings.getDefaultBranch(); @@ -92,7 +99,8 @@ export function createWorkspaceFactory( const tmuxEnabled = projectSettings.tmux ?? false; const taskLevelSettings = await getEffectiveTaskSettings({ projectSettings: context.settings, - taskFs: workspaceFs, + taskFs: fileSystem, + taskConfigPath: configPath, }); const shellSetup = taskLevelSettings.shellSetup ?? projectSettings.shellSetup; const scripts = taskLevelSettings.scripts; @@ -129,12 +137,6 @@ export function createWorkspaceFactory( terminals: workspaceTerminals, }); - const runtimeLease = await context.workspaceRuntime.manager.acquire( - context.workspaceRuntime.machine - ); - const worktreeLease = await runtimeLease.value.git.openWorktree(workDir); - const gitWorktree = worktreeLease.value; - const gitRepository = context.gitRepository ?? new GitRepositoryService(gitWorktree.repository, context.settings); @@ -143,11 +145,15 @@ export function createWorkspaceFactory( context.gitRepositoryFetchService ?? new GitRepositoryFetchService(gitRepository, () => gitRepository.getBaseRemote()); let unsubscribeGitUpdates: (() => void) | undefined; + let unsubscribeFileTreeUpdates: (() => void) | undefined; + let unsubscribeFileChanges: (() => void) | undefined; const workspace: Workspace = { id: workspaceId, path: workDir, - fs: workspaceFs, + configPath, + fileSystem, + fileTree, gitWorktree, settings: context.settings, lifecycleService, @@ -156,8 +162,11 @@ export function createWorkspaceFactory( dispose: async () => { unsubscribeGitUpdates?.(); unsubscribeGitUpdates = undefined; - await worktreeLease.release(); - await runtimeLease.release(); + unsubscribeFileTreeUpdates?.(); + unsubscribeFileTreeUpdates = undefined; + unsubscribeFileChanges?.(); + unsubscribeFileChanges = undefined; + await runtime.release(); }, }; @@ -165,8 +174,16 @@ export function createWorkspaceFactory( return { workspace, + sshFilesRuntime: type.kind === 'ssh' ? filesRuntime : undefined, onCreateSideEffect: (ws) => { + void workspaceFileIndexService.onWorkspaceActivated(workspaceId, { + rootPath: ws.path, + enumerate: (root) => { + const fs = filesRuntime.fileSystem(); + return fs.success ? fs.data.enumerate(root) : fs; + }, + }); unsubscribeGitUpdates = ws.gitWorktree.subscribe((update) => handleGitWorktreeUpdate(workspaceId, update, (emitted) => { events.emit(gitWorktreeUpdateChannel, { @@ -176,11 +193,44 @@ export function createWorkspaceFactory( }); }) ); + unsubscribeFileTreeUpdates = ws.fileTree.subscribe((update) => { + events.emit(fileTreeUpdateChannel, { + projectId: context.projectId, + workspaceId, + update, + }); + }); + const fileChanges = filesRuntime.watchChanges(workDir, (update) => { + if (type.kind === 'ssh') { + invalidateLegacySshGitWorktreeStatus(ws.gitWorktree); + } + events.emit(fileChangesChannel, { + projectId: context.projectId, + workspaceId, + update, + }); + workspaceFileIndexService.onWorkspaceFileChange(workspaceId, update); + }); + if (fileChanges.success) { + unsubscribeFileChanges = fileChanges.data.unsubscribe; + void fileChanges.data.ready().then((result) => { + if (!result.success) { + log.warn('WorkspaceFactory: file change feed failed to become ready', { + workspaceId, + error: result.error, + }); + } + }); + } else { + log.warn('WorkspaceFactory: failed to start file change feed', { + workspaceId, + error: fileChanges.error, + }); + } if (ownsFetchService) { gitRepositoryFetchService.start(); } - void workspaceFileIndexService.onWorkspaceCreated(workspaceId, ws); void (async () => { if (scripts?.setup && (projectSettings.autoRunSetupScriptOnTaskCreation ?? true)) { const setupResult = await runLifecycleScriptWithPolicy({ @@ -232,12 +282,13 @@ export function createWorkspaceFactory( if (ownsFetchService) { gitRepositoryFetchService.stop(); } - workspaceFileIndexService.onWorkspaceDestroyed(workspaceId); + workspaceFileIndexService.onWorkspaceDeactivated(workspaceId); + const latestProjectSettings = await context.settings.get(); const latestTaskSettings = await getEffectiveTaskSettings({ projectSettings: context.settings, - taskFs: ws.fs, + taskFs: ws.fileSystem, + taskConfigPath: ws.configPath, }); - const latestProjectSettings = await context.settings.get(); const latestShellSetup = latestTaskSettings.shellSetup ?? latestProjectSettings.shellSetup; const teardownScript = latestTaskSettings.scripts?.teardown; @@ -271,6 +322,43 @@ export function createWorkspaceFactory( }; } +async function acquireWorkspaceRuntime( + workspaceRuntime: WorkspaceFactoryContext['workspaceRuntime'], + workDir: string +) { + const runtimeLease = await workspaceRuntime.manager.acquire(workspaceRuntime.machine); + try { + const worktreeLease = await runtimeLease.value.git.openWorktree(workDir); + try { + const openedFileTree = await runtimeLease.value.files.openTree(workDir); + if (!openedFileTree.success) { + throw new Error(`Failed to open file tree: ${JSON.stringify(openedFileTree.error)}`); + } + const fileTreeLease = openedFileTree.data; + + let released = false; + return { + gitWorktree: worktreeLease.value, + fileTree: fileTreeLease.value, + filesRuntime: runtimeLease.value.files, + release: async () => { + if (released) return; + released = true; + await fileTreeLease.release(); + await worktreeLease.release(); + await runtimeLease.release(); + }, + }; + } catch (error) { + await worktreeLease.release(); + throw error; + } + } catch (error) { + await runtimeLease.release(); + throw error; + } +} + type TaskProviderOpts = { projectId: string; taskId: string; @@ -279,6 +367,7 @@ type TaskProviderOpts = { tmuxEnabled: boolean; shellSetup?: string; taskEnvVars: Record; + filesRuntime?: IFilesRuntime; }; async function resolveLocalConversationShellProfile(taskId: string): Promise { @@ -306,6 +395,9 @@ export async function buildTaskProviders( opts: TaskProviderOpts ): Promise<{ conversations: ConversationProvider; terminals: TerminalProvider }> { if (type.kind === 'ssh') { + if (!opts.filesRuntime) { + throw new Error('Missing SSH files runtime for SSH task provider'); + } const ctx = new SshExecutionContext(type.proxy); return { conversations: new SshConversationProvider({ @@ -316,6 +408,7 @@ export async function buildTaskProviders( shellSetup: opts.shellSetup, ctx, proxy: type.proxy, + filesRuntime: opts.filesRuntime, taskEnvVars: opts.taskEnvVars, }), terminals: new SshTerminalProvider({ @@ -365,7 +458,7 @@ export async function buildTaskProviders( */ export async function resolveTaskEnv( task: Pick, - workspace: Pick, + workspace: Pick, projectPath: string, settings: ProjectSettingsProvider ): Promise<{ @@ -377,7 +470,8 @@ export async function resolveTaskEnv( const defaultBranch = await settings.getDefaultBranch(); const taskLevelSettings = await getEffectiveTaskSettings({ projectSettings: settings, - taskFs: workspace.fs, + taskFs: workspace.fileSystem, + taskConfigPath: workspace.configPath, }); return { taskEnvVars: getTaskEnvVars({ diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.test.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.test.ts index ae8d4dd4c6..44838f68e1 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.test.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.test.ts @@ -5,16 +5,20 @@ import { WorkspaceRegistry } from './workspace-registry'; function makeWorkspace(id: string): { workspace: Workspace; dispose: ReturnType; + fileTreeDispose: ReturnType; gitDispose: ReturnType; } { const dispose = vi.fn(async () => {}); + const fileTreeDispose = vi.fn(); const gitDispose = vi.fn(); return { workspace: { id, path: `/tmp/${id}`, - fs: {} as Workspace['fs'], + configPath: `/tmp/${id}/.emdash.json`, + fileSystem: {} as Workspace['fileSystem'], + fileTree: { dispose: fileTreeDispose } as unknown as Workspace['fileTree'], gitWorktree: { dispose: gitDispose } as unknown as Workspace['gitWorktree'], settings: {} as Workspace['settings'], lifecycleService: { @@ -24,6 +28,7 @@ function makeWorkspace(id: string): { gitRepositoryFetchService: {} as Workspace['gitRepositoryFetchService'], }, dispose, + fileTreeDispose, gitDispose, }; } @@ -37,8 +42,8 @@ describe('WorkspaceRegistry', () => { const first = await registry.acquire('branch:main', 'test-project', factory); const second = await registry.acquire('branch:main', 'test-project', factory); - expect(first).toBe(workspace); - expect(second).toBe(workspace); + expect(first.workspace).toBe(workspace); + expect(second.workspace).toBe(workspace); expect(factory).toHaveBeenCalledTimes(1); expect(registry.get('branch:main')).toBe(workspace); expect(registry.refCount('branch:main')).toBe(2); @@ -61,14 +66,14 @@ describe('WorkspaceRegistry', () => { expect(factory).toHaveBeenCalledTimes(1); resolveFactory?.({ workspace }); - await expect(first).resolves.toBe(workspace); - await expect(second).resolves.toBe(workspace); + await expect(first.then((acquired) => acquired.workspace)).resolves.toBe(workspace); + await expect(second.then((acquired) => acquired.workspace)).resolves.toBe(workspace); expect(registry.refCount('branch:main')).toBe(2); }); it('disposes workspace resources when ref count reaches zero', async () => { const registry = new WorkspaceRegistry(); - const { workspace, dispose, gitDispose } = makeWorkspace('branch:main'); + const { workspace, dispose, fileTreeDispose, gitDispose } = makeWorkspace('branch:main'); const factory = vi.fn(async () => ({ workspace })); await registry.acquire('branch:main', 'test-project', factory); @@ -76,10 +81,12 @@ describe('WorkspaceRegistry', () => { await registry.teardown('branch:main'); expect(dispose).not.toHaveBeenCalled(); + expect(fileTreeDispose).not.toHaveBeenCalled(); expect(gitDispose).not.toHaveBeenCalled(); expect(registry.refCount('branch:main')).toBe(1); await registry.teardown('branch:main'); + expect(fileTreeDispose).toHaveBeenCalledTimes(1); expect(gitDispose).toHaveBeenCalledTimes(1); expect(dispose).toHaveBeenCalledTimes(1); expect(registry.get('branch:main')).toBeUndefined(); @@ -101,8 +108,10 @@ describe('WorkspaceRegistry', () => { await registry.teardownAll(); + expect(first.fileTreeDispose).toHaveBeenCalledTimes(1); expect(first.gitDispose).toHaveBeenCalledTimes(1); expect(first.dispose).toHaveBeenCalledTimes(1); + expect(second.fileTreeDispose).toHaveBeenCalledTimes(1); expect(second.gitDispose).toHaveBeenCalledTimes(1); expect(second.dispose).toHaveBeenCalledTimes(1); expect(registry.refCount('branch:main')).toBe(0); @@ -138,9 +147,9 @@ describe('WorkspaceRegistry', () => { }); const factory = vi.fn(async () => ({ workspace, onCreate })); - const acquired = registry.acquire('branch:main', 'test-project', factory).then((ws) => { + const acquired = registry.acquire('branch:main', 'test-project', factory).then((result) => { order.push('acquired'); - return ws; + return result.workspace; }); await acquired; @@ -180,13 +189,16 @@ describe('WorkspaceRegistry', () => { it('calls onDestroy before git.dispose and lifecycleService.dispose', async () => { const registry = new WorkspaceRegistry(); - const { workspace, dispose, gitDispose } = makeWorkspace('branch:main'); + const { workspace, dispose, fileTreeDispose, gitDispose } = makeWorkspace('branch:main'); const order: string[] = []; dispose.mockImplementation(() => { order.push('lifecycleDispose'); return undefined; }); + fileTreeDispose.mockImplementation(() => { + order.push('fileTreeDispose'); + }); gitDispose.mockImplementation(() => { order.push('gitDispose'); }); @@ -200,7 +212,7 @@ describe('WorkspaceRegistry', () => { await registry.acquire('branch:main', 'test-project', factory); await registry.teardown('branch:main'); - expect(order).toEqual(['onDestroy', 'gitDispose', 'lifecycleDispose']); + expect(order).toEqual(['onDestroy', 'fileTreeDispose', 'gitDispose', 'lifecycleDispose']); }); it('calls onDestroy for each entry in teardownAll', async () => { @@ -293,7 +305,7 @@ describe('WorkspaceRegistry', () => { it('releases leases for a project without running teardown hooks', async () => { const registry = new WorkspaceRegistry(); - const { workspace, dispose, gitDispose } = makeWorkspace('branch:main'); + const { workspace, dispose, fileTreeDispose, gitDispose } = makeWorkspace('branch:main'); const onDestroy = vi.fn(async () => {}); const onDetach = vi.fn(async () => {}); @@ -305,6 +317,7 @@ describe('WorkspaceRegistry', () => { await registry.releaseLeasesForProject('test-project'); + expect(fileTreeDispose).toHaveBeenCalledTimes(1); expect(gitDispose).toHaveBeenCalledTimes(1); expect(dispose).not.toHaveBeenCalled(); expect(onDestroy).not.toHaveBeenCalled(); @@ -313,6 +326,7 @@ describe('WorkspaceRegistry', () => { await registry.teardownAllForProject('test-project'); + expect(fileTreeDispose).toHaveBeenCalledTimes(1); expect(gitDispose).toHaveBeenCalledTimes(1); expect(dispose).toHaveBeenCalledTimes(1); expect(onDestroy).toHaveBeenCalledTimes(1); diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.ts index 6445032395..acb90a9638 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace-registry.ts @@ -1,4 +1,5 @@ import { once } from '@emdash/shared'; +import type { IFilesRuntime } from '@main/core/runtime/types'; import type { Workspace } from './workspace'; export type TeardownMode = 'detach' | 'terminate'; @@ -10,10 +11,17 @@ type WorkspaceHooks = { onDetach?: (workspace: Workspace) => Promise; }; -export type WorkspaceFactoryResult = { workspace: Workspace } & WorkspaceHooks; +export type WorkspaceAcquireResult = { + workspace: Workspace; + /** Transitional SSH-only capability for legacy SSH conversation adapters. */ + sshFilesRuntime?: IFilesRuntime; +}; + +export type WorkspaceFactoryResult = WorkspaceAcquireResult & WorkspaceHooks; type WorkspaceEntry = { workspace: Workspace; + sshFilesRuntime?: IFilesRuntime; refCount: number; projectId: string; onDestroy?: (workspace: Workspace) => Promise; @@ -24,25 +32,25 @@ type WorkspaceEntry = { export class WorkspaceRegistry { private entries = new Map(); - private acquiring = new Map>(); + private acquiring = new Map>(); async acquire( key: string, projectId: string, factory: () => Promise - ): Promise { + ): Promise { const existing = this.entries.get(key); if (existing) { existing.refCount += 1; - return existing.workspace; + return { workspace: existing.workspace, sshFilesRuntime: existing.sshFilesRuntime }; } const inFlight = this.acquiring.get(key); if (inFlight) { - const workspace = await inFlight; + const acquired = await inFlight; const current = this.entries.get(key); if (current) current.refCount += 1; - return workspace; + return acquired; } const pending = factory() @@ -50,18 +58,22 @@ export class WorkspaceRegistry { const workspace = result.workspace; this.entries.set(key, { workspace, + sshFilesRuntime: result.sshFilesRuntime, refCount: 1, projectId, onDestroy: result.onDestroy, onDetach: result.onDetach, release: once(async () => { await workspace.dispose?.(); - if (!workspace.dispose) await workspace.gitWorktree.dispose(); + if (!workspace.dispose) { + await workspace.fileTree.dispose(); + await workspace.gitWorktree.dispose(); + } }), }); result.onCreateSideEffect?.(workspace); await result.onCreate?.(workspace); - return workspace; + return { workspace, sshFilesRuntime: result.sshFilesRuntime }; }) .finally(() => { this.acquiring.delete(key); diff --git a/apps/emdash-desktop/src/main/core/workspaces/workspace.ts b/apps/emdash-desktop/src/main/core/workspaces/workspace.ts index 2b8b2e3d4c..a379951d47 100644 --- a/apps/emdash-desktop/src/main/core/workspaces/workspace.ts +++ b/apps/emdash-desktop/src/main/core/workspaces/workspace.ts @@ -1,5 +1,5 @@ +import type { IFileSystem, IFileTree } from '@emdash/core/files'; import type { IGitWorktree } from '@emdash/core/git'; -import type { FileSystemProvider } from '@main/core/fs/types'; import type { GitRepositoryFetchService } from '@main/core/git/repository/fetch-service'; import type { GitRepositoryService } from '@main/core/git/repository/service'; import type { ProjectSettingsProvider } from '@main/core/projects/settings/provider'; @@ -8,7 +8,9 @@ import type { LifecycleScriptService } from './workspace-lifecycle-service'; export interface Workspace { readonly id: string; readonly path: string; - readonly fs: FileSystemProvider; + readonly configPath: string; + readonly fileSystem: IFileSystem; + readonly fileTree: IFileTree; readonly gitWorktree: IGitWorktree; readonly settings: ProjectSettingsProvider; readonly lifecycleService: LifecycleScriptService; diff --git a/apps/emdash-desktop/src/main/db/initialize.ts b/apps/emdash-desktop/src/main/db/initialize.ts index 10f39db187..5bfb3ba1bc 100644 --- a/apps/emdash-desktop/src/main/db/initialize.ts +++ b/apps/emdash-desktop/src/main/db/initialize.ts @@ -91,7 +91,7 @@ function ensureSearchIndex(connection: BetterSqlite3.Database): void { * changes without a full Drizzle migration. */ function ensureFileIndex(connection: BetterSqlite3.Database): void { - const FILE_INDEX_VERSION = '1'; + const FILE_INDEX_VERSION = '3'; const row = connection.prepare(`SELECT value FROM kv WHERE key = 'file_index_version'`).get() as | { value: string } @@ -111,8 +111,14 @@ function ensureFileIndex(connection: BetterSqlite3.Database): void { `); connection.exec(` CREATE TABLE workspace_file_index_meta ( - workspace_id TEXT PRIMARY KEY, - indexed_at INTEGER NOT NULL + workspace_id TEXT PRIMARY KEY, + indexed_at INTEGER NOT NULL, + root_path TEXT NOT NULL, + status TEXT NOT NULL + CHECK (status IN ('complete', 'stale', 'truncated')), + file_count INTEGER NOT NULL, + truncate_reason TEXT + CHECK (truncate_reason IS NULL OR truncate_reason IN ('maxEntries', 'timeBudget')) ) `); connection diff --git a/apps/emdash-desktop/src/main/rpc.ts b/apps/emdash-desktop/src/main/rpc.ts index 5f5df792ff..fe2c2979c9 100644 --- a/apps/emdash-desktop/src/main/rpc.ts +++ b/apps/emdash-desktop/src/main/rpc.ts @@ -8,8 +8,10 @@ import { browserController } from './core/browser/controller'; import { conversationController } from './core/conversations/controller'; import { editorBufferController } from './core/editor/controller'; import { featurebaseController } from './core/featurebase/controller'; +import { machineFilesController } from './core/files/controller'; +import { workspaceFileSystemController } from './core/files/file-system/controller'; +import { fileTreeController } from './core/files/file-tree/controller'; import { forgejoController } from './core/forgejo/controller'; -import { filesController } from './core/fs/controller'; import { gitRepositoryController } from './core/git/repository/controller'; import { gitWorktreeController } from './core/git/worktree/controller'; import { githubController } from './core/github/controller'; @@ -58,6 +60,7 @@ export const rpcRouter = createRPCRouter({ asana: asanaController, featurebase: featurebaseController, forgejo: forgejoController, + files: machineFilesController, github: githubController, gitlab: gitlabController, issues: issueController, @@ -84,7 +87,8 @@ export const rpcRouter = createRPCRouter({ projectSettings: projectSettingsController, workspace: createRPCNamespace({ gitWorktree: gitWorktreeController, - fs: filesController, + files: workspaceFileSystemController, + fileTree: fileTreeController, editor: editorBufferController, }), }); diff --git a/apps/emdash-desktop/src/renderer/features/command-palette/command-palette-modal.tsx b/apps/emdash-desktop/src/renderer/features/command-palette/command-palette-modal.tsx index c7440ff99b..78d40f850e 100644 --- a/apps/emdash-desktop/src/renderer/features/command-palette/command-palette-modal.tsx +++ b/apps/emdash-desktop/src/renderer/features/command-palette/command-palette-modal.tsx @@ -6,6 +6,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useAppSettingsKey } from '@renderer/features/settings/use-app-settings-key'; import { conversationRegistry } from '@renderer/features/tasks/stores/conversation-registry'; import { getTaskStore, getTaskView } from '@renderer/features/tasks/stores/task-selectors'; +import { workspaceRegistry } from '@renderer/features/tasks/stores/workspace-registry'; import { commandRegistry } from '@renderer/lib/commands/registry'; import { FileIcon } from '@renderer/lib/editor/file-icon'; import { useDebounce } from '@renderer/lib/hooks/useDebounce'; @@ -25,7 +26,7 @@ import { PaletteNotificationsGroup } from './palette-notifications-group'; import { PaletteProjectsGroup } from './palette-projects-group'; import { PaletteTaskItem } from './palette-task-item'; import { ResourceMonitorView } from './resource-monitor-view'; -import { applyContextAffinity } from './search-utils'; +import { applyContextAffinity, getPaletteFileDisplayPath } from './search-utils'; interface CommandPaletteProps { projectId?: string; @@ -98,18 +99,26 @@ function PaletteItem({ function PaletteFileItem({ value, item, + workspacePath, onSelect, }: { value: string; item: SearchItem; + workspacePath?: string; onSelect: () => void; }) { + const displayPath = getPaletteFileDisplayPath({ + workspacePath, + filePath: item.id, + fallback: item.subtitle, + }); + return ( {item.title} - {item.subtitle} + {displayPath} ); @@ -214,6 +223,8 @@ export function CommandPaletteModal({ const rankedDb = applyContextAffinity(dbResults, { projectId }); const actionResults = actions; + const workspacePath = + projectId && workspaceId ? workspaceRegistry.get(projectId, workspaceId)?.path : undefined; const q = debouncedQuery.toLowerCase(); const matchedResourceMonitor = @@ -386,6 +397,7 @@ export function CommandPaletteModal({ key={`file:${item.id}`} value={`file:${item.id}`} item={item} + workspacePath={workspacePath} onSelect={() => handleOpenFile(item)} /> ); diff --git a/apps/emdash-desktop/src/renderer/features/command-palette/search-utils.test.ts b/apps/emdash-desktop/src/renderer/features/command-palette/search-utils.test.ts new file mode 100644 index 0000000000..73c42f4135 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/command-palette/search-utils.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { getPaletteFileDisplayPath } from './search-utils'; + +describe('getPaletteFileDisplayPath', () => { + it('returns a workspace-relative display path for absolute file identities', () => { + expect( + getPaletteFileDisplayPath({ + workspacePath: '/repo', + filePath: '/repo/src/command-k.ts', + fallback: '/repo/src/command-k.ts', + }) + ).toBe('src/command-k.ts'); + }); + + it('falls back to the indexed path when the workspace path is unknown', () => { + expect( + getPaletteFileDisplayPath({ + filePath: '/repo/src/command-k.ts', + fallback: '/repo/src/command-k.ts', + }) + ).toBe('/repo/src/command-k.ts'); + }); +}); diff --git a/apps/emdash-desktop/src/renderer/features/command-palette/search-utils.ts b/apps/emdash-desktop/src/renderer/features/command-palette/search-utils.ts index 3a7fff0418..dc9cb7c190 100644 --- a/apps/emdash-desktop/src/renderer/features/command-palette/search-utils.ts +++ b/apps/emdash-desktop/src/renderer/features/command-palette/search-utils.ts @@ -1,3 +1,4 @@ +import { relativeToWorkspace } from '@renderer/features/tasks/stores/workspace-path'; import type { SearchItem } from '@shared/core/search'; /** @@ -16,3 +17,16 @@ export function applyContextAffinity( return diff !== 0 ? diff : a.score - b.score; }); } + +export function getPaletteFileDisplayPath({ + workspacePath, + filePath, + fallback, +}: { + workspacePath?: string; + filePath: string; + fallback?: string; +}): string { + if (!workspacePath) return fallback ?? filePath.replace(/\\/g, '/'); + return relativeToWorkspace(workspacePath, filePath); +} diff --git a/apps/emdash-desktop/src/renderer/features/projects/components/add-project-modal/remote-directory-selector.tsx b/apps/emdash-desktop/src/renderer/features/projects/components/add-project-modal/remote-directory-selector.tsx index 472e81ba65..093689aa9f 100644 --- a/apps/emdash-desktop/src/renderer/features/projects/components/add-project-modal/remote-directory-selector.tsx +++ b/apps/emdash-desktop/src/renderer/features/projects/components/add-project-modal/remote-directory-selector.tsx @@ -14,7 +14,7 @@ import { Button } from '@renderer/lib/ui/button'; import { Input } from '@renderer/lib/ui/input'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/lib/ui/popover'; import { cn } from '@renderer/utils/utils'; -import type { FileEntry } from '@shared/core/ssh/ssh'; +import type { BrowseDirectoryResult, DirectoryEntry } from '@shared/core/fs/fs'; interface RemoteDirectorySelectorProps { connectionId: string | undefined; @@ -83,12 +83,12 @@ function directoryHistoryReducer( function useRemoteDirectoryBrowser(connectionId: string | undefined, initialPath: string) { const [currentPath, setCurrentPath] = useState(initialPath); - const [fileEntries, setFileEntries] = useState([]); + const [fileEntries, setFileEntries] = useState([]); const [isBrowsing, setIsBrowsing] = useState(false); const [browseError, setBrowseError] = useState(null); const [loadedPath, setLoadedPath] = useState(null); - const directoryCacheRef = useRef(new Map()); - const inFlightRequestsRef = useRef(new Map>()); + const directoryCacheRef = useRef(new Map()); + const inFlightRequestsRef = useRef(new Map>()); const cacheWriteRequestIdsRef = useRef(new Map()); const latestRequestIdRef = useRef(0); @@ -123,13 +123,13 @@ function useRemoteDirectoryBrowser(connectionId: string | undefined, initialPath if (!request) { cacheWriteRequestIdsRef.current.set(cacheKey, requestId); - request = rpc.ssh - .listFiles({ connectionId, path: nextPath }) - .then((entries) => { - if (cacheWriteRequestIdsRef.current.get(cacheKey) === requestId) { - directoryCacheRef.current.set(cacheKey, entries); + request = rpc.files + .browseDirectory({ type: 'ssh', connectionId, path: nextPath }) + .then((result) => { + if (result.success && cacheWriteRequestIdsRef.current.get(cacheKey) === requestId) { + directoryCacheRef.current.set(cacheKey, result.data); } - return entries; + return result; }) .finally(() => { if (inFlightRequestsRef.current.get(cacheKey) === request) { @@ -144,9 +144,16 @@ function useRemoteDirectoryBrowser(connectionId: string | undefined, initialPath } try { - const entries = await request; + const result = await request; if (latestRequestIdRef.current !== requestId) return false; - + if (!result.success) { + setBrowseError(result.error.message); + setFileEntries([]); + setLoadedPath(null); + return false; + } + + const entries = result.data; setFileEntries(entries); setLoadedPath(nextPath); return true; @@ -228,7 +235,7 @@ export function RemoteDirectorySelector({ dispatchHistory({ type: options?.replaceHistory ? 'replace' : 'push', path: nextPath }); }; - const navigateTo = (entry: FileEntry) => { + const navigateTo = (entry: DirectoryEntry) => { if (entry.type !== 'directory') return; void navigateToPath(entry.path); }; @@ -319,8 +326,6 @@ export function RemoteDirectorySelector({ > {isDirectory ? ( - ) : entry.type === 'symlink' ? ( - ) : ( )} diff --git a/apps/emdash-desktop/src/renderer/features/projects/stores/project-settings-store.ts b/apps/emdash-desktop/src/renderer/features/projects/stores/project-settings-store.ts index 08f35a49e0..f77bcae704 100644 --- a/apps/emdash-desktop/src/renderer/features/projects/stores/project-settings-store.ts +++ b/apps/emdash-desktop/src/renderer/features/projects/stores/project-settings-store.ts @@ -1,9 +1,9 @@ import type { Result } from '@emdash/shared'; import { events, rpc } from '@renderer/lib/ipc'; import { Resource } from '@renderer/lib/stores/resource'; -import { fsWatchEventChannel } from '@shared/core/fs/fsEvents'; +import { fileChangesChannel } from '@shared/core/fs/fsEvents'; import { - PROJECT_CONFIG_FILE, + isProjectConfigPath, type MigrateProjectConfigRequest, type MigrateProjectConfigResult, type ProjectConfigMigration, @@ -34,12 +34,11 @@ export class ProjectSettingsStore { return result.data; }, [{ kind: 'demand' }]); - this._unsubscribeConfigWatch = events.on(fsWatchEventChannel, (data) => { + this._unsubscribeConfigWatch = events.on(fileChangesChannel, (data) => { if (data.projectId !== projectId) return; if ( - data.events.some( - (event) => event.path === PROJECT_CONFIG_FILE || event.oldPath === PROJECT_CONFIG_FILE - ) + data.update.kind === 'resync' || + data.update.changes.some((change) => isProjectConfigPath(change.path)) ) { this.pageData.invalidate(); } diff --git a/apps/emdash-desktop/src/renderer/features/tabs/pane-store.test.ts b/apps/emdash-desktop/src/renderer/features/tabs/pane-store.test.ts index 1f06cb21a4..116660d28b 100644 --- a/apps/emdash-desktop/src/renderer/features/tabs/pane-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/tabs/pane-store.test.ts @@ -28,6 +28,18 @@ vi.mock('@renderer/lib/monaco/monaco-model-registry', () => ({ }, })); +vi.mock('@renderer/lib/stores/app-state', () => ({ + appState: { + projects: { + projects: new Map(), + }, + sshConnections: { + healthFor: vi.fn(() => ({ status: 'ok' })), + }, + }, + sidebarStore: {}, +})); + vi.mock('@renderer/utils/telemetry-scope', () => ({ setTelemetryConversationScope: vi.fn(), })); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/changes-list-item.tsx b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/changes-list-item.tsx index 6a2a3fb2d7..b4dac64a0e 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/changes-list-item.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/changes-list-item.tsx @@ -6,17 +6,23 @@ import { FileIcon } from '@renderer/lib/editor/file-icon'; import { Checkbox } from '@renderer/lib/ui/checkbox'; import { formatDiffLineCount } from '@renderer/utils/format-diff-line-count'; import { cn } from '@renderer/utils/utils'; +import { displayPathForChange } from './changes-tree-utils'; interface ChangesListItemProps extends ButtonHTMLAttributes { change: GitChange; + rootPath?: string; isSelected?: boolean; isActive?: boolean; onToggleSelect?: (path: string) => void; } export const ChangesListItem = forwardRef( - ({ change, isSelected, isActive, onToggleSelect, className, ...props }, ref) => { - const { filename, directory } = useMemo(() => splitPath(change.path), [change.path]); + ({ change, rootPath, isSelected, isActive, onToggleSelect, className, ...props }, ref) => { + const displayPath = useMemo( + () => displayPathForChange(change.path, rootPath), + [change.path, rootPath] + ); + const { filename, directory } = useMemo(() => splitPath(displayPath), [displayPath]); return (