@@ -19,45 +19,24 @@ import {
1919import { collectAllNamedVolumes } from "./volumes.ts" ;
2020import type { ComposeData , ServiceDef } from "./types.ts" ;
2121
22- // ---------------------------------------------------------------------------
23- // Types
24- // ---------------------------------------------------------------------------
25-
2622export interface GenerateOptions {
27- /** Stack names to generate (undefined = all discovered). */
2823 stacks ?: string [ ] ;
29- /** Repository root path. */
24+ configStackNames ?: string [ ] ;
3025 repoRoot : string ;
31- /** Output directory for generated stacks (default: <repoRoot>/stacks). */
3226 outputDir ?: string ;
33- /** Whether this is a dry run (no files written). */
3427 dryRun ?: boolean ;
28+ network ?: string ;
3529}
3630
3731export interface GenerateResult {
38- /** Map of stack name -> YAML string content. */
3932 generated : Record < string , string > ;
40- /** Warnings encountered (non-fatal). */
4133 warnings : string [ ] ;
42- /** Errors encountered (non-fatal — some stacks may still succeed). */
4334 errors : string [ ] ;
44- /** Files that were (or would be) written. */
4535 files : string [ ] ;
4636}
4737
48- // ---------------------------------------------------------------------------
49- // Constants
50- // ---------------------------------------------------------------------------
51-
52- const NETWORK_NAME = "traefik-public" ;
53-
54- // ---------------------------------------------------------------------------
55- // Entry point
56- // ---------------------------------------------------------------------------
38+ const DEFAULT_NETWORK_NAME = "traefik-public" ;
5739
58- /**
59- * Generate canonical Swarm stack files from per-service Compose sources.
60- */
6140export async function generateStacks (
6241 options : GenerateOptions ,
6342) : Promise < GenerateResult > {
@@ -69,44 +48,46 @@ export async function generateStacks(
6948 files : [ ] ,
7049 } ;
7150
72- // 1. Discover all compose files
7351 const discovery = await discoverComposeFiles ( { repoRoot : options . repoRoot } ) ;
7452
7553 for ( const err of discovery . errors ) {
76- result . warnings . push ( ` Discovery error at ${ err . path } : ${ err . message } ` ) ;
54+ result . warnings . push ( " Discovery error at " + err . path + ": " + err . message ) ;
7755 }
7856
79- // 2. Determine which stacks to generate
80- const targetStacks = options . stacks ?? Object . keys ( discovery . stacks ) ;
57+ const targetStacks = options . stacks ??
58+ ( options . configStackNames && options . configStackNames . length > 0
59+ ? options . configStackNames
60+ : Object . keys ( discovery . stacks ) ) ;
8161
8262 if ( targetStacks . length === 0 ) {
8363 result . warnings . push ( "No stacks discovered" ) ;
8464 return result ;
8565 }
8666
87- // 3. Ensure output directory exists
8867 if ( ! options . dryRun ) {
8968 await ensureDir ( outputDir ) ;
9069 }
9170
92- // 4. Generate each stack
71+ const network = options . network || DEFAULT_NETWORK_NAME ;
72+
9373 for ( const stackName of targetStacks ) {
9474 try {
9575 const composePaths = discovery . stacks [ stackName ] ;
9676 if ( ! composePaths || composePaths . length === 0 ) {
97- result . errors . push ( ` No compose files found for stack "${ stackName } "` ) ;
77+ result . errors . push ( ' No compose files found for stack "' + stackName + '"' ) ;
9878 continue ;
9979 }
10080
10181 const output = await generateSingleStack (
10282 stackName ,
10383 composePaths ,
10484 options . repoRoot ,
85+ network ,
10586 ) ;
10687
10788 result . generated [ stackName ] = output ;
10889
109- const outPath = join ( outputDir , ` ${ stackName } .yml` ) ;
90+ const outPath = join ( outputDir , stackName + " .yml" ) ;
11091 if ( options . dryRun ) {
11192 result . files . push ( outPath ) ;
11293 } else {
@@ -115,24 +96,22 @@ export async function generateStacks(
11596 }
11697 } catch ( err : unknown ) {
11798 result . errors . push (
118- ` Stack "${ stackName } ": ${ err instanceof Error ? err . message : String ( err ) } ` ,
99+ ' Stack "' + stackName + ' ": ' + ( err instanceof Error ? err . message : String ( err ) ) ,
119100 ) ;
120101 }
121102 }
122103
123104 return result ;
124105}
125106
126- // ---------------------------------------------------------------------------
127- // Single-stack generation
128- // ---------------------------------------------------------------------------
129-
130107async function generateSingleStack (
131108 _stackName : string ,
132109 composePaths : string [ ] ,
133110 repoRoot : string ,
111+ network ?: string ,
134112) : Promise < string > {
135- // 1. Load all compose files + fragments
113+ const networkName = network || DEFAULT_NETWORK_NAME ;
114+
136115 const sources = await Promise . all (
137116 composePaths . map ( async ( path ) => {
138117 const composeDir = path . substring ( 0 , path . lastIndexOf ( "/" ) ) ;
@@ -142,60 +121,86 @@ async function generateSingleStack(
142121 } ) ,
143122 ) ;
144123
145- // 2. Merge: compose data + fragment per-source, then merge all into one
124+ const serviceDirMap = new Map < string , string > ( ) ;
125+ for ( const src of sources ) {
126+ if ( src . data . services ) {
127+ for ( const svcName of Object . keys ( src . data . services ) ) {
128+ if ( ! serviceDirMap . has ( svcName ) ) {
129+ serviceDirMap . set ( svcName , src . composeDir ) ;
130+ }
131+ }
132+ }
133+ if ( src . fragment . services ) {
134+ for ( const svcName of Object . keys ( src . fragment . services ) ) {
135+ if ( ! serviceDirMap . has ( svcName ) ) {
136+ serviceDirMap . set ( svcName , src . composeDir ) ;
137+ }
138+ }
139+ }
140+ }
141+
146142 let merged : ComposeData = { } ;
147143 for ( const src of sources ) {
148144 const combined = composeDeepMerge ( src . data , src . fragment ) ;
149145 merged = composeDeepMerge ( merged , combined ) ;
150146 }
151147
152- // 3. Transform services
153148 if ( merged . services ) {
154149 const transformed : Record < string , ServiceDef > = { } ;
155150 for ( const [ svcName , svc ] of Object . entries ( merged . services ) ) {
151+ const svcDir = serviceDirMap . get ( svcName ) ?? sources [ 0 ] ?. composeDir ?? "" ;
156152 let t = stripComposeOnlyKeys ( svc ) ;
157153 t = applyLoggingDefaults ( t ) ;
158- t = rewriteEnvFile ( t , sources [ 0 ] ?. composeDir ?? "" , repoRoot ) ;
159- t = rewriteBindMountPaths ( t , sources [ 0 ] ?. composeDir ?? "" , repoRoot ) ;
154+ t = rewriteEnvFile ( t , svcDir , repoRoot ) ;
155+ t = rewriteBindMountPaths ( t , svcDir , repoRoot ) ;
160156 transformed [ svcName ] = t ;
161157 }
162158 merged = { ...merged , services : transformed } ;
163159 }
164160
165- // 4. Collect named volumes
166161 const namedVolumes = collectAllNamedVolumes ( merged . services ) ;
167162
168- // 5. Assemble output structure
169163 const output : Record < string , unknown > = { } ;
170164
171- // Services
172165 if ( merged . services && Object . keys ( merged . services ) . length > 0 ) {
173- output . services = merged . services ;
166+ const svcs : Record < string , unknown > = { } ;
167+ for ( const key of Object . keys ( merged . services ) . sort ( ) ) {
168+ svcs [ key ] = merged . services [ key ] ;
169+ }
170+ output . services = svcs ;
174171 }
175172
176- // Networks
177173 output . networks = {
178174 default : {
179- name : NETWORK_NAME ,
175+ name : networkName ,
180176 external : true ,
181177 } ,
182178 } ;
183179
184- // Volumes (only if named volumes exist)
185180 if ( namedVolumes . length > 0 ) {
186181 const volumes : Record < string , unknown > = { } ;
187- for ( const name of namedVolumes ) {
188- volumes [ name ] = { external : true } ;
182+ const topLevelVolumes = ( merged . volumes ?? { } ) as Record < string , unknown > ;
183+ for ( const name of namedVolumes . sort ( ) ) {
184+ const existingDef = topLevelVolumes [ name ] ;
185+ if ( existingDef && typeof existingDef === "object" && existingDef !== null ) {
186+ const def = { ...( existingDef as Record < string , unknown > ) } ;
187+ if ( def . external === undefined ) {
188+ def . external = true ;
189+ }
190+ volumes [ name ] = def ;
191+ } else {
192+ volumes [ name ] = { external : true } ;
193+ }
189194 }
190195 output . volumes = volumes ;
191196 }
192197
193- // 6. Serialise to YAML
194198 const header = "# Generated by stackctl generate — do not edit manually.\n" ;
195199 const body = stringifyYaml ( output , {
196200 indent : 2 ,
197201 lineWidth : 120 ,
198- noRefs : true ,
199- } as Record < string , unknown > ) ;
202+ useAnchors : false ,
203+ sortKeys : true ,
204+ } ) ;
200205 return header + body ;
201206}
0 commit comments