1- import { prepareProjectSsh , waitForProjectSshReady } from "@effect-template/lib"
1+ import { type AppError , prepareProjectSsh , renderError , waitForProjectSshReady } from "@effect-template/lib"
2+ import { runCommandCapture } from "@effect-template/lib/shell/command-runner"
3+ import { parseInspectNetworkEntry } from "@effect-template/lib/shell/docker-inspect-parse"
4+ import { CommandFailedError } from "@effect-template/lib/shell/errors"
5+ import type { ProjectItem } from "@effect-template/lib/usecases/projects"
6+ import * as FileSystem from "@effect/platform/FileSystem"
27import * as ParseResult from "@effect/schema/ParseResult"
38import * as Schema from "@effect/schema/Schema"
49import { Effect , Either } from "effect"
@@ -56,6 +61,9 @@ const TerminalClientMessageSchema = Schema.parseJson(
5661
5762const nowIso = ( ) : string => new Date ( ) . toISOString ( )
5863
64+ const isAppError = ( value : unknown ) : value is AppError =>
65+ typeof value === "object" && value !== null && "_tag" in value
66+
5967const updateSession = (
6068 record : TerminalRecord ,
6169 patch : Partial < TerminalSession >
@@ -71,12 +79,100 @@ const toApiInternalError = (error: unknown): ApiInternalError =>
7179 error instanceof ApiInternalError
7280 ? error
7381 : new ApiInternalError ( {
74- message : describeUnknown ( error ) ,
82+ message : isAppError ( error ) ? renderError ( error ) : describeUnknown ( error ) ,
7583 cause : error
7684 } )
7785
86+ const normalizeSshKeyPermissions = ( sshKeyPath : string | null ) =>
87+ sshKeyPath === null
88+ ? Effect . void
89+ : FileSystem . FileSystem . pipe (
90+ Effect . flatMap ( ( fs ) => fs . chmod ( sshKeyPath , 0o600 ) . pipe ( Effect . orElseSucceed ( ( ) => void 0 ) ) )
91+ )
92+
93+ type ContainerNetworkEntry = {
94+ readonly ipAddress : string
95+ readonly name : string
96+ }
97+
98+ const dockerGitApiContainerName = ( ) : string => process . env [ "DOCKER_GIT_API_CONTAINER_NAME" ] ?. trim ( ) || "docker-git-api"
99+
100+ const parseContainerNetworkEntries = ( output : string ) : ReadonlyArray < ContainerNetworkEntry > =>
101+ output
102+ . trim ( )
103+ . split ( / \r ? \n / u)
104+ . flatMap ( ( line ) => parseInspectNetworkEntry ( line ) )
105+ . map ( ( [ name , ipAddress ] ) => ( { name, ipAddress } ) )
106+
107+ const selectReachableProjectNetwork = (
108+ entries : ReadonlyArray < ContainerNetworkEntry >
109+ ) : ContainerNetworkEntry | null =>
110+ entries . find ( ( entry ) => entry . name !== "bridge" ) ?? entries [ 0 ] ?? null
111+
112+ const inspectContainerNetworks = (
113+ containerName : string
114+ ) =>
115+ runCommandCapture (
116+ {
117+ cwd : process . cwd ( ) ,
118+ command : "docker" ,
119+ args : [
120+ "inspect" ,
121+ "-f" ,
122+ String . raw `{{range $k,$v := .NetworkSettings.Networks}}{{printf "%s=%s\n" $k $v.IPAddress}}{{end}}` ,
123+ containerName
124+ ]
125+ } ,
126+ [ 0 ] ,
127+ ( exitCode ) => new CommandFailedError ( { command : "docker inspect networks" , exitCode } )
128+ ) . pipe ( Effect . map ( parseContainerNetworkEntries ) )
129+
130+ const connectContainerToNetwork = (
131+ networkName : string ,
132+ containerName : string
133+ ) =>
134+ networkName === "bridge"
135+ ? Effect . void
136+ : runCommandCapture (
137+ {
138+ cwd : process . cwd ( ) ,
139+ command : "docker" ,
140+ args : [ "network" , "connect" , networkName , containerName ]
141+ } ,
142+ [ 0 ] ,
143+ ( exitCode ) => new CommandFailedError ( { command : `docker network connect ${ networkName } ` , exitCode } )
144+ ) . pipe (
145+ Effect . asVoid ,
146+ Effect . orElseSucceed ( ( ) => void 0 )
147+ )
148+
149+ const resolveControllerReachableProject = (
150+ projectItem : ProjectItem
151+ ) =>
152+ Effect . gen ( function * ( _ ) {
153+ const networkEntries = yield * _ ( inspectContainerNetworks ( projectItem . containerName ) . pipe ( Effect . orElseSucceed ( ( ) => [ ] ) ) )
154+ yield * _ (
155+ Effect . forEach (
156+ networkEntries . filter ( ( entry ) => entry . name !== "bridge" ) ,
157+ ( entry ) => connectContainerToNetwork ( entry . name , dockerGitApiContainerName ( ) ) ,
158+ { discard : true }
159+ )
160+ )
161+ const preferredNetwork = selectReachableProjectNetwork ( networkEntries )
162+ if ( preferredNetwork === null ) {
163+ return projectItem
164+ }
165+ return {
166+ ...projectItem ,
167+ ipAddress : preferredNetwork . ipAddress
168+ }
169+ } )
170+
78171const encodeServerMessage = ( message : TerminalServerMessage ) : string => JSON . stringify ( message )
79172
173+ const renderPreparedSshCommand = ( prepared : ReturnType < typeof prepareProjectSsh > ) : string =>
174+ [ prepared . command , ...prepared . args ] . join ( " " )
175+
80176const sendServerMessage = ( socket : WebSocket | null , message : TerminalServerMessage ) : void => {
81177 if ( socket === null || socket . readyState !== WebSocket . OPEN ) {
82178 return
@@ -196,7 +292,7 @@ const registerRecord = (
196292 createdAt : nowIso ( ) ,
197293 id : randomUUID ( ) ,
198294 projectId,
199- sshCommand : prepared . item . sshCommand ,
295+ sshCommand : renderPreparedSshCommand ( prepared ) ,
200296 status : "ready"
201297 }
202298 const record : TerminalRecord = {
@@ -225,7 +321,9 @@ export const createTerminalSession = (
225321 } )
226322 )
227323 const project = yield * _ ( upProject ( projectId , undefined , true ) )
228- const projectItem = yield * _ ( getProjectItemById ( projectId ) )
324+ const loadedProjectItem = yield * _ ( getProjectItemById ( projectId ) )
325+ const projectItem = yield * _ ( resolveControllerReachableProject ( loadedProjectItem ) )
326+ yield * _ ( normalizeSshKeyPermissions ( projectItem . sshKeyPath ) )
229327 yield * _ (
230328 Effect . sync ( ( ) => {
231329 emitProjectEvent ( projectId , "project.deployment.status" , {
0 commit comments