From 2d12171352a0e1ef306e40457be596c5199f7c8e Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 25 Mar 2022 16:26:55 -0500 Subject: [PATCH] feat(cli): scaffold out `astro add` command (#2849) * feat(cli): scaffold out `astro add` command * added first babel transforms * Format output * Added changes confirmation * Error flow * Add dependencies * feat(cli): astro add cleanup pass * feat: add support for tailwind * chore: update lockfile * fix: types * chore: rever @proload/core bump * chore: add changeset * chore: rollback dep update * Added spinners * chore: remove extra deps * Removed extra argument * Use `execa` instead of `exec` * Changed how lines are trimmed within diffLines * refactor: move add to core * refactor: remove old add entrypoint * refactor: simplify wording * feat: improve diff * feat: improve diff and logging, add interactive prompt when no args passed * Formatted files * Added --yes * feat: improve logging for install command * Fixed execa * Added help message to add * refactor: extract consts to own file * feat: remove implicit projectRoot behavior * feat: improve error handling, existing integrations * fix(tailwind): ensure existing tailwind config is not overwritten * refactor: prefer cwd to projectRoot flag * chore: add refactor notes * refactor: throw createPrettyError > implicit bail * refactor: cleanup language * feat(cli): prompt user before generating tailwind config * fix(cli): update config generation to use cwd * fix: resolve root from cwd * chore: update changelog Co-authored-by: JuanM04 --- package.json | 10 + src/cli/index.ts | 88 +++----- src/core/add/babel.ts | 17 ++ src/core/add/consts.ts | 26 +++ src/core/add/imports.ts | 35 +++ src/core/add/index.ts | 459 ++++++++++++++++++++++++++++++++++++++++ src/core/add/wrapper.ts | 11 + src/core/config.ts | 23 +- src/core/messages.ts | 86 +++++++- 9 files changed, 696 insertions(+), 59 deletions(-) create mode 100644 src/core/add/babel.ts create mode 100644 src/core/add/consts.ts create mode 100644 src/core/add/imports.ts create mode 100644 src/core/add/index.ts create mode 100644 src/core/add/wrapper.ts diff --git a/package.json b/package.json index 23926f8d0b33..da89cddd9a14 100644 --- a/package.json +++ b/package.json @@ -79,13 +79,17 @@ "@astrojs/prism": "0.4.1-next.0", "@astrojs/webapi": "^0.11.0", "@babel/core": "^7.17.8", + "@babel/generator": "^7.17.7", + "@babel/parser": "^7.17.8", "@babel/traverse": "^7.17.3", "@proload/core": "^0.2.2", "@proload/plugin-tsm": "^0.1.1", "@web/parse5-utils": "^1.3.0", + "boxen": "^6.2.1", "ci-info": "^3.3.0", "common-ancestor-path": "^1.0.1", "debug": "^4.3.4", + "diff": "^5.0.0", "eol": "^0.9.1", "es-module-lexer": "^0.10.4", "esbuild": "0.14.25", @@ -99,11 +103,14 @@ "magic-string": "^0.25.9", "micromorph": "^0.1.2", "mime": "^3.0.0", + "ora": "^6.1.0", "parse5": "^6.0.1", "path-to-regexp": "^6.2.0", "postcss": "^8.4.12", "postcss-load-config": "^3.1.3", + "preferred-pm": "^3.0.3", "prismjs": "^1.27.0", + "prompts": "^2.4.2", "rehype-slug": "^5.0.1", "resolve": "^1.22.0", "rollup": "^2.70.1", @@ -126,16 +133,19 @@ "devDependencies": { "@babel/types": "^7.17.0", "@types/babel__core": "^7.1.19", + "@types/babel__generator": "^7.6.4", "@types/babel__traverse": "^7.14.2", "@types/chai": "^4.3.0", "@types/common-ancestor-path": "^1.0.0", "@types/connect": "^3.4.35", "@types/debug": "^4.1.7", + "@types/diff": "^5.0.2", "@types/estree": "^0.0.51", "@types/html-escaper": "^3.0.0", "@types/mime": "^2.0.3", "@types/mocha": "^9.1.0", "@types/parse5": "^6.0.3", + "@types/prettier": "^2.4.4", "@types/resolve": "^1.20.1", "@types/rimraf": "^3.0.2", "@types/send": "^0.17.1", diff --git a/src/cli/index.ts b/src/cli/index.ts index ffa1071ccc77..fa6fc7547aac 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -8,23 +8,23 @@ import yargs from 'yargs-parser'; import { z } from 'zod'; import { defaultLogDestination } from '../core/logger.js'; import build from '../core/build/index.js'; +import add from '../core/add/index.js'; import devServer from '../core/dev/index.js'; import preview from '../core/preview/index.js'; import { check } from './check.js'; import { formatConfigError, loadConfig } from '../core/config.js'; -import { pad } from '../core/dev/util.js'; +import { printHelp } from '../core/messages.js'; type Arguments = yargs.Arguments; -type CLICommand = 'help' | 'version' | 'dev' | 'build' | 'preview' | 'reload' | 'check'; +type CLICommand = 'help' | 'version' | 'add' | 'dev' | 'build' | 'preview' | 'reload' | 'check'; /** Display --help flag */ -function printHelp() { - linebreak(); - headline('astro', 'Futuristic web development tool.'); - linebreak(); - title('Commands'); - table( - [ +function printAstroHelp() { + printHelp({ + commandName: 'astro', + headline: 'Futuristic web development tool.', + commands: [ + ['add', 'Add an integration to your configuration.'], ['dev', 'Run Astro in development mode.'], ['build', 'Build a pre-compiled production-ready site.'], ['preview', 'Preview your build locally before deploying.'], @@ -32,12 +32,7 @@ function printHelp() { ['--version', 'Show the version number and exit.'], ['--help', 'Show this help message.'], ], - { padding: 28, prefix: ' astro ' } - ); - linebreak(); - title('Flags'); - table( - [ + flags: [ ['--host [optional IP]', 'Expose server on network'], ['--config ', 'Specify the path to the Astro config file.'], ['--project-root ', 'Specify the path to the project root folder.'], @@ -48,39 +43,7 @@ function printHelp() { ['--verbose', 'Enable verbose logging'], ['--silent', 'Disable logging'], ], - { padding: 28, prefix: ' ' } - ); - - // Logging utils - function linebreak() { - console.log(); - } - - function headline(name: string, tagline: string) { - console.log(` ${colors.bgGreen(colors.black(` ${name} `))} ${colors.green(`v${process.env.PACKAGE_VERSION ?? ''}`)} ${tagline}`); - } - function title(label: string) { - console.log(` ${colors.bgWhite(colors.black(` ${label} `))}`); - } - function table(rows: [string, string][], opts: { padding: number; prefix: string }) { - const split = rows.some((row) => { - const message = `${opts.prefix}${' '.repeat(opts.padding)}${row[1]}`; - return message.length > process.stdout.columns; - }); - for (const row of rows) { - row.forEach((col, i) => { - if (i === 0) { - process.stdout.write(`${opts.prefix}${colors.bold(pad(`${col}`, opts.padding - opts.prefix.length))}`); - } else { - if (split) { - process.stdout.write('\n '); - } - process.stdout.write(colors.dim(col) + '\n'); - } - }); - } - return ''; - } + }); } /** Display --version flag */ @@ -93,15 +56,15 @@ async function printVersion() { /** Determine which command the user requested */ function resolveCommand(flags: Arguments): CLICommand { - if (flags.version) { - return 'version'; - } else if (flags.help) { - return 'help'; - } const cmd = flags._[2] as string; + if (cmd === 'add') return 'add'; + + if (flags.version) return 'version'; + else if (flags.help) return 'help'; + const supportedCommands = new Set(['dev', 'build', 'preview', 'check']); if (supportedCommands.has(cmd)) { - return cmd as 'dev' | 'build' | 'preview' | 'check'; + return cmd as CLICommand; } return 'help'; } @@ -110,11 +73,11 @@ function resolveCommand(flags: Arguments): CLICommand { export async function cli(args: string[]) { const flags = yargs(args); const cmd = resolveCommand(flags); - const projectRoot = flags.projectRoot || flags._[3]; + const projectRoot = flags.projectRoot; switch (cmd) { case 'help': - printHelp(); + printAstroHelp(); return process.exit(0); case 'version': await printVersion(); @@ -135,6 +98,8 @@ export async function cli(args: string[]) { let config: AstroConfig; try { + // Note: ideally, `loadConfig` would return the config AND its filePath + // For now, `add` has to resolve the config again internally config = await loadConfig({ cwd: projectRoot, flags }); } catch (err) { throwAndExit(err); @@ -142,6 +107,16 @@ export async function cli(args: string[]) { } switch (cmd) { + case 'add': { + try { + const packages = flags._.slice(3) as string[]; + await add(packages, { cwd: projectRoot, flags, logging }); + process.exit(0); + } catch (err) { + throwAndExit(err); + } + return; + } case 'dev': { try { await devServer(config, { logging }); @@ -150,7 +125,6 @@ export async function cli(args: string[]) { } catch (err) { throwAndExit(err); } - return; } diff --git a/src/core/add/babel.ts b/src/core/add/babel.ts new file mode 100644 index 000000000000..8ec31cd46da4 --- /dev/null +++ b/src/core/add/babel.ts @@ -0,0 +1,17 @@ +import traverse from '@babel/traverse'; +import generator from '@babel/generator'; +import * as t from '@babel/types'; +import parser from '@babel/parser'; + +// @ts-ignore @babel/traverse isn't ESM and needs this trick +export const visit = traverse.default as typeof traverse; +export { t }; + +export async function generate(ast: t.File) { + // @ts-ignore @babel/generator isn't ESM and needs this trick + const astToText = generator.default as typeof generator; + const { code } = astToText(ast); + return code; +} + +export const parse = (code: string) => parser.parse(code, { sourceType: 'unambiguous', plugins: ['typescript'] }); diff --git a/src/core/add/consts.ts b/src/core/add/consts.ts new file mode 100644 index 000000000000..7eb64b4db539 --- /dev/null +++ b/src/core/add/consts.ts @@ -0,0 +1,26 @@ +export const FIRST_PARTY_FRAMEWORKS = [ + { value: 'react', title: 'React' }, + { value: 'preact', title: 'Preact' }, + { value: 'vue', title: 'Vue' }, + { value: 'svelte', title: 'Svelte' }, + { value: 'solid-js', title: 'Solid' }, + { value: 'lit', title: 'Lit' }, +]; +export const FIRST_PARTY_ADDONS = [ + { value: 'tailwind', title: 'Tailwind' }, + { value: 'turbolinks', title: 'Turbolinks' }, + { value: 'partytown', title: 'Partytown' }, + { value: 'sitemap', title: 'Sitemap' }, +]; +export const ALIASES = new Map([ + ['solid', 'solid-js'], + ['tailwindcss', 'tailwind'], +]); +export const CONFIG_STUB = `import { defineConfig } from 'astro/config';\n\nexport default defineConfig({});`; +export const TAILWIND_CONFIG_STUB = `module.exports = { + content: [], + theme: { + extend: {}, + }, + plugins: [], +}\n`; diff --git a/src/core/add/imports.ts b/src/core/add/imports.ts new file mode 100644 index 000000000000..bae8c7443781 --- /dev/null +++ b/src/core/add/imports.ts @@ -0,0 +1,35 @@ +import { t, visit } from './babel.js'; + +export function ensureImport(root: t.File, importDeclaration: t.ImportDeclaration) { + let specifiersToFind = [...importDeclaration.specifiers]; + + visit(root, { + ImportDeclaration(path) { + if (path.node.source.value === importDeclaration.source.value) { + path.node.specifiers.forEach((specifier) => + specifiersToFind.forEach((specifierToFind, i) => { + if (specifier.type !== specifierToFind.type) return; + if (specifier.local.name === specifierToFind.local.name) { + specifiersToFind.splice(i, 1); + } + }) + ); + } + }, + }); + + if (specifiersToFind.length === 0) return; + + visit(root, { + Program(path) { + const declaration = t.importDeclaration(specifiersToFind, importDeclaration.source); + const latestImport = path + .get('body') + .filter((statement) => statement.isImportDeclaration()) + .pop(); + + if (latestImport) latestImport.insertAfter(declaration); + else path.unshiftContainer('body', declaration); + }, + }); +} diff --git a/src/core/add/index.ts b/src/core/add/index.ts new file mode 100644 index 000000000000..4175cbf2f72a --- /dev/null +++ b/src/core/add/index.ts @@ -0,0 +1,459 @@ +import type yargs from 'yargs-parser'; +import path from 'path'; +import { existsSync, promises as fs } from 'fs'; +import { execa } from 'execa'; +import { fileURLToPath, pathToFileURL } from 'url'; +import { diffWords } from 'diff'; +import boxen from 'boxen'; +import prompts from 'prompts'; +import preferredPM from 'preferred-pm'; +import ora from 'ora'; +import { resolveConfigURL } from '../config.js'; +import { apply as applyPolyfill } from '../polyfill.js'; +import { error, info, debug, LogOptions } from '../logger.js'; +import { printHelp } from '../messages.js'; +import * as msg from '../messages.js'; +import * as CONSTS from './consts.js'; +import { dim, red, cyan, green, magenta, bold } from 'kleur/colors'; +import { parseNpmName } from '../util.js'; +import { wrapDefaultExport } from './wrapper.js'; +import { ensureImport } from './imports.js'; +import { t, parse, visit, generate } from './babel.js'; + +export interface AddOptions { + logging: LogOptions; + flags: yargs.Arguments; + cwd?: string; +} + +export interface IntegrationInfo { + id: string; + packageName: string; + dependencies: [name: string, version: string][]; +} + +export default async function add(names: string[], { cwd, flags, logging }: AddOptions) { + if (flags.help) { + printHelp({ + commandName: 'astro add', + usage: '[FLAGS] [INTEGRATIONS...]', + flags: [ + ['--yes', 'Add the integration without user interaction.'], + ['--help', 'Show this help message.'], + ], + }); + return; + } + let configURL: URL | undefined; + const root = pathToFileURL(cwd ? path.resolve(cwd) : process.cwd()); + // TODO: improve error handling for invalid configs + configURL = await resolveConfigURL({ cwd, flags }); + + if (configURL?.pathname.endsWith('package.json')) { + throw new Error(`Unable to use astro add with package.json#astro configuration! Try migrating to \`astro.config.mjs\` and try again.`); + } + applyPolyfill(); + + if (names.length === 0) { + const response = await prompts([ + { + type: 'multiselect', + name: 'frameworks', + message: 'What frameworks would you like to enable?', + instructions: '\n Space to select. Return to submit', + choices: CONSTS.FIRST_PARTY_FRAMEWORKS, + }, + { + type: 'multiselect', + name: 'addons', + message: 'What additional integrations would you like to enable?', + instructions: '\n Space to select. Return to submit', + choices: CONSTS.FIRST_PARTY_ADDONS, + }, + ]); + + if (!response.frameworks && !response.addons) { + info(logging, null, msg.cancelled(`Integrations skipped.`, `You can always run ${cyan('astro add')} later!`)); + return; + } + const selected = [response.frameworks ?? [], response.addons ?? []].flat(1); + if (selected.length === 0) { + error(logging, null, `\n${red('No integrations specified!')}\n${dim('Try running')} astro add again.`); + return; + } + names = selected; + } + + // Some packages might have a common alias! We normalize those here. + names = names.map((name) => (CONSTS.ALIASES.has(name) ? CONSTS.ALIASES.get(name)! : name)); + + if (configURL) { + debug('add', `Found config at ${configURL}`); + } else { + info(logging, 'add', `Unable to locate a config file, generating one for you.`); + configURL = new URL('./astro.config.mjs', root); + await fs.writeFile(fileURLToPath(configURL), CONSTS.CONFIG_STUB, { encoding: 'utf-8' }); + } + + const integrations = await validateIntegrations(names); + + let ast: t.File | null = null; + try { + ast = await parseAstroConfig(configURL); + + debug('add', 'Parsed astro config'); + + const defineConfig = t.identifier('defineConfig'); + ensureImport(ast, t.importDeclaration([t.importSpecifier(defineConfig, defineConfig)], t.stringLiteral('astro/config'))); + wrapDefaultExport(ast, defineConfig); + + debug('add', 'Astro config ensured `defineConfig`'); + + for (const integration of integrations) { + await addIntegration(ast, integration); + debug('add', `Astro config added integration ${integration.id}`); + } + } catch (err) { + debug('add', 'Error parsing/modifying astro config: ', err); + throw createPrettyError(err as Error); + } + + let configResult: UpdateResult | undefined; + let installResult: UpdateResult | undefined; + + if (ast) { + try { + configResult = await updateAstroConfig({ configURL, ast, flags, logging }); + } catch (err) { + debug('add', 'Error updating astro config', err); + throw createPrettyError(err as Error); + } + } + + switch (configResult) { + case UpdateResult.cancelled: { + info(logging, null, msg.cancelled(`Your configuration has ${bold('NOT')} been updated.`)); + return; + } + case UpdateResult.none: { + const pkgURL = new URL('./package.json', configURL); + if (existsSync(fileURLToPath(pkgURL))) { + const { dependencies = {}, devDependencies = {} } = await fs.readFile(fileURLToPath(pkgURL)).then(res => JSON.parse(res.toString())); + const deps = Object.keys(Object.assign(dependencies, devDependencies)); + const missingDeps = integrations.filter(integration => !deps.includes(integration.packageName)); + if (missingDeps.length === 0) { + info(logging, null, msg.success(`Configuration up-to-date.`)); + return; + } + } + + info(logging, null, msg.success(`Configuration up-to-date.`)); + break; + } + } + + installResult = await tryToInstallIntegrations({ integrations, cwd, flags, logging }); + + switch (installResult) { + case UpdateResult.updated: { + const len = integrations.length; + if (integrations.find((integration) => integration.id === 'tailwind')) { + const possibleConfigFiles = ['./tailwind.config.cjs', './tailwind.config.mjs', './tailwind.config.js'].map(p => fileURLToPath(new URL(p, configURL))); + let alreadyConfigured = false; + for (const possibleConfigPath of possibleConfigFiles) { + if (existsSync(possibleConfigPath)) { + alreadyConfigured = true; + break; + } + } + if (!alreadyConfigured) { + info(logging, null, `\n ${magenta(`Astro will generate a minimal ${bold('./tailwind.config.cjs')} file.`)}\n`); + if (await askToContinue({ flags })) { + await fs.writeFile(fileURLToPath(new URL('./tailwind.config.cjs', configURL)), CONSTS.TAILWIND_CONFIG_STUB, { encoding: 'utf-8' }); + debug('add', `Generated default ./tailwind.config.cjs file`); + } + } else { + debug('add', `Using existing Tailwind configuration`); + } + } + const list = integrations.map(integration => ` - ${integration.packageName}`).join('\n') + info(logging, null, msg.success(`Added the following integration${len === 1 ? '' : 's'} to your project:\n${list}`)); + return; + } + case UpdateResult.cancelled: { + info(logging, null, msg.cancelled(`Dependencies ${bold('NOT')} installed.`, `Be sure to install them manually before continuing!`)); + return; + } + case UpdateResult.failure: { + throw createPrettyError(new Error(`Unable to install dependencies`)); + } + } +} + +async function parseAstroConfig(configURL: URL): Promise { + const source = await fs.readFile(fileURLToPath(configURL), { encoding: 'utf-8' }); + const result = parse(source); + + if (!result) throw new Error('Unknown error parsing astro config'); + if (result.errors.length > 0) throw new Error('Error parsing astro config: ' + JSON.stringify(result.errors)); + + return result; +} + +const toIdent = (name: string) => { + if (name.includes('-')) { + return name.split('-')[0]; + } + return name; +}; + +function createPrettyError(err: Error) { + err.message = `Astro could not update your astro.config.js file safely. +Reason: ${err.message} + +You will need to add these integration(s) manually. +Documentation: https://next--astro-docs-2.netlify.app/en/guides/integrations-guide/` + return err; +} + +async function addIntegration(ast: t.File, integration: IntegrationInfo) { + const integrationId = t.identifier(toIdent(integration.id)); + + ensureImport(ast, t.importDeclaration([t.importDefaultSpecifier(integrationId)], t.stringLiteral(integration.packageName))); + + visit(ast, { + // eslint-disable-next-line @typescript-eslint/no-shadow + ExportDefaultDeclaration(path) { + if (!t.isCallExpression(path.node.declaration)) return; + + const configObject = path.node.declaration.arguments[0]; + if (!t.isObjectExpression(configObject)) return; + + let integrationsProp = configObject.properties.find((prop) => { + if (prop.type !== 'ObjectProperty') return false; + if (prop.key.type === 'Identifier') { + if (prop.key.name === 'integrations') return true; + } + if (prop.key.type === 'StringLiteral') { + if (prop.key.value === 'integrations') return true; + } + return false; + }) as t.ObjectProperty | undefined; + + const integrationCall = t.callExpression(integrationId, []); + + if (!integrationsProp) { + configObject.properties.push(t.objectProperty(t.identifier('integrations'), t.arrayExpression([integrationCall]))); + return; + } + + if (integrationsProp.value.type !== 'ArrayExpression') throw new Error('Unable to parse integrations'); + + const existingIntegrationCall = integrationsProp.value.elements.find( + (expr) => t.isCallExpression(expr) && t.isIdentifier(expr.callee) && expr.callee.name === integrationId.name + ); + + if (existingIntegrationCall) return; + + integrationsProp.value.elements.push(integrationCall); + }, + }); +} + +const enum UpdateResult { + none, + updated, + cancelled, + failure, +} + +async function updateAstroConfig({ configURL, ast, flags, logging }: { configURL: URL; ast: t.File; flags: yargs.Arguments; logging: LogOptions }): Promise { + const input = await fs.readFile(fileURLToPath(configURL), { encoding: 'utf-8' }); + let output = await generate(ast); + const comment = '// https://astro.build/config'; + const defaultExport = 'export default defineConfig'; + output = output.replace(` ${comment}`, ''); + output = output.replace(`${defaultExport}`, `\n${comment}\n${defaultExport}`); + + if (input === output) { + return UpdateResult.none; + } + + let changes = []; + for (const change of diffWords(input, output)) { + let lines = change.value + .trim() + .split('\n') + .slice(0, change.count) + if (lines.length === 0) continue; + if (change.added) { + if (!change.value.trim()) continue; + changes.push(change.value); + } + } + if (changes.length === 0) { + return UpdateResult.none; + } + + let diffed = output; + for (let newContent of changes) { + const coloredOutput = newContent + .split('\n') + .map((ln) => (ln ? green(ln) : '')) + .join('\n'); + diffed = diffed.replace(newContent, coloredOutput); + } + + const message = `\n${boxen(diffed, { margin: 0.5, padding: 0.5, borderStyle: 'round', title: configURL.pathname.split('/').pop() })}\n`; + + info(logging, null, `\n ${magenta('Astro will make the following changes to your config file:')}\n${message}`); + + if (await askToContinue({ flags })) { + await fs.writeFile(fileURLToPath(configURL), output, { encoding: 'utf-8' }); + debug('add', `Updated astro config`); + return UpdateResult.updated; + } else { + return UpdateResult.cancelled; + } +} + +interface InstallCommand { + pm: string; + command: string; + flags: string[]; + dependencies: string[]; +} +async function getInstallIntegrationsCommand({ integrations, cwd = process.cwd() }: { integrations: IntegrationInfo[]; cwd?: string }): Promise { + const pm = await preferredPM(cwd); + debug('add', `package manager: ${JSON.stringify(pm)}`); + if (!pm) return null; + + let dependencies = integrations + .map<[string, string | null][]>((i) => [[i.packageName, null], ...i.dependencies]) + .flat(1) + .filter((dep, i, arr) => arr.findIndex((d) => d[0] === dep[0]) === i) + .map(([name, version]) => (version === null ? name : `${name}@${version}`)) + .sort(); + + switch (pm.name) { + case 'npm': + return { pm: 'npm', command: 'install', flags: ['--save-dev'], dependencies }; + case 'yarn': + return { pm: 'yarn', command: 'add', flags: ['--dev'], dependencies }; + case 'pnpm': + return { pm: 'pnpm', command: 'install', flags: ['--save-dev'], dependencies }; + default: + return null; + } +} + +async function tryToInstallIntegrations({ + integrations, + cwd, + flags, + logging, +}: { + integrations: IntegrationInfo[]; + cwd?: string; + flags: yargs.Arguments; + logging: LogOptions; +}): Promise { + const installCommand = await getInstallIntegrationsCommand({ integrations, cwd }); + + if (installCommand === null) { + info(logging, null); + return UpdateResult.none; + } else { + const coloredOutput = `${bold(installCommand.pm)} ${installCommand.command} ${installCommand.flags.join(' ')} ${cyan(installCommand.dependencies.join(' '))}`; + const message = `\n${boxen(coloredOutput, { margin: 0.5, padding: 0.5, borderStyle: 'round' })}\n`; + info( + logging, + null, + `\n ${magenta('Astro will run the following command:')}\n ${dim('If you skip this step, you can always run it yourself later')}\n${message}` + ); + + if (await askToContinue({ flags })) { + const spinner = ora('Installing dependencies...').start(); + try { + await execa(installCommand.pm, [installCommand.command, ...installCommand.flags, ...installCommand.dependencies], { cwd }); + spinner.succeed(); + return UpdateResult.updated; + } catch (err) { + debug('add', 'Error installing dependencies', err); + spinner.fail(); + return UpdateResult.failure; + } + } else { + return UpdateResult.cancelled; + } + } +} + +export async function validateIntegrations(integrations: string[]): Promise { + const spinner = ora('Resolving integrations...').start(); + const integrationEntries = await Promise.all( + integrations.map(async (integration): Promise => { + const parsed = parseIntegrationName(integration); + if (!parsed) { + spinner.fail(); + throw new Error(`${integration} does not appear to be a valid package name!`); + } + + let { scope = '', name, tag } = parsed; + // Allow third-party integrations starting with `astro-` namespace + if (!name.startsWith('astro-')) { + scope = `astrojs`; + } + const packageName = `${scope ? `@${scope}/` : ''}${name}`; + + const result = await fetch(`https://registry.npmjs.org/${packageName}/${tag}`).then((res) => { + if (res.status === 404) { + spinner.fail(); + throw new Error(`Unable to fetch ${packageName}. Does this package exist?`); + } + return res.json(); + }); + + let dependencies: IntegrationInfo['dependencies'] = [[result['name'], `^${result['version']}`]]; + + if (result['peerDependencies']) { + for (const peer in result['peerDependencies']) { + dependencies.push([peer, result['peerDependencies'][peer]]); + } + } + + return { id: integration, packageName, dependencies }; + }) + ); + spinner.succeed(); + return integrationEntries; +} + +function parseIntegrationName(spec: string) { + const result = parseNpmName(spec); + if (!result) return; + let { scope, name } = result; + let tag = 'latest'; + if (scope) { + name = name.replace(scope + '/', ''); + } + if (name.includes('@')) { + const tagged = name.split('@'); + name = tagged[0]; + tag = tagged[1]; + } + return { scope, name, tag }; +} + +async function askToContinue({ flags }: { flags: yargs.Arguments }): Promise { + if (flags.yes) return true; + + const response = await prompts({ + type: 'confirm', + name: 'askToContinue', + message: 'Continue?', + initial: true, + }); + + return Boolean(response.askToContinue); +} diff --git a/src/core/add/wrapper.ts b/src/core/add/wrapper.ts new file mode 100644 index 000000000000..a8f6b3bc847f --- /dev/null +++ b/src/core/add/wrapper.ts @@ -0,0 +1,11 @@ +import { t, visit } from './babel.js'; + +export function wrapDefaultExport(ast: t.File, functionIdentifier: t.Identifier) { + visit(ast, { + ExportDefaultDeclaration(path) { + if (!t.isExpression(path.node.declaration)) return; + if (t.isCallExpression(path.node.declaration) && t.isIdentifier(path.node.declaration.callee) && path.node.declaration.callee.name === functionIdentifier.name) return; + path.node.declaration = t.callExpression(functionIdentifier, [path.node.declaration]); + }, + }); +} diff --git a/src/core/config.ts b/src/core/config.ts index 26ebc09311b6..7bc121963758 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -11,7 +11,6 @@ import load from '@proload/core'; import loadTypeScript from '@proload/plugin-tsm'; import postcssrc from 'postcss-load-config'; import { arraify, isObject } from './util.js'; -import ssgAdapter from '../adapter-ssg/index.js'; load.use([loadTypeScript]); @@ -266,6 +265,28 @@ interface LoadConfigOptions { flags?: Flags; } +/** + * Resolve the file URL of the user's `astro.config.js|cjs|mjs|ts` file + * Note: currently the same as loadConfig but only returns the `filePath` + * instead of the resolved config + */ +export async function resolveConfigURL(configOptions: LoadConfigOptions): Promise { + const root = configOptions.cwd ? path.resolve(configOptions.cwd) : process.cwd(); + const flags = resolveFlags(configOptions.flags || {}); + let userConfigPath: string | undefined; + + if (flags?.config) { + userConfigPath = /^\.*\//.test(flags.config) ? flags.config : `./${flags.config}`; + userConfigPath = fileURLToPath(new URL(userConfigPath, `file://${root}/`)); + } + // Automatically load config file using Proload + // If `userConfigPath` is `undefined`, Proload will search for `astro.config.[cm]?[jt]s` + const config = await load('astro', { mustExist: false, cwd: root, filePath: userConfigPath }); + if (config) { + return pathToFileURL(config.filePath); + } +} + /** Attempt to load an `astro.config.mjs` file */ export async function loadConfig(configOptions: LoadConfigOptions): Promise { const root = configOptions.cwd ? path.resolve(configOptions.cwd) : process.cwd(); diff --git a/src/core/messages.ts b/src/core/messages.ts index c84a454d122c..e3a7741c44fe 100644 --- a/src/core/messages.ts +++ b/src/core/messages.ts @@ -3,7 +3,7 @@ */ import stripAnsi from 'strip-ansi'; -import { bold, dim, red, green, underline, yellow, bgYellow, cyan, bgGreen, black } from 'kleur/colors'; +import { bold, dim, red, green, underline, yellow, bgYellow, cyan, bgGreen, black, bgRed, bgWhite } from 'kleur/colors'; import { pad, emoji, getLocalAddress, getNetworkLogging } from './dev/util.js'; import os from 'os'; import type { AddressInfo } from 'net'; @@ -87,6 +87,36 @@ export function prerelease({ currentVersion }: { currentVersion: string }) { return [headline, warning, ''].map((msg) => ` ${msg}`).join('\n'); } +export function success(message: string, tip?: string) { + const badge = bgGreen(black(` success `)); + const headline = green(message); + const footer = tip ? `\n ▶ ${tip}` : undefined; + return ['', badge, headline, footer] + .filter((v) => v !== undefined) + .map((msg) => ` ${msg}`) + .join('\n'); +} + +export function failure(message: string, tip?: string) { + const badge = bgRed(black(` error `)); + const headline = red(message); + const footer = tip ? `\n ▶ ${tip}` : undefined; + return ['', badge, headline, footer] + .filter((v) => v !== undefined) + .map((msg) => ` ${msg}`) + .join('\n'); +} + +export function cancelled(message: string, tip?: string) { + const badge = bgYellow(black(` cancelled `)); + const headline = yellow(message); + const footer = tip ? `\n ▶ ${tip}` : undefined; + return ['', badge, headline, footer] + .filter((v) => v !== undefined) + .map((msg) => ` ${msg}`) + .join('\n'); +} + /** Display port in use */ export function portInUse({ port }: { port: number }): string { return `Port ${port} in use. Trying a new one…`; @@ -102,3 +132,57 @@ export function err(error: Error): string { stack = stack.slice(split).replace(/^\n+/, ''); return `${message}\n${dim(stack)}`; } + +export function printHelp({ + commandName, + headline, + usage, + commands, + flags, +}: { + commandName: string; + headline?: string; + usage?: string; + commands?: [command: string, help: string][]; + flags?: [flag: string, help: string][]; +}) { + const linebreak = () => ''; + const title = (label: string) => ` ${bgWhite(black(` ${label} `))}`; + const table = (rows: [string, string][], opts: { padding: number; prefix: string }) => { + const split = rows.some((row) => { + const message = `${opts.prefix}${' '.repeat(opts.padding)}${row[1]}`; + return message.length > process.stdout.columns; + }); + + let raw = ''; + + for (const row of rows) { + raw += `${opts.prefix}${bold(pad(`${row[0]}`, opts.padding - opts.prefix.length))}`; + if (split) raw += '\n '; + raw += dim(row[1]) + '\n'; + } + + return raw.slice(0, -1); // remove latest \n + }; + + let message = []; + + if (headline) { + message.push(linebreak(), ` ${bgGreen(black(` ${commandName} `))} ${green(`v${process.env.PACKAGE_VERSION ?? ''}`)} ${headline}`); + } + + if (usage) { + message.push(linebreak(), ` ${green(commandName)} ${bold(usage)}`); + } + + if (commands) { + message.push(linebreak(), title('Commands'), table(commands, { padding: 28, prefix: ' astro ' })); + } + + if (flags) { + message.push(linebreak(), title('Flags'), table(flags, { padding: 28, prefix: ' ' })); + } + + // eslint-disable-next-line no-console + console.log(message.join('\n')); +}