import chalk from 'chalk'; import { Command } from 'commander'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { parse } from 'yaml'; import { join } from 'node:path'; /** * ⛔ THE FACADE, not the sub-packages — this command is Story 6.2's first real consumer. * * 6.2 shipped `@beehexa/hexasync-template-engine` and nothing imported it, so the divergence it exists to prevent was * unchanged: a package and an assertion, not a constraint. This file previously reached into FOUR sub-packages directly. * * ⚠️ THE RULE THAT FORBADE THAT IS GONE, and saying so is the point of this paragraph. It was * `migrated-consumers-use-the-facade` in `.dependency-cruiser.cjs`, scoped to the three commands that had migrated so * it could be widened one consumer at a time. It stopped being able to fire during Epic 2 batches 1–2b — its `to.path` * named eleven `packages/hexasync-template-*` DIRECTORIES and those packages became npm specifiers when they moved, a * form the rule had no pattern for (batch 2c wrote that down at the `group` docblock rather than repairing it) — and * Story 2.4 deleted the whole config with the last package, because every other rule in it was anchored on a * `packages/` directory this repository no longer has. * * So *"use the facade, do not reach into a sub-package"* is a CONVENTION here now, not a gate. Restoring it means a * rule matching the bare specifiers `@beehexa/hexasync-template-*` from these command files — which is a change with * its own verification (the commands legitimately import the facade, and a specifier rule that fires on that is * worse than none), and is why Story 2.4 recorded it instead of doing it in a commit that had to contain only a move. * * The namespaces are the point: `validate.explainRule` and `workerFlow.WORKER_STAGES` name their owner at the call site. */ import { assets, frontendFlow, validate, workerFlow, } from '@beehexa/hexasync-template-engine'; /** * `hexasync explain` — one thing at a time (Story 6.5). * * The command is a thin shell over `explainRule`: it parses arguments and prints. Every decision about what an answer * contains lives in the package that owns the knowledge, so the CLI and any other consumer cannot answer the same * question differently — which is the failure this epic's facade exists to prevent. */ export function ExplainCommand(): Command { const cmd = new Command('explain') .description('Explain one rule, bounded by default') .addHelpText( 'after', '\nExamples:\n' + ' $ hexasync explain rule STEP-1\n' + ' $ hexasync explain rule STEP-1 --full\n' + ' $ hexasync explain rules # every id, one line each\n', ); cmd .command('rule ') .description('What one validation rule means, and what it costs you') .option('--full', 'The whole entry, rather than the bounded answer') .action((id: string, options: { full?: boolean }) => { const answer = validate.explainRule(String(id).toUpperCase(), { full: options.full === true, }); console.log(''); console.log(chalk.bold(`${answer.id} — ${answer.title}`)); console.log(''); console.log(answer.text); if (answer.more) { console.log(''); console.log(chalk.gray(answer.more)); } console.log(''); /** * ⛔ Gated on `found`, not on `didYouMean`. * * `didYouMean` is set only when the FAMILY exists, so `explain rule REF-99` exited 1 while `explain rule banana` * exited 0 — backwards for an agent gating on the exit code, since the wholly invented id is the worse answer. */ if (!answer.found) process.exitCode = 1; }); /** * The installed knowledge, or `undefined`. * * ⚠️ Read from `.hexasync/intellisense/` — what `Install IntelliSense` wrote — rather than from a bundle this package * does not have. So `explain` answers about the vocabulary the project is actually pinned to, and says plainly when * that has not been installed instead of guessing from something newer. */ const installed = (relative: string): unknown => { const path = join(process.cwd(), assets.CANONICAL_SUBPATH, relative); try { return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : undefined; } catch { return undefined; } }; const print = (answer: validate.SubjectAnswer): void => { console.log(''); console.log(chalk.bold(`${answer.title}`)); console.log(''); console.log(answer.text); if (answer.more) { console.log(''); console.log(chalk.gray(answer.more)); } console.log(''); if (!answer.found) process.exitCode = 1; }; const needsInstall = (what: string): void => { console.log(''); console.log(chalk.red(`✗ ${what} is not installed here.`)); console.log( chalk.gray( ` Run \`hexasync intellisense install\` (or *Install IntelliSense* in the editor) and try again.`, ), ); console.log(''); process.exitCode = 1; }; cmd .command('type ') .description('What a step, transformation or validation type accepts') .option('--full', 'The whole entry') .action((name: string, options: { full?: boolean }) => { const asset = installed('steps/inputs.json') as | { contracts?: Record< string, { fields?: Record; note?: string } >; } | undefined; if (!asset?.contracts) return needsInstall('The step contract'); const types = new Map( Object.entries(asset.contracts).map(([type, contract]) => [ type, [ `A **${type}** step accepts: ${Object.keys(contract.fields ?? {}) .map((f) => `\`${f}\``) .join(', ')}.`, ...Object.entries(contract.fields ?? {}).map( ([field, text]) => `\`${field}\` — ${text}`, ), contract.note ?? '', ] .filter(Boolean) .join(' '), ]), ); print( validate.explainType(name.toUpperCase(), types, { full: options.full === true, }), ); }); cmd .command('connector ') .description('One connector, by its code') .option('--full', 'The whole entry') .action((code: string, options: { full?: boolean }) => { const catalog = installed('connectors/catalog.json'); const rows = Array.isArray(catalog) ? catalog : ((catalog as { connectors?: unknown[] } | undefined)?.connectors ?? undefined); if (!Array.isArray(rows)) return needsInstall('The connector catalog'); print( validate.explainConnector(code, rows, { full: options.full === true }), ); }); cmd .command('examples') .description('Find a working example by facet') .option('--kind ', 'project, component, template or demo') .option( '--feature ', 'pull, push, webhook, metrics, tables or startup', ) .option('--connector ', 'a connector code') .option('--entity ', 'part of an entity name') .option('--full', 'Every match, not the first page') .action( (options: { kind?: string; feature?: string; connector?: string; entity?: string; full?: boolean; }) => { const index = installed('docs/examples.json') as { entries?: unknown[] } | undefined; if (!Array.isArray(index?.entries)) return needsInstall('The example index'); print( validate.searchExamples( { ...(options.kind ? { kind: options.kind } : {}), ...(options.feature ? { feature: options.feature } : {}), ...(options.connector ? { connector: options.connector } : {}), ...(options.entity ? { entity: options.entity } : {}), }, index.entries as never, { full: options.full === true }, ), ); }, ); cmd .command('flow ') .description("A component's stages, in the order they run") .option('--full', 'Every stage and every step') .action((componentId: string, options: { full?: boolean }) => { /** * ⚠️ Read from the AUTHORED project in the working directory, through the same stage table the diagrams use, so * this cannot disagree with what the report draws. */ const stages = collectStages(process.cwd(), componentId); if (stages === undefined) return needsInstall('A HexaSync project'); print( validate.explainFlow(componentId, stages, { full: options.full === true, }), ); }); cmd .command('rules') .description('Every rule id with its title, one line each') .action(() => { const ids = Object.keys(validate.RULE_CATALOG).sort(); console.log(''); for (const id of ids) { const entry = ( validate.RULE_CATALOG as Record )[id]; console.log(`${chalk.bold(id.padEnd(9))} ${entry?.title ?? ''}`); } console.log(''); console.log( chalk.gray( `${ids.length} rules. \`hexasync explain rule \` for one of them.`, ), ); console.log(''); }); return cmd; } /** * A component's stages, read from the authored project. * * `undefined` means there is no project here — a different answer from "the component has no steps", which * `explainFlow` reports. Every stage key comes from `WORKER_STAGES` and `FRONTEND_WORKFLOW_KEYS`, the same tables that * draw every diagram, so this cannot disagree with the report. */ function collectStages( cwd: string, componentId: string, ): { label: string; steps: string[] }[] | undefined { const partials = join(cwd, 'partials'); if (!existsSync(partials)) return undefined; const stageKeys = [ ...Object.values( workerFlow.WORKER_STAGES as Record< string, readonly { key: string; label: string }[] >, ).flat(), ...frontendFlow.FRONTEND_WORKFLOW_KEYS.map((key) => ({ key, label: key })), ]; const found = new Map(); const walk = (dir: string): void => { for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.name.startsWith('.')) continue; const full = join(dir, entry.name); if (entry.isDirectory()) { walk(full); continue; } if (!/\.ya?ml$/.test(entry.name) || /^output.*\.ya?ml$/.test(entry.name)) continue; let doc: unknown; try { doc = parse(readFileSync(full, 'utf8')); } catch { continue; // A file the project cannot parse is not this command's business. } for (const collection of [ 'pullers', 'pushers', 'webhooks', 'connectors', 'objects', ]) { const rows = (doc as Record | null)?.[collection]; for (const row of Array.isArray(rows) ? rows : []) { const component = row as Record; if ( String(component?.['id'] ?? '') !== componentId && String(component?.['key'] ?? '') !== componentId ) { continue; } for (const stage of stageKeys) { const steps = component[stage.key]; if (!Array.isArray(steps) || steps.length === 0) continue; found.set( stage.label, steps.map((step, at) => String((step as Record)?.['key'] ?? `#${at}`), ), ); } } } } }; walk(partials); return [...found].map(([label, steps]) => ({ label, steps })); }