diff --git a/.changeset/cli-output-formatters.md b/.changeset/cli-output-formatters.md new file mode 100644 index 000000000000..7f3135e943f0 --- /dev/null +++ b/.changeset/cli-output-formatters.md @@ -0,0 +1,6 @@ +--- +'@astryxdesign/cli': patch +--- + +[feat] CLI human (non-`--json`) output now renders through a small, documented formatter kit: consistent, plain-ASCII `key: value` records/sections that mirror `--json` and are greppable by field. Every command was migrated onto it (a lint rule keeps output funneled through the single `emit` sink), and `astryx --help` documents the output contract. `--json` output is unchanged. (#4686) +@joeyfarina diff --git a/.github/scripts/cli-smoke-test.mjs b/.github/scripts/cli-smoke-test.mjs index adea93013036..148164b1dc5a 100644 --- a/.github/scripts/cli-smoke-test.mjs +++ b/.github/scripts/cli-smoke-test.mjs @@ -85,43 +85,52 @@ const DETAIL_LEVELS = detailMatch console.log(`detail levels: ${DETAIL_LEVELS.join(', ')}`); +// Parse a command's --json envelope. The JSON output is the CLI's source of +// truth; the human text is a projection, so discovery must not scrape it. +function runJson(args) { + const {stdout} = run(args); + try { + return JSON.parse(stdout); + } catch { + return null; + } +} + // --------------------------------------------------------------------------- -// 2. Discover components from astryx component --list +// 2. Discover components from astryx component --list --json // --------------------------------------------------------------------------- console.log('\ncomponent listing'); check('astryx component --list', ['component', '--list']); -const listResult = run(['component', '--list']); -const componentNames = listResult.stdout - .split('\n') - .filter(line => /^\s+[A-Z]/.test(line)) - .map(line => line.trim().split(/\s{2,}/)[0]); +// data.components is a map of category/group key -> [{name, package}, ...]. +const listData = runJson(['component', '--list', '--json'])?.data; +const componentGroups = + /** @type {Record>} */ (listData?.components ?? {}); +const componentNames = Object.values(componentGroups) + .flat() + .map(entry => entry.name); console.log(`discovered ${componentNames.length} components`); if (componentNames.length === 0) { - console.log('\nFATAL: no components discovered from --list output. Aborting.'); + console.log('\nFATAL: no components discovered from --list --json output. Aborting.'); process.exit(1); } // --------------------------------------------------------------------------- -// 3. Discover categories from the --list output +// 3. Categories are the group keys of the component map (valid --category args) // --------------------------------------------------------------------------- -const categories = listResult.stdout - .split('\n') - .filter(line => /^[A-Z].*\(group\)$/.test(line)) - .map(line => line.replace(/\s*\(group\)$/, '')); +const categories = Object.keys(componentGroups); console.log(`discovered ${categories.length} categories: ${categories.join(', ')}`); // --------------------------------------------------------------------------- -// 4. Discover doc topics from astryx docs +// 4. Discover doc topics from astryx docs --json // --------------------------------------------------------------------------- -const docsResult = run(['docs']); -const docTopics = docsResult.stdout - .split('\n') - .filter(line => /^\s{2}\w+\s{2,}/.test(line)) - .map(line => line.trim().split(/\s{2,}/)[0]); +const docsData = runJson(['docs', '--json'])?.data; +const docTopics = + /** @type {Array<{topic: string}>} */ (Array.isArray(docsData) ? docsData : []) + .map(entry => entry.topic); console.log(`discovered ${docTopics.length} doc topics: ${docTopics.join(', ')}`); diff --git a/internal/eslint-plugin-astryx/no-raw-console-cli.js b/internal/eslint-plugin-astryx/no-raw-console-cli.js index 8e4eb8faf475..7062942a1272 100644 --- a/internal/eslint-plugin-astryx/no-raw-console-cli.js +++ b/internal/eslint-plugin-astryx/no-raw-console-cli.js @@ -2,25 +2,29 @@ /** * @file no-raw-console-cli.js - * @description Ban bare `console.log` in CLI runtime files so it can never - * corrupt the `--json` stdout contract (#2467). + * @description Enforce the CLI's output funnels so `--json` stdout stays clean + * and human output stays consistent (#2467). * - * The CLI's machine-readable JSON output owns stdout in `--json` mode. A stray - * `console.log` writes to that same stream and breaks JSON consumers. The - * sanctioned escape hatch is `humanLog()` (lib/json.mjs), which is a no-op in - * JSON mode. This rule pushes authors toward it: + * Two bans: * - * Banned: console.log(...) (writes raw stdout) - * Allowed: console.error(...) (stderr — never corrupts JSON) - * console.warn(...) (stderr) - * humanLog(...) (json-aware stdout) + * 1. Bare `console.log` in ANY CLI runtime file. The machine-readable JSON + * output owns stdout in `--json` mode; a stray `console.log` corrupts it. + * Banned: console.log(...) + * Allowed: console.error / console.warn (stderr — never corrupts JSON) * - * Autofix rewrites `console.log(` → `humanLog(`. The author is responsible for - * ensuring `humanLog` is imported from `../lib/json.mjs`; the fix only renames - * the call so the wrong primitive isn't silently kept. + * 2. `humanLog(...)` / `humanWarn(...)` in COMMAND files + * (clients/cli/commands/**). Command human output must go through the single + * formatter sink `emit(...)` (clients/cli/formatters) so every command renders + * consistently; errors/warnings go through `cliError()` (stderr). `humanLog` + * is now an internal primitive used only by `emit` and the shared `logger` — + * commands should never call it directly. * - * A handful of files legitimately write raw stdout — the JSON envelope writers - * and the banner — and are exempt: + * The console.log autofix rewrites `console.log(` → `humanLog(` for non-command + * files (the logger/formatters/etc.). There is no autofix for ban #2: moving a + * command to `emit` isn't a mechanical rename (emit takes Blocks, not strings). + * + * Files that legitimately write raw stdout or own the funnels are exempt from + * ban #1: * - packages/cli/foundation/response/json.mjs (defines humanLog / jsonOut) * - packages/cli/clients/cli/index.mjs (wiring / banner) * - packages/cli/clients/cli/bin/astryx.mjs (entrypoint / error boundary) @@ -32,9 +36,18 @@ const EXEMPT_SUFFIXES = [ 'packages/cli/clients/cli/bin/astryx.mjs', ]; +// Command files must funnel human output through emit(); humanLog/humanWarn are +// off-limits there (they remain available to the sink implementers: the logger, +// the formatters, and cli-error, none of which live under commands/). +const COMMANDS_DIR = 'clients/cli/commands/'; + +function normalize(filename) { + // Normalize Windows separators so path matching is platform-agnostic. + return filename.replace(/\\/g, '/'); +} + function isExempt(filename) { - // Normalize Windows separators so suffix matching is path-agnostic. - const normalized = filename.replace(/\\/g, '/'); + const normalized = normalize(filename); return EXEMPT_SUFFIXES.some(suffix => normalized.endsWith(suffix)); } @@ -43,7 +56,7 @@ const rule = { type: 'problem', docs: { description: - 'Ban bare console.log in CLI runtime files; use humanLog so --json stdout stays clean', + 'Enforce CLI output funnels: no console.log anywhere; no humanLog/humanWarn in command files (use emit)', category: 'Astryx Conventions', recommended: true, }, @@ -53,6 +66,10 @@ const rule = { 'Do not use console.log in CLI runtime code — it writes raw stdout and ' + 'can corrupt --json output. Use humanLog() (from lib/json.mjs), or ' + 'console.error/console.warn for stderr.', + noHumanLogInCommand: + 'Do not call {{name}}() in a command file — human stdout must go through ' + + 'emit() (clients/cli/formatters), and errors/warnings through cliError(). ' + + 'humanLog/humanWarn are internal primitives for the logger and formatters.', }, schema: [], }, @@ -61,10 +78,13 @@ const rule = { if (isExempt(filename)) { return {}; } + const inCommands = normalize(filename).includes(COMMANDS_DIR); return { CallExpression(node) { const callee = node.callee; + + // Ban #1: console.log (all CLI runtime files). if ( callee.type === 'MemberExpression' && !callee.computed && @@ -82,6 +102,20 @@ const rule = { return fixer.replaceText(callee, 'humanLog'); }, }); + return; + } + + // Ban #2: humanLog/humanWarn inside command files. + if ( + inCommands && + callee.type === 'Identifier' && + (callee.name === 'humanLog' || callee.name === 'humanWarn') + ) { + context.report({ + node: callee, + messageId: 'noHumanLogInCommand', + data: {name: callee.name}, + }); } }, }; diff --git a/internal/eslint-plugin-astryx/no-raw-console-cli.test.mjs b/internal/eslint-plugin-astryx/no-raw-console-cli.test.mjs index 6ba4e6be414a..1a62d40804af 100644 --- a/internal/eslint-plugin-astryx/no-raw-console-cli.test.mjs +++ b/internal/eslint-plugin-astryx/no-raw-console-cli.test.mjs @@ -26,10 +26,11 @@ ruleTester.run('no-raw-console-cli', rule, { code: `console.warn('heads up');`, filename: '/repo/packages/cli/clients/cli/commands/search.mjs', }, - // humanLog is the sanctioned json-aware stdout primitive + // humanLog is allowed OUTSIDE command files — it's the stdout primitive the + // formatter sink (emit) and the shared logger are built on. { code: `import {humanLog} from '../../lib/json.mjs'; humanLog('hi');`, - filename: '/repo/packages/cli/clients/cli/commands/search.mjs', + filename: '/repo/packages/cli/clients/cli/formatters/index.mjs', }, // Exempt: lib/json.mjs defines the raw writers { @@ -77,6 +78,18 @@ ruleTester.run('no-raw-console-cli', rule, { {messageId: 'noRawConsoleLog'}, ], }, + // Ban #2: humanLog() in a command file must funnel through emit() + { + code: `humanLog('hi');`, + filename: '/repo/packages/cli/clients/cli/commands/search.mjs', + errors: [{messageId: 'noHumanLogInCommand'}], + }, + // Ban #2: humanWarn() in a command file — warnings go through cliError() + { + code: `humanWarn('careful');`, + filename: '/repo/packages/cli/clients/cli/commands/build.mjs', + errors: [{messageId: 'noHumanLogInCommand'}], + }, ], }); diff --git a/packages/cli/clients/cli/commands/blog.mjs b/packages/cli/clients/cli/commands/blog.mjs index 8c92ba0ebe83..062f612e17dc 100644 --- a/packages/cli/clients/cli/commands/blog.mjs +++ b/packages/cli/clients/cli/commands/blog.mjs @@ -15,31 +15,11 @@ */ import {getRunPrefix} from '../../../foundation/env/package-manager.mjs'; -import {humanLog, jsonOut} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, section, text, record, records, code} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {blog as blogApi} from '../../../api/blog/blog.mjs'; -/** - * @param {import('../../../api/blog/blog.type.mjs').BlogListData} data - * @param {string} run - */ -function formatList({feedUrl, posts}, run) { - const lines = [`\nAstryx blog · feed: ${feedUrl}\n`]; - if (posts.length === 0) { - lines.push('No posts found in the feed.'); - return lines.join('\n'); - } - for (const p of posts) { - lines.push(` ${p.slug}`); - lines.push(` ${p.title}`); - if (p.type) lines.push(` ${p.type}`); - if (p.textUrl) lines.push(` ${p.textUrl}`); - lines.push(''); - } - lines.push(`Read one: ${run} astryx blog `); - return lines.join('\n'); -} - /** * @param {import('commander').Command} program */ @@ -68,11 +48,25 @@ export function registerBlog(program) { } if (result.type === 'blog.list') { - humanLog(formatList(result.data, run)); + const {feedUrl, posts} = result.data; + if (posts.length === 0) { + emit( + section('Astryx blog', `feed: ${feedUrl}`), + text('No posts found in the feed.'), + ); + return; + } + // One record per post — fields mirror the JSON post shape; empty + // fields (type/textUrl) are skipped by record(). + emit( + section('Astryx blog', `feed: ${feedUrl}`), + records(posts, {fields: ['slug', 'title', 'type', 'textUrl']}), + text(`Read one: ${run} astryx blog `), + ); } else { - // blog.detail — print the feed URL, then the plaintext body. - humanLog(`Feed: ${result.data.feedUrl}\n`); - humanLog(result.data.text); + // blog.detail — the feed URL, then the post body emitted verbatim + // (code() so article typography/spacing isn't ASCII-normalized). + emit(record({feed: result.data.feedUrl}), code(result.data.text)); } }); } diff --git a/packages/cli/clients/cli/commands/blog.test.mjs b/packages/cli/clients/cli/commands/blog.test.mjs index 569bcc82799a..c9fec4fb30f0 100644 --- a/packages/cli/clients/cli/commands/blog.test.mjs +++ b/packages/cli/clients/cli/commands/blog.test.mjs @@ -82,7 +82,8 @@ describe('blog CLI — json-enabled', () => { it('blog (non-json) still prints the human feed listing', async () => { const {status, stdout} = await runCli(['blog']); expect(status).toBe(0); - expect(stdout).toMatch(/Astryx blog · feed:/); + expect(stdout).toMatch(/Astryx blog/); + expect(stdout).toMatch(/feed:/); expect(stdout).toMatch(/how-astryx-works/); }); diff --git a/packages/cli/clients/cli/commands/build-theme.mjs b/packages/cli/clients/cli/commands/build-theme.mjs index d5c6d2733f21..eec3ac35e0c4 100644 --- a/packages/cli/clients/cli/commands/build-theme.mjs +++ b/packages/cli/clients/cli/commands/build-theme.mjs @@ -20,7 +20,8 @@ import * as path from 'node:path'; import {fileURLToPath} from 'node:url'; import {spawn} from 'node:child_process'; import {getCliInvocation} from '../../../foundation/env/package-manager.mjs'; -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, section, text, list, code} from '../formatters/index.mjs'; import {logger} from '../../../api/logger.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs'; @@ -91,10 +92,10 @@ async function runThemeBuildWatch(file, filePath, options) { return; } building = true; - humanLog(`\n♻️ Change detected — rebuilding ${rel}...`); + emit(text(`\nChange detected — rebuilding ${rel}...`)); await runThemeBuildOnceChild(file, options); building = false; - humanLog(`\n👀 Watching ${rel} for changes — press Ctrl-C to stop.`); + emit(text(`\nWatching ${rel} for changes — press Ctrl-C to stop.`)); if (queued) { queued = false; rebuild(); @@ -116,13 +117,13 @@ async function runThemeBuildWatch(file, filePath, options) { // Announce readiness only AFTER fs.watch is armed — the log is the "safe to // edit" signal (tests and humans rely on it), so printing it before the watch // is registered would race: a change in that gap is silently missed. - humanLog(`\n👀 Watching ${rel} for changes — press Ctrl-C to stop.`); + emit(text(`\nWatching ${rel} for changes — press Ctrl-C to stop.`)); await new Promise((/** @type {(value?: void) => void} */ resolve) => { const stop = () => { clearTimeout(debounce); watcher.close(); - humanLog('\nStopped watching.'); + emit(text('\nStopped watching.')); resolve(); }; process.once('SIGINT', stop); @@ -130,6 +131,32 @@ async function runThemeBuildWatch(file, filePath, options) { }); } +/** + * Emit the bundled themes as a bulleted list plus the `theme add` usage hint — + * the human projection of a `theme.list` envelope. Shared by `theme list` and + * the list affordance of `theme add` (bare `theme add` / `--list`). + * @param {import('../../../api/theme/theme.type.mjs').ThemeListEntry[]} themes + */ +function printThemeList(themes) { + if (themes.length === 0) { + emit(text('No themes are bundled with this CLI build.')); + return; + } + const run = getCliInvocation(); + emit( + section('Themes'), + list( + themes.map(t => { + const head = t.maintained ? `${t.slug} (maintained)` : t.slug; + return t.description ? [head, t.description] : head; + }), + ), + text( + `Usage:\n ${run} theme add [target-path] Scaffold a theme file you own`, + ), + ); +} + /** * @param {import('commander').Command} program */ @@ -266,21 +293,7 @@ export function registerTheme(program) { if (json) return jsonOut(result); - const themes = result.data; - if (themes.length === 0) { - humanLog('\nNo themes are bundled with this CLI build.\n'); - return; - } - humanLog('\nThemes:\n'); - for (const t of themes) { - const tag = t.maintained ? ' (maintained)' : ''; - humanLog(` ${t.slug}${tag}`); - if (t.description) humanLog(` ${t.description}`); - } - humanLog('\nUsage:'); - humanLog( - ` ${getCliInvocation()} theme add [target-path] Scaffold a theme file you own\n`, - ); + printThemeList(result.data); }); theme @@ -324,41 +337,30 @@ export function registerTheme(program) { if (json) return jsonOut(result); if (result.type === 'theme.list') { - const themes = result.data; - humanLog('\nThemes:\n'); - for (const t of themes) { - const tag = t.maintained ? ' (maintained)' : ''; - humanLog(` ${t.slug}${tag}`); - if (t.description) humanLog(` ${t.description}`); - } - humanLog('\nUsage:'); - humanLog( - ` ${getCliInvocation()} theme add [target-path] Scaffold a theme file you own\n`, - ); + printThemeList(result.data); return; } // theme.add — print where files landed + how to use the theme. const {displayName, outputDir, entry, exportName, files} = result.data; - humanLog(`\n✓ Added ${displayName} theme to ${outputDir}/`); - for (const f of files) { - humanLog(` ${outputDir}/${f}`); - } const entryModule = importSpecifier( outputDir, entry.replace(/\.tsx?$/, ''), ); - humanLog(` -Use it in your app (import path is relative to a file in src/ — adjust if yours lives elsewhere): - - import { ${exportName} } from '${entryModule}'; - - - - - -This is your copy of the ${displayName} theme — edit ${entry} to make it your own. -`); + emit( + text(`[ok] Added ${displayName} theme to ${outputDir}/`), + list(files.map(f => `${outputDir}/${f}`)), + text( + 'Use it in your app (import path is relative to a file in src/ — adjust if yours lives elsewhere):', + ), + code( + `import { ${exportName} } from '${entryModule}';\n\n` + + `\n \n`, + ), + text( + `This is your copy of the ${displayName} theme — edit ${entry} to make it your own.`, + ), + ); }, ); } diff --git a/packages/cli/clients/cli/commands/build.mjs b/packages/cli/clients/cli/commands/build.mjs index 5100c7b7928a..59aef6ce4502 100644 --- a/packages/cli/clients/cli/commands/build.mjs +++ b/packages/cli/clients/cli/commands/build.mjs @@ -13,42 +13,53 @@ */ import {getCliInvocation, formatCliCommand} from '../../../foundation/env/package-manager.mjs'; -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, section, text, record, records, ARROW} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {build as buildApi} from '../../../api/build/build.mjs'; /** - * Print the build playbook (shown when `build` is run with no query). + * Emit the build playbook (shown when `build` is run with no query). * @param {string} run - The CLI invocation prefix (e.g. `npx astryx`). */ function printPlaybook(run) { - const lines = [ - '', - 'How to build a page with Astryx', - '', - "1. Find a starting point for what you're building:", - ` ${run} build ""`, - ' → returns the closest [page] template, the [block]s that cover parts,', - ' and the [component]s to fill the gaps, with a "Compose:" suggestion.', - '', - '2. If a [page] template matches → scaffold it and adapt:', - ` ${run} template [path]`, - '', - '3. If nothing matches exactly → compose:', - ` ${run} template --skeleton # study a close page's layout`, - ` ${run} template # drop in each block from the kit`, - ` ${run} component # fill remaining gaps (read props)`, - '', - '4. Rules (keep it on-system):', - ' - No
/raw HTML for layout — use VStack/HStack/Grid/Stack/Card etc.', - ` - No style={{}} — use component props; design tokens via \`${run} docs tokens\`.`, - ' - Wrap the app in and import core reset.css + astryx.css.', - '', - `Tip: \`${run} build ""\` is the fastest way in. For a neutral`, - `lookup of any component/doc/template, use \`${run} search \`.`, - '', - ]; - for (const l of lines) humanLog(l); + emit( + section('How to build a page with Astryx'), + text( + [ + "1. Find a starting point for what you're building:", + ` ${run} build ""`, + ` ${ARROW} returns the closest [page] template, the [block]s that cover parts,`, + ' and the [component]s to fill the gaps, with a "Compose:" suggestion.', + ].join('\n'), + ), + text( + [ + `2. If a [page] template matches ${ARROW} scaffold it and adapt:`, + ` ${run} template [path]`, + ].join('\n'), + ), + text( + [ + `3. If nothing matches exactly ${ARROW} compose:`, + ` ${run} template --skeleton # study a close page's layout`, + ` ${run} template # drop in each block from the kit`, + ` ${run} component # fill remaining gaps (read props)`, + ].join('\n'), + ), + text( + [ + '4. Rules (keep it on-system):', + ' - No
/raw HTML for layout — use VStack/HStack/Grid/Stack/Card etc.', + ` - No style={{}} — use component props; design tokens via \`${run} docs tokens\`.`, + ' - Wrap the app in and import core reset.css + astryx.css.', + ].join('\n'), + ), + text( + `Tip: \`${run} build ""\` is the fastest way in. For a neutral ` + + `lookup of any component/doc/template, use \`${run} search \`.`, + ), + ); } /** @@ -98,71 +109,91 @@ export function registerBuild(program) { result.data; if (!hasResults) { - humanLog(''); - humanLog(`No matches for "${q}".`); - humanLog(`Try a broader term, or browse: ${run} component --list`); - humanLog(''); + emit( + text(`No matches for "${q}".`), + text(`Try a broader term, or browse: ${run} component --list`), + ); return; } - const printItem = (/** @type {import('../../../api/search/search.type.mjs').SearchResultEntry} */ r, /** @type {string} */ label) => { - const display = r.domain === 'template' && r.displayName ? r.displayName : r.name; - humanLog(''); - humanLog(` [${label}] ${display}`); - if (r.description) humanLog(` ${r.description}`); - humanLog(` → ${formatCliCommand(r.command)}`); - if (options.verbose) { - if (r.import) humanLog(` import: ${r.import}`); - humanLog(` match: ${r.reason} (score ${r.score})`); - } - }; - - humanLog(''); - humanLog(`Building "${q}":`); - - // START — the single recommended path. - humanLog(''); - if (directMatch) { - humanLog(`START → Scaffold the \`${pages[0].name}\` page template, then adapt: ${run} template ${pages[0].name} ./src/App.tsx`); - } else if (pages.length) { - humanLog(`START → No exact page template. Use \`${pages[0].name}\` as a layout reference (${run} template ${pages[0].name} --skeleton) and compose the pieces below.`); - } else { - humanLog(`START → No page template fits. Frame with AppShell and compose the blocks + components below.`); - } + // Same JSON->text projection as search, but leaner: the section header + // already says the kind, so drop `domain`/`import` by default (they're in + // --json and under --verbose). Keeps each item to name/displayName/desc/cmd. + const fields = options.verbose + ? ['name', 'domain', 'displayName', 'score', 'reason', 'import', 'description', 'command'] + : ['name', 'displayName', 'description', 'command']; + /** @type {import('../formatters/index.mjs').RecordOptions} */ + const recordOpts = {fields, format: {command: formatCliCommand}}; + + // `template ` scaffolds into your project; is the file + // (or folder) to write it to — a placeholder, since we can't know your + // layout. `--skeleton` and `component ` just print, so no path. + const startCmd = directMatch + ? `${run} template ${pages[0].name} ` + : pages.length + ? `${run} template ${pages[0].name} --skeleton` + : `${run} component AppShell`; + const startNote = directMatch + ? `This \`${pages[0].name}\` page template appears to be the closest to what you want, so we recommend scaffolding it into your project — replace \`\` with the file (or folder) to write it to — then adapting. Otherwise, browse PAGE TEMPLATES first, then BLOCKS and DOMAIN COMPONENTS below.` + : pages.length + ? `No exact match, but \`${pages[0].name}\` is the closest page template — run the above to print its layout as a reference, then compose. Otherwise, browse PAGE TEMPLATES first, then BLOCKS and DOMAIN COMPONENTS below.` + : 'No page template fits — frame with AppShell, then compose from BLOCKS and DOMAIN COMPONENTS below.'; + + // A short legend up top: what this output is, how to use it, and the exact + // order of the sections below (only the ones actually present) so it reads + // clearly and parses predictably. + const sectionsOrder = ['RECOMMENDED START']; + if (pages.length) sectionsOrder.push('PAGE TEMPLATES'); + if (blocks.length) sectionsOrder.push('BLOCKS'); + if (domain.length) sectionsOrder.push('DOMAIN COMPONENTS'); + sectionsOrder.push('FRAME + FOUNDATION'); + + /** @type {import('../formatters/index.mjs').Block[]} */ + const out = [ + section(`Build kit for "${q}"`), + text( + 'A recommended set of pieces to assemble this page, in the order to use them. ' + + 'Begin with RECOMMENDED START, then pull from the sections below — each ' + + 'recommended item includes a `command:` to run next.\n' + + `Sections in order: ${sectionsOrder.join(', ')}.`, + ), + section('RECOMMENDED START', `${startNote}\n${startCmd}`), + ]; - // PAGE if (pages.length) { - humanLog(''); - humanLog(directMatch ? 'PAGE TEMPLATE — direct match:' : 'CLOSEST PAGE TEMPLATES — layout reference:'); - pages.forEach(p => printItem(p, directMatch ? 'page' : 'closest')); + out.push( + section( + 'PAGE TEMPLATES', + directMatch + ? 'Closest full-page templates — scaffold one, then adapt it.' + : 'Closest full-page templates — use as a layout reference.', + ), + records(pages, recordOpts), + ); } - - // FRAME — always (the page shell). - humanLog(''); - humanLog(`FRAME — page shell (always): ${frame.join(', ')}`); - humanLog(` full-page → AppShell; or Layout + SideNav/TopNav. ${run} component AppShell`); - - // BLOCKS — idea-specific composed patterns. if (blocks.length) { - humanLog(''); - humanLog('BLOCKS — drop-in patterns that cover parts of it:'); - blocks.forEach(b => printItem(b, 'block')); + out.push( + section('BLOCKS', 'Drop-in patterns that cover parts of the page.'), + records(blocks, recordOpts), + ); } - - // DOMAIN COMPONENTS — idea-specific atoms. if (domain.length) { - humanLog(''); - humanLog('DOMAIN COMPONENTS — specific to this idea:'); - domain.forEach(c => printItem(c, c.domain === 'hook' ? 'hook' : 'component')); + out.push( + section('DOMAIN COMPONENTS', 'Components specific to this idea.'), + records(domain, recordOpts), + ); } - // FOUNDATION — always (layout/typography/actions). - humanLog(''); - humanLog(`FOUNDATION — always available (layout/text/actions): ${foundation.join(' ')}`); - - // SETUP — so it renders / stays on-system. - humanLog(''); - humanLog('SETUP — import "@astryxdesign/core/reset.css" + "astryx.css". No
/style for layout — use Stack/Grid + tokens.'); - humanLog(''); + out.push( + section('FRAME + FOUNDATION', 'Always-available shell + layout/text/action primitives.'), + record({ + frame, + foundation, + setup: + 'import "@astryxdesign/core/reset.css" + "astryx.css"; no
/style for layout — use Stack/Grid + tokens', + }), + ); + + emit(...out); }); } diff --git a/packages/cli/clients/cli/commands/component/index.mjs b/packages/cli/clients/cli/commands/component/index.mjs index a447136bbfdb..88fefffc3505 100644 --- a/packages/cli/clients/cli/commands/component/index.mjs +++ b/packages/cli/clients/cli/commands/component/index.mjs @@ -19,7 +19,8 @@ import { } from '../../lib/component-format.mjs'; import {resolveTheme} from '../../lib/resolve-theme.mjs'; import {getCliInvocation} from '../../../../foundation/env/package-manager.mjs'; -import {jsonOut, humanLog} from '../../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../../foundation/response/json.mjs'; +import {emit, section, text, list, record, records, code} from '../../formatters/index.mjs'; import {cliError} from '../../lib/cli-error.mjs'; import {ERROR_CODES} from '../../../../foundation/response/error-codes.mjs'; import {component as componentApi} from '../../../../api/component/component.mjs'; @@ -114,47 +115,51 @@ export function registerComponent(program) { const coreDir = /** @type {string} */ (findCoreDir(process.cwd())); const themeData = resolveTheme(process.cwd()); + // Footer shared by the compact + names list views (prose → text()). + const listFooter = text( + [ + `Import from the path shown (e.g. import {Button} from '@astryxdesign/core/Button')`, + `Usage: ${run} component `, + ].join('\n'), + ); + switch (result.type) { case 'component.list': { // One list type across all three detail levels; the depth is carried // in result.data.detail and the grouped map in result.data.components. if (result.data.detail === 'full') { - // --detail full — dense per-component docs (signature, props, theming, examples). - humanLog(await formatBriefAll(coreDir, {zh, lang, themeData})); + // --detail full — dense per-component docs (signature, props, theming, + // examples). Verbatim doc block from the shared formatter. + emit(code(await formatBriefAll(coreDir, {zh, lang, themeData}))); break; } if (result.data.detail === 'compact') { - // --detail compact — name + 1-line description per entry. + // --detail compact — one record per entry (name + import + 1-line + // description), grouped by category. Fields mirror the JSON keys. const groups = result.data.components; - humanLog(''); const entries = Object.entries(groups); + /** @type {import('../../formatters/index.mjs').Block[]} */ + const out = []; for (const [cat, items] of entries) { // Skip the synthetic group header when there's only one ungrouped category const isUngrouped = entries.length === 1 && items.length === 1 && items[0]?.name === cat; - if (!isUngrouped) humanLog(`${cat} (group)`); - for (const item of items) { - const importHint = item.import ? ` ← ${item.import}` : ''; - const desc = item.description ? ` — ${item.description}` : ''; - humanLog(` XDS${item.name}${importHint}${desc}`); - } - humanLog(''); + if (!isUngrouped) out.push(section(cat)); + out.push(records(items, {fields: ['name', 'import', 'description']})); } - humanLog(`Import from the path shown (e.g. import {Button} from '@astryxdesign/core/Button')`); - humanLog(`Usage: ${run} component `); - humanLog(''); + out.push(listFooter); + emit(...out); break; } - // --detail names (default for list views). The API now returns - // package-qualified entries ({name, package}); the human view omits - // the core package label for readability but ALWAYS shows the package - // for integration components (and whenever names collide). + // --detail names (default for list views): one record per component + // (name + import), sorted A-Z. The import field already conveys + // families, so we skip the choppy per-family grouping that interleaved + // headerless singletons with "(group)" sections. External or + // name-colliding entries stay package-qualified. const groups = result.data.components; const CORE_PKG = '@astryxdesign/core'; - // Names that appear under more than one package across the whole - // listing — these must always be package-qualified to disambiguate. /** @type {Map>} */ const nameCounts = new Map(); for (const items of Object.values(groups)) { @@ -164,113 +169,92 @@ export function registerComponent(program) { nameCounts.set(item.name, set); } } - /** @param {string} n */ - const isCollision = n => (nameCounts.get(n)?.size ?? 0) > 1; /** @param {import('../../../../api/component/component.type.mjs').ComponentListEntry} item */ - const pkgSuffix = item => { - if (item.package !== CORE_PKG) return ` [${item.package}]`; - if (isCollision(item.name)) return ` [${item.package}]`; - return ''; + const importCell = item => { + const importPath = resolveImportPath(coreDir, item.name); + const qualify = + item.package !== CORE_PKG || (nameCounts.get(item.name)?.size ?? 0) > 1; + return qualify ? `${importPath} [${item.package}]` : importPath; }; - if (options.category) { - const [cat, comps] = Object.entries(groups)[0]; - humanLog(`\n${cat}:`); - for (const item of comps) { - const importPath = resolveImportPath(coreDir, item.name); - humanLog(` ${item.name} ← ${importPath}${pkgSuffix(item)}`); - } - humanLog(''); - } else { - humanLog(''); - for (const [key, comps] of Object.entries(groups)) { - const isUngrouped = comps.length === 1 && comps[0]?.name === key; - if (isUngrouped) { - const item = comps[0]; - const importPath = resolveImportPath(coreDir, item.name); - humanLog(`${item.name} ← ${importPath}${pkgSuffix(item)}`); - } else { - humanLog(`${key} (group)`); - for (const item of comps) { - const importPath = resolveImportPath(coreDir, item.name); - humanLog(` ${item.name} ← ${importPath}${pkgSuffix(item)}`); - } - } - } - humanLog(''); - humanLog(`Import from the path shown (e.g. import {Button} from '@astryxdesign/core/Button')`); - humanLog(`Usage: ${run} component `); - humanLog(''); - } + const firstGroup = Object.entries(groups)[0]; + const entries = + options.category && firstGroup ? firstGroup[1] : Object.values(groups).flat(); + const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name)); + + emit( + options.category && firstGroup + ? section(firstGroup[0]) + : section(`Components (${sorted.length})`), + records( + sorted.map(item => ({name: item.name, import: importCell(item)})), + {fields: ['name', 'import']}, + ), + listFooter, + ); break; } case 'component.detail': { - if (detail === 'brief') { - const resolvedName = (name || '').replace(/^XDS/, ''); - const importHint = resolveImportPath(coreDir, resolvedName); - humanLog(formatBrief(result.data, resolvedName, importHint, {themeData})); - } else if (detail === 'compact') { - const resolvedName = (name || '').replace(/^XDS/, ''); - const importHint = resolveImportPath(coreDir, resolvedName); - humanLog(formatCompact(result.data, resolvedName, importHint)); - } else { - const resolvedName = (name || '').replace(/^XDS/, ''); - const importHint = resolveImportPath(coreDir, resolvedName); - humanLog(formatFull(result.data, {themeData, importHint})); - } - const compName = (name || '').replace(/^XDS/, ''); - const related = await findRelatedBlocks(compName); - if (related.length > 0) { - humanLog('\nRelated block templates:\n'); - for (const b of related) { - humanLog(` ${b.dirName}`); - if (b.description) humanLog(` ${b.description}`); - } - humanLog(''); - } + const resolvedName = (name || '').replace(/^XDS/, ''); + const importHint = resolveImportPath(coreDir, resolvedName); + const doc = + detail === 'brief' + ? code(formatBrief(result.data, resolvedName, importHint, {themeData})) + : detail === 'compact' + ? code(formatCompact(result.data, resolvedName, importHint)) + : code(formatFull(result.data, {themeData, importHint})); + const related = await findRelatedBlocks(resolvedName); + emit( + doc, + related.length > 0 && section('Related block templates'), + related.length > 0 && records(related, {fields: ['dirName', 'description']}), + ); break; } case 'component.detail.props': { const resolvedName = (name || '').replace(/^XDS/, ''); - humanLog(formatProps({props: result.data}, resolvedName)); + emit(code(formatProps({props: result.data}, resolvedName))); break; } case 'component.detail.source': { - humanLog(result.data.source); + emit(code(result.data.source)); break; } case 'component.detail.showcase': { - humanLog(result.data.source); + emit(code(result.data.source)); break; } case 'component.detail.blocks': { const {showcase, examples, related} = result.data; + /** @type {import('../../formatters/index.mjs').Block[]} */ + const out = []; if (showcase) { - humanLog(`\nShowcase: ${showcase.displayName}`); - if (showcase.description) humanLog(` ${showcase.description}`); + out.push( + section('Showcase'), + record(showcase, {fields: ['displayName', 'description']}), + ); } if (examples.length > 0) { - humanLog('\nExamples:\n'); - for (const b of examples) { - humanLog(` ${b.name}`); - if (b.description) humanLog(` ${b.description}`); - } + out.push( + section('Examples'), + records(examples, {fields: ['name', 'description']}), + ); } if (related.length > 0) { - humanLog(`\nRelated: ${related.length} blocks that use ${result.data.component}\n`); - for (const b of related) { - humanLog(` ${b.name}`); - } + out.push( + section(`Related: ${related.length} blocks that use ${result.data.component}`), + list(related.map(b => b.name)), + ); } if (!showcase && examples.length === 0 && related.length === 0) { - humanLog(`\nNo blocks found for ${result.data.component}`); + out.push(text(`No blocks found for ${result.data.component}`)); } - humanLog(''); + emit(...out); break; } } diff --git a/packages/cli/clients/cli/commands/detail-levels.test.mjs b/packages/cli/clients/cli/commands/detail-levels.test.mjs index b95261182c76..ec6c96975566 100644 --- a/packages/cli/clients/cli/commands/detail-levels.test.mjs +++ b/packages/cli/clients/cli/commands/detail-levels.test.mjs @@ -58,8 +58,10 @@ describe('--detail level ordering: component --list', () => { expect(brief).not.toMatch(/ \u2014 /); }); - it('compact has 1-line descriptions (name + em-dash separator)', () => { - expect(compact).toMatch(/ \u2014 /); + it('compact has descriptions (records expose a description field)', () => { + // Migrated to the shared formatter kit: compact renders one record per entry + // (name/import/description) rather than a single em-dash-joined line. + expect(compact).toMatch(/^description:/m); }); it('full has dense per-entry docs (props, targets, and import hints)', () => { @@ -114,8 +116,8 @@ describe('--detail level ordering: hook --list', () => { expect(brief).not.toMatch(/ \u2014 /); }); - it('compact has 1-line descriptions (name + em-dash separator)', () => { - expect(compact).toMatch(/ \u2014 /); + it('compact has per-hook descriptions (records with a description field)', () => { + expect(compact).toMatch(/description:/); }); it('full has dense docs (param tables and import statements)', () => { diff --git a/packages/cli/clients/cli/commands/discover.mjs b/packages/cli/clients/cli/commands/discover.mjs index 3f14113bcdec..159aa6618399 100644 --- a/packages/cli/clients/cli/commands/discover.mjs +++ b/packages/cli/clients/cli/commands/discover.mjs @@ -11,11 +11,15 @@ */ import {formatFull, formatBrief, formatCompact} from '../lib/component-format.mjs'; -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, section, text, record, records, list, code} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {discover as discoverApi} from '../../../api/discover/discover.mjs'; import {getCliInvocation} from '../../../foundation/env/package-manager.mjs'; +// Max components to list inline per package before summarizing with "+N more". +const MAX_COMPONENTS_SHOWN = 10; + /** * @param {import('commander').Command} program */ @@ -55,87 +59,77 @@ export function registerDiscover(program) { switch (result.type) { case 'discover.list': { if (result.data.length === 0) { - humanLog(''); if (result.meta && result.meta.configured === false) { - humanLog('No integrations configured.'); - humanLog(''); - humanLog('Add integration package names to astryx.config.mjs:'); - humanLog(''); - humanLog(' export default {'); - humanLog(" integrations: ['@scope/your-integration'],"); - humanLog(' };'); + emit( + text('No integrations configured.'), + text('Add integration package names to astryx.config.mjs:'), + code( + "export default {\n integrations: ['@scope/your-integration'],\n};", + ), + ); } else { - humanLog('No external components found in configured integrations.'); + emit(text('No external components found in configured integrations.')); } - humanLog(''); - } else { - humanLog(''); - for (const pkg of result.data) { - const count = pkg.components.length; - const label = count === 1 ? 'component' : 'components'; - const heading = pkg.displayName - ? pkg.displayName + ' ' + pkg.name + ' (' + count + ' ' + label + ')' - : pkg.name + ' (' + count + ' ' + label + ')'; - humanLog(heading); - if (pkg.description) humanLog(' ' + pkg.description); + break; + } - if (options.components) { - for (const comp of pkg.components) humanLog(' ' + comp); - } else { - const maxShow = 10; - const shown = pkg.components.slice(0, maxShow); - const remaining = count - maxShow; - const list = shown.join(', '); - humanLog(remaining > 0 ? ' ' + list + ', +' + remaining + ' more' : ' ' + list); - } - humanLog(''); - } - humanLog('Usage:'); - humanLog(` ${run} discover Browse a package`); - humanLog(` ${run} discover /Component View component docs`); - humanLog(` ${run} discover Search all packages`); - humanLog(''); + // One record per package — fields mirror the JSON entry. The default + // view summarizes each package's components (first N + "+N more"); + // --components lists them all (record comma-joins the full array). + /** @type {import('../formatters/index.mjs').RecordOptions} */ + const listOpts = {fields: ['displayName', 'name', 'description', 'components']}; + if (!options.components) { + listOpts.format = { + components: (/** @type {string[]} */ comps) => { + const shown = comps.slice(0, MAX_COMPONENTS_SHOWN); + const remaining = comps.length - MAX_COMPONENTS_SHOWN; + return remaining > 0 + ? `${shown.join(', ')}, +${remaining} more` + : shown.join(', '); + }, + }; } + emit( + records(result.data, listOpts), + text( + [ + 'Usage:', + ` ${run} discover Browse a package`, + ` ${run} discover /Component View component docs`, + ` ${run} discover Search all packages`, + ].join('\n'), + ), + ); break; } case 'discover.detail': { - humanLog(''); const d = result.data; - const detailHeading = d.displayName - ? d.displayName + ' ' + d.name + ' (' + d.components.length + ' components)' - : d.name + ' (' + d.components.length + ' components)'; - humanLog(detailHeading); - if (d.description) humanLog(' ' + d.description); - humanLog(''); - for (const comp of result.data.components) humanLog(' ' + comp); - humanLog(''); - humanLog(`Usage: ${run} discover ` + result.data.name + '/'); - humanLog(''); + emit( + record(d, {fields: ['displayName', 'name', 'description', 'components']}), + text(`Usage: ${run} discover ${d.name}/`), + ); break; } case 'discover.detail.doc': { const docs = result.data; - if (detail === 'brief') { - humanLog(formatBrief(docs, docs.name, '')); - } else if (detail === 'compact') { - humanLog(formatCompact(docs, docs.name, '')); - } else { - humanLog(formatFull(docs)); - } - humanLog(''); + const md = + detail === 'brief' + ? formatBrief(docs, docs.name, '') + : detail === 'compact' + ? formatCompact(docs, docs.name, '') + : formatFull(docs); + emit(code(md)); break; } case 'discover.search': { - humanLog(''); - humanLog('Found ' + result.data.matches.length + ' matches for "' + result.data.query + '":'); - humanLog(''); - for (const m of result.data.matches) { - humanLog(` ${run} discover ` + m.package + '/' + m.component); - } - humanLog(''); + const {query: q, matches} = result.data; + emit( + section(`Found ${matches.length} matches for "${q}"`), + list(matches.map(m => `${run} discover ${m.package}/${m.component}`)), + ); break; } } diff --git a/packages/cli/clients/cli/commands/docs.mjs b/packages/cli/clients/cli/commands/docs.mjs index 2b29688e08cd..fc641b0f5b3c 100644 --- a/packages/cli/clients/cli/commands/docs.mjs +++ b/packages/cli/clients/cli/commands/docs.mjs @@ -13,7 +13,8 @@ */ import {getCliInvocation} from '../../../foundation/env/package-manager.mjs'; -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, section, records, text, code} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {docs as docsApi} from '../../../api/docs/docs.mjs'; @@ -76,8 +77,8 @@ function formatBlock(block, detail) { case 'list': { const prefix = block.style === 'ordered' ? (/** @type {number} */ i) => `${i + 1}. ` - : block.style === 'dont' ? () => '❌ ' - : block.style === 'do' ? () => '✓ ' + : block.style === 'dont' ? () => 'x ' + : block.style === 'do' ? () => '+ ' : () => '- '; return block.items.map((item, i) => `${prefix(i)}${item}`).join('\n'); } @@ -135,7 +136,7 @@ export function registerDocs(program) { program .command('docs [topic] [section]') .description('Print reference docs') - .action(async (/** @type {string | undefined} */ topic, /** @type {string | undefined} */ section) => { + .action(async (/** @type {string | undefined} */ topic, /** @type {string | undefined} */ sectionName) => { const run = getCliInvocation(); const lang = program.opts().lang || null; const zh = program.opts().zh || false; @@ -145,7 +146,7 @@ export function registerDocs(program) { let result; try { - result = await docsApi(topic, section, {lang, zh, dense}); + result = await docsApi(topic, sectionName, {lang, zh, dense}); } catch (e) { // docs API throws structured errors with {name, reason} suggestions — // pass them through untouched so the CLI envelope matches the API. @@ -158,22 +159,28 @@ export function registerDocs(program) { switch (result.type) { case 'docs.list': { - humanLog('\nAvailable docs:\n'); - for (const entry of result.data) { - humanLog(` ${entry.topic.padEnd(14)} ${entry.description}`); - } - humanLog(`\nUsage: ${run} docs `); - humanLog(` ${run} docs
\n`); + // The text view mirrors the JSON list: one record per topic (topic + + // description), then the usage footer as plain prose. + emit( + section('Available docs'), + records(result.data, {fields: ['topic', 'description']}), + text( + [ + `Usage: ${run} docs `, + ` ${run} docs
`, + ].join('\n'), + ), + ); break; } case 'docs.detail': { - humanLog(formatReferenceFull(result.data, detail)); + emit(code(formatReferenceFull(result.data, detail))); break; } case 'docs.detail.section': { - humanLog(formatSection(result.data, detail)); + emit(code(formatSection(result.data, detail))); break; } } diff --git a/packages/cli/clients/cli/commands/doctor.mjs b/packages/cli/clients/cli/commands/doctor.mjs index df45a109e800..4811ac86de03 100644 --- a/packages/cli/clients/cli/commands/doctor.mjs +++ b/packages/cli/clients/cli/commands/doctor.mjs @@ -11,45 +11,51 @@ */ import {runChecks} from '../../../api/doctor/doctor.mjs'; -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, section, records, text} from '../formatters/index.mjs'; -/** Status → human glyph (monochrome, matching the rest of the CLI). */ -const GLYPH = { - pass: '\u2713', // ✓ - warn: '\u26a0', // ⚠ - fail: '\u2717', // ✗ - info: '\u2139', // ℹ +/** Status -> ASCII token (plain, matching the rest of the CLI). */ +const STATUS = { + pass: '[ok]', + warn: '[warn]', + fail: '[fail]', + info: '[info]', }; +/** @param {string} status */ +function statusToken(status) { + return STATUS[/** @type {keyof typeof STATUS} */ (status)] ?? status; +} + /** - * Render the report as a human-readable checklist. + * Render the report as a human-readable checklist. Each check is a record whose + * fields mirror the JSON (status/label/message/fix); the status glyph is an + * ASCII token so the output is byte-deterministic in any terminal. * @param {import('../../../api/doctor/doctor.mjs').DoctorReport} report */ function printHuman(report) { - humanLog('astryx doctor — diagnosing your setup\n'); - for (const check of report.checks) { - const glyph = GLYPH[check.status] ?? '\u00b7'; - humanLog(` ${glyph} ${check.label}`); - humanLog(` ${check.message}`); - if (check.fix) { - humanLog(` \u2192 fix: ${check.fix}`); - } - } - const {pass, warn, fail, info} = report.summary; - humanLog(''); - humanLog( - `Summary: ${pass} passed, ${warn} warning${warn === 1 ? '' : 's'}, ` + - `${fail} failure${fail === 1 ? '' : 's'}` + - (info ? `, ${info} info` : ''), + const closing = + fail > 0 + ? 'Some checks failed. Address the items marked [fail] above.' + : warn > 0 + ? 'No failures — but review the [warn] warnings above when you can.' + : 'All checks passed. Your XDS setup looks healthy.'; + + emit( + section('astryx doctor — diagnosing your setup'), + records(report.checks, { + fields: ['status', 'label', 'message', 'fix'], + labels: {label: 'check'}, + format: {status: statusToken}, + }), + text( + `Summary: ${pass} passed, ${warn} warning${warn === 1 ? '' : 's'}, ` + + `${fail} failure${fail === 1 ? '' : 's'}` + + (info ? `, ${info} info` : ''), + ), + text(closing), ); - if (fail > 0) { - humanLog('\nSome checks failed. Address the items marked \u2717 above.'); - } else if (warn > 0) { - humanLog('\nNo failures — but review the \u26a0 warnings above when you can.'); - } else { - humanLog('\nAll checks passed. Your XDS setup looks healthy.'); - } } /** diff --git a/packages/cli/clients/cli/commands/hook/index.mjs b/packages/cli/clients/cli/commands/hook/index.mjs index 22124ba54f4f..1ee308328d0b 100644 --- a/packages/cli/clients/cli/commands/hook/index.mjs +++ b/packages/cli/clients/cli/commands/hook/index.mjs @@ -13,7 +13,8 @@ import { formatHookParams, } from '../../lib/hook-format.mjs'; import {getCliInvocation} from '../../../../foundation/env/package-manager.mjs'; -import {jsonOut, humanLog} from '../../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../../foundation/response/json.mjs'; +import {emit, section, text, list, records, code} from '../../formatters/index.mjs'; import {cliError} from '../../lib/cli-error.mjs'; import {ERROR_CODES} from '../../../../foundation/response/error-codes.mjs'; import {hook as hookApi} from '../../../../api/hook/hook.mjs'; @@ -88,64 +89,66 @@ export function registerHook(program) { if (result.data.detail === 'full') { // --detail full — dense per-hook docs grouped by category // (import block, best practices, full params + returns tables, related). + // The whole view is one markdown document: a `## ` heading + // over each category's concatenated hook docs. const groups = result.data.components; - humanLog(''); + /** @type {import('../../formatters/index.mjs').Block[]} */ + const out = []; for (const [cat, items] of Object.entries(groups)) { - humanLog(`## ${cat}\n`); - for (const item of items) { - const importPath = item.importPath || '@astryxdesign/core/hooks'; - humanLog(formatHookCompact(item, importPath)); - } + const body = items + .map(item => + formatHookCompact(item, item.importPath || '@astryxdesign/core/hooks'), + ) + .join('\n'); + out.push(code(`## ${cat}\n\n${body}`)); } + emit(...out); break; } if (result.data.detail === 'compact') { - // --detail compact — name + 1-line description per entry. + // --detail compact — one record (name + description) per hook, + // grouped by category. const groups = result.data.components; - humanLog(''); + /** @type {import('../../formatters/index.mjs').Block[]} */ + const out = []; for (const [cat, items] of Object.entries(groups)) { - humanLog(cat); - for (const item of items) { - const desc = item.description ? ` — ${item.description}` : ''; - humanLog(` ${item.name}${desc}`); - } - humanLog(''); + out.push(section(cat), records(items, {fields: ['name', 'description']})); } - humanLog(`Usage: ${run} hook `); - humanLog(''); + out.push(text(`Usage: ${run} hook `)); + emit(...out); break; } - // --detail names (default for list views) — names only. + // --detail names (default for list views) — names only, grouped by + // category. const groups = result.data.components; if (options.category) { const [cat, hookNames] = Object.entries(groups)[0]; - humanLog(`\n${cat}:`); - for (const h of hookNames) humanLog(` ${h}`); - humanLog(''); + emit(section(`${cat}:`), list(hookNames)); } else { - humanLog(''); + /** @type {import('../../formatters/index.mjs').Block[]} */ + const out = []; for (const [category, hookNames] of Object.entries(groups)) { - humanLog(category); - for (const h of hookNames) humanLog(` ${h}`); + out.push(section(category), list(hookNames)); } - humanLog(''); - humanLog(`Usage: ${run} hook `); - humanLog(''); + out.push(text(`Usage: ${run} hook `)); + emit(...out); } break; } case 'hook.detail': { - if (detail === 'brief') { - humanLog(formatHookBrief(result.data)); - } else if (detail === 'compact') { - const importPath = result.data.importPath || '@astryxdesign/core/hooks'; - humanLog(formatHookCompact(result.data, importPath)); - } else { - humanLog(formatHookFull(result.data)); - } + const doc = + detail === 'brief' + ? formatHookBrief(result.data) + : detail === 'compact' + ? formatHookCompact( + result.data, + result.data.importPath || '@astryxdesign/core/hooks', + ) + : formatHookFull(result.data); + // Show related block templates from relatedComponents const relatedComps = result.data.relatedComponents || []; /** @type {import('../../../../api/template/template.mjs').DiscoveredTemplate[]} */ @@ -158,19 +161,18 @@ export function registerHook(program) { } } } - if (allBlocks.length > 0) { - humanLog('\nRelated block templates:\n'); - for (const b of allBlocks) { - humanLog(` ${b.dirName}`); - if (b.description) humanLog(` ${b.description}`); - } - humanLog(''); - } + + emit( + code(doc), + allBlocks.length > 0 && section('Related block templates'), + allBlocks.length > 0 && + records(allBlocks, {fields: ['dirName', 'description']}), + ); break; } case 'hook.detail.params': { - humanLog(formatHookParams({params: result.data, name: name})); + emit(code(formatHookParams({params: result.data, name}))); break; } } diff --git a/packages/cli/clients/cli/commands/layout.mjs b/packages/cli/clients/cli/commands/layout.mjs index df49a4c138ff..2e0b9ff541a2 100644 --- a/packages/cli/clients/cli/commands/layout.mjs +++ b/packages/cli/clients/cli/commands/layout.mjs @@ -14,7 +14,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, section, text, list, record, code, WARN} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs'; import {layoutExpand, layoutCheck, layoutGrammar} from '../../../api/layout/layout.mjs'; @@ -136,17 +137,30 @@ export function registerLayout(program) { } if (json) return jsonOut(result); - for (const warning of result.data.warnings) humanLog(`⚠ ${warning}`); + /** @type {import('../formatters/index.mjs').Block[]} */ + const out = []; + if (result.data.warnings.length > 0) { + out.push(text(result.data.warnings.map(w => `${WARN} ${w}`).join('\n'))); + } if (result.data.written) { - humanLog(`\n✓ Expanded to ${result.data.written}`); - humanLog(` Components: ${result.data.componentsUsed.join(', ')}`); - if (result.data.todos.length > 0) { - humanLog(` TODOs: ${result.data.todos.length} (search for "TODO(xle)")`); - } - humanLog(''); + out.push( + text(`[ok] Expanded to ${result.data.written}`), + record( + { + components: result.data.componentsUsed, + todos: + result.data.todos.length > 0 + ? `${result.data.todos.length} (search for "TODO(xle)")` + : '', + }, + {labels: {components: 'Components', todos: 'TODOs'}}, + ), + ); } else { - humanLog(result.data.code); + // Raw expanded TSX (no target path) — preformatted, emitted verbatim. + out.push(code(result.data.code)); } + emit(...out); }); layoutCmd @@ -188,21 +202,27 @@ export function registerLayout(program) { const {valid, form, errors, warnings, compact, outline} = result.data; if (!valid) { - humanLog(`\n✗ Invalid (${errors.length} error${errors.length === 1 ? '' : 's'}):`); - for (const e of errors) { - humanLog(` - ${e.formatted}`); - if (e.suggestions && e.suggestions.length > 0) humanLog(` did you mean: ${e.suggestions.join(', ')}?`); - } - humanLog(''); + // Each error: the formatted issue, with a hanging "did you mean" line. + const items = errors.map(e => + e.suggestions && e.suggestions.length > 0 + ? [e.formatted, `did you mean: ${e.suggestions.join(', ')}?`] + : [e.formatted], + ); + emit( + text(`[fail] Invalid (${errors.length} error${errors.length === 1 ? '' : 's'}):`), + list(items), + ); return; } - humanLog(`\n✓ Valid (parsed as ${form})`); - for (const warning of warnings) humanLog(`⚠ ${warning}`); - humanLog('\ncompact:'); - humanLog(` ${compact}`); - humanLog('\noutline:'); - humanLog(outline.split('\n').map(l => ` ${l}`).join('\n')); - humanLog(''); + + /** @type {import('../formatters/index.mjs').Block[]} */ + const out = [text(`[ok] Valid (parsed as ${form})`)]; + if (warnings.length > 0) { + out.push(text(warnings.map(w => `${WARN} ${w}`).join('\n'))); + } + // The canonical compact/outline surfaces are preformatted — emit verbatim. + out.push(section('compact'), code(compact), section('outline'), code(outline)); + emit(...out); }); layoutCmd @@ -220,6 +240,7 @@ export function registerLayout(program) { return; } if (json) return jsonOut(result); - humanLog(result.data.text); + // The cheatsheet is a preformatted document — emit verbatim. + emit(code(result.data.text)); }); } diff --git a/packages/cli/clients/cli/commands/search.mjs b/packages/cli/clients/cli/commands/search.mjs index fef3c28a6139..b7368b3c76a9 100644 --- a/packages/cli/clients/cli/commands/search.mjs +++ b/packages/cli/clients/cli/commands/search.mjs @@ -17,17 +17,11 @@ */ import {getCliInvocation, formatCliCommand} from '../../../foundation/env/package-manager.mjs'; -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, section, text, records} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {search as searchApi, SEARCH_DOMAINS} from '../../../api/search/search.mjs'; -const DOMAIN_LABEL = { - component: 'component', - hook: 'hook', - doc: 'docs', - template: 'template', -}; - /** * @param {import('commander').Command} program */ @@ -71,31 +65,24 @@ export function registerSearch(program) { // No matches is a valid, successful outcome — clean message, exit 0. if (results.length === 0) { - humanLog(''); - humanLog(`No results for "${q}".`); - humanLog(''); - humanLog(`Try a broader term, or browse: ${run} component --list`); - humanLog(''); + emit( + text(`No results for "${q}".`), + text(`Try a broader term, or browse: ${run} component --list`), + ); return; } - humanLog(''); - humanLog(`Results for "${q}" (${results.length}):`); - humanLog(''); - for (const r of results) { - const tag = `[${DOMAIN_LABEL[r.domain] || r.domain}]`.padEnd(12); - const display = r.domain === 'template' && r.displayName ? r.displayName : r.name; - humanLog(` ${tag} ${display}`); - if (r.description) { - humanLog(` ${r.description}`); - } - humanLog(` → ${formatCliCommand(r.command)}`); - if (options.verbose) { - if (r.import) humanLog(` import: ${r.import}`); - humanLog(` match: ${r.reason} (score ${r.score})`); - } - humanLog(''); - } + // The text view is just a projection of the JSON: one record per result, + // fields in a fixed order (missing ones skipped), command prefixed for the + // caller's package manager. Ranked order is preserved (best match first). + const fields = options.verbose + ? ['name', 'domain', 'displayName', 'score', 'reason', 'import', 'description', 'command'] + : ['name', 'domain', 'displayName', 'import', 'description', 'command']; + + emit( + section(`Results for "${q}" (${results.length})`), + records(results, {fields, format: {command: formatCliCommand}}), + ); }); } diff --git a/packages/cli/clients/cli/commands/search.test.mjs b/packages/cli/clients/cli/commands/search.test.mjs index 8a87039ffc6a..a4731923f877 100644 --- a/packages/cli/clients/cli/commands/search.test.mjs +++ b/packages/cli/clients/cli/commands/search.test.mjs @@ -191,10 +191,13 @@ describe('search CLI — exit codes + JSON contract', () => { expect(parsed.data.results).toEqual([]); }); - it('shows the follow-up command hint in human output', async () => { + it('renders each result as a greppable key: value record', async () => { const r = await runCli(['search', 'button'], REPO_ROOT); expect(r.stdout).toContain('astryx component Button'); - expect(r.stdout).toContain('[component]'); + // Fields mirror the JSON object and are line-greppable. + expect(r.stdout).toMatch(/^name:\s+Button$/m); + expect(r.stdout).toMatch(/^domain:\s+component$/m); + expect(r.stdout).toContain('description:'); }); it('--verbose exits 0 and prints import/match detail', async () => { @@ -205,6 +208,9 @@ describe('search CLI — exit codes + JSON contract', () => { const r = await runCli(['search', 'button', '--verbose'], REPO_ROOT); expect(r.status).toBe(0); expect(r.stdout).toContain('import:'); - expect(r.stdout).toContain('match:'); + // Ranking detail: pre-formatter this was a single `match: (score N)` + // line; it's now separate `score:` / `reason:` record fields mirroring --json. + expect(r.stdout).toContain('score:'); + expect(r.stdout).toContain('reason:'); }); }, SCAN_TIMEOUT); diff --git a/packages/cli/clients/cli/commands/swizzle.mjs b/packages/cli/clients/cli/commands/swizzle.mjs index 4dbc0b024c78..a48ac2fba6eb 100644 --- a/packages/cli/clients/cli/commands/swizzle.mjs +++ b/packages/cli/clients/cli/commands/swizzle.mjs @@ -10,7 +10,8 @@ * commands for the caller's package manager. */ -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, section, list, text, WARN} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {getCliInvocation} from '../../../foundation/env/package-manager.mjs'; import {swizzle as swizzleApi} from '../../../api/swizzle/swizzle.mjs'; @@ -50,54 +51,63 @@ export function registerSwizzle(program) { if (result.type === 'swizzle.list') { const components = result.data; - humanLog('\nAvailable components:\n'); - for (const name of components) { - humanLog(` ${name}`); - } - humanLog(`\nUsage: ${run} swizzle \n`); - humanLog(`Example: ${run} swizzle Button`); - humanLog(` ${run} swizzle XDSButton (XDS prefix also works)\n`); + emit( + section('Available components'), + list(components), + text(`Usage: ${run} swizzle `), + text( + [ + `Example: ${run} swizzle Button`, + ` ${run} swizzle XDSButton (XDS prefix also works)`, + ].join('\n'), + ), + ); return; } const {package: ownerPackage, outputDir, filesCopied, usesStyleX, feedback} = result.data; - humanLog(`\n✓ Copied ${filesCopied} files to ${outputDir}/\n`); - humanLog(`Relative imports have been rewritten to use ${ownerPackage}.`); - humanLog('You can now customize the component source freely.\n'); + /** @type {import('../formatters/index.mjs').Block[]} */ + const out = [ + text(`[ok] Copied ${filesCopied} files to ${outputDir}/`), + text( + [ + `Relative imports have been rewritten to use ${ownerPackage}.`, + 'You can now customize the component source freely.', + ].join('\n'), + ), + ]; // StyleX build requirement — swizzled StyleX source renders unstyled with // no error unless the consumer's build runs a StyleX compiler. if (usesStyleX) { - humanLog( - '⚠ These components use StyleX and require a StyleX compiler in your build.', - ); - humanLog( - ' Without one they render unstyled (no error). See setup per framework:', - ); - humanLog(` ${run} docs styling`); - humanLog( - ' Next.js note: the StyleX Babel plugin disables SWC and breaks next/font —', + out.push( + text( + [ + `${WARN} These components use StyleX and require a StyleX compiler in your build.`, + ' Without one they render unstyled (no error). See setup per framework:', + ` ${run} docs styling`, + ' Next.js note: the StyleX Babel plugin disables SWC and breaks next/font -', + ' use an SWC-based StyleX transform instead (covered in the guide).', + ].join('\n'), + ), ); - humanLog( - ' use an SWC-based StyleX transform instead (covered in the guide).', - ); - humanLog(''); } // Maintainer feedback note (skipped when the owner ships no issues URL). if (feedback) { - humanLog( - 'Customizing a component often signals a gap in the design system.', + out.push( + text( + [ + 'Customizing a component often signals a gap in the design system.', + 'Let the maintainers know what you needed:', + ` ${feedback.ghCommand || feedback.issuesUrl}`, + ].join('\n'), + ), ); - humanLog('Let the maintainers know what you needed:'); - if (feedback.ghCommand) { - humanLog(` ${feedback.ghCommand}`); - } else { - humanLog(` ${feedback.issuesUrl}`); - } - humanLog(''); } + + emit(...out); }); } diff --git a/packages/cli/clients/cli/commands/template.mjs b/packages/cli/clients/cli/commands/template.mjs index fb8d518dd31b..a36d41a0b499 100644 --- a/packages/cli/clients/cli/commands/template.mjs +++ b/packages/cli/clients/cli/commands/template.mjs @@ -6,7 +6,8 @@ import * as path from 'node:path'; import * as fs from 'node:fs'; -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, section, text, records, code} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs'; import {template as templateApi} from '../../../api/template/template.mjs'; @@ -126,48 +127,59 @@ export function registerTemplate(program) { case 'template.list': { const pages = result.data.filter(t => t.type === 'page'); const blocks = result.data.filter(t => t.type === 'block'); + // Project each entry to its JSON-mirroring fields; the WIP marker is + // folded into `name` (as before) and the package is shown only when it + // isn't the built-in core package. /** @param {import('../../../api/template/template.type.mjs').TemplateListEntry} t */ - const renderEntry = t => { - const status = t.isReady ? '' : ' (WIP)'; - const pkg = - t.package && t.package !== '@astryxdesign/core' - ? ` [${t.package}]` - : ''; - humanLog(` ${t.name}${status}${pkg}`); - if (t.description) humanLog(` ${t.description}`); - }; - if (pages.length > 0) { - humanLog('\nPage Templates:\n'); - for (const t of pages) renderEntry(t); - } - if (blocks.length > 0) { - humanLog('\nBlock Templates:\n'); - for (const t of blocks) renderEntry(t); - } - humanLog('\nUsage:'); - humanLog(` ${run} template [target-path] Scaffold page or block`); - humanLog(` ${run} template --skeleton Layout reference`); - humanLog(` ${run} template --list --type block List only blocks`); - humanLog(` ${run} template --list --package List from one package\n`); + const toRow = t => ({ + name: t.isReady ? t.name : `${t.name} (WIP)`, + description: t.description, + package: + t.package && t.package !== '@astryxdesign/core' ? t.package : '', + }); + const fields = ['name', 'description', 'package']; + emit( + pages.length > 0 && section('Page Templates'), + pages.length > 0 && records(pages.map(toRow), {fields}), + blocks.length > 0 && section('Block Templates'), + blocks.length > 0 && records(blocks.map(toRow), {fields}), + section('Usage'), + text( + [ + `${run} template [target-path] Scaffold page or block`, + `${run} template --skeleton Layout reference`, + `${run} template --list --type block List only blocks`, + `${run} template --list --package List from one package`, + ].join('\n'), + ), + ); break; } case 'template.skeleton': { const {template: tName, description, components, skeleton} = result.data; - humanLog(`\n# ${tName}${description ? ' — ' + description : ''}`); - humanLog(`# Components: ${components.join(', ')}\n`); - humanLog(skeleton); - humanLog(''); + emit( + text( + `# ${tName}${description ? ' — ' + description : ''}\n` + + `# Components: ${components.join(', ')}`, + ), + code(skeleton), + ); break; } case 'template.show': { - humanLog(result.data.source); + // Source must survive piping byte-for-byte. + emit(code(result.data.source)); break; } case 'template.copy': { - humanLog(`\n✓ Copied template to ${result.data.outputDir}/${result.data.fileName}\n`); + emit( + text( + `Copied template to ${result.data.outputDir}/${result.data.fileName}`, + ), + ); break; } } diff --git a/packages/cli/clients/cli/commands/validate-integration.mjs b/packages/cli/clients/cli/commands/validate-integration.mjs index 0ca04fadd333..9014417a08fb 100644 --- a/packages/cli/clients/cli/commands/validate-integration.mjs +++ b/packages/cli/clients/cli/commands/validate-integration.mjs @@ -14,7 +14,8 @@ * an integration package is not a failure). */ -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, section, text, records} from '../formatters/index.mjs'; import { validateIntegration, summarizeIssues, @@ -27,21 +28,24 @@ import { function printHuman(data) { const label = data.version != null ? `${data.name}@${data.version}` : data.name; - humanLog(`Validating integration: ${label}`); if (data.issues.length === 0) { - humanLog('\n\u2713 No issues found.'); + emit( + section(`Validating integration: ${label}`), + text('[ok] No issues found.'), + ); return; } - humanLog(''); - for (const issue of data.issues) { - humanLog(` ${issue.severity} ${issue.code}: ${issue.message}`); - } - + // The issue list is a projection of the JSON: one record per issue, fields + // mirroring the JSON keys (severity/code/message). const {errors, warnings} = summarizeIssues(data.issues); - humanLog( - `\n${data.issues.length} issue(s): ${errors} error(s), ${warnings} warning(s)`, + emit( + section(`Validating integration: ${label}`), + records(data.issues, {fields: ['severity', 'code', 'message']}), + text( + `${data.issues.length} issue(s): ${errors} error(s), ${warnings} warning(s)`, + ), ); } @@ -77,7 +81,7 @@ export function registerValidateIntegration(program) { jsonOut(result); } else if (result.data.name === null) { // No-arg + no local manifest: guidance, not an error. - humanLog(NO_MANIFEST_GUIDANCE); + emit(text(NO_MANIFEST_GUIDANCE)); } else { printHuman(result.data); } diff --git a/packages/cli/clients/cli/formatters/index.mjs b/packages/cli/clients/cli/formatters/index.mjs new file mode 100644 index 000000000000..8947b1c1c88e --- /dev/null +++ b/packages/cli/clients/cli/formatters/index.mjs @@ -0,0 +1,219 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file The CLI's human-output formatters. + * + * The mental model: `--json` is the source of truth, and the plain-text output + * is just a human-readable projection of that same JSON. So the core renderers + * take JSON-native values (objects, arrays of objects, string arrays) directly + * and turn them into readable text — a command shouldn't have to hand-map every + * field. `record(obj)` / `records(arr)` are the workhorses; you mostly just pick + * which fields to show and in what order. + * + * Constraints (deliberately narrow): + * - Plain ASCII only. No color, no TTY detection, no width wrapping. Output is + * byte-for-byte deterministic whether printed or piped to an agent. + * - Renderers return an opaque {@link Block}; `emit` accepts ONLY Blocks, so a + * stray string can't leak onto stdout (the compiler rejects `emit('x')`). + * `emit` is the one stdout sink; errors/warnings go to stderr via + * cli-error.mjs, never here. + * - Field names in the text mirror the JSON keys 1:1, so every field is + * greppable (`grep '^description:'`) and the two views stay in sync. + */ + +import {humanLog} from '../../../foundation/response/json.mjs'; + +/** + * Shared ASCII vocabulary — used here and by stderr diagnostics (cli-error.mjs) + * so the whole CLI speaks one plain-text dialect. ASCII on purpose: renders + * identically in every terminal, pager, and captured log. + */ +export const ARROW = '->'; +export const BULLET = '-'; +export const ERR = '!!'; +export const WARN = '!'; + +/** + * An opaque, renderer-produced block of output. Nominal via a private field: + * nothing outside this file can construct one, so `emit` can trust that whatever + * it receives came from a renderer (a plain string is not assignable to Block). + */ +export class Block { + /** @type {string} */ + #text; + /** @param {string} text */ + constructor(text) { + this.#text = text; + } + /** @returns {string} */ + toString() { + return this.#text; + } +} + +/** + * Anything `emit` accepts: a Block, or a falsy value so callers can inline + * conditionals (`cond && section(...)`). Falsy entries are dropped; a raw string + * is intentionally NOT assignable. + * @typedef {Block | false | null | undefined} Emittable + */ + +/** + * Options shared by {@link record} and {@link records}. + * @typedef {object} RecordOptions + * @property {string[]} [fields] - Keys to show, in order. Missing/empty keys are + * skipped. Defaults to the object's own keys. + * @property {string[]} [omit] - Keys to exclude (when `fields` is not given). + * @property {Record} [labels] - Rename a key for display. + * @property {Record string>} [format] - Transform a + * value before rendering (e.g. prefix a command with the package manager). + */ + +/** @param {unknown} v @returns {boolean} */ +function isEmpty(v) { + return ( + v === null || + v === undefined || + v === '' || + (Array.isArray(v) && v.length === 0) + ); +} + +/** @param {unknown} v @returns {string} */ +function renderValue(v) { + if (Array.isArray(v)) return v.map(x => String(x)).join(', '); + return String(v); +} + +/** + * Normalize common non-ASCII typography to ASCII so human output stays plain and + * consistent no matter what the source data contains (em/en dashes -> "-", curly + * quotes -> straight, ellipsis -> "...", non-breaking space -> space). The + * the verbatim renderer (code) deliberately skips this. `--json` is + * unaffected — it always carries the original data. + * @param {string} s + * @returns {string} + */ +function toAscii(s) { + return String(s) + .replace(/[\u2014\u2013]/g, '-') + .replace(/[\u2018\u2019]/g, "'") + .replace(/[\u201C\u201D]/g, '"') + .replace(/\u2026/g, '...') + .replace(/\u00a0/g, ' '); +} + +/** + * A group label, optionally with an explanatory subtitle rendered on the line(s) + * directly beneath the heading (no blank line between). Whatever list/records + * follow are separate blocks, so emit's blank line separates them from the + * header + subtitle. + * @param {string} heading + * @param {string} [subtitle] + * @returns {Block} + */ +export function section(heading, subtitle) { + return new Block(toAscii(subtitle ? `${heading}\n${subtitle}` : String(heading))); +} + +/** + * A prose block, printed as-is (no wrapping). Multi-line strings kept verbatim. + * @param {string} content + * @returns {Block} + */ +export function text(content) { + return new Block(toAscii(String(content))); +} + +/** + * A bulleted list of primitives (e.g. a string array). Each item is a string or + * an array of lines (first line is the head, the rest hang-indent). For arrays + * of OBJECTS use {@link records} instead. + * @param {Array} items + * @returns {Block} + */ +export function list(items) { + const hang = ' '.repeat(BULLET.length + 1); + const rendered = items.map(item => { + const lines = Array.isArray(item) ? item : String(item).split('\n'); + const [head = '', ...rest] = lines; + return [`${BULLET} ${head}`, ...rest.map(l => `${hang}${l}`)].join('\n'); + }); + const multiline = rendered.some(r => r.includes('\n')); + return new Block(toAscii(rendered.join(multiline ? '\n\n' : '\n'))); +} + +/** + * Render a JSON object as aligned `key: value` lines — the readable projection + * of that object. Values: strings as-is, arrays comma-joined, other primitives + * String()'d; empty/missing fields are skipped. Field names mirror the JSON keys + * so the output is greppable and maps 1:1 to `--json`. + * @param {any} obj + * @param {RecordOptions} [options] + * @returns {Block} + */ +export function record(obj, options = {}) { + const src = /** @type {Record} */ (obj); + const keys = (options.fields ?? Object.keys(src)).filter( + k => !options.omit?.includes(k) && !isEmpty(src[k]), + ); + /** @param {string} k */ + const label = k => options.labels?.[k] ?? k; + const keyWidth = keys.reduce((max, k) => Math.max(max, label(k).length), 0); + const lines = keys.map(k => { + const fmt = options.format?.[k]; + const value = fmt ? fmt(src[k]) : renderValue(src[k]); + return `${`${label(k)}:`.padEnd(keyWidth + 2)}${value}`; + }); + return new Block(toAscii(lines.join('\n'))); +} + +/** + * Render an array of JSON objects as {@link record}s, one per object, separated + * by a blank line. The single easiest way to turn `data.results` (or any object + * array) into readable text. + * @param {any[]} items + * @param {RecordOptions} [options] + * @returns {Block} + */ +export function records(items, options = {}) { + const blocks = items.map(o => record(o, options).toString()).filter(Boolean); + return new Block(blocks.join('\n\n')); +} + +/** + * A verbatim block — source dumps, layout skeletons, or a markdown doc. Output + * is byte-for-byte (NOT ASCII-normalized), so it survives piping + * (`astryx template X > file.tsx`) and preserves doc/source content exactly. + * @param {string} source + * @param {{lang?: string}} [_options] - Reserved (intent label); output verbatim. + * @returns {Block} + */ +export function code(source, _options = {}) { + return new Block(String(source)); +} + +/** + * @param {Emittable} b + * @returns {b is Block} + */ +function isBlock(b) { + return b instanceof Block; +} + +/** + * The one sanctioned way to write human output to stdout. Joins blocks with a + * single blank line and hands the result to `humanLog`, which is a no-op in + * `--json` mode (stdout carries only the envelope there). Accepts only Blocks + * (plus falsy placeholders); a bare string will not type-check. + * @param {Emittable[]} blocks + * @returns {void} + */ +export function emit(...blocks) { + const body = blocks + .filter(isBlock) + .map(b => b.toString()) + .filter(s => s.length > 0) + .join('\n\n'); + humanLog(body); +} diff --git a/packages/cli/clients/cli/formatters/index.test.mjs b/packages/cli/clients/cli/formatters/index.test.mjs new file mode 100644 index 000000000000..efc1a518ee2f --- /dev/null +++ b/packages/cli/clients/cli/formatters/index.test.mjs @@ -0,0 +1,150 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {describe, it, expect, vi, afterEach} from 'vitest'; +import { + emit, + section, + text, + list, + record, + records, + code, + Block, + BULLET, + ARROW, +} from './index.mjs'; +import {setJsonMode} from '../../../foundation/response/json.mjs'; + +afterEach(() => { + setJsonMode(false); + vi.restoreAllMocks(); +}); + +describe('constants', () => { + it('are plain ASCII', () => { + expect(BULLET).toBe('-'); + expect(ARROW).toBe('->'); + expect(/[^\x20-\x7E]/.test(`${BULLET}${ARROW}`)).toBe(false); + }); +}); + +describe('renderers return Block', () => { + it('produces nominal Block instances', () => { + expect(section('x')).toBeInstanceOf(Block); + expect(record({a: 1})).toBeInstanceOf(Block); + expect(records([{a: 1}])).toBeInstanceOf(Block); + expect(code('a')).toBeInstanceOf(Block); + }); +}); + +describe('section', () => { + it('renders a heading alone, or heading + subtitle directly beneath', () => { + expect(section('PAGE TEMPLATES').toString()).toBe('PAGE TEMPLATES'); + expect(section('PAGE TEMPLATES', 'Closest full-page templates.').toString()).toBe( + 'PAGE TEMPLATES\nClosest full-page templates.', + ); + }); +}); + +describe('list', () => { + it('renders single-line items tightly with bullets', () => { + expect(list(['alpha', 'beta']).toString()).toBe('- alpha\n- beta'); + }); + + it('hang-indents multi-line items and separates them with a blank line', () => { + expect(list([['head', 'detail'], 'solo']).toString()).toBe( + '- head\n detail\n\n- solo', + ); + }); +}); + +describe('record', () => { + it('renders a JSON object as aligned key: value lines', () => { + const lines = record({ + name: 'Button', + domain: 'component', + description: 'A button.', + }) + .toString() + .split('\n'); + expect(lines[0]).toMatch(/^name:\s+Button$/); + expect(lines[1]).toMatch(/^domain:\s+component$/); + expect(lines[2]).toMatch(/^description: A button\.$/); + // Values align to one column (keyWidth 'description' = 11, +2 = 13). + expect(lines[0].indexOf('Button')).toBe(13); + expect(lines[2].indexOf('A button.')).toBe(13); + }); + + it('picks + orders fields and skips missing/empty ones', () => { + const out = record( + {name: 'X', domain: 'component', description: ''}, + {fields: ['domain', 'name', 'displayName', 'description']}, + ).toString(); + expect(out).toBe('domain: component\nname: X'); + }); + + it('applies labels and format transforms', () => { + const out = record( + {command: 'astryx x'}, + {labels: {command: 'run'}, format: {command: v => `pnpm exec ${v}`}}, + ).toString(); + expect(out).toBe('run: pnpm exec astryx x'); + }); + + it('joins array values with commas', () => { + expect(record({frame: ['AppShell', 'TopNav']}).toString()).toBe( + 'frame: AppShell, TopNav', + ); + }); +}); + +describe('records', () => { + it('renders one record per object separated by a blank line', () => { + expect(records([{name: 'A'}, {name: 'B'}]).toString()).toBe('name: A\n\nname: B'); + }); +}); + +describe('ASCII normalization', () => { + it('converts em/en dashes, curly quotes, and ellipsis in prose + records', () => { + expect(text('a \u2014 b').toString()).toBe('a - b'); + expect(section('X \u2013 Y').toString()).toBe('X - Y'); + expect(record({name: '\u201cSettings\u201d \u2014 Form\u2026'}).toString()).toBe( + 'name: "Settings" - Form...', + ); + expect(list(['Avatar \u2014 Group']).toString()).toBe('- Avatar - Group'); + }); + + it('leaves code verbatim (no normalization)', () => { + expect(code('a \u2014 b').toString()).toBe('a \u2014 b'); + }); +}); + +describe('code', () => { + it('is byte-for-byte verbatim (source or docs)', () => { + const src = 'const x = 1;\n const y = 2;\n'; + expect(code(src).toString()).toBe(src); + expect(code('# Title\n\n- a\n').toString()).toBe('# Title\n\n- a\n'); + }); +}); + +describe('emit', () => { + it('joins blocks with a single blank line via one console.log', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + emit(section('A'), text('B')); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('A\n\nB'); + }); + + it('drops falsy placeholders', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + emit(section('A'), false, null, undefined, text('B')); + expect(spy).toHaveBeenCalledWith('A\n\nB'); + }); + + it('is a no-op in --json mode (stdout stays clean)', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + setJsonMode(true); + emit(section('A'), text('B')); + expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/clients/cli/formatters/type-tests.mjs b/packages/cli/clients/cli/formatters/type-tests.mjs new file mode 100644 index 000000000000..769bffc56276 --- /dev/null +++ b/packages/cli/clients/cli/formatters/type-tests.mjs @@ -0,0 +1,31 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Compile-time guards for the formatter contract. Never executed — its + * only job is to make `tsc` (checkJs + strict, via tsconfig.strict.json) prove + * that `emit` accepts renderer output and rejects raw strings. If any + * `@ts-expect-error` below stops erroring, the contract has regressed and the + * type check fails. + */ + +import {emit, section, text, list, record, records, code} from './index.mjs'; + +/** @returns {void} */ +export function __formatterTypeGuards() { + // Renderer output is emittable. + emit(section('s'), section('H', 'subtitle'), text('p')); + emit(list(['a', ['head', 'detail']])); + emit(record({name: 'Button', domain: 'component'}, {fields: ['name', 'domain']})); + emit(records([{name: 'A'}, {name: 'B'}], {format: {name: v => String(v)}})); + emit(code('const x = 1;')); + + // Falsy placeholders are allowed (inline conditionals). + const show = false; + emit(section('s'), show && section('maybe'), null, undefined); + + // @ts-expect-error a bare string is not a Block — must go through a renderer. + emit('raw string'); + + // @ts-expect-error a number is not a Block. + emit(42); +} diff --git a/packages/cli/clients/cli/index.mjs b/packages/cli/clients/cli/index.mjs index fafd861a5923..989cb81416fe 100644 --- a/packages/cli/clients/cli/index.mjs +++ b/packages/cli/clients/cli/index.mjs @@ -21,6 +21,7 @@ import {getCliInvocation} from '../../foundation/env/package-manager.mjs'; import {API_VERSION, setJsonMode} from '../../foundation/response/json.mjs'; import {buildManifest} from './lib/manifest.mjs'; import {cliError} from './lib/cli-error.mjs'; +import {emit, section, text, records} from './formatters/index.mjs'; import {ERROR_CODES} from '../../foundation/response/error-codes.mjs'; import {levenshteinDistance} from '../../foundation/text/string-utils.mjs'; import {installJsonShim} from './lib/json-shim.mjs'; @@ -135,6 +136,50 @@ const SETUP_NUDGE_EXEMPT = new Set(['init', 'agent-docs']); export async function createProgram() { const program = new Command(); + // Deterministic, single-line help. Commander wraps each option/command + // description to a column width (80 when captured non-TTY), which splits long + // descriptions like --json across several indented lines. Override `wrap` to a + // no-op so every item stays on one line — matching the rest of the CLI's + // plain, unwrapped, width-independent output. Set before subcommands are + // registered so they inherit it via copyInheritedSettings. + program.configureHelp({wrap: (str) => str}); + + // Document the text-output contract in --help so agents know how to parse/grep + // it (and when to reach for --json instead). Kept in sync with the formatter + // kit in clients/cli/formatters. + program.addHelpText( + 'after', + '\n' + + [ + section( + 'Output format', + 'Text mirrors --json (the machine-readable surface); it is built from these blocks:', + ), + records( + [ + { + block: 'Record', + shape: 'aligned "key: value" lines = one item; records separated by a blank line', + }, + { + block: 'Section', + shape: 'a header line (no "key:"), optional one-line subtitle, then its records/list', + }, + {block: 'List', shape: '"- value" lines for a simple sequence of values'}, + {block: 'Text', shape: 'free-form prose / notes'}, + {block: 'Code', shape: 'a verbatim block (source, skeleton, or doc), emitted exactly'}, + ], + {fields: ['block', 'shape']}, + ), + text( + 'Grep a field across records, e.g. astryx search button | grep "^command:". ' + + 'Errors/warnings go to stderr; use --json for structured parsing.', + ), + ] + .map(block => block.toString()) + .join('\n\n'), + ); + program .name('astryx') .description('Design system CLI — components, themes, and tooling') @@ -360,14 +405,21 @@ export async function createProgram() { console.log(JSON.stringify({apiVersion: API_VERSION, type: 'manifest', data: manifest}, null, 2)); return; } - // Human-readable summary. Agents should use --json. - console.log(`\n${manifest.name} v${manifest.version} — ${manifest.commands.length} commands\n`); - for (const c of manifest.commands) { - const tag = c.json ? ' [--json]' : ''; - console.log(` ${c.name}${tag}`); - if (c.description) console.log(` ${c.description}`); - } - console.log(`\nRun \`${getCliInvocation()} manifest --json\` for the full structured manifest.\n`); + // Human-readable summary as greppable records (agents should use --json). + // One record per command: name, whether it supports --json, and the + // description. + emit( + section(`${manifest.name} v${manifest.version} (${manifest.commands.length} commands)`), + records( + manifest.commands.map(c => ({ + command: c.name, + json: c.json ? 'yes' : '', + description: c.description || '', + })), + {fields: ['command', 'json', 'description']}, + ), + text(`Run \`${getCliInvocation()} manifest --json\` for the full structured manifest.`), + ); }); // Hidden command used by package.json postinstall scripts