Clone project hosts over SSH

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-11 12:33:59 -07:00
co-authored by Orca
parent 359cb1baef
commit 67f8e792c7
13 changed files with 343 additions and 65 deletions
@@ -317,9 +317,9 @@ Needs:
Current branch status: partially implemented. Ready project-host setups can be
selected from the composer `Run on` menu, and known hosts that still need setup
now appear as setup-target rows instead of being invisible. The composer can
import an existing folder for those hosts and can clone onto non-SSH
local/runtime hosts, then switch to the new ready setup. SSH clone and
provisioning actions are still missing.
import an existing folder for those hosts and can clone onto local, runtime, and
SSH hosts, then switch to the new ready setup. Provisioning actions are still
missing.
### 5. Project Setup Flow
@@ -337,8 +337,9 @@ Needs:
Current branch status: existing-folder setup is partially implemented through
API/CLI plumbing and a project settings import form for known local, SSH, and
active runtime hosts. Composer clone setup is implemented for local/runtime
hosts by composing the existing clone APIs with existing-folder setup. SSH
clone, provisioning, and bulk setup are not complete.
hosts by composing the existing clone APIs with existing-folder setup; SSH clone
uses the relay git provider and then links the cloned path. Provisioning and
bulk setup are not complete.
### 6. Sidebar Row Model
@@ -482,13 +483,13 @@ Already partially landed:
set up yet
- inline `Run on` import-existing-folder setup for known hosts where the
selected project is not set up yet
- inline `Run on` clone setup for not-yet-set-up non-SSH local/runtime hosts
- inline `Run on` clone setup for not-yet-set-up local, runtime, and SSH hosts
- repo-backed setup method metadata so imported/cloned setup methods survive
compatibility projection and persistence sync
Not complete yet:
- SSH clone/provision flows
- SSH clone progress/abort/cleanup parity and provisioning flows
- independent project-host setup persistence beyond the repo-backed
compatibility records
- bulk setup flows and setup for hosts that are not already known to the client
+6 -7
View File
@@ -238,8 +238,8 @@ Needs:
internals while old APIs remain
Status: partially implemented. Ready setups and setup-target rows exist in the
composer; inline import exists; inline clone is wired for non-SSH local/runtime
hosts; SSH clone and provisioning remain.
composer; inline import exists; inline clone is wired for local, runtime, and
SSH hosts; provisioning remains.
### 5. Project Setup Flow
@@ -255,8 +255,7 @@ Needs:
- future cloud provisioning hook
Status: existing-folder setup is partially implemented; clone-on-host is wired
for local/runtime from the composer; SSH clone, provisioning, and bulk setup
remain.
for local/runtime/SSH from the composer; provisioning and bulk setup remain.
### 6. Sidebar Row Model
@@ -406,13 +405,13 @@ Already partially landed:
- setup-target `Run on` rows for known hosts where the selected project is not
set up yet
- inline import-existing-folder setup from the composer
- inline clone setup from the composer for not-yet-set-up non-SSH
local/runtime hosts
- inline clone setup from the composer for not-yet-set-up local, runtime, and
SSH hosts
- repo-backed setup method metadata for imported/cloned project-host setups
Still needed:
- finish SSH clone/provision flows
- finish SSH clone progress/abort/cleanup parity and provisioning flows
- independent project-host setup persistence beyond the repo-backed
compatibility records
- bulk setup flows and setup for newly added hosts
+16 -16
View File
@@ -750,23 +750,23 @@ Landed so far:
- Added an inline composer action for those not-yet-set-up hosts to import an
existing folder on that host. After import, the composer switches to the new
ready setup so workspace creation can continue on that host.
- Added an inline composer clone action for not-yet-set-up non-SSH hosts. It
seeds a GitHub HTTPS clone URL when durable project identity is available,
lets the user paste a different URL, clones into the selected parent
directory through the existing local/runtime clone APIs, then links the
resulting checkout as the project's host setup.
- Added an inline composer clone action for not-yet-set-up hosts. It seeds a
GitHub HTTPS clone URL when durable project identity is available, lets the
user paste a different URL, clones into the selected parent directory through
the existing local/runtime clone APIs or SSH relay git provider, then links
the resulting checkout as the project's host setup.
- Added repo-backed setup method metadata so imported and cloned setup flows
survive persistence/projection sync as `imported-existing-folder` or `cloned`
while `Repo` remains the compatibility source of truth.
- Added tests for local repos, SSH repos, same-provider multi-host grouping,
no-identity same-name non-grouping, selector cache behavior, persistence
backfill, repo mutation synchronization, renderer hydration, runtime RPC
routing, setup method persistence, local/runtime clone setup composition, and
GitHub clone URL inference. Sidebar row-builder tests now cover project-first
multi-host grouping and same-name repo separation without project identity.
Workspace target tests cover local-only fallback, focused-host setup
selection, explicit project-plus-host resolution, same-name non-merging, and
unavailable setup reasons.
routing, setup method persistence, local/runtime/SSH clone setup composition,
remote clone IPC, and GitHub clone URL inference. Sidebar row-builder tests
now cover project-first multi-host grouping and same-name repo separation
without project identity. Workspace target tests cover local-only fallback,
focused-host setup selection, explicit project-plus-host resolution, same-name
non-merging, and unavailable setup reasons.
Important limitation:
@@ -777,10 +777,9 @@ Important limitation:
through their repo compatibility record. Settings now expose setup-specific
host panes and existing-folder setup, but still use repo compatibility records
underneath.
- SSH clone setup is not implemented yet. SSH hosts can import an existing
folder today; true SSH clone should either reuse a remote git-provider exec
path or add a remote clone flow with progress, abort, safe path derivation,
cleanup, and auth parity.
- SSH clone setup is implemented through the relay git provider, but it does
not yet have local-clone parity for progress events, abort, and owned partial
clone cleanup.
- Project-host setup records are still regenerated from repo compatibility
records. The setup method now persists through that projection, but the final
independent setup table is still future work.
@@ -788,7 +787,8 @@ Important limitation:
Remaining end-to-end work:
- broaden setup-on-host flows beyond known local, SSH, and active runtime hosts
- finish SSH clone, provisioning, and bulk setup-on-host flows
- finish SSH clone progress/abort/cleanup parity, provisioning, and bulk
setup-on-host flows
- split settings into explicit client, host, project, and project-host setup
scopes
- validate the default project-first sidebar view in Electron and continue
+16 -11
View File
@@ -14,6 +14,21 @@ export type ClaimedCloneTarget = {
type CloneDirectoryIdentity = Pick<Stats, 'dev' | 'ino' | 'birthtimeMs'>
export function deriveCloneRepoNameFromUrl(url: string): string {
// Why: direct callers can supply URLs whose default git clone folder would
// be "." or ".."; rejecting them prevents parent/destination deletion.
const source = url.replace(/\.git\/?$/, '')
const isWindowsLocalSource = /^[A-Za-z]:[\\/]/.test(source) || source.startsWith('\\\\')
const repoName = isWindowsLocalSource ? win32.basename(source) : posix.basename(source)
if (!repoName || repoName === '.' || repoName === '..') {
throw new Error('Invalid repository name derived from URL')
}
if (repoName.includes('/') || repoName.includes('\\')) {
throw new Error('Invalid repository name derived from URL')
}
return repoName
}
export function deriveValidatedClonePath(args: { url: string; destination: string }): string {
if (
!args.destination ||
@@ -23,17 +38,7 @@ export function deriveValidatedClonePath(args: { url: string; destination: strin
throw new Error('Clone destination must be an absolute path')
}
// Why: direct callers can supply URLs whose default git clone folder would
// be "." or ".."; rejecting them prevents parent/destination deletion.
const source = args.url.replace(/\.git\/?$/, '')
const isWindowsLocalSource = /^[A-Za-z]:[\\/]/.test(source) || source.startsWith('\\\\')
const repoName = isWindowsLocalSource ? win32.basename(source) : posix.basename(source)
if (!repoName || repoName === '.' || repoName === '..') {
throw new Error('Invalid repository name derived from URL')
}
if (repoName.includes('/') || repoName.includes('\\')) {
throw new Error('Invalid repository name derived from URL')
}
const repoName = deriveCloneRepoNameFromUrl(args.url)
const clonePath = join(args.destination, repoName)
const resolvedDestination = resolve(args.destination)
+119 -1
View File
@@ -38,7 +38,16 @@ const {
mockGitProvider: {
isGitRepo: vi.fn().mockReturnValue(true),
isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }),
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' })
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
getHostPlatform: vi.fn().mockReturnValue({
relayPlatform: 'linux-x64',
os: 'linux',
arch: 'x64',
pathFlavor: 'posix',
commandDialect: 'posix',
pathSeparator: '/',
pathDelimiter: ':'
})
},
mockFilesystemProvider: {
readDir: vi.fn().mockResolvedValue([]),
@@ -809,6 +818,18 @@ describe('repos:addRemote', () => {
mockStore.updateRepo.mockReset()
mockGitProvider.isGitRepoAsync.mockReset()
mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: true, rootPath: null })
mockGitProvider.exec.mockReset()
mockGitProvider.exec.mockResolvedValue({ stdout: '', stderr: '' })
mockGitProvider.getHostPlatform.mockReset()
mockGitProvider.getHostPlatform.mockReturnValue({
relayPlatform: 'linux-x64',
os: 'linux',
arch: 'x64',
pathFlavor: 'posix',
commandDialect: 'posix',
pathSeparator: '/',
pathDelimiter: ':'
})
mockMultiplexer.request.mockReset()
mockMultiplexer.notify.mockReset()
gitSpawnMock.mockReset()
@@ -827,6 +848,10 @@ describe('repos:addRemote', () => {
expect(handlers.has('repos:addRemote')).toBe(true)
})
it('registers the repos:cloneRemote handler', () => {
expect(handlers.has('repos:cloneRemote')).toBe(true)
})
it('creates a remote repo with connectionId', async () => {
const result = await handlers.get('repos:addRemote')!(null, {
connectionId: 'conn-1',
@@ -865,6 +890,99 @@ describe('repos:addRemote', () => {
expect(result).toHaveProperty('repo.displayName', 'My Server Repo')
})
it('clones a repo on an SSH target and registers the cloned path', async () => {
const result = await handlers.get('repos:cloneRemote')!(null, {
connectionId: 'conn-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/home/user'
})
expect(mockGitProvider.exec).toHaveBeenCalledWith(
['clone', '--', 'https://github.com/stablyai/orca.git', 'orca'],
'/home/user'
)
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
path: '/home/user/orca',
connectionId: 'conn-1',
kind: 'git',
displayName: 'orca',
badgeColor: DEFAULT_REPO_BADGE_COLOR,
externalWorktreeVisibility: 'hide',
externalWorktreeVisibilityLegacy: false
})
)
expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', {
rootPath: '/home/user/orca'
})
expect(result).toHaveProperty('path', '/home/user/orca')
expect(result).toHaveProperty('connectionId', 'conn-1')
})
it('returns an existing SSH repo instead of cloning the same target again', async () => {
const existing = {
id: 'existing-id',
path: '/home/user/orca',
connectionId: 'conn-1',
displayName: 'orca',
badgeColor: '#fff',
addedAt: 1000,
kind: 'git'
}
mockStore.getRepos.mockReturnValue([existing])
const result = await handlers.get('repos:cloneRemote')!(null, {
connectionId: 'conn-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/home/user'
})
expect(result).toBe(existing)
expect(mockGitProvider.exec).not.toHaveBeenCalled()
expect(mockStore.addRepo).not.toHaveBeenCalled()
})
it('upgrades an existing SSH folder repo after cloning into that path', async () => {
const existing = {
id: 'existing-folder',
path: '/home/user/orca',
connectionId: 'conn-1',
displayName: 'orca',
badgeColor: '#fff',
addedAt: 1000,
kind: 'folder'
}
const updated = { ...existing, kind: 'git' }
mockStore.getRepos.mockReturnValue([existing])
mockStore.updateRepo.mockReturnValue(updated)
const result = await handlers.get('repos:cloneRemote')!(null, {
connectionId: 'conn-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/home/user'
})
expect(mockGitProvider.exec).toHaveBeenCalledWith(
['clone', '--', 'https://github.com/stablyai/orca.git', 'orca'],
'/home/user'
)
expect(mockStore.updateRepo).toHaveBeenCalledWith('existing-folder', { kind: 'git' })
expect(mockStore.addRepo).not.toHaveBeenCalled()
expect(result).toBe(updated)
})
it('rejects SSH clone destinations that are not absolute host paths', async () => {
await expect(
handlers.get('repos:cloneRemote')!(null, {
connectionId: 'conn-1',
url: 'https://github.com/stablyai/orca.git',
destination: 'relative/path'
})
).rejects.toThrow('Clone destination must be an absolute path on the SSH host')
expect(mockGitProvider.exec).not.toHaveBeenCalled()
})
it('returns existing repo if same connectionId and path already added', async () => {
const existing = {
id: 'existing-id',
+82 -1
View File
@@ -22,15 +22,20 @@ import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
import { normalizeRepoBadgeColor } from '../../shared/repo-badge-color'
import { sanitizeRepoIcon } from '../../shared/repo-icon'
import { normalizeRepoSourceControlAiOverrides } from '../../shared/source-control-ai'
import {
isRuntimePathAbsolute,
normalizeRuntimePathForComparison,
relativePathInsideRoot
} from '../../shared/cross-platform-path'
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
import type { ChildProcess } from 'child_process'
import { access, mkdir, readdir, rm } from 'fs/promises'
import { gitExecFileAsync, gitSpawn } from '../git/runner'
import { isAbsolute, join, posix } from 'path'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import {
cleanupClaimedCloneTarget,
claimCloneTarget,
deriveCloneRepoNameFromUrl,
deriveValidatedClonePath,
getClonePathComparisonKey
} from '../git/repo-clone-path'
@@ -65,6 +70,7 @@ import type { RepoMethod } from '../../shared/telemetry-events'
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection'
import { parseExecutionHostId } from '../../shared/execution-host'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
// Why: `method` answers "which entry point did the user take?", not "what did
// they add?" — so the IPC the renderer invoked IS the method. We never send
@@ -277,6 +283,68 @@ function getRemoteRepoFolderName(remotePath: string): string {
return trimmed.split(/[\\/]/).at(-1) || remotePath
}
async function cloneRemoteRepo(
store: Store,
args: {
connectionId: string
url: string
destination: string
}
): Promise<Repo> {
const gitProvider = getSshGitProvider(args.connectionId)
if (!gitProvider) {
throw new Error(`SSH connection "${args.connectionId}" not found or not connected`)
}
const host = gitProvider.getHostPlatform?.()
if (!host) {
throw new Error('SSH host platform is unavailable. Reconnect the SSH target before cloning.')
}
const trimmedDestination = args.destination.trim()
if (!isRuntimePathAbsolute(trimmedDestination, host.pathFlavor)) {
throw new Error('Clone destination must be an absolute path on the SSH host')
}
const repoName = deriveCloneRepoNameFromUrl(args.url.trim())
const clonePath = joinRemotePath(host, trimmedDestination, repoName)
if (relativePathInsideRoot(trimmedDestination, clonePath) === null) {
throw new Error('Clone path must be inside the destination directory')
}
const clonePathKey = normalizeRuntimePathForComparison(clonePath)
const existing = store.getRepos().find((repo) => {
return (
repo.connectionId === args.connectionId &&
normalizeRuntimePathForComparison(repo.path) === clonePathKey
)
})
if (existing && !isFolderRepo(existing)) {
emitRepoAdded('clone_url', true)
return existing
}
// Why: the SSH relay exposes argv-based git execution, not a shell. Use the
// repo folder name as the target so git creates it inside the chosen parent.
await gitProvider.exec(['clone', '--', args.url.trim(), repoName], trimmedDestination)
if (existing && isFolderRepo(existing)) {
const updated = store.updateRepo(existing.id, { kind: 'git' })
if (updated) {
emitRepoAdded('clone_url', false)
getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', {
rootPath: clonePath
})
return updated
}
}
const result = await addRemoteRepoFromPath(store, {
connectionId: args.connectionId,
remotePath: clonePath,
kind: 'git'
})
if ('error' in result) {
throw new Error(result.error)
}
emitRepoAdded('clone_url', result.alreadyExisted)
return result.repo
}
type ActiveCloneMetadata = {
path: string
pathKey: string
@@ -642,6 +710,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.removeHandler('repos:pickDirectory')
ipcMain.removeHandler('repos:clone')
ipcMain.removeHandler('repos:cloneAbort')
ipcMain.removeHandler('repos:cloneRemote')
ipcMain.removeHandler('repos:getGitUsername')
ipcMain.removeHandler('repos:getBaseRefDefault')
ipcMain.removeHandler('repos:searchBaseRefs')
@@ -1559,6 +1628,18 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
)
ipcMain.handle(
'repos:cloneRemote',
async (
_event,
args: { connectionId: string; url: string; destination: string }
): Promise<Repo> => {
const repo = await cloneRemoteRepo(store, args)
notifyReposChanged(mainWindow)
return repo
}
)
ipcMain.handle('repos:getGitUsername', async (_event, args: { repoId: string }) => {
const repo = store.getRepo(args.repoId)
if (!repo || isFolderRepo(repo)) {
+10 -1
View File
@@ -21,6 +21,7 @@ import { JsonRpcErrorCode } from '../ssh/relay-protocol'
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
import type { CommitMessagePlan } from '../../shared/commit-message-plan'
import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation'
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
type NonInteractiveExecQueueEntry = {
started: boolean
@@ -56,7 +57,11 @@ export class SshGitProvider implements IGitProvider {
private nonInteractiveExecQueues = new Map<string, NonInteractiveExecQueueEntry[]>()
private loggedWorktreeIsCleanFallback = false
constructor(connectionId: string, mux: SshChannelMultiplexer) {
constructor(
connectionId: string,
mux: SshChannelMultiplexer,
private readonly hostPlatform: RemoteHostPlatform | null = null
) {
this.connectionId = connectionId
this.mux = mux
}
@@ -65,6 +70,10 @@ export class SshGitProvider implements IGitProvider {
return this.connectionId
}
getHostPlatform(): RemoteHostPlatform | null {
return this.hostPlatform
}
async getStatus(
worktreePath: string,
options?: { includeIgnored?: boolean }
+5 -1
View File
@@ -529,7 +529,11 @@ export class SshRelaySession {
)
registerSshFilesystemProvider(this.targetId, fsProvider)
const gitProvider = new SshGitProvider(this.targetId, mux)
const gitProvider = new SshGitProvider(
this.targetId,
mux,
this.remoteCliBridgeEnv?.hostPlatform ?? null
)
registerSshGitProvider(this.targetId, gitProvider)
this.wireUpPtyEvents(ptyProvider)
+1
View File
@@ -751,6 +751,7 @@ export type PreloadApi = {
pickFolder: () => Promise<string | null>
pickDirectory: () => Promise<string | null>
clone: (args: { url: string; destination: string }) => Promise<Repo>
cloneRemote: (args: { connectionId: string; url: string; destination: string }) => Promise<Repo>
cloneAbort: () => Promise<void>
// Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`.
addRemote: (args: {
+2
View File
@@ -468,6 +468,8 @@ const api = {
clone: (args) => ipcRenderer.invoke('repos:clone', args),
cloneRemote: (args) => ipcRenderer.invoke('repos:cloneRemote', args),
cloneAbort: () => ipcRenderer.invoke('repos:cloneAbort'),
onCloneProgress: (
@@ -456,8 +456,7 @@ export default function NewWorkspaceComposerCard({
setPendingSetupImporting(false)
}
}, [onProjectHostExistingFolderSetup, pendingSetupHostOption, pendingSetupKind, pendingSetupPath])
const canClonePendingSetup =
!!pendingSetupHostOption && !pendingSetupHostOption.hostId.startsWith('ssh:')
const canClonePendingSetup = !!pendingSetupHostOption
const handleCloneProjectHost = React.useCallback(async (): Promise<void> => {
if (!pendingSetupHostOption || !pendingCloneUrl.trim() || !pendingCloneDestination.trim()) {
return
@@ -37,6 +37,7 @@ const reposList = vi.fn()
const reposAdd = vi.fn()
const reposPickFolder = vi.fn()
const reposClone = vi.fn()
const reposCloneRemote = vi.fn()
const reposRemove = vi.fn()
const reposUpdate = vi.fn()
const reposReorder = vi.fn()
@@ -52,6 +53,7 @@ beforeEach(() => {
reposAdd.mockReset()
reposPickFolder.mockReset()
reposClone.mockReset()
reposCloneRemote.mockReset()
reposRemove.mockReset()
reposUpdate.mockReset()
reposReorder.mockReset()
@@ -69,6 +71,7 @@ beforeEach(() => {
list: reposList,
add: reposAdd,
clone: reposClone,
cloneRemote: reposCloneRemote,
pickFolder: reposPickFolder,
remove: reposRemove,
update: reposUpdate,
@@ -542,6 +545,61 @@ describe('repo slice runtime routing', () => {
})
})
it('clones a project on an SSH host before aligning it as a host setup', async () => {
const project: Project = {
id: 'project-1',
displayName: 'Project',
badgeColor: '#000',
sourceRepoIds: ['ssh-repo'],
createdAt: 1,
updatedAt: 1
}
const clonedRepo = { ...sshRepo, path: '/srv/project' }
const setup: ProjectHostSetup = {
id: clonedRepo.id,
projectId: project.id,
hostId: 'ssh:ssh-1',
repoId: clonedRepo.id,
path: clonedRepo.path,
displayName: clonedRepo.displayName,
setupState: 'ready',
setupMethod: 'cloned',
createdAt: 1,
updatedAt: 1
}
reposCloneRemote.mockResolvedValue(clonedRepo)
projectsSetupExistingFolder.mockResolvedValue({ project, setup, repo: clonedRepo })
const store = createTestStore()
await expect(
store.getState().setupProjectClone({
projectId: project.id,
hostId: 'ssh:ssh-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/srv',
displayName: 'Project'
})
).resolves.toEqual({
project,
setup,
repo: { ...clonedRepo, executionHostId: 'ssh:ssh-1' }
})
expect(reposCloneRemote).toHaveBeenCalledWith({
connectionId: 'ssh-1',
url: 'https://github.com/stablyai/orca.git',
destination: '/srv'
})
expect(projectsSetupExistingFolder).toHaveBeenCalledWith({
projectId: project.id,
hostId: 'ssh:ssh-1',
path: clonedRepo.path,
kind: 'git',
displayName: 'Project',
setupMethod: 'cloned'
})
})
it('keeps runtime ownership when a runtime repo is moved between groups', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-move',
+19 -18
View File
@@ -719,29 +719,30 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
setupProjectClone: async (args) => {
try {
const parsedHost = parseExecutionHostId(args.hostId)
if (parsedHost?.kind === 'ssh') {
throw new Error(
'Clone setup is not available for SSH hosts yet. Import an existing folder on that host instead.'
)
}
const target = getProjectSetupRuntimeTarget(args.hostId)
const repo =
target.kind === 'local'
? await window.api.repos.clone({
parsedHost?.kind === 'ssh'
? await window.api.repos.cloneRemote({
connectionId: parsedHost.targetId,
url: args.url,
destination: args.destination
})
: (
await callRuntimeRpc<{ repo: Repo }>(
target,
'repo.clone',
{
url: args.url,
destination: args.destination
},
{ timeoutMs: 10 * 60_000 }
)
).repo
: target.kind === 'local'
? await window.api.repos.clone({
url: args.url,
destination: args.destination
})
: (
await callRuntimeRpc<{ repo: Repo }>(
target,
'repo.clone',
{
url: args.url,
destination: args.destination
},
{ timeoutMs: 10 * 60_000 }
)
).repo
return await get().setupProjectExistingFolder({
projectId: args.projectId,
hostId: args.hostId,