Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions drizzle/0013_remove_projects_path_unique.sql
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 13 additions & 0 deletions drizzle/meta/0013_snapshot.json
Original file line number Diff line number Diff line change
@@ -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": {}
}
}
7 changes: 7 additions & 0 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
3 changes: 2 additions & 1 deletion src/main/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
Expand Down
33 changes: 33 additions & 0 deletions src/main/ipc/sshIpc.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,6 +15,7 @@ import {
parseSshConfigFile,
resolveIdentityAgent,
resolveProxyCommand,
resolveSshConfigHost,
} from '../utils/sshConfigParser';
import type {
SshConfig,
Expand Down Expand Up @@ -450,6 +452,37 @@ 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.identityFile ? 'key' : 'agent',
privateKeyPath: sshConfigHost.identityFile,
useAgent: !sshConfigHost.identityFile,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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({
Expand Down
21 changes: 16 additions & 5 deletions src/main/services/DatabaseService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,18 +208,29 @@ 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))
.limit(1);
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,
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const values = {
Expand Down
15 changes: 12 additions & 3 deletions src/renderer/components/sidebar/LeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,18 @@ interface ProjectItemProps {
const ProjectItem = React.memo<ProjectItemProps>(({ project }) => {
const remote = useRemoteProject(project);
const connectionId = getConnectionId(project);
const isRemote = isRemoteProject(project);

if (!connectionId && !isRemoteProject(project)) {
return <span className="flex-1 truncate">{project.name}</span>;
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 <span className="flex-1 truncate">{displayName}</span>;
}

return (
Expand All @@ -100,7 +109,7 @@ const ProjectItem = React.memo<ProjectItemProps>(({ project }) => {
disabled={remote.isLoading}
/>
)}
<span className="flex-1 truncate">{project.name}</span>
<span className="flex-1 truncate">{displayName}</span>
</div>
);
});
Expand Down
62 changes: 62 additions & 0 deletions src/test/main/DatabaseService.saveProject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})
);
Comment thread
yuzhichang marked this conversation as resolved.

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<Project, 'createdAt' | 'updatedAt'> = {
...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();
});
});