|
| 1 | +import { readdirSync, statSync } from "node:fs"; |
| 2 | +import path from "node:path"; |
| 3 | +import type { Command } from "commander"; |
| 4 | +import { WORKFLOW_EXTENSIONS } from "@/common/extensions.ts"; |
| 5 | +import { hasAllTags, parseTagFilter } from "@/common/tags.ts"; |
| 6 | +import { getEffectiveExternalizeThreshold, loadCLIConfig } from "@/config/claude-md.ts"; |
| 7 | +import { convertWorkflowFile, type TargetFormat } from "@/convert/converter.ts"; |
| 8 | +import { parseWorkflowFile } from "@/importer/scanner.ts"; |
| 9 | + |
| 10 | +/** registerConvertCommand registers the convert subcommand. */ |
| 11 | +export function registerConvertCommand(program: Command): void { |
| 12 | + program |
| 13 | + .command("convert") |
| 14 | + .description("Convert workflow files between formats (JSON ↔ YAML)") |
| 15 | + .requiredOption("--format <format>", "Target format: json, yaml") |
| 16 | + .option("-d, --directory <dir>", "Directory to scan for workflow files") |
| 17 | + .option("--ids <ids>", "Comma-separated workflow IDs to convert") |
| 18 | + .option("--tags <tags>", "Filter by tags (comma-separated, AND condition)") |
| 19 | + .option("-t, --threshold <n>", "Minimum lines for code externalization (JSON→YAML)", "0") |
| 20 | + .option("--dry-run", "Preview conversions without writing files", false) |
| 21 | + .option("--keep", "Keep original files after conversion", false) |
| 22 | + .argument("[files...]", "Specific workflow files to convert") |
| 23 | + .action( |
| 24 | + async ( |
| 25 | + files: string[], |
| 26 | + opts: { |
| 27 | + format: string; |
| 28 | + directory?: string; |
| 29 | + ids?: string; |
| 30 | + tags?: string; |
| 31 | + threshold: string; |
| 32 | + dryRun: boolean; |
| 33 | + keep: boolean; |
| 34 | + }, |
| 35 | + ) => { |
| 36 | + // Validate target format |
| 37 | + const targetFormat = opts.format as TargetFormat; |
| 38 | + if (targetFormat !== "json" && targetFormat !== "yaml") { |
| 39 | + console.error(`Error: unsupported format "${opts.format}". Use "json" or "yaml".`); |
| 40 | + process.exit(1); |
| 41 | + } |
| 42 | + |
| 43 | + // Load config for threshold |
| 44 | + const cliConfig = loadCLIConfig(); |
| 45 | + const threshold = getEffectiveExternalizeThreshold( |
| 46 | + Number.parseInt(opts.threshold, 10) || 0, |
| 47 | + cliConfig, |
| 48 | + ); |
| 49 | + |
| 50 | + // Collect target files |
| 51 | + const targetFiles = [...files]; |
| 52 | + if (opts.directory) { |
| 53 | + targetFiles.push(...scanWorkflowFiles(opts.directory)); |
| 54 | + } |
| 55 | + |
| 56 | + if (targetFiles.length === 0) { |
| 57 | + console.error("Error: no files specified. Use -d <directory> or provide file paths."); |
| 58 | + process.exit(1); |
| 59 | + } |
| 60 | + |
| 61 | + // Parse --ids filter |
| 62 | + const idsFilter = opts.ids |
| 63 | + ? new Set( |
| 64 | + opts.ids |
| 65 | + .split(",") |
| 66 | + .map((s) => s.trim()) |
| 67 | + .filter(Boolean), |
| 68 | + ) |
| 69 | + : null; |
| 70 | + |
| 71 | + // Parse --tags filter |
| 72 | + const filterByTags = parseTagFilter(opts.tags); |
| 73 | + |
| 74 | + if (filterByTags.length > 0) { |
| 75 | + console.error(`Filtering by tags: ${filterByTags.join(", ")} (AND)`); |
| 76 | + } |
| 77 | + |
| 78 | + let converted = 0; |
| 79 | + let skipped = 0; |
| 80 | + let errors = 0; |
| 81 | + |
| 82 | + for (const filePath of targetFiles) { |
| 83 | + // ID filtering |
| 84 | + if (idsFilter) { |
| 85 | + try { |
| 86 | + const wf = parseWorkflowFile(filePath); |
| 87 | + if (!wf.id || !idsFilter.has(wf.id)) { |
| 88 | + continue; |
| 89 | + } |
| 90 | + } catch { |
| 91 | + // Cannot parse — skip |
| 92 | + continue; |
| 93 | + } |
| 94 | + } |
| 95 | + |
| 96 | + // Tag filtering |
| 97 | + if (filterByTags.length > 0) { |
| 98 | + try { |
| 99 | + const wf = parseWorkflowFile(filePath); |
| 100 | + if (!hasAllTags(wf.tags, filterByTags)) { |
| 101 | + continue; |
| 102 | + } |
| 103 | + } catch { |
| 104 | + // Cannot parse — skip |
| 105 | + continue; |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + const directory = opts.directory ?? path.dirname(filePath); |
| 110 | + |
| 111 | + const result = convertWorkflowFile(filePath, { |
| 112 | + targetFormat, |
| 113 | + directory, |
| 114 | + externalizeThreshold: threshold, |
| 115 | + dryRun: opts.dryRun, |
| 116 | + keepOriginal: opts.keep, |
| 117 | + }); |
| 118 | + |
| 119 | + if (result.error) { |
| 120 | + console.error(`Error: ${filePath}: ${result.error.message}`); |
| 121 | + errors++; |
| 122 | + continue; |
| 123 | + } |
| 124 | + |
| 125 | + if (result.skipped) { |
| 126 | + console.error(`Skip: ${filePath}: ${result.skipReason}`); |
| 127 | + skipped++; |
| 128 | + continue; |
| 129 | + } |
| 130 | + |
| 131 | + const dryRunLabel = opts.dryRun ? " (dry-run)" : ""; |
| 132 | + console.log(`Converted${dryRunLabel}: ${filePath} → ${result.outputPath}`); |
| 133 | + |
| 134 | + if (result.removedFiles.length > 0) { |
| 135 | + for (const removed of result.removedFiles) { |
| 136 | + console.log(` Removed: ${removed}`); |
| 137 | + } |
| 138 | + } |
| 139 | + |
| 140 | + converted++; |
| 141 | + } |
| 142 | + |
| 143 | + console.log(`\nConverted: ${converted}, Skipped: ${skipped}, Errors: ${errors}`); |
| 144 | + |
| 145 | + if (errors > 0) { |
| 146 | + process.exit(1); |
| 147 | + } |
| 148 | + }, |
| 149 | + ); |
| 150 | +} |
| 151 | + |
| 152 | +/** Recursively scans a directory for workflow files (.json, .yaml, .yml). */ |
| 153 | +function scanWorkflowFiles(dir: string): string[] { |
| 154 | + const results: string[] = []; |
| 155 | + |
| 156 | + try { |
| 157 | + const entries = readdirSync(dir); |
| 158 | + for (const entry of entries) { |
| 159 | + const fullPath = path.join(dir, entry); |
| 160 | + const stat = statSync(fullPath); |
| 161 | + |
| 162 | + if (stat.isDirectory()) { |
| 163 | + if (entry.startsWith("_")) continue; |
| 164 | + results.push(...scanWorkflowFiles(fullPath)); |
| 165 | + } else { |
| 166 | + const ext = path.extname(entry).toLowerCase(); |
| 167 | + if (WORKFLOW_EXTENSIONS.has(ext)) { |
| 168 | + results.push(fullPath); |
| 169 | + } |
| 170 | + } |
| 171 | + } |
| 172 | + } catch { |
| 173 | + // Skip unreadable directories |
| 174 | + } |
| 175 | + |
| 176 | + return results; |
| 177 | +} |
0 commit comments