@@ -4,10 +4,11 @@ import {
44 LOCAL_EMULATOR_ADMIN_USER_ID ,
55 LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE ,
66 LOCAL_EMULATOR_OWNER_TEAM_ID ,
7- isLocalEmulatorOnboardingEnabledInConfig ,
87 isLocalEmulatorEnabled ,
8+ isLocalEmulatorOnboardingEnabledInConfig ,
99 readConfigFromFile ,
1010 resolveEmulatorPath ,
11+ writeConfigToFile ,
1112 writeShowOnboardingConfigToFile ,
1213} from "@/lib/local-emulator" ;
1314import { DEFAULT_BRANCH_ID , getSoleTenancyFromProjectBranch } from "@/lib/tenancies" ;
@@ -18,6 +19,7 @@ import {
1819 projectOnboardingStatusSchema ,
1920 projectOnboardingStatusValues ,
2021 type ProjectOnboardingStatus ,
22+ yupArray ,
2123 yupBoolean ,
2224 yupNumber ,
2325 yupObject ,
@@ -37,6 +39,14 @@ function isProjectOnboardingStatus(value: string): value is ProjectOnboardingSta
3739 return projectOnboardingStatusValues . some ( ( status ) => status === value ) ;
3840}
3941
42+ function deriveDisplayLabel ( absoluteFilePath : string ) : string {
43+ const base = path . basename ( absoluteFilePath ) ;
44+ if ( base . toLowerCase ( ) === "stack.config.ts" ) {
45+ return path . basename ( path . dirname ( absoluteFilePath ) ) || base ;
46+ }
47+ return base ;
48+ }
49+
4050async function assertLocalEmulatorOwnerTeamReadiness ( ) {
4151 const internalTenancy = await getSoleTenancyFromProjectBranch ( "internal" , DEFAULT_BRANCH_ID ) ;
4252 const internalPrisma = await getPrismaClientForTenancy ( internalTenancy ) ;
@@ -90,7 +100,7 @@ async function getOrCreateLocalEmulatorProjectId(absoluteFilePath: string): Prom
90100 update : { } ,
91101 create : {
92102 id : projectId ,
93- displayName : `Local Emulator: ${ path . basename ( absoluteFilePath ) || "Project" } ` ,
103+ displayName : `Local Emulator: ${ deriveDisplayLabel ( absoluteFilePath ) || "Project" } ` ,
94104 description : `Local emulator project for ${ absoluteFilePath } ` ,
95105 isProductionMode : false ,
96106 ownerTeamId : LOCAL_EMULATOR_OWNER_TEAM_ID ,
@@ -287,14 +297,30 @@ export const POST = createSmartRouteHandler({
287297 if ( ! isLocalEmulatorEnabled ( ) ) {
288298 throw new StatusError ( StatusError . BadRequest , LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE ) ;
289299 }
290- if ( ! path . isAbsolute ( req . body . absolute_file_path ) ) {
291- throw new StatusError ( StatusError . BadRequest , "absolute_file_path must be an absolute path." ) ;
300+ if ( ! path . posix . isAbsolute ( req . body . absolute_file_path ) ) {
301+ const looksWindows = path . win32 . isAbsolute ( req . body . absolute_file_path ) ;
302+ throw new StatusError (
303+ StatusError . BadRequest ,
304+ looksWindows
305+ ? "absolute_file_path must be a POSIX absolute path. The local emulator runs in a Linux VM and does not accept Windows-style paths. Use the in-VM path or run the emulator from WSL."
306+ : "absolute_file_path must be an absolute path." ,
307+ ) ;
292308 }
293309
294- const absoluteFilePath = path . resolve ( req . body . absolute_file_path ) ;
295- const resolvedFilePath = resolveEmulatorPath ( absoluteFilePath ) ;
310+ const inputPath = path . resolve ( req . body . absolute_file_path ) ;
311+ let inputStat ;
312+ try {
313+ inputStat = await fs . stat ( resolveEmulatorPath ( inputPath ) ) ;
314+ } catch {
315+ inputStat = undefined ;
316+ }
296317
297- // Validate file exists before creating a project
318+ const looksLikeConfigFile = / \. ( t s | j s | m j s ) $ / i. test ( inputPath ) ;
319+ const absoluteFilePath = ( inputStat ?. isDirectory ( ) || ( ! inputStat && ! looksLikeConfigFile ) )
320+ ? path . join ( inputPath , "stack.config.ts" )
321+ : inputPath ;
322+
323+ const resolvedFilePath = resolveEmulatorPath ( absoluteFilePath ) ;
298324 let fileExists : boolean ;
299325 try {
300326 await fs . access ( resolvedFilePath ) ;
@@ -303,7 +329,7 @@ export const POST = createSmartRouteHandler({
303329 fileExists = false ;
304330 }
305331 if ( ! fileExists ) {
306- throw new StatusError ( StatusError . BadRequest , `Config file not found: ${ absoluteFilePath } ` ) ;
332+ await writeConfigToFile ( absoluteFilePath , { } ) ;
307333 }
308334
309335 const fileContent = await fs . readFile ( resolvedFilePath , "utf-8" ) ;
@@ -335,3 +361,71 @@ export const POST = createSmartRouteHandler({
335361 } ;
336362 } ,
337363} ) ;
364+
365+ type LocalEmulatorProjectListRow = {
366+ projectId : string ,
367+ absoluteFilePath : string ,
368+ updatedAt : Date ,
369+ } ;
370+
371+ export const GET = createSmartRouteHandler ( {
372+ metadata : {
373+ hidden : true ,
374+ summary : "List recent local emulator projects" ,
375+ description : "Returns previously opened local emulator project mappings, most-recent first." ,
376+ tags : [ "Local Emulator" ] ,
377+ } ,
378+ request : yupObject ( {
379+ auth : yupObject ( {
380+ type : clientOrHigherAuthTypeSchema . defined ( ) ,
381+ project : yupObject ( {
382+ id : yupString ( ) . oneOf ( [ "internal" ] ) . defined ( ) ,
383+ } ) . defined ( ) ,
384+ } ) . defined ( ) ,
385+ method : yupString ( ) . oneOf ( [ "GET" ] ) . defined ( ) ,
386+ } ) ,
387+ response : yupObject ( {
388+ statusCode : yupNumber ( ) . oneOf ( [ 200 ] ) . defined ( ) ,
389+ bodyType : yupString ( ) . oneOf ( [ "json" ] ) . defined ( ) ,
390+ body : yupObject ( {
391+ projects : yupArray ( yupObject ( {
392+ project_id : yupString ( ) . defined ( ) ,
393+ absolute_file_path : yupString ( ) . defined ( ) ,
394+ display_name : yupString ( ) . defined ( ) ,
395+ } ) . defined ( ) ) . defined ( ) ,
396+ } ) . defined ( ) ,
397+ } ) ,
398+ handler : async ( ) => {
399+ if ( ! isLocalEmulatorEnabled ( ) ) {
400+ throw new StatusError ( StatusError . BadRequest , LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE ) ;
401+ }
402+
403+ const rows = await globalPrismaClient . $queryRaw < LocalEmulatorProjectListRow [ ] > ( Prisma . sql `
404+ SELECT "projectId", "absoluteFilePath", "updatedAt"
405+ FROM "LocalEmulatorProject"
406+ ORDER BY "updatedAt" DESC
407+ LIMIT 20
408+ ` ) ;
409+
410+ const projectIds = rows . map ( ( r ) => r . projectId ) ;
411+ const projects = projectIds . length > 0
412+ ? await globalPrismaClient . project . findMany ( {
413+ where : { id : { in : projectIds } } ,
414+ select : { id : true , displayName : true } ,
415+ } )
416+ : [ ] ;
417+ const displayNameById = new Map ( projects . map ( ( p ) => [ p . id , p . displayName ] ) ) ;
418+
419+ return {
420+ statusCode : 200 as const ,
421+ bodyType : "json" as const ,
422+ body : {
423+ projects : rows . map ( ( r ) => ( {
424+ project_id : r . projectId ,
425+ absolute_file_path : r . absoluteFilePath ,
426+ display_name : displayNameById . get ( r . projectId ) ?? deriveDisplayLabel ( r . absoluteFilePath ) ,
427+ } ) ) ,
428+ } ,
429+ } ;
430+ } ,
431+ } ) ;
0 commit comments