diff --git a/packages/astro/src/cli/create-key/core/create-key.ts b/packages/astro/src/cli/create-key/core/create-key.ts index 50bfb594ead6..71e0696ce4fe 100644 --- a/packages/astro/src/cli/create-key/core/create-key.ts +++ b/packages/astro/src/cli/create-key/core/create-key.ts @@ -1,18 +1,26 @@ import type { Logger } from '../../../core/logger/core.js'; import type { KeyGenerator } from '../definitions.js'; +import { defineCommand } from '../domain/command.js'; -interface CreateKeyOptions { +interface Options { logger: Logger; keyGenerator: KeyGenerator; } -export async function createKey({ logger, keyGenerator }: CreateKeyOptions) { - const key = await keyGenerator.generate(); +export const createKeyCommand = defineCommand({ + help: { + commandName: 'astro create-key', + tables: { + Flags: [['--help (-h)', 'See all available flags.']], + }, + description: 'Generates a key to encrypt props passed to server islands.', + }, + async run({ logger, keyGenerator }: Options) { + const key = await keyGenerator.generate(); - logger.info( - 'crypto', - `Generated a key to encrypt props passed to server islands. To reuse the same key across builds, set this value as ASTRO_KEY in an environment variable on your build server. - -ASTRO_KEY=${key}`, - ); -} + logger.info( + 'crypto', + `Generated a key to encrypt props passed to server islands. To reuse the same key across builds, set this value as ASTRO_KEY in an environment variable on your build server.\n\nASTRO_KEY=${key}`, + ); + }, +}); diff --git a/packages/astro/src/cli/create-key/definitions.ts b/packages/astro/src/cli/create-key/definitions.ts index f5ad451c9d4d..6a3464cba8bb 100644 --- a/packages/astro/src/cli/create-key/definitions.ts +++ b/packages/astro/src/cli/create-key/definitions.ts @@ -1,3 +1,28 @@ +import type { AnyCommand } from './domain/command.js'; +import type { HelpPayload } from './domain/help-payload.js'; + export interface KeyGenerator { generate: () => Promise; } + +export interface HelpDisplay { + shouldFire: () => boolean; + show: (payload: HelpPayload) => void; +} + +export interface TextStyler { + bgWhite: (msg: string) => string; + black: (msg: string) => string; + dim: (msg: string) => string; + green: (msg: string) => string; + bold: (msg: string) => string; + bgGreen: (msg: string) => string; +} + +export interface AstroVersionProvider { + getVersion: () => string; +} + +export interface CommandRunner { + run: (command: T, ...args: Parameters) => ReturnType; +} diff --git a/packages/astro/src/cli/create-key/domain/command.ts b/packages/astro/src/cli/create-key/domain/command.ts new file mode 100644 index 000000000000..5546cbaca517 --- /dev/null +++ b/packages/astro/src/cli/create-key/domain/command.ts @@ -0,0 +1,12 @@ +import type { HelpPayload } from './help-payload.js'; + +interface Command) => any> { + help: HelpPayload; + run: T; +} + +export type AnyCommand = Command<(...args: Array) => any>; + +export function defineCommand(command: T) { + return command; +} diff --git a/packages/astro/src/cli/create-key/domain/help-payload.ts b/packages/astro/src/cli/create-key/domain/help-payload.ts new file mode 100644 index 000000000000..2ed6e72cd678 --- /dev/null +++ b/packages/astro/src/cli/create-key/domain/help-payload.ts @@ -0,0 +1,7 @@ +export interface HelpPayload { + commandName: string; + headline?: string; + usage?: string; + tables?: Record; + description?: string; +} diff --git a/packages/astro/src/cli/create-key/infra/build-time-astro-version-provider.ts b/packages/astro/src/cli/create-key/infra/build-time-astro-version-provider.ts new file mode 100644 index 000000000000..dea44640ccce --- /dev/null +++ b/packages/astro/src/cli/create-key/infra/build-time-astro-version-provider.ts @@ -0,0 +1,9 @@ +import type { AstroVersionProvider } from '../definitions.js'; + +export function createBuildTimeAstroVersionProvider(): AstroVersionProvider { + return { + getVersion() { + return process.env.PACKAGE_VERSION ?? ''; + }, + }; +} diff --git a/packages/astro/src/cli/create-key/infra/cli-command-runner.ts b/packages/astro/src/cli/create-key/infra/cli-command-runner.ts new file mode 100644 index 000000000000..2fcf8137840e --- /dev/null +++ b/packages/astro/src/cli/create-key/infra/cli-command-runner.ts @@ -0,0 +1,17 @@ +import type { CommandRunner, HelpDisplay } from '../definitions.js'; + +interface Options { + helpDisplay: HelpDisplay; +} + +export function createCliCommandRunner({ helpDisplay }: Options): CommandRunner { + return { + run(command, ...args) { + if (helpDisplay.shouldFire()) { + helpDisplay.show(command.help); + return; + } + return command.run(...args); + }, + }; +} diff --git a/packages/astro/src/cli/create-key/infra/kleur-text-styler.ts b/packages/astro/src/cli/create-key/infra/kleur-text-styler.ts new file mode 100644 index 000000000000..4988f2fc22a6 --- /dev/null +++ b/packages/astro/src/cli/create-key/infra/kleur-text-styler.ts @@ -0,0 +1,6 @@ +import * as colors from 'kleur/colors'; +import type { TextStyler } from '../definitions.js'; + +export function createKleurTextStyler(): TextStyler { + return colors; +} diff --git a/packages/astro/src/cli/create-key/infra/logger-help-display.ts b/packages/astro/src/cli/create-key/infra/logger-help-display.ts new file mode 100644 index 000000000000..ab0662bbe3bc --- /dev/null +++ b/packages/astro/src/cli/create-key/infra/logger-help-display.ts @@ -0,0 +1,76 @@ +import type { Logger } from '../../../core/logger/core.js'; +import type { Flags } from '../../flags.js'; +import type { AstroVersionProvider, HelpDisplay, TextStyler } from '../definitions.js'; + +interface Options { + logger: Logger; + textStyler: TextStyler; + astroVersionProvider: AstroVersionProvider; + // TODO: find something better + flags: Flags; +} + +export function createLoggerHelpDisplay({ + logger, + flags, + textStyler, + astroVersionProvider, +}: Options): HelpDisplay { + return { + shouldFire() { + return !!(flags.help || flags.h); + }, + show({ commandName, description, headline, tables, usage }) { + const linebreak = () => ''; + const title = (label: string) => ` ${textStyler.bgWhite(textStyler.black(` ${label} `))}`; + const table = (rows: [string, string][], { padding }: { padding: number }) => { + const split = process.stdout.columns < 60; + let raw = ''; + + for (const row of rows) { + if (split) { + raw += ` ${row[0]}\n `; + } else { + raw += `${`${row[0]}`.padStart(padding)}`; + } + raw += ' ' + textStyler.dim(row[1]) + '\n'; + } + + return raw.slice(0, -1); // remove latest \n + }; + + let message = []; + + if (headline) { + message.push( + linebreak(), + ` ${textStyler.bgGreen(textStyler.black(` ${commandName} `))} ${textStyler.green( + `v${astroVersionProvider.getVersion()}`, + )} ${headline}`, + ); + } + + if (usage) { + message.push(linebreak(), ` ${textStyler.green(commandName)} ${textStyler.bold(usage)}`); + } + + if (tables) { + function calculateTablePadding(rows: [string, string][]) { + return rows.reduce((val, [first]) => Math.max(val, first.length), 0) + 2; + } + + const tableEntries = Object.entries(tables); + const padding = Math.max(...tableEntries.map(([, rows]) => calculateTablePadding(rows))); + for (const [tableTitle, tableRows] of tableEntries) { + message.push(linebreak(), title(tableTitle), table(tableRows, { padding })); + } + } + + if (description) { + message.push(linebreak(), `${description}`); + } + + logger.info('SKIP_FORMAT', message.join('\n') + '\n'); + }, + }; +} diff --git a/packages/astro/src/cli/index.ts b/packages/astro/src/cli/index.ts index 9b84ee48bcdb..a395288f2112 100644 --- a/packages/astro/src/cli/index.ts +++ b/packages/astro/src/cli/index.ts @@ -109,16 +109,35 @@ async function runCommand(cmd: string, flags: yargs.Arguments) { return; } case 'create-key': { - const [{ createKey }, { createLoggerFromFlags }, { createCryptoKeyGenerator }] = - await Promise.all([ - import('./create-key/core/create-key.js'), - import('./flags.js'), - import('./create-key/infra/crypto-key-generator.js'), - ]); + const [ + { createLoggerFromFlags }, + { createCryptoKeyGenerator }, + { createKleurTextStyler }, + { createBuildTimeAstroVersionProvider }, + { createLoggerHelpDisplay }, + { createCliCommandRunner }, + { createKeyCommand }, + ] = await Promise.all([ + import('./flags.js'), + import('./create-key/infra/crypto-key-generator.js'), + import('./create-key/infra/kleur-text-styler.js'), + import('./create-key/infra/build-time-astro-version-provider.js'), + import('./create-key/infra/logger-help-display.js'), + import('./create-key/infra/cli-command-runner.js'), + import('./create-key/core/create-key.js'), + ]); const logger = createLoggerFromFlags(flags); const keyGenerator = createCryptoKeyGenerator(); - await createKey({ logger, keyGenerator }); - return; + const textStyler = createKleurTextStyler(); + const astroVersionProvider = createBuildTimeAstroVersionProvider(); + const helpDisplay = createLoggerHelpDisplay({ + logger, + flags, + textStyler, + astroVersionProvider, + }); + const runner = createCliCommandRunner({ helpDisplay }); + return await runner.run(createKeyCommand, { logger, keyGenerator }); } case 'docs': { const { docs } = await import('./docs/index.js'); diff --git a/packages/astro/src/core/messages.ts b/packages/astro/src/core/messages.ts index caa00ac139ea..7a2746de02da 100644 --- a/packages/astro/src/core/messages.ts +++ b/packages/astro/src/core/messages.ts @@ -333,6 +333,7 @@ export function formatErrorMessage(err: ErrorWithMetadata, showFullStacktrace: b return output.join('\n'); } +/** @deprecated Migrate to HelpDisplay */ export function printHelp({ commandName, headline, diff --git a/packages/astro/test/units/cli/create-key.test.js b/packages/astro/test/units/cli/create-key.test.js index 8d728c141cb4..261190e7508b 100644 --- a/packages/astro/test/units/cli/create-key.test.js +++ b/packages/astro/test/units/cli/create-key.test.js @@ -1,21 +1,29 @@ // @ts-check import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { createKey } from '../../../dist/cli/create-key/core/create-key.js'; +import { createKeyCommand } from '../../../dist/cli/create-key/core/create-key.js'; +import { createBuildTimeAstroVersionProvider } from '../../../dist/cli/create-key/infra/build-time-astro-version-provider.js'; +import { createCliCommandRunner } from '../../../dist/cli/create-key/infra/cli-command-runner.js'; +import { createLoggerHelpDisplay } from '../../../dist/cli/create-key/infra/logger-help-display.js'; +import packageJson from '../../../package.json' with { type: 'json' }; import { createSpyLogger } from '../test-utils.js'; +import { + createFakeAstroVersionProvider, + createFakeKeyGenerator, + createPassthroughCommandRunner, + createPassthroughTextStyler, + createSpyHelpDisplay, +} from './utils.js'; describe('CLI create-key', () => { describe('core', () => { - describe('create-key', () => { + describe('createKey()', () => { it('logs the generated key', async () => { const { logger, logs } = createSpyLogger(); + const runner = createPassthroughCommandRunner(); + const keyGenerator = createFakeKeyGenerator('FOO'); - await createKey({ - logger, - keyGenerator: { - generate: async () => 'FOO', - }, - }); + await runner.run(createKeyCommand, { logger, keyGenerator }); assert.equal(logs[0].type, 'info'); assert.equal(logs[0].label, 'crypto'); @@ -23,4 +31,153 @@ describe('CLI create-key', () => { }); }); }); + + describe('infra', () => { + describe('createCliCommandRunner()', () => { + it('logs the help if it should fire', () => { + const { payloads, helpDisplay } = createSpyHelpDisplay(true); + const runner = createCliCommandRunner({ helpDisplay }); + let ran = false; + + runner.run({ + help: { + commandName: 'foo', + }, + run: () => { + ran = true; + }, + }); + + assert.equal(payloads.length, 1); + assert.equal(ran, false); + }); + + it('does not log the help if it should not should fire', () => { + const { payloads, helpDisplay } = createSpyHelpDisplay(false); + const runner = createCliCommandRunner({ helpDisplay }); + let ran = false; + + runner.run({ + help: { + commandName: 'foo', + }, + run: () => { + ran = true; + }, + }); + + assert.equal(payloads.length, 0); + assert.equal(ran, true); + }); + }); + + describe('createBuildTimeAstroVersionProvider()', () => { + it('returns the value from the build', () => { + const astroVersionProvider = createBuildTimeAstroVersionProvider(); + + assert.equal(astroVersionProvider.getVersion(), packageJson.version); + }); + }); + + describe('createLoggerHelpDisplay()', () => { + describe('shouldFire()', () => { + it('returns false if no relevant flag is enabled', () => { + const { logger, logs } = createSpyLogger(); + const textStyler = createPassthroughTextStyler(); + const astroVersionProvider = createFakeAstroVersionProvider('1.0.0'); + const helpDisplay = createLoggerHelpDisplay({ + logger, + astroVersionProvider, + flags: { + _: [], + }, + textStyler, + }); + + assert.equal(helpDisplay.shouldFire(), false); + assert.deepStrictEqual(logs, []); + }); + + it('returns true if help flag is enabled', () => { + const { logger, logs } = createSpyLogger(); + const textStyler = createPassthroughTextStyler(); + const astroVersionProvider = createFakeAstroVersionProvider('1.0.0'); + const helpDisplay = createLoggerHelpDisplay({ + logger, + astroVersionProvider, + flags: { + _: [], + help: true, + }, + textStyler, + }); + + assert.equal(helpDisplay.shouldFire(), true); + assert.deepStrictEqual(logs, []); + }); + + it('returns true if h flag is enabled', () => { + const { logger, logs } = createSpyLogger(); + const textStyler = createPassthroughTextStyler(); + const astroVersionProvider = createFakeAstroVersionProvider('1.0.0'); + const helpDisplay = createLoggerHelpDisplay({ + logger, + astroVersionProvider, + flags: { + _: [], + h: true, + }, + textStyler, + }); + + assert.equal(helpDisplay.shouldFire(), true); + assert.deepStrictEqual(logs, []); + }); + }); + + describe('show()', () => { + it('works', () => { + const { logger, logs } = createSpyLogger(); + const textStyler = createPassthroughTextStyler(); + const astroVersionProvider = createFakeAstroVersionProvider('1.0.0'); + const helpDisplay = createLoggerHelpDisplay({ + logger, + astroVersionProvider, + flags: { + _: [], + }, + textStyler, + }); + + helpDisplay.show({ + commandName: 'astro preview', + usage: '[...flags]', + tables: { + Flags: [ + ['--port', `Specify which port to run on. Defaults to 4321.`], + ['--host', `Listen on all addresses, including LAN and public addresses.`], + ], + }, + description: 'Starts a local server to serve your static dist/ directory.', + }); + + assert.deepStrictEqual(logs, [ + { + type: 'info', + label: 'SKIP_FORMAT', + message: ` + astro preview [...flags] + + Flags + --port Specify which port to run on. Defaults to 4321. + --host Listen on all addresses, including LAN and public addresses. + +Starts a local server to serve your static dist/ directory. +`, + }, + ]); + }); + }); + }); + }); }); diff --git a/packages/astro/test/units/cli/utils.js b/packages/astro/test/units/cli/utils.js new file mode 100644 index 000000000000..cc652cbc3398 --- /dev/null +++ b/packages/astro/test/units/cli/utils.js @@ -0,0 +1,69 @@ +// @ts-check + +/** @returns {import("../../../dist/cli/create-key/definitions.js").CommandRunner} */ +export function createPassthroughCommandRunner() { + return { + run(command, ...args) { + return command.run(...args); + }, + }; +} + +/** + * @param {string} key + * @returns {import("../../../dist/cli/create-key/definitions.js").KeyGenerator} + * */ +export function createFakeKeyGenerator(key) { + return { + async generate() { + return key; + }, + }; +} + +/** + * @param {boolean} shouldFire + */ +export function createSpyHelpDisplay(shouldFire) { + /** @type {Array} */ + const payloads = []; + + /** @type {import("../../../dist/cli/create-key/definitions.js").HelpDisplay} */ + const helpDisplay = { + shouldFire() { + return shouldFire; + }, + show(payload) { + payloads.push(payload); + }, + }; + + return { + payloads, + helpDisplay, + }; +} + +/** @returns {import("../../../dist/cli/create-key/definitions.js").TextStyler} */ +export function createPassthroughTextStyler() { + return { + bgWhite: (msg) => msg, + black: (msg) => msg, + dim: (msg) => msg, + green: (msg) => msg, + bold: (msg) => msg, + bgGreen: (msg) => msg, + }; +} + +/** + * @param {string} version + * @returns {import("../../../dist/cli/create-key/definitions.js").AstroVersionProvider} + * */ +export function createFakeAstroVersionProvider(version) { + return { + getVersion() { + return version; + }, + }; +}