1- import type { ProjectItem } from "@lib/usecases/projects"
1+ import { defaultTemplateConfig } from "@lib/core/domain"
2+ import { runDockerInspectContainerRuntimeInfo , type DockerContainerRuntimeInfo } from "@lib/shell/docker"
3+ import { buildSshCommand , connectProjectSsh , probeProjectSshReady , type ProjectItem } from "@lib/usecases/projects"
24import { Effect , pipe } from "effect"
35
46import type { OpenCommand } from "@lib/core/domain"
@@ -12,9 +14,16 @@ import { resolveApiProjectItem } from "./project-item.js"
1214
1315type OpenResolvedProjectSshDeps < E , R > = {
1416 readonly log : ( message : string ) => Effect . Effect < void , E , R >
17+ readonly resolvePreferredItem : ( item : ProjectItem ) => Effect . Effect < ProjectItem | null , E , R >
18+ readonly probeReady : ( item : ProjectItem ) => Effect . Effect < boolean , E , R >
19+ readonly connect : ( item : ProjectItem ) => Effect . Effect < void , E , R >
1520 readonly connectWithUp : ( item : ProjectItem ) => Effect . Effect < void , E , R >
1621}
1722
23+ type ResolveOpenProjectDeps < E , R > = {
24+ readonly inspectRuntime : ( containerName : string ) => Effect . Effect < DockerContainerRuntimeInfo | null , E , R >
25+ }
26+
1827const normalizeText = ( value : string ) : string => value . trim ( ) . toLowerCase ( )
1928
2029const normalizePath = ( value : string ) : string => {
@@ -96,6 +105,36 @@ const preferSingleRunning = (
96105 return running . length === 1 ? ( running [ 0 ] ?? null ) : null
97106}
98107
108+ const exactRuntimeMatches = (
109+ selector : string ,
110+ projects : ReadonlyArray < ApiProjectDetails >
111+ ) : ReadonlyArray < ApiProjectDetails > => {
112+ const normalizedSelector = normalizeText ( selector )
113+ if ( normalizedSelector . length === 0 ) {
114+ return [ ]
115+ }
116+ return projects . filter ( ( project ) =>
117+ normalizedSelector === normalizeText ( project . containerName ) ||
118+ normalizedSelector === normalizeText ( project . serviceName )
119+ )
120+ }
121+
122+ const resolvesExactRuntimeSelector = (
123+ selector : string ,
124+ projects : ReadonlyArray < ApiProjectDetails >
125+ ) : ApiProjectDetails | null => {
126+ if ( projects . length === 0 ) {
127+ return null
128+ }
129+
130+ const matches = exactRuntimeMatches ( selector , projects )
131+ if ( matches . length !== projects . length ) {
132+ return null
133+ }
134+
135+ return preferSingleRunning ( matches ) ?? matches [ 0 ] ?? null
136+ }
137+
99138const resolveUniqueProject = (
100139 matches : ReadonlyArray < ApiProjectDetails > ,
101140 notFoundMessage : string ,
@@ -162,6 +201,11 @@ export const selectOpenProject = (
162201 )
163202
164203 if ( directMatches . length > 0 ) {
204+ const exactRuntimeMatch = resolvesExactRuntimeSelector ( trimmed , directMatches )
205+ if ( exactRuntimeMatch !== null ) {
206+ return Effect . succeed ( exactRuntimeMatch )
207+ }
208+
165209 return resolveUniqueProject (
166210 directMatches ,
167211 `No docker-git project matched '${ trimmed } '.` ,
@@ -177,6 +221,45 @@ export const selectOpenProject = (
177221 )
178222}
179223
224+ const uniqueContainerNames = ( projects : ReadonlyArray < ApiProjectDetails > ) : ReadonlyArray < string > =>
225+ Array . from ( new Set ( projects . map ( ( project ) => project . containerName ) ) )
226+
227+ export const resolveRuntimeOwnedProject = < E , R > (
228+ projects : ReadonlyArray < ApiProjectDetails > ,
229+ selector : string | undefined ,
230+ deps : ResolveOpenProjectDeps < E , R >
231+ ) : Effect . Effect < ApiProjectDetails | null , E , R > =>
232+ Effect . gen ( function * ( _ ) {
233+ const trimmed = selector ?. trim ( ) ?? ""
234+ const matches = exactRuntimeMatches ( trimmed , projects )
235+ if ( matches . length === 0 ) {
236+ return null
237+ }
238+
239+ for ( const containerName of uniqueContainerNames ( matches ) ) {
240+ const runtime = yield * _ ( deps . inspectRuntime ( containerName ) )
241+ const ownerDir = runtime ?. projectWorkingDir
242+ if ( ownerDir === undefined ) {
243+ continue
244+ }
245+ const owner = matches . find ( ( project ) => normalizePath ( project . projectDir ) === normalizePath ( ownerDir ) )
246+ if ( owner !== undefined ) {
247+ return owner
248+ }
249+ }
250+
251+ return null
252+ } )
253+
254+ export const resolveOpenProjectEffect = < E , R > (
255+ projects : ReadonlyArray < ApiProjectDetails > ,
256+ selector : string | undefined ,
257+ deps : ResolveOpenProjectDeps < E , R >
258+ ) : Effect . Effect < ApiProjectDetails , ProjectResolutionError | E , R > =>
259+ resolveRuntimeOwnedProject ( projects , selector , deps ) . pipe (
260+ Effect . flatMap ( ( ownedProject ) => ownedProject === null ? selectOpenProject ( projects , selector ) : Effect . succeed ( ownedProject ) )
261+ )
262+
180263const listProjectDetails = ( ) =>
181264 Effect . gen ( function * ( _ ) {
182265 const summaries = yield * _ ( listProjects ( ) )
@@ -190,20 +273,96 @@ const listProjectDetails = () =>
190273 return details . filter ( ( project ) : project is ApiProjectDetails => project !== null )
191274 } )
192275
276+ const withProjectItemIpAddress = (
277+ item : ProjectItem ,
278+ ipAddress : string
279+ ) : ProjectItem => ( {
280+ ...item ,
281+ ipAddress,
282+ sshCommand : buildSshCommand (
283+ {
284+ ...defaultTemplateConfig ,
285+ containerName : item . containerName ,
286+ serviceName : item . serviceName ,
287+ sshUser : item . sshUser ,
288+ sshPort : item . sshPort ,
289+ repoUrl : item . repoUrl ,
290+ repoRef : item . repoRef ,
291+ targetDir : item . targetDir ,
292+ envGlobalPath : item . envGlobalPath ,
293+ envProjectPath : item . envProjectPath ,
294+ codexAuthPath : item . codexAuthPath ,
295+ codexSharedAuthPath : item . codexAuthPath ,
296+ codexHome : item . codexHome ,
297+ clonedOnHostname : item . clonedOnHostname
298+ } ,
299+ item . sshKeyPath ,
300+ ipAddress
301+ )
302+ } )
303+
304+ const sameConnectionTarget = ( left : ProjectItem , right : ProjectItem ) : boolean =>
305+ left . ipAddress === right . ipAddress &&
306+ left . sshPort === right . sshPort &&
307+ left . sshKeyPath === right . sshKeyPath &&
308+ left . sshUser === right . sshUser
309+
310+ const attemptDirectConnect = < E , R > (
311+ item : ProjectItem ,
312+ deps : Pick < OpenResolvedProjectSshDeps < E , R > , "connect" | "log" | "probeReady" >
313+ ) : Effect . Effect < boolean , E , R > =>
314+ deps . probeReady ( item ) . pipe (
315+ Effect . flatMap ( ( ready ) =>
316+ ready
317+ ? pipe (
318+ deps . log ( `Opening SSH: ${ item . sshCommand } ` ) ,
319+ Effect . zipRight ( deps . connect ( item ) ) ,
320+ Effect . as ( true )
321+ )
322+ : Effect . succeed ( false )
323+ )
324+ )
325+
193326export const openResolvedProjectSshEffect = < E , R > (
194327 item : ProjectItem ,
195328 deps : OpenResolvedProjectSshDeps < E , R >
196329) =>
197- pipe (
198- deps . log ( `Opening SSH: ${ item . sshCommand } ` ) ,
199- Effect . zipRight ( deps . connectWithUp ( item ) )
200- )
330+ Effect . gen ( function * ( _ ) {
331+ const preferredItem = yield * _ ( deps . resolvePreferredItem ( item ) )
332+ if ( preferredItem !== null ) {
333+ const connected = yield * _ ( attemptDirectConnect ( preferredItem , deps ) )
334+ if ( connected ) {
335+ return
336+ }
337+ }
338+
339+ const shouldRetryOriginal = preferredItem === null || ! sameConnectionTarget ( preferredItem , item )
340+ if ( shouldRetryOriginal ) {
341+ const connected = yield * _ ( attemptDirectConnect ( item , deps ) )
342+ if ( connected ) {
343+ return
344+ }
345+ }
346+
347+ yield * _ ( deps . log ( `Opening SSH: ${ item . sshCommand } ` ) )
348+ yield * _ ( deps . connectWithUp ( item ) )
349+ } )
201350
202351export const openResolvedProjectSsh = (
203352 item : ProjectItem
204353) =>
205354 openResolvedProjectSshEffect ( item , {
206355 log : ( message ) => Effect . log ( message ) ,
356+ resolvePreferredItem : ( selected ) =>
357+ runDockerInspectContainerRuntimeInfo ( process . cwd ( ) , selected . containerName ) . pipe (
358+ Effect . map ( ( runtime ) =>
359+ runtime !== null && runtime . ipAddress . length > 0
360+ ? withProjectItemIpAddress ( selected , runtime . ipAddress )
361+ : null
362+ )
363+ ) ,
364+ probeReady : ( selected ) => probeProjectSshReady ( selected ) ,
365+ connect : ( selected ) => connectProjectSsh ( selected ) ,
207366 connectWithUp : ( selected ) => connectMenuProjectSshWithUp ( selected )
208367 } )
209368
@@ -212,7 +371,12 @@ export const openExistingProjectSsh = (
212371) =>
213372 Effect . gen ( function * ( _ ) {
214373 const projects = yield * _ ( listProjectDetails ( ) )
215- const project = yield * _ ( selectOpenProject ( projects , command . projectDir ?? command . projectRef ) )
374+ const selector = command . projectDir ?? command . projectRef
375+ const project = yield * _ (
376+ resolveOpenProjectEffect ( projects , selector , {
377+ inspectRuntime : ( containerName ) => runDockerInspectContainerRuntimeInfo ( process . cwd ( ) , containerName )
378+ } )
379+ )
216380 const item = yield * _ ( resolveApiProjectItem ( project ) )
217381 yield * _ ( openResolvedProjectSsh ( item ) )
218382 } )
0 commit comments