@@ -351,6 +351,12 @@ export default class Maestro {
351351 // Determine base directory for zip structure
352352 // If we have a single directory, use it as base; otherwise use common ancestor or flatten
353353 const baseDir = baseDirs . length === 1 ? baseDirs [ 0 ] : undefined ;
354+
355+ // Log files being included in the zip
356+ if ( ! this . options . quiet ) {
357+ this . logIncludedFiles ( allFlowFiles , baseDir ) ;
358+ }
359+
354360 zipPath = await this . createFlowsZip ( allFlowFiles , baseDir ) ;
355361 shouldCleanup = true ;
356362
@@ -416,81 +422,47 @@ export default class Maestro {
416422 dependencies . forEach ( ( dep ) => allFiles . add ( dep ) ) ;
417423 }
418424
425+ // Include config.yaml if it exists
426+ if ( config ) {
427+ allFiles . add ( configPath ) ;
428+ }
429+
419430 return Array . from ( allFiles ) ;
420431 }
421432
422433 private async discoverDependencies (
423434 flowFile : string ,
424435 baseDir : string ,
436+ visited : Set < string > = new Set ( ) ,
425437 ) : Promise < string [ ] > {
438+ // Normalize path to handle different relative path references to same file
439+ const normalizedFlowFile = path . resolve ( flowFile ) ;
440+
441+ // Prevent circular dependencies
442+ if ( visited . has ( normalizedFlowFile ) ) {
443+ return [ ] ;
444+ }
445+ visited . add ( normalizedFlowFile ) ;
446+
426447 const dependencies : string [ ] = [ ] ;
427448
428449 try {
429450 const content = await fs . promises . readFile ( flowFile , 'utf-8' ) ;
430- const flowData = yaml . load ( content ) ;
431-
432- if ( Array . isArray ( flowData ) ) {
433- for ( const step of flowData ) {
434- if ( typeof step === 'object' && step !== null ) {
435- // Check for runFlow
436- if ( 'runFlow' in step ) {
437- const runFlowValue = step . runFlow ;
438- const refFile =
439- typeof runFlowValue === 'string'
440- ? runFlowValue
441- : runFlowValue ?. file ;
442- if ( refFile ) {
443- const depPath = path . resolve ( path . dirname ( flowFile ) , refFile ) ;
444- if (
445- ( await fs . promises . access ( depPath ) . catch ( ( ) => false ) ) ===
446- undefined
447- ) {
448- dependencies . push ( depPath ) ;
449- const nestedDeps = await this . discoverDependencies (
450- depPath ,
451- baseDir ,
452- ) ;
453- dependencies . push ( ...nestedDeps ) ;
454- }
455- }
456- }
457- // Check for runScript
458- if ( 'runScript' in step ) {
459- const scriptFile = step . runScript ?. file ;
460- if ( scriptFile ) {
461- const depPath = path . resolve (
462- path . dirname ( flowFile ) ,
463- scriptFile ,
464- ) ;
465- if (
466- ( await fs . promises . access ( depPath ) . catch ( ( ) => false ) ) ===
467- undefined
468- ) {
469- dependencies . push ( depPath ) ;
470- }
471- }
472- }
473- // Check for addMedia
474- if ( 'addMedia' in step ) {
475- const mediaFiles = Array . isArray ( step . addMedia )
476- ? step . addMedia
477- : [ step . addMedia ] ;
478- for ( const mediaFile of mediaFiles ) {
479- if ( typeof mediaFile === 'string' ) {
480- const depPath = path . resolve (
481- path . dirname ( flowFile ) ,
482- mediaFile ,
483- ) ;
484- if (
485- ( await fs . promises . access ( depPath ) . catch ( ( ) => false ) ) ===
486- undefined
487- ) {
488- dependencies . push ( depPath ) ;
489- }
490- }
491- }
492- }
493- }
451+
452+ // Maestro YAML files can have front matter (metadata) followed by ---
453+ // and then the actual flow steps. Use loadAll to handle both cases.
454+ const documents : unknown [ ] = [ ] ;
455+ yaml . loadAll ( content , ( doc ) => documents . push ( doc ) ) ;
456+
457+ for ( const flowData of documents ) {
458+ if ( flowData !== null && typeof flowData === 'object' ) {
459+ const deps = await this . extractPathsFromValue (
460+ flowData ,
461+ flowFile ,
462+ baseDir ,
463+ visited ,
464+ ) ;
465+ dependencies . push ( ...deps ) ;
494466 }
495467 }
496468 } catch {
@@ -500,6 +472,242 @@ export default class Maestro {
500472 return dependencies ;
501473 }
502474
475+ /**
476+ * Check if a string looks like a file path (relative path with extension)
477+ */
478+ private looksLikePath ( value : string ) : boolean {
479+ // Must be a relative path (starts with . or contains /)
480+ const isRelative = value . startsWith ( './' ) || value . startsWith ( '../' ) ;
481+ const hasPathSeparator = value . includes ( '/' ) ;
482+
483+ // Must have a file extension
484+ const hasExtension = / \. [ a - z A - Z 0 - 9 ] + $ / . test ( value ) ;
485+
486+ // Exclude URLs
487+ const isUrl =
488+ value . startsWith ( 'http://' ) ||
489+ value . startsWith ( 'https://' ) ||
490+ value . startsWith ( 'file://' ) ;
491+
492+ // Exclude template variables that are just ${...}
493+ const isOnlyVariable = / ^ \$ \{ [ ^ } ] + \} $ / . test ( value ) ;
494+
495+ return ( isRelative || hasPathSeparator ) && hasExtension && ! isUrl && ! isOnlyVariable ;
496+ }
497+
498+ /**
499+ * Try to add a file path as a dependency if it exists
500+ */
501+ private async tryAddDependency (
502+ filePath : string ,
503+ flowFile : string ,
504+ baseDir : string ,
505+ dependencies : string [ ] ,
506+ visited : Set < string > ,
507+ ) : Promise < void > {
508+ const depPath = path . resolve ( path . dirname ( flowFile ) , filePath ) ;
509+
510+ // Check if already added (handles deduplication for non-YAML files)
511+ // YAML files are tracked by discoverDependencies to handle circular refs
512+ if ( visited . has ( depPath ) ) {
513+ return ;
514+ }
515+
516+ if (
517+ ( await fs . promises . access ( depPath ) . catch ( ( ) => false ) ) === undefined
518+ ) {
519+ dependencies . push ( depPath ) ;
520+
521+ // If it's a YAML file, recursively discover its dependencies
522+ // discoverDependencies will add it to visited to prevent circular refs
523+ const ext = path . extname ( depPath ) . toLowerCase ( ) ;
524+ if ( ext === '.yaml' || ext === '.yml' ) {
525+ const nestedDeps = await this . discoverDependencies ( depPath , baseDir , visited ) ;
526+ dependencies . push ( ...nestedDeps ) ;
527+ } else {
528+ // For non-YAML files, add to visited here to prevent duplicates
529+ visited . add ( depPath ) ;
530+ }
531+ }
532+ }
533+
534+ /**
535+ * Recursively extract file paths from any value in the YAML structure
536+ */
537+ private async extractPathsFromValue (
538+ value : unknown ,
539+ flowFile : string ,
540+ baseDir : string ,
541+ visited : Set < string > ,
542+ ) : Promise < string [ ] > {
543+ const dependencies : string [ ] = [ ] ;
544+
545+ if ( typeof value === 'string' ) {
546+ // Check if this string looks like a file path
547+ if ( this . looksLikePath ( value ) ) {
548+ await this . tryAddDependency ( value , flowFile , baseDir , dependencies , visited ) ;
549+ }
550+ } else if ( Array . isArray ( value ) ) {
551+ // Recursively check array elements
552+ for ( const item of value ) {
553+ const deps = await this . extractPathsFromValue ( item , flowFile , baseDir , visited ) ;
554+ dependencies . push ( ...deps ) ;
555+ }
556+ } else if ( value !== null && typeof value === 'object' ) {
557+ const obj = value as Record < string , unknown > ;
558+
559+ // Track which keys we've handled specially to avoid double-processing
560+ const handledKeys = new Set < string > ( ) ;
561+
562+ // Handle known Maestro commands that reference files
563+ // These should always be treated as file paths, even without path separators
564+
565+ // runScript: can be string or { file: "..." }
566+ if ( 'runScript' in obj ) {
567+ handledKeys . add ( 'runScript' ) ;
568+ const runScript = obj . runScript ;
569+ const scriptFile =
570+ typeof runScript === 'string'
571+ ? runScript
572+ : ( runScript as Record < string , unknown > ) ?. file ;
573+ if ( typeof scriptFile === 'string' ) {
574+ await this . tryAddDependency ( scriptFile , flowFile , baseDir , dependencies , visited ) ;
575+ }
576+ }
577+
578+ // runFlow: can be string or { file: "...", commands: [...] }
579+ if ( 'runFlow' in obj ) {
580+ handledKeys . add ( 'runFlow' ) ;
581+ const runFlow = obj . runFlow ;
582+ const flowRef =
583+ typeof runFlow === 'string'
584+ ? runFlow
585+ : ( runFlow as Record < string , unknown > ) ?. file ;
586+ if ( typeof flowRef === 'string' ) {
587+ await this . tryAddDependency ( flowRef , flowFile , baseDir , dependencies , visited ) ;
588+ }
589+ // Recurse into runFlow for inline commands
590+ if ( typeof runFlow === 'object' && runFlow !== null ) {
591+ const deps = await this . extractPathsFromValue ( runFlow , flowFile , baseDir , visited ) ;
592+ dependencies . push ( ...deps ) ;
593+ }
594+ }
595+
596+ // addMedia: can be string or array of strings
597+ if ( 'addMedia' in obj ) {
598+ handledKeys . add ( 'addMedia' ) ;
599+ const addMedia = obj . addMedia ;
600+ const mediaFiles = Array . isArray ( addMedia ) ? addMedia : [ addMedia ] ;
601+ for ( const mediaFile of mediaFiles ) {
602+ if ( typeof mediaFile === 'string' ) {
603+ await this . tryAddDependency ( mediaFile , flowFile , baseDir , dependencies , visited ) ;
604+ }
605+ }
606+ }
607+
608+ // onFlowStart: array of commands in frontmatter
609+ if ( 'onFlowStart' in obj ) {
610+ handledKeys . add ( 'onFlowStart' ) ;
611+ const onFlowStart = obj . onFlowStart ;
612+ if ( Array . isArray ( onFlowStart ) ) {
613+ const deps = await this . extractPathsFromValue ( onFlowStart , flowFile , baseDir , visited ) ;
614+ dependencies . push ( ...deps ) ;
615+ }
616+ }
617+
618+ // onFlowComplete: array of commands in frontmatter
619+ if ( 'onFlowComplete' in obj ) {
620+ handledKeys . add ( 'onFlowComplete' ) ;
621+ const onFlowComplete = obj . onFlowComplete ;
622+ if ( Array . isArray ( onFlowComplete ) ) {
623+ const deps = await this . extractPathsFromValue ( onFlowComplete , flowFile , baseDir , visited ) ;
624+ dependencies . push ( ...deps ) ;
625+ }
626+ }
627+
628+ // Generic handling for any command with nested 'commands' array
629+ // This covers repeat, retry, doubleTapOn, longPressOn, and any future commands
630+ // that use the commands pattern
631+ if ( 'commands' in obj ) {
632+ handledKeys . add ( 'commands' ) ;
633+ const commands = obj . commands ;
634+ if ( Array . isArray ( commands ) ) {
635+ const deps = await this . extractPathsFromValue ( commands , flowFile , baseDir , visited ) ;
636+ dependencies . push ( ...deps ) ;
637+ }
638+ }
639+
640+ // Generic handling for 'file' property in any command (e.g., retry: { file: ... })
641+ if ( 'file' in obj && typeof obj . file === 'string' ) {
642+ handledKeys . add ( 'file' ) ;
643+ await this . tryAddDependency ( obj . file , flowFile , baseDir , dependencies , visited ) ;
644+ }
645+
646+ // Recursively check remaining object properties for nested structures
647+ for ( const [ key , propValue ] of Object . entries ( obj ) ) {
648+ if ( ! handledKeys . has ( key ) ) {
649+ const deps = await this . extractPathsFromValue (
650+ propValue ,
651+ flowFile ,
652+ baseDir ,
653+ visited ,
654+ ) ;
655+ dependencies . push ( ...deps ) ;
656+ }
657+ }
658+ }
659+
660+ return dependencies ;
661+ }
662+
663+ private logIncludedFiles ( files : string [ ] , baseDir ?: string ) : void {
664+ // Get relative paths for display
665+ const relativePaths = files
666+ . map ( ( f ) => ( baseDir ? path . relative ( baseDir , f ) : path . basename ( f ) ) )
667+ . sort ( ) ;
668+
669+ // Group by file type
670+ const groups : Record < string , string [ ] > = {
671+ 'Flow files' : [ ] ,
672+ 'Scripts' : [ ] ,
673+ 'Media files' : [ ] ,
674+ 'Config files' : [ ] ,
675+ 'Other' : [ ] ,
676+ } ;
677+
678+ for ( const filePath of relativePaths ) {
679+ const ext = path . extname ( filePath ) . toLowerCase ( ) ;
680+ if ( ext === '.yaml' || ext === '.yml' ) {
681+ if ( filePath === 'config.yaml' || filePath . endsWith ( '/config.yaml' ) ) {
682+ groups [ 'Config files' ] . push ( filePath ) ;
683+ } else {
684+ groups [ 'Flow files' ] . push ( filePath ) ;
685+ }
686+ } else if ( ext === '.js' || ext === '.ts' ) {
687+ groups [ 'Scripts' ] . push ( filePath ) ;
688+ } else if ( [ '.jpg' , '.jpeg' , '.png' , '.gif' , '.webp' , '.mp4' , '.mov' ] . includes ( ext ) ) {
689+ groups [ 'Media files' ] . push ( filePath ) ;
690+ } else {
691+ groups [ 'Other' ] . push ( filePath ) ;
692+ }
693+ }
694+
695+ logger . info ( `Bundling ${ files . length } files into flows.zip:` ) ;
696+ for ( const [ groupName , groupFiles ] of Object . entries ( groups ) ) {
697+ if ( groupFiles . length > 0 ) {
698+ logger . info ( ` ${ groupName } (${ groupFiles . length } ):` ) ;
699+ // Show first 10 files, then summarize if more
700+ const displayFiles = groupFiles . slice ( 0 , 10 ) ;
701+ for ( const file of displayFiles ) {
702+ logger . info ( ` - ${ file } ` ) ;
703+ }
704+ if ( groupFiles . length > 10 ) {
705+ logger . info ( ` ... and ${ groupFiles . length - 10 } more` ) ;
706+ }
707+ }
708+ }
709+ }
710+
503711 private async createFlowsZip (
504712 files : string [ ] ,
505713 baseDir ?: string ,
0 commit comments