/** * `hexasync intellisense install|update` — the CLI twin of the editor command (FR-17). * * A thin caller. Every decision lives in @beehexa/hexasync-template-assets so the CLI and * the extension cannot drift into two implementations. */ import { Command } from 'commander'; import chalk from 'chalk'; import { classifyTarget, installAssets, updateAssets, type AssetKind, } from '@beehexa/hexasync-template-assets'; import { directoryAssetSource, nodeFileReader, nodeFileSink, nodeHasher, systemClock, targetUriFor, } from './nodeAdapters'; interface UpdateArgs { target: string; assets: string; kind?: 'schemas' | 'docs' | 'all'; yes?: boolean; dryRun?: boolean; } /** One line per state, so an update is auditable rather than a bare success message. */ function report(classification: { installedBundleVersion?: number; availableBundleVersion: number; hasManifest: boolean; files: readonly { path: string; state: string; reason: string }[]; }): void { const counts = new Map(); for (const file of classification.files) { counts.set(file.state, (counts.get(file.state) ?? 0) + 1); } console.error( chalk.cyan( ` installed bundle ${classification.installedBundleVersion ?? '(none)'} → available ` + `${classification.availableBundleVersion}` + (classification.hasManifest ? '' : ' — no manifest, so every file is treated as possibly edited'), ), ); for (const [state, count] of [...counts].sort()) { console.error(chalk.gray(` ${String(count).padStart(4)} ${state}`)); } } export function IntellisenseCommand(): Command { const cmd = new Command('intellisense'); cmd .alias('is') .description('Install or update the HexaSync IntelliSense assets'); cmd .command('install') .description( 'Install the canonical IntelliSense assets into a target folder', ) .requiredOption( '-t, --target ', 'Folder to install into (receives .hexasync/intellisense)', ) .requiredOption('-a, --assets ', 'Asset bundle to install from') // Story 2.7: the two halves install independently, and the manifest then describes // exactly what was installed rather than what the bundle happens to hold. .option('-k, --kind ', 'schemas | docs | all', 'all') .action( async ({ target, assets, kind, }: { target: string; assets: string; kind?: AssetKind; }) => { const result = await installAssets({ source: directoryAssetSource(assets), sink: nodeFileSink(), hasher: nodeHasher, clock: systemClock, targetUri: await targetUriFor(target), kind, }); if (result.empty) { // Reported, never passed off as a successful install (Story 2.2 Dev Notes). console.warn( chalk.yellow( `⚠ The asset bundle at ${assets} contains no files. Bundle version ` + `${result.assetBundleVersion} was recorded, but nothing was installed.`, ), ); return; } console.log( chalk.green( `✓ Installed ${result.filesWritten} file(s) of asset bundle ${result.assetBundleVersion}`, ), ); console.log(chalk.gray(` ${result.destinationUri}`)); console.log(chalk.gray(` manifest: ${result.manifestUri}`)); }, ); cmd .command('update') .description( 'Update installed assets, classifying each file before overwriting it', ) .requiredOption('-t, --target ', 'Folder holding the installed assets') .requiredOption( '-a, --assets ', 'Folder holding the canonical asset bundle', ) .option('-k, --kind ', 'schemas | docs | all', 'all') .option('-y, --yes', 'Overwrite locally-modified files without asking') .option('--dry-run', 'Classify and report, writing nothing') .action(async (options: UpdateArgs) => { const source = directoryAssetSource(options.assets); const reader = nodeFileReader(); const targetUri = await targetUriFor(options.target); if (options.dryRun) { const classification = await classifyTarget({ source, reader, hasher: nodeHasher, targetUri, }); report(classification); return; } const result = await updateAssets({ source, reader, sink: nodeFileSink(), hasher: nodeHasher, clock: systemClock, targetUri, kind: options.kind, // Non-interactive by default. A CLI that silently overwrote a developer's edits, or one // that hung waiting for input in CI, would both be wrong — so it declines unless `--yes` // is given, and says which files it skipped. confirmOverwrite: async (file) => { if (options.yes === true) return true; console.error( chalk.yellow( ` ~ ${file.path} — ${file.reason} Skipped. Re-run with --yes to overwrite.`, ), ); return false; }, }); if (result.pinned) { console.error( chalk.cyan( `ℹ ${result.destinationUri} is pinned to bundle ` + `${result.classification.installedBundleVersion}. Nothing was changed.`, ), ); return; } report(result.classification); console.error( chalk.green(`✓ ${result.written} file(s) updated`) + (result.declined > 0 ? chalk.yellow(`, ${result.declined} left as they were`) : ''), ); }); cmd .command('lint') .description( 'Check authored template files against the structural conventions', ) .requiredOption('-d, --dir ', 'Folder of authored templates to check') .option( '--max-criticals ', 'Exit 0 while at or below this many criticals', '0', ) .action(async (options: { dir: string; maxCriticals: string }) => { const { lintTree } = await import('./lintTree'); const summary = await lintTree(options.dir); for (const d of summary.diagnostics.slice(0, 40)) { console.error( `${chalk.red(d.severity)} ${chalk.gray(d.ruleId)} ${d.path}\n ${d.message}`, ); } if (summary.diagnostics.length > 40) { console.error( chalk.gray(` … and ${summary.diagnostics.length - 40} more`), ); } console.error(''); console.error(chalk.cyan(` ${summary.filesLinted} file(s) checked`)); for (const [rule, count] of Object.entries(summary.byRule)) { console.error(chalk.gray(` ${String(count).padStart(5)} ${rule}`)); } // A budget rather than a bare gate: the rules flag hundreds of pre-existing files, so a // hard zero would be red on day one and get switched off. Ratchet it down instead. const budget = Number.parseInt(options.maxCriticals, 10); if (summary.criticalCount > budget) { console.error( chalk.red( `✗ ${summary.criticalCount} critical(s), budget ${budget}.`, ) + chalk.gray( ' Lower the budget as files are fixed so the count cannot creep back up.', ), ); process.exitCode = 1; return; } console.error( chalk.green( `✓ ${summary.criticalCount} critical(s), within budget ${budget}`, ), ); }); return cmd; }