@@ -530,44 +530,67 @@ export async function getStack(stackId: string): Promise<Stack> {
530
530
/**
531
531
* Updates a stack with new configuration and/or .env file
532
532
*/
533
- export async function updateStack ( stackId : string , updates : StackUpdate ) : Promise < Stack > {
534
- // stackId is dirName
535
- const stackDir = await getStackDir ( stackId ) ;
536
- let composePath = await getComposeFilePath ( stackId ) ; // May be null if only .env is being updated and compose doesn't exist yet (edge case)
533
+ export async function updateStack ( currentStackId : string , updates : StackUpdate ) : Promise < Stack > {
534
+ let effectiveStackId = currentStackId ;
535
+ let stackAfterRename : Stack | null = null ;
536
+
537
+ // 1. Handle potential rename first
538
+ if ( updates . name ) {
539
+ const newSlugifiedName = slugify ( updates . name , {
540
+ lower : true ,
541
+ strict : true ,
542
+ replacement : '-' ,
543
+ trim : true
544
+ } ) ;
537
545
538
- // If a name update is provided, it's ignored as the name is the directory name.
539
- if ( updates . name && updates . name !== stackId ) {
540
- console . warn ( `Updating stack name via 'updateStack' is not supported. Stack name is derived from directory: ${ stackId } .` ) ;
546
+ if ( newSlugifiedName !== currentStackId ) {
547
+ console . log ( `Rename requested for stack '${ currentStackId } ' to '${ updates . name } ' (slug: '${ newSlugifiedName } ').` ) ;
548
+ // renameStack will throw an error if the stack is running or if other rename conditions are not met.
549
+ stackAfterRename = await renameStack ( currentStackId , updates . name ) ;
550
+ effectiveStackId = stackAfterRename . id ;
551
+ console . log ( `Stack '${ currentStackId } ' successfully renamed to '${ effectiveStackId } '.` ) ;
552
+ } else {
553
+ // Name provided is the same as current, or slugifies to the same. No rename action needed.
554
+ console . log ( `Provided name '${ updates . name } ' is effectively the same as current stack ID '${ currentStackId } '. No rename action.` ) ;
555
+ }
541
556
}
542
557
543
- try {
544
- const promises = [ ] ;
558
+ // 2. Handle content updates (compose or .env)
559
+ // These updates will apply to the new directory if a rename occurred.
560
+ let contentUpdated = false ;
561
+ const stackDirForContent = await getStackDir ( effectiveStackId ) ;
545
562
546
- if ( updates . composeContent !== undefined ) {
547
- // Determine the correct compose file path to write to, defaulting to compose.yaml
548
- const targetComposePath = composePath || path . join ( stackDir , 'compose.yaml' ) ;
549
- promises . push ( fs . writeFile ( targetComposePath , updates . composeContent , 'utf8' ) ) ;
550
- composePath = targetComposePath ; // Update composePath if it was initially null
551
- }
563
+ const promises = [ ] ;
552
564
553
- if ( updates . envContent !== undefined ) {
554
- promises . push ( saveEnvFile ( stackId , updates . envContent ) ) ;
555
- }
565
+ if ( updates . composeContent !== undefined ) {
566
+ let currentComposePath = await getComposeFilePath ( effectiveStackId ) ; // Check existing, might be null
567
+ const targetComposePath = currentComposePath || path . join ( stackDirForContent , 'compose.yaml' ) ; // Default to compose.yaml if not found
556
568
557
- if ( promises . length === 0 && ! updates . name ) {
558
- // Check if any actual file update was requested
559
- console . warn ( `No content updates provided for stack ${ stackId } .` ) ;
560
- return getStack ( stackId ) ; // Return current stack data
561
- }
569
+ promises . push ( fs . writeFile ( targetComposePath , updates . composeContent , 'utf8' ) ) ;
570
+ contentUpdated = true ;
571
+ console . log ( `Updating composeContent for stack '${ effectiveStackId } '.` ) ;
572
+ }
562
573
574
+ if ( updates . envContent !== undefined ) {
575
+ promises . push ( saveEnvFile ( effectiveStackId , updates . envContent ) ) ;
576
+ contentUpdated = true ;
577
+ console . log ( `Updating envContent for stack '${ effectiveStackId } '.` ) ;
578
+ }
579
+
580
+ if ( promises . length > 0 ) {
563
581
await Promise . all ( promises ) ;
582
+ }
564
583
565
- // Re-fetch stack data to reflect changes and get updated modification times
566
- // This is simpler than trying to manually update parts of the Stack object.
567
- return getStack ( stackId ) ;
568
- } catch ( err ) {
569
- console . error ( `Error updating stack ${ stackId } :` , err ) ;
570
- throw new Error ( `Failed to update stack ${ stackId } ` ) ;
584
+ // 3. Return the final stack state
585
+ if ( stackAfterRename && ! contentUpdated ) {
586
+ // Only a rename occurred, no subsequent content changes in this call.
587
+ // The stackAfterRename object is fresh from renameStack's getStack call.
588
+ return stackAfterRename ;
589
+ } else {
590
+ // Content was updated, or no rename occurred but content might have.
591
+ // Or both rename and content update occurred.
592
+ // Fetch the latest stack details.
593
+ return getStack ( effectiveStackId ) ;
571
594
}
572
595
}
573
596
@@ -848,6 +871,83 @@ export async function removeStack(stackId: string): Promise<boolean> {
848
871
}
849
872
}
850
873
874
+ export async function renameStack ( currentStackId : string , newName : string ) : Promise < Stack > {
875
+ if ( ! currentStackId || ! newName ) {
876
+ throw new Error ( 'Current stack ID and new name must be provided.' ) ;
877
+ }
878
+
879
+ const currentStackDir = await getStackDir ( currentStackId ) ;
880
+ try {
881
+ await fs . access ( currentStackDir ) ; // Check if current stack directory exists
882
+ } catch ( e ) {
883
+ throw new Error ( `Stack with ID '${ currentStackId } ' not found at ${ currentStackDir } .` ) ;
884
+ }
885
+
886
+ // Slugify the new name to create a valid base for the directory name
887
+ const newDirBaseName = slugify ( newName , {
888
+ lower : true ,
889
+ strict : true ,
890
+ replacement : '-' ,
891
+ trim : true
892
+ } ) ;
893
+
894
+ if ( newDirBaseName === currentStackId ) {
895
+ throw new Error ( `The new name '${ newName } ' (resolves to '${ newDirBaseName } ') is effectively the same as the current stack ID '${ currentStackId } '. No changes made.` ) ;
896
+ }
897
+
898
+ // Check if the stack is running
899
+ const running = await isStackRunning ( currentStackId ) ;
900
+ if ( running ) {
901
+ throw new Error ( `Stack '${ currentStackId } ' is currently running. Please stop it before renaming.` ) ;
902
+ }
903
+
904
+ const stacksDir = await ensureStacksDir ( ) ;
905
+ let newUniqueDirName = newDirBaseName ;
906
+ let counter = 1 ;
907
+ const MAX_ATTEMPTS = 100 ; // Safety break for the loop
908
+
909
+ // Find a unique directory name that is not the currentStackId
910
+ while ( counter <= MAX_ATTEMPTS ) {
911
+ const pathToCheck = join ( stacksDir , newUniqueDirName ) ;
912
+ const exists = await directoryExists ( pathToCheck ) ;
913
+
914
+ if ( ! exists && newUniqueDirName !== currentStackId ) {
915
+ break ; // Found a suitable unique name
916
+ }
917
+
918
+ // If it exists or it's the same as currentStackId, generate a new one
919
+ newUniqueDirName = `${ newDirBaseName } -${ counter } ` ;
920
+ counter ++ ;
921
+ }
922
+
923
+ if ( counter > MAX_ATTEMPTS || newUniqueDirName === currentStackId || ( await directoryExists ( join ( stacksDir , newUniqueDirName ) ) ) ) {
924
+ // This means after MAX_ATTEMPTS, we couldn't find a suitable unique name
925
+ throw new Error ( `Could not generate a unique directory name for '${ newName } ' that is different from '${ currentStackId } ' and does not already exist. Please try a different name.` ) ;
926
+ }
927
+
928
+ const newStackDir = join ( stacksDir , newUniqueDirName ) ;
929
+
930
+ try {
931
+ console . log ( `Renaming stack directory from '${ currentStackDir } ' to '${ newStackDir } '...` ) ;
932
+ await fs . rename ( currentStackDir , newStackDir ) ;
933
+ console . log ( `Stack directory for '${ currentStackId } ' successfully renamed to '${ newUniqueDirName } '.` ) ;
934
+
935
+ // The stack was stopped. When it's started next using `startStack(newUniqueDirName)`,
936
+ // dockerode-compose will use `newUniqueDirName` as the project name,
937
+ // effectively creating a "new" project from Docker's perspective with the existing files.
938
+ // Old Docker resources (containers, networks, volumes) tied to `currentStackId` will remain
939
+ // until manually pruned or if they conflict and Docker handles it.
940
+
941
+ return await getStack ( newUniqueDirName ) ; // Return the stack info under its new ID/name
942
+ } catch ( err ) {
943
+ console . error ( `Error renaming stack directory for '${ currentStackId } ' to '${ newUniqueDirName } ':` , err ) ;
944
+ // If fs.rename fails, the original directory should ideally still be there.
945
+ // No complex rollback needed for fs.rename itself.
946
+ const errorMessage = err instanceof Error ? err . message : String ( err ) ;
947
+ throw new Error ( `Failed to rename stack: ${ errorMessage } ` ) ;
948
+ }
949
+ }
950
+
851
951
/**
852
952
* The function `discoverExternalStacks` asynchronously discovers external Docker stacks and their
853
953
* services, categorizing them based on their running status.
0 commit comments