Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cli-output-formatters.md
Original file line number Diff line number Diff line change
@@ -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
70 changes: 52 additions & 18 deletions internal/eslint-plugin-astryx/no-raw-console-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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));
}

Expand All @@ -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,
},
Expand All @@ -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: [],
},
Expand All @@ -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 &&
Expand All @@ -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},
});
}
},
};
Expand Down
46 changes: 20 additions & 26 deletions packages/cli/clients/cli/commands/blog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <slug>`);
return lines.join('\n');
}

/**
* @param {import('commander').Command} program
*/
Expand Down Expand Up @@ -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 <slug>`),
);
} 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));
}
});
}
3 changes: 2 additions & 1 deletion packages/cli/clients/cli/commands/blog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});

Expand Down
94 changes: 48 additions & 46 deletions packages/cli/clients/cli/commands/build-theme.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -116,20 +117,46 @@ 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);
process.once('SIGTERM', stop);
});
}

/**
* 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 <slug> [target-path] Scaffold a theme file you own`,
),
);
}

/**
* @param {import('commander').Command} program
*/
Expand Down Expand Up @@ -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 <slug> [target-path] Scaffold a theme file you own\n`,
);
printThemeList(result.data);
});

theme
Expand Down Expand Up @@ -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 <slug> [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}';

<Theme theme={${exportName}}>
<App />
</Theme>

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` +
`<Theme theme={${exportName}}>\n <App />\n</Theme>`,
),
text(
`This is your copy of the ${displayName} theme — edit ${entry} to make it your own.`,
),
);
},
);
}
Loading