import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from "fs"; import { join, resolve, basename, dirname } from "path"; import { formatSuccess, formatWarning, formatError } from "../format"; import type { TemplateIR, ResourceIR, ParameterIR, TemplateParser } from "../../import/parser"; import type { GeneratedFile, TypeScriptGenerator } from "../../import/generator"; import { loadPlugins, resolveProjectLexicons } from "../plugins"; import type { LexiconPlugin, ResourceSelector } from "../../lexicon"; /** * Import command options */ export interface ImportOptions { /** Path to template file */ templatePath: string; /** Output directory (defaults to ./infra/) */ output?: string; /** Force overwrite existing files */ force?: boolean; } /** * Import command result */ export interface ImportResult { /** Whether import succeeded */ success: boolean; /** Generated files */ generatedFiles: string[]; /** Warning messages */ warnings: string[]; /** Error message if failed */ error?: string; /** Detected lexicon */ lexicon?: string; } /** * Resource category for organizing files */ type ResourceCategory = "storage" | "compute" | "network" | "other"; /** * Detect which plugin handles a template by asking each plugin. * @param data - Parsed JSON object * @param plugins - Loaded lexicon plugins * @returns The matching plugin, or undefined if none match */ function detectPlugin(data: unknown, plugins: LexiconPlugin[]): LexiconPlugin | undefined { for (const plugin of plugins) { if (plugin.detectTemplate?.(data)) { return plugin; } } return undefined; } /** * Get the category for a resource type */ function getResourceCategory(type: string): ResourceCategory { const typeLower = type.toLowerCase(); // Storage resources if (typeLower.includes("bucket") || typeLower.includes("storage") || typeLower.includes("queue")) { return "storage"; } // Compute resources if (typeLower.includes("container") || typeLower.includes("service") || typeLower.includes("function")) { return "compute"; } // Network resources if (typeLower.includes("loadbalancer") || typeLower.includes("lb") || typeLower.includes("network")) { return "network"; } return "other"; } /** * Organize resources into categories */ function organizeByCategory(ir: TemplateIR): Map { const categories = new Map(); for (const resource of ir.resources) { const category = getResourceCategory(resource.type); const existing = categories.get(category) ?? []; existing.push(resource); categories.set(category, existing); } return categories; } /** * Generate organized files with separate modules */ function generateOrganizedFiles( ir: TemplateIR, generator: TypeScriptGenerator, ): GeneratedFile[] { const files: GeneratedFile[] = []; const categories = organizeByCategory(ir); const exports: string[] = []; // If all resources fit in one file, just generate main.ts if (ir.resources.length <= 3) { return generator.generate(ir); } // Generate files for each category for (const [category, resources] of categories) { if (resources.length === 0) continue; const categoryIr: TemplateIR = { parameters: category === "other" ? ir.parameters : [], resources, }; const generated = generator.generate(categoryIr); const fileName = `${category}.ts`; files.push({ path: fileName, content: generated[0].content, }); // Track exports for (const resource of resources) { const varName = resource.logicalId.charAt(0).toLowerCase() + resource.logicalId.slice(1); exports.push(`export { ${varName} } from "./${category}";`); } } // Handle parameters separately if not included in other category if (ir.parameters.length > 0 && !categories.has("other")) { const paramsIr: TemplateIR = { parameters: ir.parameters, resources: [], }; const generated = generator.generate(paramsIr); files.push({ path: "parameters.ts", content: generated[0].content, }); for (const param of ir.parameters) { const varName = param.name.charAt(0).toLowerCase() + param.name.slice(1); exports.push(`export { ${varName} } from "./parameters";`); } } // Generate index.ts if (exports.length > 0) { files.push({ path: "index.ts", content: exports.join("\n") + "\n", }); } return files; } /** * Execute the import command */ export async function importCommand(options: ImportOptions): Promise { const templatePath = resolve(options.templatePath); const outputDir = resolve(options.output ?? "./infra/"); const generatedFiles: string[] = []; const warnings: string[] = []; // Check if template exists if (!existsSync(templatePath)) { return { success: false, generatedFiles: [], warnings: [], error: `Template file not found: ${templatePath}`, }; } // Read template content let content: string; try { content = readFileSync(templatePath, "utf-8"); } catch (err) { return { success: false, generatedFiles: [], warnings: [], error: `Failed to read template: ${err}`, }; } // Load plugins and detect lexicon let data: unknown; try { data = JSON.parse(content); } catch { return { success: false, generatedFiles: [], warnings: [], error: "Template is not valid JSON.", }; } // Load plugins — resolve from the output directory (or CWD) so that // project config is found relative to where the user is working, not // an arbitrary monorepo root. const projectDir = resolve(options.output ? dirname(options.output) : "."); let plugins: LexiconPlugin[]; try { const lexiconNames = await resolveProjectLexicons(projectDir); plugins = await loadPlugins(lexiconNames); } catch { plugins = []; } // If no plugins resolved (no config, no source files), try common lexicons if (plugins.length === 0) { try { plugins = await loadPlugins(["aws"]); } catch { // No lexicons available at all } } const plugin = detectPlugin(data, plugins); if (!plugin) { return { success: false, generatedFiles: [], warnings: [], error: "Could not detect template lexicon. No installed lexicon recognizes this template.", }; } const lexicon = plugin.name; return parseAndWrite(plugin, content, outputDir, options.force, warnings, generatedFiles, lexicon); } /** * Import from an in-memory template string through a KNOWN plugin — no * detection, no JSON assumption (#1548). This is the seam * `chant import --kustomize ` drives with `kustomize build` output * through the k8s plugin's YAML parser; `importCommand` above is the same * pipeline behind file reading + JSON detection. */ export interface ContentImportOptions { /** The raw template content (YAML or JSON — the plugin's parser decides). */ content: string; /** The lexicon whose parser/generator handle it, e.g. "k8s". */ lexicon: string; output?: string; force?: boolean; } export async function importFromContent(options: ContentImportOptions): Promise { const outputDir = resolve(options.output ?? "./infra/"); let plugins: LexiconPlugin[]; try { plugins = await loadPlugins([options.lexicon]); } catch (err) { return { success: false, generatedFiles: [], warnings: [], error: `Could not load lexicon "${options.lexicon}": ${err}`, }; } const plugin = plugins[0]; if (!plugin) { return { success: false, generatedFiles: [], warnings: [], error: `Lexicon "${options.lexicon}" not available.` }; } if (!plugin.templateParser || !plugin.templateGenerator) { return { success: false, generatedFiles: [], warnings: [], error: `Lexicon "${plugin.name}" does not support template import.`, lexicon: plugin.name, }; } return parseAndWrite(plugin, options.content, outputDir, options.force, [], [], plugin.name); } /** The shared tail of every template import: parse → generate → write. */ function parseAndWrite( plugin: LexiconPlugin, content: string, outputDir: string, force: boolean | undefined, warnings: string[], generatedFiles: string[], lexicon: string, ): ImportResult { // Parse template let ir: TemplateIR; try { const parser = plugin.templateParser!(); ir = parser.parse(content); } catch (err) { return { success: false, generatedFiles: [], warnings: [], error: `Failed to parse template: ${err}`, }; } const generator = plugin.templateGenerator!(); // Check output directory if (existsSync(outputDir) && !force) { const files = readdirSync(outputDir); if (files.length > 0) { warnings.push(`Output directory ${outputDir} is not empty. Use --force to overwrite.`); } } // Create output directory if (!existsSync(outputDir)) { mkdirSync(outputDir, { recursive: true }); } // Generate files const files = generateOrganizedFiles(ir, generator); // Write files for (const file of files) { const filePath = join(outputDir, file.path); const dirPath = join(outputDir, file.path.split("/").slice(0, -1).join("/")); if (dirPath && !existsSync(dirPath)) { mkdirSync(dirPath, { recursive: true }); } // Check for existing file if (existsSync(filePath) && !force) { warnings.push(`File ${file.path} already exists, skipping`); continue; } writeFileSync(filePath, file.content); generatedFiles.push(file.path); } return { success: true, generatedFiles, warnings, lexicon, }; } /** * Live import options — read from a running cloud/cluster instead of a file. */ export interface LiveImportOptions { /** Environment to resolve (passed to each lexicon's exportResources). */ environment: string; /** The deployed stack to export from, for a multi-stack project (#932). When * omitted, the single-stack convention applies (the stack named after the * environment). */ stack?: string; /** Restrict to one lexicon by name (e.g. "aws", "k8s"). */ lexicon?: string; /** Output directory (defaults to ./infra/). */ output?: string; /** Force overwrite existing files. */ force?: boolean; /** Selector forwarded to the lexicon. */ selector?: ResourceSelector; /** Restrict to chant-owned resources (inert until ownership marking lands). */ owned?: boolean; /** Keep server-defaulted fields instead of stripping to declared shape. */ verbatim?: boolean; } /** * Merge several template IRs into one. Resources and parameters concatenate; * later metadata wins on key collisions. */ function mergeIR(parts: TemplateIR[]): TemplateIR { const resources: ResourceIR[] = []; const parameters: ParameterIR[] = []; let metadata: Record | undefined; for (const part of parts) { resources.push(...part.resources); parameters.push(...part.parameters); if (part.metadata) metadata = { ...(metadata ?? {}), ...part.metadata }; } return { resources, parameters, metadata }; } /** * Import directly from a live environment: ask each lexicon's exportResources * for full-fidelity IR, then generate chant TypeScript from it. * * Unlike file import, the live config may contain secrets — the caller prints a * warning. Reuses the same by-category output organization as file import. */ export async function importFromLive(options: LiveImportOptions): Promise { const projectDir = resolve(options.output ? dirname(options.output) : "."); // Resolve project lexicons, then hand off to the testable core. let plugins: LexiconPlugin[]; try { const lexiconNames = await resolveProjectLexicons(projectDir); plugins = await loadPlugins(lexiconNames); } catch { plugins = []; } return liveImportFromPlugins(plugins, options); } /** * Multi-stack live import (#932): for a project that declares `stacks` in * chant.config, import each stack from its own live CloudFormation stack * (`exportResources({ stack })`) into its own source directory (`src`). Plugins * are resolved once from the project root; each stack regenerates independently, * so a reconcile of a multi-stack project touches the right source per stack * instead of one flat import against a single env-named stack. Returns one * result per stack, in declaration order. */ export async function importFromLiveStacks( options: Omit, stacks: Array<{ name: string; src: string }>, ): Promise> { let plugins: LexiconPlugin[]; try { const lexiconNames = await resolveProjectLexicons(resolve(".")); plugins = await loadPlugins(lexiconNames); } catch { plugins = []; } const results: Array<{ stack: string; result: ImportResult }> = []; for (const s of stacks) { const result = await liveImportFromPlugins(plugins, { ...options, stack: s.name, output: s.src }); results.push({ stack: s.name, result }); } return results; } /** * Live-import core: given resolved plugins, export and generate. Split from * plugin resolution so it can be tested with fake exporters (no cloud calls). */ export async function liveImportFromPlugins( plugins: LexiconPlugin[], options: LiveImportOptions, ): Promise { const outputDir = resolve(options.output ?? "./infra/"); const warnings: string[] = []; let exporters = plugins.filter((p) => p.exportResources && p.templateGenerator); if (options.lexicon) { exporters = exporters.filter((p) => p.name === options.lexicon); } if (exporters.length === 0) { return { success: false, generatedFiles: [], warnings: [], error: options.lexicon ? `Lexicon "${options.lexicon}" does not support live export, or is not in this project.` : "No project lexicon supports live export (exportResources).", }; } // Collect IR from every exporter, tagging which lexicon produced output. const irParts: TemplateIR[] = []; let generatorLexicon: LexiconPlugin | undefined; for (const plugin of exporters) { let ir: TemplateIR; try { ir = await plugin.exportResources!({ environment: options.environment, stack: options.stack, selector: options.selector, owned: options.owned, verbatim: options.verbatim, }); } catch (err) { warnings.push(`${plugin.name}: live export failed — ${err instanceof Error ? err.message : String(err)}`); continue; } if (ir.resources.length === 0) continue; irParts.push(ir); generatorLexicon ??= plugin; } if (irParts.length === 0 || !generatorLexicon) { return { success: false, generatedFiles: [], warnings, error: `No resources exported from environment "${options.environment}".`, }; } if (exporters.length > 1 && irParts.length > 1) { warnings.push("Multiple lexicons exported resources; generated with the first. Use --lexicon to target one."); } const ir = mergeIR(irParts); const generator = generatorLexicon.templateGenerator!(); if (!existsSync(outputDir)) { mkdirSync(outputDir, { recursive: true }); } const files = generateOrganizedFiles(ir, generator); const generatedFiles: string[] = []; for (const file of files) { const filePath = join(outputDir, file.path); const dirPath = join(outputDir, file.path.split("/").slice(0, -1).join("/")); if (dirPath && !existsSync(dirPath)) { mkdirSync(dirPath, { recursive: true }); } if (existsSync(filePath) && !options.force) { warnings.push(`File ${file.path} already exists, skipping`); continue; } writeFileSync(filePath, file.content); generatedFiles.push(file.path); } return { success: true, generatedFiles, warnings, lexicon: generatorLexicon.name, }; } /** * Print import result */ export function printImportResult(result: ImportResult): void { if (!result.success) { console.error(formatError({ message: result.error ?? "Import failed" })); return; } for (const warning of result.warnings) { console.error(formatWarning({ message: warning })); } if (result.lexicon) { console.log(`Detected lexicon: ${result.lexicon}`); } if (result.generatedFiles.length > 0) { console.log(formatSuccess("Generated files:")); for (const file of result.generatedFiles) { console.log(` ${file}`); } } else { console.log("No files generated."); } }