import { Command } from 'commander'; import { basenameUri, dirnameUri, joinUri, relativeUri, type IExternalReplacement, } from '@beehexa/hexasync-template-model'; import type { TemplateReader } from '@beehexa/hexasync-template-ports'; import { nodeTemplateReader, pathFromUri, } from '@beehexa/hexasync-template-io-node'; import { buildProjectCheckpoints, composeProject, prefetchSources, seedRootMainEvents, type ComposeSources, resolveMdIncludes, searchMain, type ResolverIO, reconcile, diffValueResolver, type Tracer, type WorklistItem, } from '@beehexa/hexasync-template-compose'; import path from 'path'; import fs from 'fs'; import { parse, stringify } from 'yaml'; import { getProjectPath } from '../../helpers/cluserHelper'; import { renderReport } from '@beehexa/hexasync-template-report-render'; import { renderReportHtml } from '@beehexa/hexasync-template-report-render'; import { buildValidationModel } from '@beehexa/hexasync-template-validate'; import { renderValidationReport } from '@beehexa/hexasync-template-report-render'; import { renderValidationReportHtml } from '@beehexa/hexasync-template-report-render'; function parseBooleanOption(value: string | boolean | undefined): boolean { if (typeof value === 'boolean') { return value; } if (typeof value !== 'string') { return false; } const normalized = value.trim().toLowerCase(); if (['true', '1', 'yes', 'y'].includes(normalized)) { return true; } if (['false', '0', 'no', 'n'].includes(normalized)) { return false; } throw new Error(`Invalid boolean value for --raw: ${value}`); } /** * The Node reader the binary composes with. * * `nodeTemplateReader` yields `file://` URIs from `glob`, because a URI is the one identity every * host can agree on (AD-18). The worklist's path spelling is emitted into the reports though, so * converting back to platform paths HERE — in the adapter layer, where platform knowledge belongs * — is what keeps output byte-identical (NFR-19) while core stays free of any notion of a path. */ function createNodeComposeReader(): TemplateReader { const base = nodeTemplateReader(); return { read: (uri) => base.read(uri), exists: (uri) => base.exists(uri), glob: async (baseUri, pattern) => (await base.glob(baseUri, pattern)).map(pathFromUri), }; } const defaultReader = createNodeComposeReader(); /** Production filesystem-backed ResolverIO. Reads and globs go through the port. */ export function createFsResolverIO( reader: TemplateReader = defaultReader, ): ResolverIO { return { canonical: async (p: string) => { try { return fs.realpathSync(p); } catch { // Stays node:path, deliberately. One-argument `path.resolve` resolves against // `process.cwd()`, and core's URI algebra has no cwd concept and must not gain one: // AD-3 forbids core reading `process`, and an identity that depended on the caller's // shell directory would give one project two identities (AD-18). CLI-boundary work. return path.resolve(p); } }, // `undefined` (not there) becomes `null` — the shape ResolverIO has always used. readFileIfExists: async (p: string) => (await reader.read(p)) ?? null, // The port sorts its own results (NFR-19); paths converted back from `file://` at this // boundary because the worklist's spelling is emitted into the reports. globYaml: async (componentPath: string) => [ ...(await reader.glob(componentPath, `**/*.yaml`)), ], searchMain: (folder: string) => searchMain(folder, reader), }; } export async function composeProfile( folder: string = '', raw: boolean = false, ) { if (!folder) { folder = getProjectPath(); } // CLI boundary: the user's folder argument is made absolute HERE, once, so everything inward // receives an already-absolute location. See the note on `path.resolve(p)` above. const { mainYml, componentPath } = await searchMain( path.resolve(folder), defaultReader, ); // Compose. This writes nothing and prints nothing (Story 1.7) — everything below is the // binary deciding to persist what it got back, which is the whole distinction the story draws. const result = await composeProject({ componentPath, mainYml, io: createFsResolverIO(), reader: defaultReader, raw, }); for (const w of result.warnings) console.warn(w); const { output, outputText, tokenForm, variables, worklist, projects, variableSets, tracer, sources, rootMainYml, projectTitle, hasExternals, } = result; // 06. Write to the root folder. const parentPath = dirnameUri(componentPath); const outputName = raw ? 'output-raw.yaml' : 'output.yaml'; const outputPath = joinUri(parentPath, outputName); console.log(`Writing file to ${outputPath}`); fs.writeFileSync(outputPath, outputText); // 07. Merge integrity report — written whenever externals were used. if (hasExternals) { const projectPaths = new Map(); for (const p of projects) projectPaths.set(p.project, p.componentPath); const projectCheckpoints = buildProjectCheckpoints( worklist as WorklistItem[], rootMainYml, componentPath, sources, ); seedRootMainEvents(tracer, rootMainYml, projects, componentPath); const model = reconcile( tracer.events(), tracer.notes(), tokenForm, // token-form (pre-substitution) — matches tracer ids (finding #1) projects, projectTitle, { projectPaths, reportDir: parentPath, snapshots: tracer.snapshots(), relative: (from, to) => relativeUri(from, to), variableSets, overwrites: tracer.overwrites(), projectCheckpoints, // Resolve variable tokens for the customized diff: a component added at // generation G is resolved with the pool it composed under (gen >= G), // and the final version with the full pool — so variable-value changes show. // // The recipe moved into `template-compose` when the editor's compose report needed the same // one: it decides CUSTOMIZED versus INHERITED, and two copies could classify one component // two ways with nothing to say which was right. resolveForDiff: diffValueResolver(variables, variableSets), }, ); for (const w of model.warnings) console.warn(w); // Report filenames derive from the active output filename (finding #13). const base = outputName.replace(/\.yaml$/, '.report'); const mdPath = joinUri(parentPath, `${base}.md`); const htmlPath = joinUri(parentPath, `${base}.html`); console.log(`Writing report to ${mdPath} and ${htmlPath}`); fs.writeFileSync(mdPath, renderReport(model)); fs.writeFileSync(htmlPath, renderReportHtml(model)); } // 08. Validation report — advisory, ALWAYS written (independent of inheritance, // unlike the merge-integrity report above). Fully guarded so a rule / render / // write failure can never abort compose or suppress the report above (AC 4). try { const valModel = buildValidationModel({ projectTitle, tokenForm, output, variables, raw, projects, reportDir: parentPath, componentPath, rootMainYml, tracer, sources, unadmittedVariableFiles: result.unadmittedVariableFiles, libraryVariables: result.libraryVariableSets, // Story 1.9: the same records the merge-integrity report renders. Filtered to real top-level // losses here so the rule and the report cannot disagree about what counts as one. overwrites: tracer .overwrites() .filter((o) => o.depth === 0 && o.hadComponents), /** * The `!md[...]` targets the compose could not read — rule MDI-1. Forwardable since `2608.21.2`, * which is when both ends of it were published; this repo pins later, and the sentence is about the * release that made the line possible rather than about the current pin. * * ⛔ Without this line the finding cannot exist HERE, and the absence was a straight * regression rather than a missing nicety: `resolveMdIncludes` used to THROW on a missing * description file, and now resolves it to an empty string and reports through * `onMissingInclude`. The editor collects that hook in `template-index`'s graph builder; * this command composes through `composeProject`, which had no way to hand it over. So a * missing include was loud, then silent, and `compose` wrote a clean report over a * component whose description had vanished. */ missingIncludes: result.missingIncludes, relative: (from, to) => relativeUri(from, to), }); const valBase = outputName.replace(/\.yaml$/, '.validation.report'); const valMdPath = joinUri(parentPath, `${valBase}.md`); console.log( `Writing validation report to ${valMdPath} and ${valBase}.html`, ); fs.writeFileSync(valMdPath, renderValidationReport(valModel)); fs.writeFileSync( joinUri(parentPath, `${valBase}.html`), renderValidationReportHtml(valModel), ); } catch (e) { // Advisory feature — never break a successful compose. console.warn(`Validation report skipped: ${e}`); } } /** * Resolve ONLY the `!md[...]` Markdown includes in a single YAML file and write * the result next to it as `.output.yaml`. Unlike `composeProfile`, this * does NOT merge, inherit, or substitute variables — it is a focused preview of * a component with its Markdown descriptions inlined. Include paths resolve * relative to the file's own folder. Returns the output path. */ export async function composeDescription(filePath: string): Promise { // CLI boundary, cwd-relative — see the note on `path.resolve(p)` above. const abs = path.resolve(filePath); if (!fs.existsSync(abs)) { throw new Error(`File not found: ${abs}`); } const raw = fs.readFileSync(abs, 'utf8'); const cnf = parse(raw, { keepSourceTokens: true }); // Same two-phase shape as compose: gather asynchronously, then resolve synchronously. const sources = await prefetchSources( [{ path: abs, replacements: [] }], defaultReader, ); const resolved = resolveMdIncludes(cnf, dirnameUri(abs), abs, sources); const outPath = abs.replace(/\.ya?ml$/i, '') + '.output.yaml'; fs.writeFileSync( outPath, stringify(resolved, { keepSourceTokens: true, lineWidth: 0 }), ); return outPath; } export function ComposeCommand(): Command { const composeCmd = new Command('compose'); composeCmd .alias('c') .description( 'Compose a bunch of HexaSync Components to a HexaSync Yaml Template', ) // Description + Version is auto read from package.json .option( '-f, --folder ', 'Compose a folder to generate profile definition', ) .option( '--raw [raw]', 'Keep raw variable tokens in the output instead of replacing them', parseBooleanOption, false, ) .action(async ({ folder, raw }: { folder: string; raw: boolean }) => { await composeProfile(folder, raw); }); composeCmd .command('description ') .alias('desc') .description( 'Resolve only the !md[...] Markdown includes in a single YAML file; writes .output.yaml next to it', ) .action(async (file: string) => { const outPath = await composeDescription(file); console.log(`Writing file to ${outPath}`); }); composeCmd .command('validate ') .alias('val') .description( 'Compose a folder and report profile-quality issues; writes output.yaml + output.validation.report.md/.html (advisory, exits 0)', ) .action(async (folder: string) => { await composeProfile(folder, false); // Surface the validation summary for CI/local visibility (advisory; never fails). try { const { componentPath } = await searchMain(folder, defaultReader); const mdPath = joinUri( dirnameUri(componentPath), 'output.validation.report.md', ); const summary = fs .readFileSync(mdPath, 'utf8') .split('\n') .find((l) => l.startsWith('## ')); if (summary) console.log(summary.replace(/^##\s*/, 'Validation: ')); } catch { // best-effort summary; the report itself is already written } }); return composeCmd; }