Skip to content

Commit 53927b5

Browse files
fixes for building flows.zip file
1 parent 3d1d4f0 commit 53927b5

File tree

2 files changed

+918
-64
lines changed

2 files changed

+918
-64
lines changed

src/providers/maestro.ts

Lines changed: 272 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -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-zA-Z0-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

Comments
 (0)