@@ -7,21 +7,29 @@ import * as FileSystem from "@effect/platform/FileSystem"
77import * as ParseResult from "@effect/schema/ParseResult"
88import * as Schema from "@effect/schema/Schema"
99import { Effect , Either } from "effect"
10+ import { Buffer } from "node:buffer"
11+ import { spawn } from "node:child_process"
1012import { randomUUID } from "node:crypto"
1113import type { IncomingMessage , Server as HttpServer } from "node:http"
1214import type { Duplex } from "node:stream"
1315import { WebSocket , WebSocketServer , type RawData } from "ws"
1416
1517import type { TerminalSession , TerminalSessionStatus } from "../api/contracts.js"
1618import { ApiConflictError , ApiInternalError , ApiNotFoundError , describeUnknown } from "../api/errors.js"
17- import { spawnPtyBridge , type PtyBridge } from "./pty-bridge.js"
1819import { emitProjectEvent } from "./events.js"
20+ import {
21+ createTerminalImagePastePlan ,
22+ terminalImagePasteDirectory ,
23+ type TerminalImagePastePayload
24+ } from "./terminal-image-paste-core.js"
25+ import { spawnPtyBridge , type PtyBridge } from "./pty-bridge.js"
1926import { getProjectItemById , upProject } from "./projects.js"
2027import { attachWebSocketHeartbeat } from "./websocket-heartbeat.js"
2128
2229type TerminalClientMessage =
2330 | { readonly type : "input" ; readonly data : string }
2431 | { readonly type : "resize" ; readonly cols : number ; readonly rows : number }
32+ | ( { readonly type : "image" } & TerminalImagePastePayload )
2533 | { readonly type : "close" }
2634
2735type TerminalServerMessage =
@@ -36,6 +44,7 @@ type TerminalRecord = {
3644 socket : WebSocket | null
3745 attachTimeout : ReturnType < typeof setTimeout > | null
3846 detachTimeout : ReturnType < typeof setTimeout > | null
47+ projectContainerName : string
3948 projectId : string
4049 prepared : ReturnType < typeof prepareProjectSsh >
4150}
@@ -56,6 +65,13 @@ const TerminalClientMessageSchema = Schema.parseJson(
5665 cols : Schema . Number ,
5766 rows : Schema . Number
5867 } ) ,
68+ Schema . Struct ( {
69+ type : Schema . Literal ( "image" ) ,
70+ data : Schema . String ,
71+ mediaType : Schema . String ,
72+ name : Schema . String ,
73+ size : Schema . Number
74+ } ) ,
5975 Schema . Struct ( {
6076 type : Schema . Literal ( "close" )
6177 } )
@@ -264,6 +280,118 @@ const writePtyInput = (pty: PtyBridge | null, data: string): void => {
264280 }
265281}
266282
283+ const shellQuote = ( value : string ) : string => `'${ value . replace ( / ' / gu, "'\\''" ) } '`
284+
285+ const writeBufferToProjectContainer = (
286+ containerName : string ,
287+ containerPath : string ,
288+ buffer : Buffer
289+ ) : Effect . Effect < void , ApiInternalError > =>
290+ Effect . async ( ( resume ) => {
291+ const child = spawn (
292+ "docker" ,
293+ [
294+ "exec" ,
295+ "-i" ,
296+ "-u" ,
297+ "dev" ,
298+ containerName ,
299+ "bash" ,
300+ "--noprofile" ,
301+ "--norc" ,
302+ "-c" ,
303+ `mkdir -p ${ shellQuote ( terminalImagePasteDirectory ) } && cat > ${ shellQuote ( containerPath ) } `
304+ ] ,
305+ {
306+ cwd : process . cwd ( ) ,
307+ stdio : [ "pipe" , "ignore" , "pipe" ]
308+ }
309+ )
310+ const stderrChunks : Array < Buffer > = [ ]
311+ let completed = false
312+ const resumeOnce = ( effect : Effect . Effect < void , ApiInternalError > ) : void => {
313+ if ( completed ) {
314+ return
315+ }
316+ completed = true
317+ resume ( effect )
318+ }
319+ child . stderr . on ( "data" , ( chunk : Buffer | string ) => {
320+ stderrChunks . push ( Buffer . isBuffer ( chunk ) ? chunk : Buffer . from ( chunk ) )
321+ } )
322+ child . stdin . on ( "error" , ( error ) => {
323+ resumeOnce ( Effect . fail ( new ApiInternalError ( {
324+ message : `Failed to write pasted image to ${ containerName } .` ,
325+ cause : error
326+ } ) ) )
327+ } )
328+ child . on ( "error" , ( error ) => {
329+ resumeOnce ( Effect . fail ( new ApiInternalError ( {
330+ message : `Failed to run docker exec for ${ containerName } .` ,
331+ cause : error
332+ } ) ) )
333+ } )
334+ child . on ( "close" , ( exitCode ) => {
335+ if ( exitCode === 0 ) {
336+ resumeOnce ( Effect . void )
337+ return
338+ }
339+ const stderr = Buffer . concat ( stderrChunks ) . toString ( "utf8" ) . trim ( )
340+ resumeOnce ( Effect . fail ( new ApiInternalError ( {
341+ message : stderr . length > 0
342+ ? `Failed to save pasted image: ${ stderr } `
343+ : `Failed to save pasted image; docker exec exited with code ${ exitCode ?? "unknown" } .`
344+ } ) ) )
345+ } )
346+ child . stdin . end ( buffer )
347+ } )
348+
349+ const saveTerminalImagePaste = (
350+ record : TerminalRecord ,
351+ payload : TerminalImagePastePayload
352+ ) : Effect . Effect < string , ApiInternalError > =>
353+ Effect . gen ( function * ( _ ) {
354+ const plan = createTerminalImagePastePlan ( payload , randomUUID ( ) )
355+ if ( plan . _tag === "InvalidTerminalImagePaste" ) {
356+ return yield * _ ( Effect . fail ( new ApiInternalError ( { message : plan . message } ) ) )
357+ }
358+ const bytes = Buffer . from ( plan . normalizedBase64 , "base64" )
359+ if ( bytes . length !== plan . decodedBytes ) {
360+ return yield * _ ( Effect . fail ( new ApiInternalError ( { message : "Decoded image size changed during upload." } ) ) )
361+ }
362+ yield * _ ( writeBufferToProjectContainer ( record . projectContainerName , plan . containerPath , bytes ) )
363+ return plan . containerPath
364+ } )
365+
366+ const terminalOutputLine = ( line : string ) : string => `\r\n[docker-git] ${ line } \r\n`
367+
368+ const handleImagePasteMessage = (
369+ record : TerminalRecord ,
370+ message : Extract < TerminalClientMessage , { readonly type : "image" } >
371+ ) : void => {
372+ Effect . runFork (
373+ saveTerminalImagePaste ( record , message ) . pipe (
374+ Effect . tap ( ( containerPath ) =>
375+ Effect . sync ( ( ) => {
376+ sendServerMessage ( record . socket , {
377+ type : "output" ,
378+ data : terminalOutputLine ( `Pasted image saved: ${ containerPath } ` )
379+ } )
380+ writePtyInput ( record . pty , `${ containerPath } ` )
381+ } )
382+ ) ,
383+ Effect . catchAll ( ( error ) =>
384+ Effect . sync ( ( ) => {
385+ sendServerMessage ( record . socket , {
386+ type : "output" ,
387+ data : terminalOutputLine ( `Failed to paste image: ${ error . message } ` )
388+ } )
389+ } )
390+ )
391+ )
392+ )
393+ }
394+
267395const resizePty = ( pty : PtyBridge | null , cols : number , rows : number ) : void => {
268396 if ( pty === null ) {
269397 return
@@ -321,7 +449,8 @@ const createAttachTimeout = (sessionId: string): ReturnType<typeof setTimeout> =
321449
322450const registerRecord = (
323451 projectId : string ,
324- prepared : ReturnType < typeof prepareProjectSsh >
452+ prepared : ReturnType < typeof prepareProjectSsh > ,
453+ projectContainerName : string
325454) : TerminalSession => {
326455 const session : TerminalSession = {
327456 createdAt : nowIso ( ) ,
@@ -334,6 +463,7 @@ const registerRecord = (
334463 attachTimeout : null ,
335464 detachTimeout : null ,
336465 prepared,
466+ projectContainerName,
337467 projectId,
338468 pty : null ,
339469 session,
@@ -378,7 +508,7 @@ export const createTerminalSession = (
378508 } )
379509 )
380510 const prepared = prepareProjectSsh ( projectItem )
381- const session = registerRecord ( projectId , prepared )
511+ const session = registerRecord ( projectId , prepared , projectItem . containerName )
382512 yield * _ (
383513 Effect . sync ( ( ) => {
384514 emitProjectEvent ( projectId , "project.ssh.session" , {
@@ -451,6 +581,10 @@ const handleSocketMessage = (record: TerminalRecord, raw: RawData): void => {
451581 resizePty ( record . pty , clampTerminalSize ( message . cols , 120 ) , clampTerminalSize ( message . rows , 32 ) )
452582 return
453583 }
584+ if ( message . type === "image" ) {
585+ handleImagePasteMessage ( record , message )
586+ return
587+ }
454588 handleCloseMessage ( record )
455589}
456590
0 commit comments