diff --git a/drizzle/0013_remove_projects_path_unique.sql b/drizzle/0013_remove_projects_path_unique.sql new file mode 100644 index 000000000..c402774b0 --- /dev/null +++ b/drizzle/0013_remove_projects_path_unique.sql @@ -0,0 +1,2 @@ +-- Drop the unique index on projects.path to allow remote projects with the same path but different sshConnectionId +DROP INDEX IF EXISTS idx_projects_path; diff --git a/drizzle/meta/0013_snapshot.json b/drizzle/meta/0013_snapshot.json new file mode 100644 index 000000000..be2d51ec9 --- /dev/null +++ b/drizzle/meta/0013_snapshot.json @@ -0,0 +1,13 @@ +{ + "id": "d2c07f6e-5672-45a9-a9f5-fec5931b3ce2", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "6", + "dialect": "sqlite", + "tables": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 966386a2e..8da7015a4 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1743033600000, "tag": "0012_add_automation_triggers", "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1775625447056, + "tag": "0013_remove_projects_path_unique", + "breakpoints": true } ] } diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index f20adcd24..789ab4e8e 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -49,7 +49,8 @@ export const projects = sqliteTable( .default(sql`CURRENT_TIMESTAMP`), }, (table) => ({ - pathIdx: uniqueIndex('idx_projects_path').on(table.path), + // Note: path uniqueness is enforced at the application layer in DatabaseService.saveProject + // to allow remote projects with the same path but different sshConnectionId. sshConnectionIdIdx: index('idx_projects_ssh_connection_id').on(table.sshConnectionId), isRemoteIdx: index('idx_projects_is_remote').on(table.isRemote), }) diff --git a/src/main/ipc/sshIpc.ts b/src/main/ipc/sshIpc.ts index 0c85707dd..99cad2e0a 100644 --- a/src/main/ipc/sshIpc.ts +++ b/src/main/ipc/sshIpc.ts @@ -1,4 +1,5 @@ import { ipcMain } from 'electron'; +import { userInfo } from 'os'; import { SSH_IPC_CHANNELS } from '../../shared/ssh/types'; import { sshService } from '../services/ssh/SshService'; import { SshCredentialService } from '../services/ssh/SshCredentialService'; @@ -14,6 +15,7 @@ import { parseSshConfigFile, resolveIdentityAgent, resolveProxyCommand, + resolveSshConfigHost, } from '../utils/sshConfigParser'; import type { SshConfig, @@ -450,6 +452,41 @@ export function registerSshIpc() { // Accept either a saved connection id (string) or a config object. if (typeof arg === 'string') { const id = arg; + + // Handle SSH config aliases (e.g. "ssh-config:tower02") that were never saved to DB + if (id.startsWith('ssh-config:')) { + const raw = id.slice('ssh-config:'.length); + const alias = /%[0-9A-Fa-f]{2}/.test(raw) ? decodeURIComponent(raw) : raw; + + const sshConfigHost = await resolveSshConfigHost(alias); + if (!sshConfigHost) { + return { success: false, error: `SSH config host not found: ${alias}` }; + } + + const config: SshConfig = { + id, + name: alias, + host: sshConfigHost.hostname ?? alias, + port: sshConfigHost.port ?? 22, + username: sshConfigHost.user ?? userInfo().username, + authType: sshConfigHost.identityAgent + ? 'agent' + : sshConfigHost.identityFile + ? 'key' + : 'agent', + privateKeyPath: sshConfigHost.identityFile, + useAgent: Boolean(sshConfigHost.identityAgent), + }; + + const connectionId = await sshService.connect(config); + monitor.startMonitoring(connectionId, config); + monitor.updateState(connectionId, 'connected'); + void import('../telemetry').then(({ capture }) => { + void capture('ssh_connect_success', { type: config.authType }); + }); + return { success: true, connectionId }; + } + const { db } = await getDrizzleClient(); const rows = await db .select({ diff --git a/src/main/services/DatabaseService.ts b/src/main/services/DatabaseService.ts index 75a2ee27d..7b469b325 100644 --- a/src/main/services/DatabaseService.ts +++ b/src/main/services/DatabaseService.ts @@ -208,6 +208,8 @@ export class DatabaseService { id: projectsTable.id, name: projectsTable.name, path: projectsTable.path, + sshConnectionId: projectsTable.sshConnectionId, + isRemote: projectsTable.isRemote, }) .from(projectsTable) .where(eq(projectsTable.path, project.path)) @@ -215,11 +217,20 @@ export class DatabaseService { const existingByPath = existingByPathRows[0] ?? null; if (existingByPath && existingByPath.id !== project.id) { - throw new ProjectConflictError({ - existingProjectId: existingByPath.id, - existingProjectName: existingByPath.name, - projectPath: existingByPath.path, - }); + // For remote projects, allow the same path if sshConnectionId is different + const isNewProjectRemote = project.isRemote && project.sshConnectionId; + const isExistingProjectRemote = + existingByPath.isRemote === 1 && existingByPath.sshConnectionId; + const sameSshConnection = + project.sshConnectionId && existingByPath.sshConnectionId === project.sshConnectionId; + + if (!isNewProjectRemote || !isExistingProjectRemote || sameSshConnection) { + throw new ProjectConflictError({ + existingProjectId: existingByPath.id, + existingProjectName: existingByPath.name, + projectPath: existingByPath.path, + }); + } } const values = { diff --git a/src/renderer/components/sidebar/LeftSidebar.tsx b/src/renderer/components/sidebar/LeftSidebar.tsx index 03d5e7d1e..08a3e8d16 100644 --- a/src/renderer/components/sidebar/LeftSidebar.tsx +++ b/src/renderer/components/sidebar/LeftSidebar.tsx @@ -84,9 +84,18 @@ interface ProjectItemProps { const ProjectItem = React.memo(({ project }) => { const remote = useRemoteProject(project); const connectionId = getConnectionId(project); + const isRemote = isRemoteProject(project); - if (!connectionId && !isRemoteProject(project)) { - return {project.name}; + const displayName = useMemo(() => { + if (isRemote) { + const host = remote.host ?? connectionId ?? ''; + return `${project.name} (${host}:${project.path})`; + } + return `${project.name} (${project.path})`; + }, [project, isRemote, remote.host, connectionId]); + + if (!connectionId && !isRemote) { + return {displayName}; } return ( @@ -100,7 +109,7 @@ const ProjectItem = React.memo(({ project }) => { disabled={remote.isLoading} /> )} - {project.name} + {displayName} ); }); diff --git a/src/test/main/DatabaseService.saveProject.test.ts b/src/test/main/DatabaseService.saveProject.test.ts index f2dcf087a..c5bd7db50 100644 --- a/src/test/main/DatabaseService.saveProject.test.ts +++ b/src/test/main/DatabaseService.saveProject.test.ts @@ -168,4 +168,66 @@ describe('DatabaseService.saveProject', () => { }) ); }); + + it('throws ProjectConflictError when both remote projects share the same sshConnectionId and path', async () => { + // Both projects are remote with the same sshConnectionId — conflict + selectResults.push([]); + selectResults.push([ + { + id: 'project-existing', + name: 'Existing Remote', + path: '/srv/project-one', + isRemote: 1, + sshConnectionId: 'ssh-1', + }, + ]); + + await expect(service.saveProject(baseProject)).rejects.toEqual( + expect.objectContaining({ + name: 'ProjectConflictError', + code: 'PROJECT_CONFLICT', + existingProjectId: 'project-existing', + existingProjectName: 'Existing Remote', + projectPath: '/srv/project-one', + }) + ); + + expect(insertValuesMock).not.toHaveBeenCalled(); + expect(updateValuesMock).not.toHaveBeenCalled(); + }); + + it('allows two remote projects with the same path but different sshConnectionId', async () => { + // Both projects are remote with the same path but DIFFERENT sshConnectionId — allowed + selectResults.push([]); + selectResults.push([ + { + id: 'project-existing', + name: 'Existing Remote on Host B', + path: '/srv/project-one', + isRemote: 1, + sshConnectionId: 'ssh-host-b', + }, + ]); + + // New project on a different host (ssh-host-a) — same path, different connection + const remoteProjectOnDifferentHost: Omit = { + ...baseProject, + id: 'project-2', + name: 'Remote Project on Host A', + path: '/srv/project-one', // same path as existingByPath + sshConnectionId: 'ssh-host-a', + }; + + await expect(service.saveProject(remoteProjectOnDifferentHost)).resolves.toBeUndefined(); + + // Should have inserted a new row, not updated or thrown + expect(insertValuesMock).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'project-2', + path: '/srv/project-one', + sshConnectionId: 'ssh-host-a', + }) + ); + expect(updateValuesMock).not.toHaveBeenCalled(); + }); });