diff --git a/.changeset/new-ravens-exercise.md b/.changeset/new-ravens-exercise.md new file mode 100644 index 000000000..60cf1e64d --- /dev/null +++ b/.changeset/new-ravens-exercise.md @@ -0,0 +1,5 @@ +--- +'create-astro': major +--- + +Redesigned `create-astro` experience diff --git a/packages/create-astro/README.md b/packages/create-astro/README.md index 4c3a887ff..9d0f75e8e 100644 --- a/packages/create-astro/README.md +++ b/packages/create-astro/README.md @@ -18,15 +18,15 @@ yarn create astro ```bash # npm 6.x -npm create astro@latest my-astro-project --template starter +npm create astro@latest my-astro-project --template minimal # npm 7+, extra double-dash is needed: -npm create astro@latest my-astro-project -- --template starter +npm create astro@latest my-astro-project -- --template minimal # yarn -yarn create astro my-astro-project --template starter +yarn create astro my-astro-project --template minimal ``` -[Check out the full list][examples] of example starter templates, available on GitHub. +[Check out the full list][examples] of example templates, available on GitHub. You can also use any GitHub repo as a template: @@ -40,26 +40,13 @@ May be provided in place of prompts | Name | Description | |:-------------|:----------------------------------------------------| -| `--template` | Specify the template name ([list][examples]) | -| `--commit` | Specify a specific Git commit or branch to use from this repo (by default, `main` branch of this repo will be used) | -| `--fancy` | For Windows users, `--fancy` will enable full unicode support | -| `--typescript` | Specify the [tsconfig][typescript] to use | -| `--yes`/`-y` | Skip prompts and use default values | - -### Debugging - -To debug `create-astro`, you can use the `--verbose` flag which will log the output of degit and some more information about the command, this can be useful when you encounter an error and want to report it. - -```bash -# npm 6.x -npm create astro@latest my-astro-project --verbose - -# npm 7+, extra double-dash is needed: -npm create astro@latest my-astro-project -- --verbose - -# yarn -yarn create astro my-astro-project --verbose -``` +| `--template | Specify your template. | +| `--install / --no-install | Install dependencies (or not). | +| `--git / --no-git | Initialize git repo (or not). | +| `--yes (-y) | Skip all prompt by accepting defaults. | +| `--no (-n) | Skip all prompt by declining defaults. | +| `--dry-run | Walk through steps without executing. | +| `--skip-houston | Skip Houston animation. | [examples]: https://github.com/withastro/astro/tree/main/examples [typescript]: https://github.com/withastro/astro/tree/main/packages/astro/tsconfigs diff --git a/packages/create-astro/grubby-group b/packages/create-astro/grubby-group new file mode 160000 index 000000000..9a401ddf2 --- /dev/null +++ b/packages/create-astro/grubby-group @@ -0,0 +1 @@ +Subproject commit 9a401ddf2e7896d7928eea910c61b5d5a29481a1 diff --git a/packages/create-astro/package.json b/packages/create-astro/package.json index 80bb1f4bc..48eed59dc 100644 --- a/packages/create-astro/package.json +++ b/packages/create-astro/package.json @@ -14,44 +14,36 @@ "exports": { ".": "./create-astro.mjs" }, + "main": "./create-astro.mjs", "bin": { "create-astro": "./create-astro.mjs" }, "scripts": { - "build": "astro-scripts build \"src/**/*.ts\" && tsc", - "build:ci": "astro-scripts build \"src/**/*.ts\"", + "build": "astro-scripts build \"src/index.ts\" --bundle && tsc", + "build:ci": "astro-scripts build \"src/index.ts\" --bundle", "dev": "astro-scripts dev \"src/**/*.ts\"", - "test": "mocha --exit --timeout 20000" + "test": "mocha --exit --timeout 20000 --parallel" }, "files": [ "dist", - "create-astro.js", - "tsconfigs" + "create-astro.js" ], + "//a": "MOST PACKAGES SHOULD GO IN DEV_DEPENDENCIES! THEY WILL BE BUNDLED.", + "//b": "DEPENDENCIES IS FOR UNBUNDLED PACKAGES", "dependencies": { - "@astrojs/cli-kit": "^0.1.6", - "chalk": "^5.0.1", - "comment-json": "^4.2.3", + "@astrojs/cli-kit": "^0.2.2", + "chai": "^4.3.6", "execa": "^6.1.0", "giget": "^1.0.0", - "kleur": "^4.1.4", - "ora": "^6.1.0", - "prompts": "^2.4.2", - "strip-ansi": "^7.0.1", - "which-pm-runs": "^1.1.0", - "yargs-parser": "^21.0.1" + "mocha": "^9.2.2" }, "devDependencies": { - "@types/chai": "^4.3.1", - "@types/degit": "^2.8.3", - "@types/mocha": "^9.1.1", - "@types/prompts": "^2.0.14", "@types/which-pm-runs": "^1.0.0", - "@types/yargs-parser": "^21.0.0", + "arg": "^5.0.2", "astro-scripts": "workspace:*", - "chai": "^4.3.6", - "mocha": "^9.2.2", - "uvu": "^0.5.3" + "strip-ansi": "^7.0.1", + "strip-json-comments": "^5.0.0", + "which-pm-runs": "^1.1.0" }, "engines": { "node": ">=16.12.0" diff --git a/packages/create-astro/src/actions/context.ts b/packages/create-astro/src/actions/context.ts new file mode 100644 index 000000000..a73a42e13 --- /dev/null +++ b/packages/create-astro/src/actions/context.ts @@ -0,0 +1,101 @@ +import os from 'node:os'; +import arg from 'arg'; +import detectPackageManager from 'which-pm-runs'; +import { prompt } from '@astrojs/cli-kit'; + +import { getName, getVersion } from '../messages.js'; + +export interface Context { + help: boolean; + prompt: typeof prompt; + cwd: string; + pkgManager: string; + username: string; + version: string; + skipHouston: boolean; + dryRun?: boolean; + yes?: boolean; + projectName?: string; + template?: string; + ref: string; + install?: boolean; + git?: boolean; + typescript?: string; + stdin?: typeof process.stdin; + stdout?: typeof process.stdout; + exit(code: number): never; +} + + +export async function getContext(argv: string[]): Promise { + const flags = arg({ + '--template': String, + '--ref': String, + '--yes': Boolean, + '--no': Boolean, + '--install': Boolean, + '--no-install': Boolean, + '--git': Boolean, + '--no-git': Boolean, + '--typescript': String, + '--skip-houston': Boolean, + '--dry-run': Boolean, + '--help': Boolean, + '--fancy': Boolean, + + '-y': '--yes', + '-n': '--no', + '-h': '--help', + }, { argv, permissive: true }); + + const pkgManager = detectPackageManager()?.name ?? 'npm'; + const [username, version] = await Promise.all([getName(), getVersion()]); + let cwd = flags['_'][0] as string; + let { + '--help': help = false, + '--template': template, + '--no': no, + '--yes': yes, + '--install': install, + '--no-install': noInstall, + '--git': git, + '--no-git': noGit, + '--typescript': typescript, + '--fancy': fancy, + '--skip-houston': skipHouston, + '--dry-run': dryRun, + '--ref': ref, + } = flags; + let projectName = cwd; + + if (no) { + yes = false; + if (install == undefined) install = false; + if (git == undefined) git = false; + if (typescript == undefined) typescript = 'strict'; + } + + skipHouston = ((os.platform() === 'win32' && !fancy) || skipHouston) ?? [yes, no, install, git, typescript].some((v) => v !== undefined); + + const context: Context = { + help, + prompt, + pkgManager, + username, + version, + skipHouston, + dryRun, + projectName, + template, + ref: ref ?? 'latest', + yes, + install: install ?? (noInstall ? false : undefined), + git: git ?? (noGit ? false : undefined), + typescript, + cwd, + exit(code) { + process.exit(code); + } + } + return context; +} diff --git a/packages/create-astro/src/actions/dependencies.ts b/packages/create-astro/src/actions/dependencies.ts new file mode 100644 index 000000000..fb935c208 --- /dev/null +++ b/packages/create-astro/src/actions/dependencies.ts @@ -0,0 +1,43 @@ +import type { Context } from "./context"; + +import { title, info, spinner } from '../messages.js'; +import { execa } from 'execa'; + +export async function dependencies(ctx: Pick) { + let deps = ctx.install ?? ctx.yes; + if (deps === undefined) { + ({ deps } = await ctx.prompt({ + name: 'deps', + type: 'confirm', + label: title('deps'), + message: `Install dependencies?`, + hint: 'recommended', + initial: true, + })); + ctx.install = deps; + } + + if (ctx.dryRun) { + await info('--dry-run', `Skipping dependency installation`); + } else if (deps) { + await spinner({ + start: `Dependencies installing with ${ctx.pkgManager}...`, + end: 'Dependencies installed', + while: () => install({ pkgManager: ctx.pkgManager, cwd: ctx.cwd }), + }); + } else { + await info( + ctx.yes === false ? 'deps [skip]' : 'No problem!', + 'Remember to install dependencies after setup.' + ); + } +} + +async function install({ pkgManager, cwd }: { pkgManager: string, cwd: string }) { + const installExec = execa(pkgManager, ['install'], { cwd }); + return new Promise((resolve, reject) => { + installExec.on('error', (error) => reject(error)); + installExec.on('close', () => resolve()); + }); +} + diff --git a/packages/create-astro/src/actions/git.ts b/packages/create-astro/src/actions/git.ts new file mode 100644 index 000000000..6510a0f24 --- /dev/null +++ b/packages/create-astro/src/actions/git.ts @@ -0,0 +1,48 @@ +import type { Context } from "./context"; +import fs from 'node:fs'; +import path from 'node:path'; + +import { color } from '@astrojs/cli-kit'; +import { title, info, spinner } from '../messages.js'; +import { execa } from 'execa'; + +export async function git(ctx: Pick) { + if (fs.existsSync(path.join(ctx.cwd, '.git'))) { + await info('Nice!', `Git has already been initialized`); + return + } + let _git = ctx.git ?? ctx.yes; + if (_git === undefined) { + ({ git: _git } = await ctx.prompt({ + name: 'git', + type: 'confirm', + label: title('git'), + message: `Initialize a new git repository?`, + hint: 'optional', + initial: true, + })); + } + + if (ctx.dryRun) { + await info('--dry-run', `Skipping Git initialization`); + } else if (_git) { + await spinner({ + start: 'Git initializing...', + end: 'Git initialized', + while: () => init({ cwd: ctx.cwd }), + }); + } else { + await info( + ctx.yes === false ? 'git [skip]' : 'Sounds good!', + `You can always run ${color.reset('git init')}${color.dim(' manually.')}` + ); + } +} + +async function init({ cwd }: { cwd: string }) { + try { + await execa('git', ['init'], { cwd, stdio: 'ignore' }); + await execa('git', ['add', '-A'], { cwd, stdio: 'ignore' }); + await execa('git', ['commit', '-m', 'Initial commit from Astro', '--author="houston[bot] "'], { cwd, stdio: 'ignore' }); + } catch (e) {} +} diff --git a/packages/create-astro/src/actions/help.ts b/packages/create-astro/src/actions/help.ts new file mode 100644 index 000000000..3ab4ca8b3 --- /dev/null +++ b/packages/create-astro/src/actions/help.ts @@ -0,0 +1,20 @@ +import { printHelp } from '../messages.js'; + +export function help() { + printHelp({ + commandName: 'create-astro', + usage: '[dir] [...flags]', + headline: 'Scaffold Astro projects.', + tables: { + Flags: [ + ['--template ', 'Specify your template.'], + ['--install / --no-install', 'Install dependencies (or not).'], + ['--git / --no-git', 'Initialize git repo (or not).'], + ['--yes (-y)', 'Skip all prompt by accepting defaults.'], + ['--no (-n)', 'Skip all prompt by declining defaults.'], + ['--dry-run', 'Walk through steps without executing.'], + ['--skip-houston', 'Skip Houston animation.'], + ], + }, + }); +} diff --git a/packages/create-astro/src/actions/intro.ts b/packages/create-astro/src/actions/intro.ts new file mode 100644 index 000000000..b3ab88122 --- /dev/null +++ b/packages/create-astro/src/actions/intro.ts @@ -0,0 +1,23 @@ +import { type Context } from './context'; + +import { banner, welcome, say } from '../messages.js'; +import { label, color } from '@astrojs/cli-kit'; +import { random } from '@astrojs/cli-kit/utils'; + +export async function intro(ctx: Pick) { + if (!ctx.skipHouston) { + await say([ + [ + 'Welcome', + 'to', + label('astro', color.bgGreen, color.black), + color.green(`v${ctx.version}`) + ',', + `${ctx.username}!`, + ], + random(welcome), + ]); + await banner(ctx.version); + } else { + await banner(ctx.version); + } +} diff --git a/packages/create-astro/src/actions/next-steps.ts b/packages/create-astro/src/actions/next-steps.ts new file mode 100644 index 000000000..94b0ba71b --- /dev/null +++ b/packages/create-astro/src/actions/next-steps.ts @@ -0,0 +1,15 @@ +import { Context } from "./context"; +import path from 'node:path'; + +import { nextSteps, say } from '../messages.js'; + +export async function next(ctx: Pick) { + let projectDir = path.relative(process.cwd(), ctx.cwd); + const devCmd = ctx.pkgManager === 'npm' ? 'npm run dev' : `${ctx.pkgManager} dev`; + await nextSteps({ projectDir, devCmd }); + + if (!ctx.skipHouston) { + await say(['Good luck out there, astronaut! 🚀']); + } + return; +} diff --git a/packages/create-astro/src/actions/project-name.ts b/packages/create-astro/src/actions/project-name.ts new file mode 100644 index 000000000..c849a9060 --- /dev/null +++ b/packages/create-astro/src/actions/project-name.ts @@ -0,0 +1,58 @@ +import type { Context } from "./context"; + +import { color, generateProjectName } from '@astrojs/cli-kit'; +import { title, info, log } from '../messages.js'; +import path from 'node:path'; + +import { isEmpty, toValidName } from './shared.js'; + +export async function projectName(ctx: Pick) { + await checkCwd(ctx.cwd); + + if (!ctx.cwd || !isEmpty(ctx.cwd)) { + if (!isEmpty(ctx.cwd)) { + await info('Hmm...', `${color.reset(`"${ctx.cwd}"`)}${color.dim(` is not empty!`)}`); + } + + const { name } = await ctx.prompt({ + name: 'name', + type: 'text', + label: title('dir'), + message: 'Where should we create your new project?', + initial: `./${generateProjectName()}`, + validate(value: string) { + if (!isEmpty(value)) { + return `Directory is not empty!`; + } + return true; + }, + }); + + ctx.cwd = name!; + ctx.projectName = toValidName(name!); + } else { + let name = ctx.cwd; + if (name === '.' || name === './') { + const parts = process.cwd().split(path.sep); + name = parts[parts.length - 1]; + } else if (name.startsWith('./') || name.startsWith('../')) { + const parts = name.split('/'); + name = parts[parts.length - 1]; + } + ctx.projectName = toValidName(name); + } + + if (!ctx.cwd) { + ctx.exit(1); + } +} + +async function checkCwd(cwd: string | undefined) { + const empty = cwd && isEmpty(cwd); + if (empty) { + log(''); + await info('dir', `Using ${color.reset(cwd)}${color.dim(' as project directory')}`); + } + + return empty; +} diff --git a/packages/create-astro/src/actions/shared.ts b/packages/create-astro/src/actions/shared.ts new file mode 100644 index 000000000..838ee5e23 --- /dev/null +++ b/packages/create-astro/src/actions/shared.ts @@ -0,0 +1,61 @@ +import fs from 'node:fs'; + +// Some existing files and directories can be safely ignored when checking if a directory is a valid project directory. +// https://github.com/facebook/create-react-app/blob/d960b9e38c062584ff6cfb1a70e1512509a966e7/packages/create-react-app/createReactApp.js#L907-L934 +const VALID_PROJECT_DIRECTORY_SAFE_LIST = [ + '.DS_Store', + '.git', + '.gitkeep', + '.gitattributes', + '.gitignore', + '.gitlab-ci.yml', + '.hg', + '.hgcheck', + '.hgignore', + '.idea', + '.npmignore', + '.travis.yml', + '.yarn', + '.yarnrc.yml', + 'docs', + 'LICENSE', + 'mkdocs.yml', + 'Thumbs.db', + /\.iml$/, + /^npm-debug\.log/, + /^yarn-debug\.log/, + /^yarn-error\.log/, +]; + +export function isEmpty(dirPath: string) { + if (!fs.existsSync(dirPath)) { + return true; + } + + const conflicts = fs.readdirSync(dirPath).filter((content) => { + return !VALID_PROJECT_DIRECTORY_SAFE_LIST.some((safeContent) => { + return typeof safeContent === 'string' ? content === safeContent : safeContent.test(content); + }); + }); + + return conflicts.length === 0; +} + +export function isValidName(projectName: string) { + return /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test( + projectName, + ) +} + +export function toValidName(projectName: string) { + if (isValidName(projectName)) return projectName; + + return projectName + .trim() + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/^[._]/, '') + .replace(/[^a-z\d\-~]+/g, '-') + .replace(/^-+/, '') + .replace(/-+$/, '') +} diff --git a/packages/create-astro/src/actions/template.ts b/packages/create-astro/src/actions/template.ts new file mode 100644 index 000000000..805e9ddca --- /dev/null +++ b/packages/create-astro/src/actions/template.ts @@ -0,0 +1,94 @@ +/* eslint no-console: 'off' */ +import type { Context } from "./context"; + +import fs from 'node:fs'; +import path from 'node:path'; +import { downloadTemplate } from 'giget'; +import { error } from '../messages.js'; +import { color } from '@astrojs/cli-kit'; +import { title, info, spinner } from '../messages.js'; + +export async function template(ctx: Pick) { + if (!ctx.template) { + const { template: tmpl } = await ctx.prompt({ + name: 'template', + type: 'select', + label: title('tmpl'), + message: 'How would you like to start your new project?', + initial: 'basics', + choices: [ + { value: 'basics', label: 'Include sample files', hint: '(recommended)' }, + { value: 'blog', label: 'Use blog template' }, + { value: 'minimal', label: 'Empty' }, + ], + }); + ctx.template = tmpl; + } else { + await info('tmpl', `Using ${color.reset(ctx.template)}${color.dim(' as project template')}`); + } + + if (ctx.dryRun) { + await info('--dry-run', `Skipping template copying`); + } else if (ctx.template) { + await spinner({ + start: 'Template copying...', + end: 'Template copied', + while: () => copyTemplate(ctx.template!, ctx as Context), + }); + } else { + ctx.exit(1); + } +} + +// some files are only needed for online editors when using astro.new. Remove for create-astro installs. +const FILES_TO_REMOVE = ['sandbox.config.json', 'CHANGELOG.md']; +const FILES_TO_UPDATE = { + 'package.json': (file: string, overrides: { name: string }) => fs.promises.readFile(file, 'utf-8').then(value => ( + fs.promises.writeFile(file, JSON.stringify(Object.assign(JSON.parse(value), Object.assign(overrides, { private: undefined })), null, '\t'), 'utf-8') + )) +} + +export default async function copyTemplate(tmpl: string, ctx: Context) { + const ref = ctx.ref || 'latest'; + const isThirdParty = tmpl.includes('/'); + + const templateTarget = isThirdParty + ? tmpl + : `github:withastro/astro/examples/${tmpl}#${ref}`; + + // Copy + if (!ctx.dryRun) { + try { + await downloadTemplate(templateTarget, { + force: true, + provider: 'github', + cwd: ctx.cwd, + dir: '.', + }) + } catch (err: any) { + fs.rmdirSync(ctx.cwd); + if (err.message.includes('404')) { + await error('Error', `Template ${color.reset(tmpl)} ${color.dim('does not exist!')}`); + } else { + console.error(err.message); + } + ctx.exit(1); + } + + // Post-process in parallel + const removeFiles = FILES_TO_REMOVE.map(async (file) => { + const fileLoc = path.resolve(path.join(ctx.cwd, file)); + if (fs.existsSync(fileLoc)) { + return fs.promises.rm(fileLoc, { recursive: true }); + } + }); + const updateFiles = Object.entries(FILES_TO_UPDATE).map(async ([file, update]) => { + const fileLoc = path.resolve(path.join(ctx.cwd, file)); + if (fs.existsSync(fileLoc)) { + return update(fileLoc, { name: ctx.projectName! }) + } + }) + + await Promise.all([...removeFiles, ...updateFiles]); + } +} diff --git a/packages/create-astro/src/actions/typescript.ts b/packages/create-astro/src/actions/typescript.ts new file mode 100644 index 000000000..0c624f889 --- /dev/null +++ b/packages/create-astro/src/actions/typescript.ts @@ -0,0 +1,91 @@ +import type { Context } from "./context"; + +import fs from 'node:fs' +import { readFile } from 'node:fs/promises' +import path from 'node:path'; +import stripJsonComments from 'strip-json-comments'; +import { color } from '@astrojs/cli-kit'; +import { title, info, error, typescriptByDefault, spinner } from '../messages.js'; + +export async function typescript(ctx: Pick) { + let ts = ctx.typescript ?? (typeof ctx.yes !== 'undefined' ? 'strict' : undefined); + if (ts === undefined) { + const { useTs } = await ctx.prompt({ + name: 'useTs', + type: 'confirm', + label: title('ts'), + message: `Do you plan to write TypeScript?`, + initial: true, + }); + if (!useTs) { + await typescriptByDefault(); + return; + } + + ({ ts } = await ctx.prompt({ + name: 'ts', + type: 'select', + label: title('use'), + message: `How strict should TypeScript be?`, + initial: 'strict', + choices: [ + { value: 'strict', label: 'Strict', hint: `(recommended)` }, + { value: 'strictest', label: 'Strictest' }, + { value: 'base', label: 'Relaxed' }, + ], + })); + } else { + if (!['strict', 'strictest', 'relaxed', 'default', 'base'].includes(ts)) { + if (!ctx.dryRun) { + fs.rmSync(ctx.cwd, { recursive: true, force: true }); + } + error( + 'Error', + `Unknown TypeScript option ${color.reset(ts)}${color.dim( + '! Expected strict | strictest | relaxed' + )}` + ); + ctx.exit(1); + } + await info('ts', `Using ${color.reset(ts)}${color.dim(' TypeScript configuration')}`); + } + + if (ctx.dryRun) { + await info('--dry-run', `Skipping TypeScript setup`); + } else if (ts && ts !== 'unsure') { + if (ts === 'relaxed' || ts === 'default') { + ts = 'base'; + } + await spinner({ + start: 'TypeScript customizing...', + end: 'TypeScript customized', + while: () => setupTypeScript(ts!, { cwd: ctx.cwd }), + }); + } else { + } +} + +export async function setupTypeScript(value: string, { cwd }: { cwd: string }) { + const templateTSConfigPath = path.join(cwd, 'tsconfig.json'); + try { + const data = await readFile(templateTSConfigPath, { encoding: 'utf-8' }) + const templateTSConfig = JSON.parse(stripJsonComments(data)); + if (templateTSConfig && typeof templateTSConfig === 'object') { + const result = Object.assign(templateTSConfig, { + extends: `astro/tsconfigs/${value}`, + }); + + fs.writeFileSync(templateTSConfigPath, JSON.stringify(result, null, 2)); + } else { + throw new Error("There was an error applying the requested TypeScript settings. This could be because the template's tsconfig.json is malformed") + } + } catch (err) { + if (err && (err as any).code === 'ENOENT') { + // If the template doesn't have a tsconfig.json, let's add one instead + fs.writeFileSync( + templateTSConfigPath, + JSON.stringify({ extends: `astro/tsconfigs/${value}` }, null, 2) + ); + } + } +} diff --git a/packages/create-astro/src/gradient.ts b/packages/create-astro/src/gradient.ts deleted file mode 100644 index 539f3f17d..000000000 --- a/packages/create-astro/src/gradient.ts +++ /dev/null @@ -1,91 +0,0 @@ -import chalk from 'chalk'; -import type { Ora } from 'ora'; -import ora from 'ora'; - -const gradientColors = [ - `#ff5e00`, - `#ff4c29`, - `#ff383f`, - `#ff2453`, - `#ff0565`, - `#ff007b`, - `#f5008b`, - `#e6149c`, - `#d629ae`, - `#c238bd`, -]; - -export const rocketAscii = '■■▶'; - -// get a reference to scroll through while loading -// visual representation of what this generates: -// gradientColors: "..xxXX" -// referenceGradient: "..xxXXXXxx....xxXX" -const referenceGradient = [ - ...gradientColors, - // draw the reverse of the gradient without - // accidentally mutating the gradient (ugh, reverse()) - ...[...gradientColors].reverse(), - ...gradientColors, -]; - -// async-friendly setTimeout -const sleep = (time: number) => - new Promise((resolve) => { - setTimeout(resolve, time); - }); - -function getGradientAnimFrames() { - const frames = []; - for (let start = 0; start < gradientColors.length * 2; start++) { - const end = start + gradientColors.length - 1; - frames.push( - referenceGradient - .slice(start, end) - .map((g) => chalk.bgHex(g)(' ')) - .join('') - ); - } - return frames; -} - -function getIntroAnimFrames() { - const frames = []; - for (let end = 1; end <= gradientColors.length; end++) { - const leadingSpacesArr = Array.from( - new Array(Math.abs(gradientColors.length - end - 1)), - () => ' ' - ); - const gradientArr = gradientColors.slice(0, end).map((g) => chalk.bgHex(g)(' ')); - frames.push([...leadingSpacesArr, ...gradientArr].join('')); - } - return frames; -} - -/** - * Generate loading spinner with rocket flames! - * @param text display text next to rocket - * @returns Ora spinner for running .stop() - */ -export async function loadWithRocketGradient(text: string): Promise { - const frames = getIntroAnimFrames(); - const intro = ora({ - spinner: { - interval: 30, - frames, - }, - text: `${rocketAscii} ${text}`, - }); - intro.start(); - await sleep((frames.length - 1) * intro.interval); - intro.stop(); - const spinner = ora({ - spinner: { - interval: 80, - frames: getGradientAnimFrames(), - }, - text: `${rocketAscii} ${text}`, - }).start(); - - return spinner; -} diff --git a/packages/create-astro/src/index.ts b/packages/create-astro/src/index.ts index b760ce9bf..5ba94d53f 100644 --- a/packages/create-astro/src/index.ts +++ b/packages/create-astro/src/index.ts @@ -1,398 +1,59 @@ -/* eslint no-console: 'off' */ -import { color, generateProjectName, label, say } from '@astrojs/cli-kit'; -import { forceUnicode, random } from '@astrojs/cli-kit/utils'; -import { assign, parse, stringify } from 'comment-json'; -import { execa, execaCommand } from 'execa'; -import fs from 'fs'; -import { downloadTemplate } from 'giget'; -import { bold, dim, green, reset, yellow } from 'kleur/colors'; -import ora from 'ora'; -import { platform } from 'os'; -import path from 'path'; -import prompts from 'prompts'; -import detectPackageManager from 'which-pm-runs'; -import yargs from 'yargs-parser'; -import { loadWithRocketGradient, rocketAscii } from './gradient.js'; -import { logger } from './logger.js'; -import { - banner, - getName, - getVersion, - info, - nextSteps, - typescriptByDefault, - welcome, -} from './messages.js'; -import { TEMPLATES } from './templates.js'; +import { getContext } from './actions/context.js'; -// NOTE: In the v7.x version of npm, the default behavior of `npm init` was changed -// to no longer require `--` to pass args and instead pass `--` directly to us. This -// broke our arg parser, since `--` is a special kind of flag. Filtering for `--` here -// fixes the issue so that create-astro now works on all npm version. -const cleanArgv = process.argv.filter((arg) => arg !== '--'); -const args = yargs(cleanArgv, { boolean: ['fancy', 'y'], alias: { y: 'yes' } }); -// Always skip Houston on Windows (for now) -if (platform() === 'win32') args.skipHouston = true; -prompts.override(args); +import { setStdout } from './messages.js'; +import { help } from './actions/help.js'; +import { intro } from './actions/intro.js'; +import { projectName } from './actions/project-name.js'; +import { template } from './actions/template.js' +import { dependencies } from './actions/dependencies.js'; +import { git } from './actions/git.js'; +import { typescript, setupTypeScript } from './actions/typescript.js'; +import { next } from './actions/next-steps.js'; -// Enable full unicode support if the `--fancy` flag is passed -if (args.fancy) { - forceUnicode(); -} +const exit = () => process.exit(0) +process.on('SIGINT', exit) +process.on('SIGTERM', exit) -export function mkdirp(dir: string) { - try { - fs.mkdirSync(dir, { recursive: true }); - } catch (e: any) { - if (e.code === 'EEXIST') return; - throw e; - } -} - -// Some existing files and directories can be safely ignored when checking if a directory is a valid project directory. -// https://github.com/facebook/create-react-app/blob/d960b9e38c062584ff6cfb1a70e1512509a966e7/packages/create-react-app/createReactApp.js#L907-L934 -const VALID_PROJECT_DIRECTORY_SAFE_LIST = [ - '.DS_Store', - '.git', - '.gitattributes', - '.gitignore', - '.gitlab-ci.yml', - '.hg', - '.hgcheck', - '.hgignore', - '.idea', - '.npmignore', - '.travis.yml', - '.yarn', - '.yarnrc.yml', - 'docs', - 'LICENSE', - 'mkdocs.yml', - 'Thumbs.db', - /\.iml$/, - /^npm-debug\.log/, - /^yarn-debug\.log/, - /^yarn-error\.log/, -]; - -function isValidProjectDirectory(dirPath: string) { - if (!fs.existsSync(dirPath)) { - return true; - } - - const conflicts = fs.readdirSync(dirPath).filter((content) => { - return !VALID_PROJECT_DIRECTORY_SAFE_LIST.some((safeContent) => { - return typeof safeContent === 'string' ? content === safeContent : safeContent.test(content); - }); - }); - - return conflicts.length === 0; -} - -const FILES_TO_REMOVE = ['.stackblitzrc', 'sandbox.config.json', 'CHANGELOG.md']; // some files are only needed for online editors when using astro.new. Remove for create-astro installs. - -// Please also update the installation instructions in the docs at https://github.com/withastro/docs/blob/main/src/pages/en/install/auto.md if you make any changes to the flow or wording here. +// Please also update the installation instructions in the docs at +// https://github.com/withastro/docs/blob/main/src/pages/en/install/auto.md +// if you make any changes to the flow or wording here. export async function main() { - const pkgManager = detectPackageManager()?.name || 'npm'; - const [username, version] = await Promise.all([getName(), getVersion()]); - - logger.debug('Verbose logging turned on'); - if (!args.skipHouston) { - await say( - [ - [ - 'Welcome', - 'to', - label('astro', color.bgGreen, color.black), - color.green(`v${version}`) + ',', - `${username}!`, - ], - random(welcome), - ], - { hat: args.fancy ? '🎩' : undefined } - ); - await banner(version); + // NOTE: In the v7.x version of npm, the default behavior of `npm init` was changed + // to no longer require `--` to pass args and instead pass `--` directly to us. This + // broke our arg parser, since `--` is a special kind of flag. Filtering for `--` here + // fixes the issue so that create-astro now works on all npm versions. + const cleanArgv = process.argv.slice(2).filter((arg) => arg !== '--'); + const ctx = await getContext(cleanArgv); + if (ctx.help) { + help(); + return; } - let cwd = args['_'][2] as string; + const steps = [ + intro, + projectName, + template, + dependencies, + git, + typescript, + next + ] - if (cwd && isValidProjectDirectory(cwd)) { - let acknowledgeProjectDir = ora({ - color: 'green', - text: `Using ${bold(cwd)} as project directory.`, - }); - acknowledgeProjectDir.succeed(); - } - - if (!cwd || !isValidProjectDirectory(cwd)) { - const notEmptyMsg = (dirPath: string) => `"${bold(dirPath)}" is not empty!`; - - if (!isValidProjectDirectory(cwd)) { - let rejectProjectDir = ora({ color: 'red', text: notEmptyMsg(cwd) }); - rejectProjectDir.fail(); - } - const dirResponse = await prompts( - { - type: 'text', - name: 'directory', - message: 'Where would you like to create your new project?', - initial: generateProjectName(), - validate(value) { - if (!isValidProjectDirectory(value)) { - return notEmptyMsg(value); - } - return true; - }, - }, - { onCancel: () => ora().info(dim('Operation cancelled. See you later, astronaut!')) } - ); - cwd = dirResponse.directory; - } - - if (!cwd) { - ora().info(dim('No directory provided. See you later, astronaut!')); - process.exit(1); - } - - const options = await prompts( - [ - { - type: 'select', - name: 'template', - message: 'How would you like to setup your new project?', - choices: TEMPLATES, - }, - ], - { onCancel: () => ora().info(dim('Operation cancelled. See you later, astronaut!')) } - ); - - if (!options.template || options.template === true) { - ora().info(dim('No template provided. See you later, astronaut!')); - process.exit(1); - } - - let templateSpinner = await loadWithRocketGradient('Copying project files...'); - - const hash = args.commit ? `#${args.commit}` : ''; - - const isThirdParty = options.template.includes('/'); - const templateTarget = isThirdParty - ? options.template - : `withastro/astro/examples/${options.template}#latest`; - - // Copy - if (!args.dryRun) { - try { - await downloadTemplate(`${templateTarget}${hash}`, { - force: true, - provider: 'github', - cwd, - dir: '.', - }); - } catch (err: any) { - fs.rmdirSync(cwd); - if (err.message.includes('404')) { - console.error(`Could not find template ${color.underline(options.template)}!`); - if (isThirdParty) { - const hasBranch = options.template.includes('#'); - if (hasBranch) { - console.error('Are you sure this GitHub repo and branch exist?'); - } else { - console.error( - `Are you sure this GitHub repo exists?` + - `This command uses the ${color.bold('main')} branch by default.\n` + - `If the repo doesn't have a main branch, specify a custom branch name:\n` + - color.underline(options.template + color.bold('#branch-name')) - ); - } - } - } else { - console.error(err.message); - } - process.exit(1); - } - - // Post-process in parallel - await Promise.all( - FILES_TO_REMOVE.map(async (file) => { - const fileLoc = path.resolve(path.join(cwd, file)); - if (fs.existsSync(fileLoc)) { - return fs.promises.rm(fileLoc, {}); - } - }) - ); - } - - templateSpinner.text = green('Template copied!'); - templateSpinner.succeed(); - - const install = args.y - ? true - : ( - await prompts( - { - type: 'confirm', - name: 'install', - message: `Would you like to install ${pkgManager} dependencies? ${reset( - dim('(recommended)') - )}`, - initial: true, - }, - { - onCancel: () => { - ora().info( - dim( - 'Operation cancelled. Your project folder has already been created, however no dependencies have been installed' - ) - ); - process.exit(1); - }, - } - ) - ).install; - - if (args.dryRun) { - ora().info(dim(`--dry-run enabled, skipping.`)); - } else if (install) { - const installExec = execa(pkgManager, ['install'], { cwd }); - const installingPackagesMsg = `Installing packages${emojiWithFallback(' 📦', '...')}`; - const installSpinner = await loadWithRocketGradient(installingPackagesMsg); - await new Promise((resolve, reject) => { - installExec.stdout?.on('data', function (data) { - installSpinner.text = `${rocketAscii} ${installingPackagesMsg}\n${bold( - `[${pkgManager}]` - )} ${data}`; - }); - installExec.on('error', (error) => reject(error)); - installExec.on('close', () => resolve()); - }); - installSpinner.text = green('Packages installed!'); - installSpinner.succeed(); - } else { - await info('No problem!', 'Remember to install dependencies after setup.'); - } - - const gitResponse = args.y - ? true - : ( - await prompts( - { - type: 'confirm', - name: 'git', - message: `Would you like to initialize a new git repository? ${reset( - dim('(optional)') - )}`, - initial: true, - }, - { - onCancel: () => { - ora().info( - dim('Operation cancelled. No worries, your project folder has already been created') - ); - process.exit(1); - }, - } - ) - ).git; - - if (args.dryRun) { - ora().info(dim(`--dry-run enabled, skipping.`)); - } else if (gitResponse) { - // Add a check to see if there is already a .git directory and skip 'git init' if yes (with msg to output) - const gitDir = './.git'; - if (fs.existsSync(gitDir)) { - ora().info(dim('A .git directory already exists. Skipping creating a new Git repository.')); - } else { - await execaCommand('git init', { cwd }); - ora().succeed('Git repository created!'); - } - } else { - await info( - 'Sounds good!', - `You can come back and run ${color.reset(`git init`)}${color.dim(' later.')}` - ); - } - - if (args.y && !args.typescript) { - ora().warn(dim('--typescript missing. Defaulting to "strict"')); - args.typescript = 'strict'; - } - - let tsResponse = - args.typescript || - ( - await prompts( - { - type: 'select', - name: 'typescript', - message: 'How would you like to setup TypeScript?', - choices: [ - { value: 'strict', title: 'Strict', description: '(recommended)' }, - { value: 'strictest', title: 'Strictest' }, - { value: 'base', title: 'Relaxed' }, - { value: 'unsure', title: 'Help me choose' }, - ], - }, - { - onCancel: () => { - ora().info( - dim( - 'Operation cancelled. Your project folder has been created but no TypeScript configuration file was created.' - ) - ); - process.exit(1); - }, - } - ) - ).typescript; - - if (tsResponse === 'unsure') { - await typescriptByDefault(); - tsResponse = 'base'; - } - if (args.dryRun) { - ora().info(dim(`--dry-run enabled, skipping.`)); - } else if (tsResponse) { - const templateTSConfigPath = path.join(cwd, 'tsconfig.json'); - fs.readFile(templateTSConfigPath, (err, data) => { - if (err && err.code === 'ENOENT') { - // If the template doesn't have a tsconfig.json, let's add one instead - fs.writeFileSync( - templateTSConfigPath, - stringify({ extends: `astro/tsconfigs/${tsResponse ?? 'base'}` }, null, 2) - ); - - return; - } - - const templateTSConfig = parse(data.toString()); - - if (templateTSConfig && typeof templateTSConfig === 'object') { - const result = assign(templateTSConfig, { - extends: `astro/tsconfigs/${tsResponse ?? 'base'}`, - }); - - fs.writeFileSync(templateTSConfigPath, stringify(result, null, 2)); - } else { - console.log( - yellow( - "There was an error applying the requested TypeScript settings. This could be because the template's tsconfig.json is malformed" - ) - ); - } - }); - ora().succeed('TypeScript settings applied!'); - } - - let projectDir = path.relative(process.cwd(), cwd); - const devCmd = pkgManager === 'npm' ? 'npm run dev' : `${pkgManager} dev`; - await nextSteps({ projectDir, devCmd }); - - if (!args.skipHouston) { - await say(['Good luck out there, astronaut!']); + for (const step of steps) { + await step(ctx) } + process.exit(0); } -function emojiWithFallback(char: string, fallback: string) { - return process.platform !== 'win32' ? char : fallback; +export { + setStdout, + getContext, + intro, + projectName, + template, + dependencies, + git, + typescript, + setupTypeScript, + next } diff --git a/packages/create-astro/src/logger.ts b/packages/create-astro/src/logger.ts deleted file mode 100644 index 46f916c42..000000000 --- a/packages/create-astro/src/logger.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { blue, bold, dim, red, yellow } from 'kleur/colors'; -import { Writable } from 'stream'; -import { format as utilFormat } from 'util'; - -type ConsoleStream = Writable & { - fd: 1 | 2; -}; - -// Hey, locales are pretty complicated! Be careful modifying this logic... -// If we throw at the top-level, international users can't use Astro. -// -// Using `[]` sets the default locale properly from the system! -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#parameters -// -// Here be the dragons we've slain: -// https://github.com/withastro/astro/issues/2625 -// https://github.com/withastro/astro/issues/3309 -const dt = new Intl.DateTimeFormat([], { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', -}); - -export const defaultLogDestination = new Writable({ - objectMode: true, - write(event: LogMessage, _, callback) { - let dest: Writable = process.stderr; - if (levels[event.level] < levels['error']) dest = process.stdout; - - dest.write(dim(dt.format(new Date()) + ' ')); - - let type = event.type; - if (type) { - switch (event.level) { - case 'info': - type = bold(blue(type)); - break; - case 'warn': - type = bold(yellow(type)); - break; - case 'error': - type = bold(red(type)); - break; - } - - dest.write(`[${type}] `); - } - - dest.write(utilFormat(...event.args)); - dest.write('\n'); - - callback(); - }, -}); - -interface LogWritable extends Writable { - write: (chunk: T) => boolean; -} - -export type LoggerLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent'; // same as Pino -export type LoggerEvent = 'debug' | 'info' | 'warn' | 'error'; - -export let defaultLogLevel: LoggerLevel; -if (process.argv.includes('--verbose')) { - defaultLogLevel = 'debug'; -} else if (process.argv.includes('--silent')) { - defaultLogLevel = 'silent'; -} else { - defaultLogLevel = 'info'; -} - -export interface LogOptions { - dest?: LogWritable; - level?: LoggerLevel; -} - -export const defaultLogOptions: Required = { - dest: defaultLogDestination, - level: defaultLogLevel, -}; - -export interface LogMessage { - type: string | null; - level: LoggerLevel; - message: string; - args: Array; -} - -export const levels: Record = { - debug: 20, - info: 30, - warn: 40, - error: 50, - silent: 90, -}; - -/** Full logging API */ -export function log( - opts: LogOptions = {}, - level: LoggerLevel, - type: string | null, - ...args: Array -) { - const logLevel = opts.level ?? defaultLogOptions.level; - const dest = opts.dest ?? defaultLogOptions.dest; - const event: LogMessage = { - type, - level, - args, - message: '', - }; - - // test if this level is enabled or not - if (levels[logLevel] > levels[level]) { - return; // do nothing - } - - dest.write(event); -} - -/** Emit a message only shown in debug mode */ -export function debug(opts: LogOptions, type: string | null, ...messages: Array) { - return log(opts, 'debug', type, ...messages); -} - -/** Emit a general info message (be careful using this too much!) */ -export function info(opts: LogOptions, type: string | null, ...messages: Array) { - return log(opts, 'info', type, ...messages); -} - -/** Emit a warning a user should be aware of */ -export function warn(opts: LogOptions, type: string | null, ...messages: Array) { - return log(opts, 'warn', type, ...messages); -} - -/** Emit a fatal error message the user should address. */ -export function error(opts: LogOptions, type: string | null, ...messages: Array) { - return log(opts, 'error', type, ...messages); -} - -// A default logger for when too lazy to pass LogOptions around. -export const logger = { - debug: debug.bind(null, defaultLogOptions, 'debug'), - info: info.bind(null, defaultLogOptions, 'info'), - warn: warn.bind(null, defaultLogOptions, 'warn'), - error: error.bind(null, defaultLogOptions, 'error'), -}; diff --git a/packages/create-astro/src/messages.ts b/packages/create-astro/src/messages.ts index 2ed0608e5..c70857ada 100644 --- a/packages/create-astro/src/messages.ts +++ b/packages/create-astro/src/messages.ts @@ -1,126 +1,183 @@ /* eslint no-console: 'off' */ -import { color, label } from '@astrojs/cli-kit'; -import { sleep } from '@astrojs/cli-kit/utils'; import { exec } from 'node:child_process'; import { get } from 'node:https'; +import { color, label, spinner as load, say as houston } from '@astrojs/cli-kit'; +import { sleep, align } from '@astrojs/cli-kit/utils'; import stripAnsi from 'strip-ansi'; -export const welcome = [ - `Let's claim your corner of the internet.`, - `I'll be your assistant today.`, - `Let's build something awesome!`, - `Let's build something great!`, - `Let's build something fast!`, - `Let's make the web weird!`, - `Let's make the web a better place!`, - `Let's create a new project!`, - `Let's create something unique!`, - `Time to build a new website.`, - `Time to build a faster website.`, - `Time to build a sweet new website.`, - `We're glad to have you on board.`, - `Keeping the internet weird since 2021.`, - `Initiating launch sequence...`, - `Initiating launch sequence... right... now!`, - `Awaiting further instructions.`, -]; - -export function getName() { - return new Promise((resolve) => { - exec('git config user.name', { encoding: 'utf-8' }, (_1, gitName, _2) => { - if (gitName.trim()) { - return resolve(gitName.split(' ')[0].trim()); - } - exec('whoami', { encoding: 'utf-8' }, (_3, whoami, _4) => { - if (whoami.trim()) { - return resolve(whoami.split(' ')[0].trim()); - } - return resolve('astronaut'); - }); - }); - }); +let stdout = process.stdout; +/** @internal Used to mock `process.stdout.write` for testing purposes */ +export function setStdout(writable: typeof process.stdout) { + stdout = writable; } +export async function say(messages: string|string[], { clear = false, hat = '' } = {}) { + return houston(messages, { clear, hat, stdout }); +} + +export async function spinner(args: { start: string; end: string; while: (...args: any) => Promise; }) { + await load(args, { stdout }); +} + +export const title = (text: string) => align(label(text), 'end', 7) + ' '; + +export const welcome = [ + `Let's claim your corner of the internet.`, + `I'll be your assistant today.`, + `Let's build something awesome!`, + `Let's build something great!`, + `Let's build something fast!`, + `Let's build the web we want.`, + `Let's make the web weird!`, + `Let's make the web a better place!`, + `Let's create a new project!`, + `Let's create something unique!`, + `Time to build a new website.`, + `Time to build a faster website.`, + `Time to build a sweet new website.`, + `We're glad to have you on board.`, + `Keeping the internet weird since 2021.`, + `Initiating launch sequence...`, + `Initiating launch sequence... right... now!`, + `Awaiting further instructions.`, +] + +export const getName = () => new Promise((resolve) => { + exec('git config user.name', { encoding: 'utf-8' }, (_1, gitName, _2) => { + if (gitName.trim()) { + return resolve(gitName.split(' ')[0].trim()); + } + exec('whoami', { encoding: 'utf-8' }, (_3, whoami, _4) => { + if (whoami.trim()) { + return resolve(whoami.split(' ')[0].trim()); + } + return resolve('astronaut'); + }); + }); +}); + let v: string; -export function getVersion() { - return new Promise((resolve) => { - if (v) return resolve(v); - get('https://registry.npmjs.org/astro/latest', (res) => { - let body = ''; - res.on('data', (chunk) => (body += chunk)); - res.on('end', () => { - const { version } = JSON.parse(body); - v = version; - resolve(version); - }); - }); - }); +export const getVersion = () => new Promise((resolve) => { + if (v) return resolve(v); + get('https://registry.npmjs.org/astro/latest', (res) => { + let body = ''; + res.on('data', chunk => body += chunk) + res.on('end', () => { + const { version } = JSON.parse(body); + v = version; + resolve(version); + }) + }) +}) + +export const log = (message: string) => stdout.write(message + "\n"); +export const banner = async (version: string) => log(`\n${label('astro', color.bgGreen, color.black)} ${color.green(color.bold(`v${version}`))} ${color.bold('Launch sequence initiated.')}`); + +export const info = async (prefix: string, text: string) => { + await sleep(100) + if (stdout.columns < 80) { + log(`${' '.repeat(5)} ${color.cyan('◼')} ${color.cyan(prefix)}`); + log(`${' '.repeat(9)}${color.dim(text)}`); + } else { + log(`${' '.repeat(5)} ${color.cyan('◼')} ${color.cyan(prefix)} ${color.dim(text)}`); + } } -export async function banner(version: string) { - return console.log( - `\n${label('astro', color.bgGreen, color.black)} ${color.green( - color.bold(`v${version}`) - )} ${color.bold('Launch sequence initiated.')}\n` - ); +export const error = async (prefix: string, text: string) => { + if (stdout.columns < 80) { + log(`${' '.repeat(5)} ${color.red('▲')} ${color.red(prefix)}`); + log(`${' '.repeat(9)}${color.dim(text)}`); + } else { + log(`${' '.repeat(5)} ${color.red('▲')} ${color.red(prefix)} ${color.dim(text)}`); + } } -export async function info(prefix: string, text: string) { - await sleep(100); - if (process.stdout.columns < 80) { - console.log(`${color.cyan('◼')} ${color.cyan(prefix)}`); - console.log(`${' '.repeat(3)}${color.dim(text)}\n`); - } else { - console.log(`${color.cyan('◼')} ${color.cyan(prefix)} ${color.dim(text)}\n`); +export const typescriptByDefault = async () => { + await info(`No worries!`, 'TypeScript is supported in Astro by default,'); + log(`${' '.repeat(9)}${color.dim('but you are free to continue writing JavaScript instead.')}`); + await sleep(1000); +} + +export const nextSteps = async ({ projectDir, devCmd }: { projectDir: string, devCmd: string }) => { + const max = stdout.columns; + const prefix = max < 80 ? ' ' : ' '.repeat(9); + await sleep(200); + log(`\n ${color.bgCyan(` ${color.black('next')} `)} ${color.bold('Liftoff confirmed. Explore your project!')}`) + + await sleep(100); + if (projectDir !== '') { + const enter = [`\n${prefix}Enter your project directory using`, color.cyan(`cd ./${projectDir}`, '')]; + const len = enter[0].length + stripAnsi(enter[1]).length; + log(enter.join((len > max) ? '\n' + prefix : ' ')); + } + log(`${prefix}Run ${color.cyan(devCmd)} to start the dev server. ${color.cyan('CTRL+C')} to stop.`) + await sleep(100); + log(`${prefix}Add frameworks like ${color.cyan(`react`)} or ${color.cyan('tailwind')} using ${color.cyan('astro add')}.`) + await sleep(100); + log(`\n${prefix}Stuck? Join us at ${color.cyan(`https://astro.build/chat`)}`) + await sleep(200); +} + + +export function printHelp({ + commandName, + headline, + usage, + tables, + description, +}: { + commandName: string; + headline?: string; + usage?: string; + tables?: Record; + description?: string; +}) { + const linebreak = () => ''; + const table = (rows: [string, string][], { padding }: { padding: number }) => { + const split = stdout.columns < 60; + let raw = ''; + + for (const row of rows) { + if (split) { + raw += ` ${row[0]}\n `; + } else { + raw += `${`${row[0]}`.padStart(padding)}`; + } + raw += ' ' + color.dim(row[1]) + '\n'; + } + + return raw.slice(0, -1); // remove latest \n + }; + + let message = []; + + if (headline) { + message.push( + linebreak(), + `${title(commandName)} ${color.green( + `v${process.env.PACKAGE_VERSION ?? ''}` + )} ${headline}` + ); } -} -export async function error(prefix: string, text: string) { - if (process.stdout.columns < 80) { - console.log(`${' '.repeat(5)} ${color.red('▲')} ${color.red(prefix)}`); - console.log(`${' '.repeat(9)}${color.dim(text)}`); - } else { - console.log(`${' '.repeat(5)} ${color.red('▲')} ${color.red(prefix)} ${color.dim(text)}`); + if (usage) { + message.push(linebreak(), `${color.green(commandName)} ${color.bold(usage)}`); } -} -export async function typescriptByDefault() { - await info(`Cool!`, 'Astro comes with TypeScript support enabled by default.'); - console.log( - `${' '.repeat(3)}${color.dim(`We'll default to the most relaxed settings for you.`)}` - ); - await sleep(300); -} - -export async function nextSteps({ projectDir, devCmd }: { projectDir: string; devCmd: string }) { - const max = process.stdout.columns; - const prefix = max < 80 ? ' ' : ' '.repeat(9); - await sleep(200); - console.log( - `\n ${color.bgCyan(` ${color.black('next')} `)} ${color.bold( - 'Liftoff confirmed. Explore your project!' - )}` - ); - - await sleep(100); - if (projectDir !== '') { - const enter = [ - `\n${prefix}Enter your project directory using`, - color.cyan(`cd ./${projectDir}`, ''), - ]; - const len = enter[0].length + stripAnsi(enter[1]).length; - console.log(enter.join(len > max ? '\n' + prefix : ' ')); + if (tables) { + function calculateTablePadding(rows: [string, string][]) { + return rows.reduce((val, [first]) => Math.max(val, first.length), 0); + } + const tableEntries = Object.entries(tables); + const padding = Math.max(...tableEntries.map(([, rows]) => calculateTablePadding(rows))); + for (const [, tableRows] of tableEntries) { + message.push(linebreak(), table(tableRows, { padding })); + } } - console.log( - `${prefix}Run ${color.cyan(devCmd)} to start the dev server. ${color.cyan('CTRL+C')} to stop.` - ); - await sleep(100); - console.log( - `${prefix}Add frameworks like ${color.cyan(`react`)} or ${color.cyan( - 'tailwind' - )} using ${color.cyan('astro add')}.` - ); - await sleep(100); - console.log(`\n${prefix}Stuck? Join us at ${color.cyan(`https://astro.build/chat`)}`); - await sleep(200); + + if (description) { + message.push(linebreak(), `${description}`); + } + + log(message.join('\n') + '\n'); } diff --git a/packages/create-astro/src/templates.ts b/packages/create-astro/src/templates.ts deleted file mode 100644 index 7dff7c587..000000000 --- a/packages/create-astro/src/templates.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const TEMPLATES = [ - { value: 'basics', title: 'a few best practices (recommended)' }, - { value: 'blog', title: 'a personal website starter kit' }, - { value: 'minimal', title: 'an empty project' }, -]; diff --git a/packages/create-astro/test/context.test.js b/packages/create-astro/test/context.test.js new file mode 100644 index 000000000..d6cb1c70f --- /dev/null +++ b/packages/create-astro/test/context.test.js @@ -0,0 +1,62 @@ +import { expect } from 'chai'; + +import os from 'node:os'; +import { getContext } from '../dist/index.js'; + +describe('context', () => { + it('no arguments', async () => { + const ctx = await getContext([]); + expect(ctx.projectName).to.be.undefined; + expect(ctx.template).to.be.undefined; + expect(ctx.skipHouston).to.eq(os.platform() === 'win32'); + expect(ctx.dryRun).to.be.undefined; + }) + it('project name', async () => { + const ctx = await getContext(['foobar']); + expect(ctx.projectName).to.eq('foobar'); + }) + it('template', async () => { + const ctx = await getContext(['--template', 'minimal']); + expect(ctx.template).to.eq('minimal'); + }) + it('skip houston (explicit)', async () => { + const ctx = await getContext(['--skip-houston']); + expect(ctx.skipHouston).to.eq(true); + }) + it('skip houston (yes)', async () => { + const ctx = await getContext(['-y']); + expect(ctx.skipHouston).to.eq(true); + }) + it('skip houston (no)', async () => { + const ctx = await getContext(['-n']); + expect(ctx.skipHouston).to.eq(true); + }) + it('skip houston (install)', async () => { + const ctx = await getContext(['--install']); + expect(ctx.skipHouston).to.eq(true); + }) + it('dry run', async () => { + const ctx = await getContext(['--dry-run']); + expect(ctx.dryRun).to.eq(true); + }) + it('install', async () => { + const ctx = await getContext(['--install']); + expect(ctx.install).to.eq(true); + }) + it('no install', async () => { + const ctx = await getContext(['--no-install']); + expect(ctx.install).to.eq(false); + }) + it('git', async () => { + const ctx = await getContext(['--git']); + expect(ctx.git).to.eq(true); + }) + it('no git', async () => { + const ctx = await getContext(['--no-git']); + expect(ctx.git).to.eq(false); + }) + it('typescript', async () => { + const ctx = await getContext(['--typescript', 'strict']); + expect(ctx.typescript).to.eq('strict'); + }) +}) diff --git a/packages/create-astro/test/create-astro.test.js.skipped b/packages/create-astro/test/create-astro.test.js.skipped deleted file mode 100644 index 86a64e1f5..000000000 --- a/packages/create-astro/test/create-astro.test.js.skipped +++ /dev/null @@ -1,139 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import http from 'http'; -import { green, red } from 'kleur/colors'; -import { execa } from 'execa'; -import glob from 'tiny-glob'; -import { TEMPLATES } from '../dist/templates.js'; -import { GITHUB_SHA, FIXTURES_DIR } from './helpers.js'; - -// helpers -async function fetch(url) { - return new Promise((resolve, reject) => { - http - .get(url, (res) => { - // not OK - if (res.statusCode !== 200) { - reject(res.statusCode); - return; - } - - // OK - let body = ''; - res.on('data', (chunk) => { - body += chunk; - }); - res.on('end', () => resolve({ statusCode: res.statusCode, body })); - }) - .on('error', (err) => { - // other error - reject(err); - }); - }); -} - -function assert(a, b, message) { - if (a !== b) throw new Error(red(`✘ ${message}`)); -} - -async function testTemplate(template) { - const templateDir = path.join(FIXTURES_DIR, template); - - // test 1: install - const DOES_HAVE = ['.gitignore', 'package.json', 'public', 'src']; - const DOES_NOT_HAVE = ['.git', 'meta.json']; - - // test 1a: expect template contains essential files & folders - for (const file of DOES_HAVE) { - assert(fs.existsSync(path.join(templateDir, file)), true, `[${template}] has ${file}`); - } - // test 1b: expect template DOES NOT contain files supposed to be stripped away - for (const file of DOES_NOT_HAVE) { - assert(fs.existsSync(path.join(templateDir, file)), false, `[${template}] cleaned up ${file}`); - } - - // test 2: build - const MUST_HAVE_FILES = ['index.html', '_astro']; - await execa('npm', ['run', 'build'], { cwd: templateDir }); - const builtFiles = await glob('**/*', { cwd: path.join(templateDir, 'dist') }); - // test 2a: expect all files built successfully - for (const file of MUST_HAVE_FILES) { - assert(builtFiles.includes(file), true, `[${template}] built ${file}`); - } - - // test 3: dev server (should happen after build so dependency install can be reused) - - // TODO: fix dev server test in CI - if (process.env.CI === true) { - return; - } - - // start dev server in background & wait until ready - const templateIndex = TEMPLATES.findIndex(({ value }) => value === template); - const port = 3000 + templateIndex; // use different port per-template - const devServer = execa('npm', ['run', 'start', '--', '--port', port], { cwd: templateDir }); - let sigkill = setTimeout(() => { - throw new Error(`Dev server failed to start`); // if 10s has gone by with no update, kill process - }, 10000); - - // read stdout until "Server started" appears - await new Promise((resolve, reject) => { - devServer.stdout.on('data', (data) => { - clearTimeout(sigkill); - sigkill = setTimeout(() => { - reject(`Dev server failed to start`); - }, 10000); - if (data.toString('utf8').includes('Server started')) resolve(); - }); - devServer.stderr.on('data', (data) => { - reject(data.toString('utf8')); - }); - }); - clearTimeout(sigkill); // done! - - // send request to dev server that should be ready - const { statusCode, body } = (await fetch(`http://localhost:${port}`)) || {}; - - // test 3a: expect 200 status code - assert(statusCode, 200, `[${template}] 200 response`); - // test 3b: expect non-empty response - assert(body.length > 0, true, `[${template}] non-empty response`); - - // clean up - devServer.kill(); -} - -async function testAll() { - // setup - await Promise.all( - TEMPLATES.map(async ({ value: template }) => { - // setup: `npm init astro` - await execa( - '../../create-astro.mjs', - [template, '--template', template, '--commit', GITHUB_SHA, '--force-overwrite'], - { - cwd: FIXTURES_DIR, - } - ); - // setup: `pnpm install` (note: running multiple `pnpm`s in parallel in CI will conflict) - await execa('pnpm', ['install', '--no-package-lock', '--silent'], { - cwd: path.join(FIXTURES_DIR, template), - }); - }) - ); - - // test (note: not parallelized because Snowpack HMR reuses same port in dev) - for (let n = 0; n < TEMPLATES.length; n += 1) { - const template = TEMPLATES[n].value; - - try { - await testTemplate(template); - } catch (err) { - console.error(red(`✘ [${template}]`)); - throw err; - } - - console.info(green(`✔ [${template}] All tests passed (${n + 1}/${TEMPLATES.length})`)); - } -} -testAll(); diff --git a/packages/create-astro/test/dependencies.test.js b/packages/create-astro/test/dependencies.test.js new file mode 100644 index 000000000..515c42294 --- /dev/null +++ b/packages/create-astro/test/dependencies.test.js @@ -0,0 +1,42 @@ +import { expect } from 'chai'; + +import { dependencies } from '../dist/index.js'; +import { setup } from './utils.js'; + +describe('dependencies', () => { + const fixture = setup(); + + it('--yes', async () => { + const context = { cwd: '', yes: true, pkgManager: 'npm', dryRun: true, prompt: (() => ({ deps: true }))}; + await dependencies(context); + expect(fixture.hasMessage('Skipping dependency installation')).to.be.true; + }) + + it('prompt yes', async () => { + const context = { cwd: '', pkgManager: 'npm', dryRun: true, prompt: (() => ({ deps: true })), install: undefined }; + await dependencies(context); + expect(fixture.hasMessage('Skipping dependency installation')).to.be.true; + expect(context.install).to.eq(true); + }) + + it('prompt no', async () => { + const context = { cwd: '', pkgManager: 'npm', dryRun: true, prompt: (() => ({ deps: false })), install: undefined }; + await dependencies(context); + expect(fixture.hasMessage('Skipping dependency installation')).to.be.true; + expect(context.install).to.eq(false); + }) + + it('--install', async () => { + const context = { cwd: '', install: true, pkgManager: 'npm', dryRun: true, prompt: (() => ({ deps: false })) }; + await dependencies(context); + expect(fixture.hasMessage('Skipping dependency installation')).to.be.true; + expect(context.install).to.eq(true); + }) + + it('--no-install', async () => { + const context = { cwd: '', install: false, pkgManager: 'npm', dryRun: true, prompt: (() => ({ deps: false })) }; + await dependencies(context); + expect(fixture.hasMessage('Skipping dependency installation')).to.be.true; + expect(context.install).to.eq(false); + }) +}) diff --git a/packages/create-astro/test/directory-step.test.js b/packages/create-astro/test/directory-step.test.js deleted file mode 100644 index 15a0479c8..000000000 --- a/packages/create-astro/test/directory-step.test.js +++ /dev/null @@ -1,88 +0,0 @@ -import path from 'path'; -import { promises, existsSync } from 'fs'; -import { PROMPT_MESSAGES, testDir, setup, promiseWithTimeout, timeout } from './utils.js'; - -const inputs = { - nonEmptyDir: './fixtures/select-directory/nonempty-dir', - nonEmptySafeDir: './fixtures/select-directory/nonempty-safe-dir', - emptyDir: './fixtures/select-directory/empty-dir', - nonexistentDir: './fixtures/select-directory/banana-dir', -}; - -describe('[create-astro] select directory', function () { - this.timeout(timeout); - it('should prompt for directory when none is provided', function () { - return promiseWithTimeout((resolve, onStdout) => { - const { stdout } = setup(); - stdout.on('data', (chunk) => { - onStdout(chunk); - if (chunk.includes(PROMPT_MESSAGES.directory)) { - resolve(); - } - }); - }); - }); - it('should NOT proceed on a non-empty directory', function () { - return promiseWithTimeout((resolve, onStdout) => { - const { stdout } = setup([inputs.nonEmptyDir]); - stdout.on('data', (chunk) => { - onStdout(chunk); - if (chunk.includes(PROMPT_MESSAGES.directory)) { - resolve(); - } - }); - }); - }); - it('should proceed on a non-empty safe directory', function () { - return promiseWithTimeout((resolve) => { - const { stdout } = setup([inputs.nonEmptySafeDir]); - stdout.on('data', (chunk) => { - if (chunk.includes(PROMPT_MESSAGES.template)) { - resolve(); - } - }); - }); - }); - it('should proceed on an empty directory', async function () { - const resolvedEmptyDirPath = path.resolve(testDir, inputs.emptyDir); - if (!existsSync(resolvedEmptyDirPath)) { - await promises.mkdir(resolvedEmptyDirPath); - } - return promiseWithTimeout((resolve, onStdout) => { - const { stdout } = setup([inputs.emptyDir]); - stdout.on('data', (chunk) => { - onStdout(chunk); - if (chunk.includes(PROMPT_MESSAGES.template)) { - resolve(); - } - }); - }); - }); - it('should proceed when directory does not exist', function () { - return promiseWithTimeout((resolve, onStdout) => { - const { stdout } = setup([inputs.nonexistentDir]); - stdout.on('data', (chunk) => { - onStdout(chunk); - if (chunk.includes(PROMPT_MESSAGES.template)) { - resolve(); - } - }); - }); - }); - it('should error on bad directory selection in prompt', function () { - return promiseWithTimeout((resolve, onStdout) => { - let wrote = false; - const { stdout, stdin } = setup(); - stdout.on('data', (chunk) => { - onStdout(chunk); - if (chunk.includes('is not empty!')) { - resolve(); - } - if (!wrote && chunk.includes(PROMPT_MESSAGES.directory)) { - stdin.write(`${inputs.nonEmptyDir}\x0D`); - wrote = true; - } - }); - }); - }); -}); diff --git a/packages/create-astro/test/external.test.js.skipped b/packages/create-astro/test/external.test.js.skipped deleted file mode 100644 index 277e498e0..000000000 --- a/packages/create-astro/test/external.test.js.skipped +++ /dev/null @@ -1,27 +0,0 @@ -import assert from 'assert'; -import { execa } from 'execa'; -import { FIXTURES_URL } from './helpers.js'; -import { existsSync } from 'fs'; - -async function run(outdir, template) { - //--template cassidoo/shopify-react-astro - await execa('../../create-astro.mjs', [outdir, '--template', template, '--force-overwrite'], { - cwd: FIXTURES_URL.pathname, - }); -} - -const testCases = [['shopify', 'cassidoo/shopify-react-astro']]; - -async function tests() { - for (let [dir, tmpl] of testCases) { - await run(dir, tmpl); - - const outPath = new URL('' + dir, FIXTURES_URL); - assert.ok(existsSync(outPath)); - } -} - -tests().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/packages/create-astro/test/fixtures/select-directory/nonempty-dir/astro-origin-story.php b/packages/create-astro/test/fixtures/empty/.gitkeep similarity index 100% rename from packages/create-astro/test/fixtures/select-directory/nonempty-dir/astro-origin-story.php rename to packages/create-astro/test/fixtures/empty/.gitkeep diff --git a/packages/create-astro/test/fixtures/not-empty/package.json b/packages/create-astro/test/fixtures/not-empty/package.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/packages/create-astro/test/fixtures/not-empty/package.json @@ -0,0 +1 @@ +{} diff --git a/packages/create-astro/test/fixtures/not-empty/tsconfig.json b/packages/create-astro/test/fixtures/not-empty/tsconfig.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/packages/create-astro/test/fixtures/not-empty/tsconfig.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/packages/create-astro/test/fixtures/select-directory/nonempty-safe-dir/.gitignore b/packages/create-astro/test/fixtures/select-directory/nonempty-safe-dir/.gitignore deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/create-astro/test/fixtures/select-directory/nonempty-safe-dir/module.iml b/packages/create-astro/test/fixtures/select-directory/nonempty-safe-dir/module.iml deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/create-astro/test/git.test.js b/packages/create-astro/test/git.test.js new file mode 100644 index 000000000..4b048156a --- /dev/null +++ b/packages/create-astro/test/git.test.js @@ -0,0 +1,43 @@ +import { expect } from 'chai'; + +import fs from 'fs'; +import { execa } from 'execa'; + +import { git } from '../dist/index.js'; +import { setup } from './utils.js'; + +describe('git', () => { + const fixture = setup(); + + it('none', async () => { + const context = { cwd: '', dryRun: true, prompt: (() => ({ git: false }))}; + await git(context); + + expect(fixture.hasMessage('Skipping Git initialization')).to.be.true; + }) + + it('already initialized', async () => { + const context = { git: true, cwd: './test/fixtures/not-empty', dryRun: true, prompt: (() => ({ git: false }))}; + await execa('git', ['init'], { cwd: './test/fixtures/not-empty' }); + await git(context); + + expect(fixture.hasMessage('Git has already been initialized')).to.be.true; + + // Cleanup + fs.rmSync('./test/fixtures/not-empty/.git', { recursive: true, force: true }); + }) + + it('yes (--dry-run)', async () => { + const context = { cwd: '', dryRun: true, prompt: (() => ({ git: true }))}; + await git(context); + + expect(fixture.hasMessage('Skipping Git initialization')).to.be.true; + }) + + it('no (--dry-run)', async () => { + const context = { cwd: '', dryRun: true, prompt: (() => ({ git: false }))}; + await git(context); + + expect(fixture.hasMessage('Skipping Git initialization')).to.be.true; + }) +}) diff --git a/packages/create-astro/test/helpers.js b/packages/create-astro/test/helpers.js deleted file mode 100644 index 4f0b6ec3e..000000000 --- a/packages/create-astro/test/helpers.js +++ /dev/null @@ -1,9 +0,0 @@ -import { execaSync } from 'execa'; -import path from 'path'; -import { fileURLToPath, pathToFileURL } from 'url'; - -const GITHUB_SHA = process.env.GITHUB_SHA || execaSync('git', ['rev-parse', 'HEAD']).stdout; // process.env.GITHUB_SHA will be set in CI; if testing locally execa() will gather this -const FIXTURES_DIR = path.join(fileURLToPath(path.dirname(import.meta.url)), 'fixtures'); -const FIXTURES_URL = pathToFileURL(FIXTURES_DIR + '/'); - -export { GITHUB_SHA, FIXTURES_DIR, FIXTURES_URL }; diff --git a/packages/create-astro/test/intro.test.js b/packages/create-astro/test/intro.test.js new file mode 100644 index 000000000..af13954d1 --- /dev/null +++ b/packages/create-astro/test/intro.test.js @@ -0,0 +1,20 @@ +import { expect } from 'chai'; + +import { intro } from '../dist/index.js'; +import { setup } from './utils.js'; + +describe('intro', () => { + const fixture = setup(); + + it('no arguments', async () => { + await intro({ skipHouston: false, version: '0.0.0', username: 'user' }); + expect(fixture.hasMessage('Houston:')).to.be.true; + expect(fixture.hasMessage('Welcome to astro v0.0.0')).to.be.true; + }) + it('--skip-houston', async () => { + await intro({ skipHouston: true, version: '0.0.0', username: 'user' }); + expect(fixture.length()).to.eq(1); + expect(fixture.hasMessage('Houston:')).to.be.false; + expect(fixture.hasMessage('Launch sequence initiated')).to.be.true; + }) +}) diff --git a/packages/create-astro/test/next.test.js b/packages/create-astro/test/next.test.js new file mode 100644 index 000000000..46efdf67f --- /dev/null +++ b/packages/create-astro/test/next.test.js @@ -0,0 +1,20 @@ +import { expect } from 'chai'; + +import { next } from '../dist/index.js'; +import { setup } from './utils.js'; + +describe('next steps', () => { + const fixture = setup(); + + it('no arguments', async () => { + await next({ skipHouston: false, cwd: './it/fixtures/not-empty', pkgManager: 'npm' }); + expect(fixture.hasMessage('Liftoff confirmed.')).to.be.true; + expect(fixture.hasMessage('npm run dev')).to.be.true; + expect(fixture.hasMessage('Good luck out there, astronaut!')).to.be.true; + }) + + it('--skip-houston', async () => { + await next({ skipHouston: true, cwd: './it/fixtures/not-empty', pkgManager: 'npm' }); + expect(fixture.hasMessage('Good luck out there, astronaut!')).to.be.false; + }) +}) diff --git a/packages/create-astro/test/project-name.test.js b/packages/create-astro/test/project-name.test.js new file mode 100644 index 000000000..38f1359b6 --- /dev/null +++ b/packages/create-astro/test/project-name.test.js @@ -0,0 +1,79 @@ +import { expect } from 'chai'; + +import { projectName } from '../dist/index.js'; +import { setup } from './utils.js'; + +describe('project name', () => { + const fixture = setup(); + + it('pass in name', async () => { + const context = { projectName: '', cwd: './foo/bar/baz', prompt: (() => {})}; + await projectName(context); + + expect(context.cwd).to.eq('./foo/bar/baz'); + expect(context.projectName).to.eq('baz'); + }) + + it('dot', async () => { + const context = { projectName: '', cwd: '.', prompt: (() => ({ name: 'foobar' }))}; + await projectName(context); + + expect(fixture.hasMessage('"." is not empty!')).to.be.true; + expect(context.projectName).to.eq('foobar'); + }) + + it('dot slash', async () => { + const context = { projectName: '', cwd: './', prompt: (() => ({ name: 'foobar' }))}; + await projectName(context); + + expect(fixture.hasMessage('"./" is not empty!')).to.be.true; + expect(context.projectName).to.eq('foobar'); + }) + + it('empty', async () => { + const context = { projectName: '', cwd: './test/fixtures/empty', prompt: (() => ({ name: 'foobar' }))}; + await projectName(context); + + expect(fixture.hasMessage('"./test/fixtures/empty" is not empty!')).to.be.false; + expect(context.projectName).to.eq('empty'); + }) + + it('not empty', async () => { + const context = { projectName: '', cwd: './test/fixtures/not-empty', prompt: (() => ({ name: 'foobar' }))}; + await projectName(context); + + expect(fixture.hasMessage('"./test/fixtures/not-empty" is not empty!')).to.be.true; + expect(context.projectName).to.eq('foobar'); + }) + + it('basic', async () => { + const context = { projectName: '', cwd: '', prompt: (() => ({ name: 'foobar' }))}; + await projectName(context); + + expect(context.cwd).to.eq('foobar'); + expect(context.projectName).to.eq('foobar'); + }) + + it('normalize', async () => { + const context = { projectName: '', cwd: '', prompt: (() => ({ name: 'Invalid Name' }))}; + await projectName(context); + + expect(context.cwd).to.eq('Invalid Name'); + expect(context.projectName).to.eq('invalid-name'); + }) + + it('remove leading/trailing dashes', async () => { + const context = { projectName: '', cwd: '', prompt: (() => ({ name: '(invalid)' }))}; + await projectName(context); + + expect(context.projectName).to.eq('invalid'); + }) + + it('handles scoped packages', async () => { + const context = { projectName: '', cwd: '', prompt: (() => ({ name: '@astro/site' }))}; + await projectName(context); + + expect(context.cwd).to.eq('@astro/site'); + expect(context.projectName).to.eq('@astro/site'); + }) +}) diff --git a/packages/create-astro/test/template.test.js b/packages/create-astro/test/template.test.js new file mode 100644 index 000000000..53b9777ff --- /dev/null +++ b/packages/create-astro/test/template.test.js @@ -0,0 +1,36 @@ +import { expect } from 'chai'; + +import { template } from '../dist/index.js'; +import { setup } from './utils.js'; + +describe('template', () => { + const fixture = setup(); + + it('none', async () => { + const context = { template: '', cwd: '', dryRun: true, prompt: (() => ({ template: 'blog' })) }; + await template(context); + + expect(fixture.hasMessage('Skipping template copying')).to.be.true; + expect(context.template).to.eq('blog'); + }) + + it('minimal (--dry-run)', async () => { + const context = { template: 'minimal', cwd: '', dryRun: true, prompt: (() => {})}; + await template(context); + expect(fixture.hasMessage('Using minimal as project template')).to.be.true; + }) + + it('basics (--dry-run)', async () => { + const context = { template: 'basics', cwd: '', dryRun: true, prompt: (() => {})}; + await template(context); + + expect(fixture.hasMessage('Using basics as project template')).to.be.true; + }) + + it('blog (--dry-run)', async () => { + const context = { template: 'blog', cwd: '', dryRun: true, prompt: (() => {})}; + await template(context); + + expect(fixture.hasMessage('Using blog as project template')).to.be.true; + }) +}) diff --git a/packages/create-astro/test/typescript-step.test.js.skipped b/packages/create-astro/test/typescript-step.test.js.skipped deleted file mode 100644 index d9281b21d..000000000 --- a/packages/create-astro/test/typescript-step.test.js.skipped +++ /dev/null @@ -1,142 +0,0 @@ -import { expect } from 'chai'; -import { deleteSync } from 'del'; -import { existsSync, mkdirSync, readdirSync, readFileSync } from 'fs'; -import path from 'path'; -import { PROMPT_MESSAGES, testDir, setup, promiseWithTimeout, timeout } from './utils.js'; - -const inputs = { - emptyDir: './fixtures/select-typescript/empty-dir', -}; - -function isEmpty(dirPath) { - return !existsSync(dirPath) || readdirSync(dirPath).length === 0; -} - -function ensureEmptyDir() { - const dirPath = path.resolve(testDir, inputs.emptyDir); - if (!existsSync(dirPath)) { - mkdirSync(dirPath, { recursive: true }); - } else if (!isEmpty(dirPath)) { - const globPath = path.resolve(dirPath, '*'); - deleteSync(globPath, { dot: true }); - } -} - -function getTsConfig(installDir) { - const filePath = path.resolve(testDir, installDir, 'tsconfig.json'); - return JSON.parse(readFileSync(filePath, 'utf-8')); -} - -describe('[create-astro] select typescript', function () { - this.timeout(timeout); - - beforeEach(ensureEmptyDir); - - afterEach(ensureEmptyDir); - - it('should prompt for typescript when none is provided', async function () { - return promiseWithTimeout( - (resolve, onStdout) => { - const { stdout } = setup([ - inputs.emptyDir, - '--template', - 'minimal', - '--install', - '0', - '--git', - '0', - ]); - stdout.on('data', (chunk) => { - onStdout(chunk); - if (chunk.includes(PROMPT_MESSAGES.typescript)) { - resolve(); - } - }); - }, - () => lastStdout - ); - }); - - it('should not prompt for typescript when provided', async function () { - return promiseWithTimeout( - (resolve, onStdout) => { - const { stdout } = setup([ - inputs.emptyDir, - '--template', - 'minimal', - '--install', - '0', - '--git', - '0', - '--typescript', - 'base', - ]); - stdout.on('data', (chunk) => { - onStdout(chunk); - if (chunk.includes(PROMPT_MESSAGES.typescriptSucceed)) { - resolve(); - } - }); - }, - () => lastStdout - ); - }); - - it('should use "strict" config when specified', async function () { - return promiseWithTimeout( - (resolve, onStdout) => { - let wrote = false; - const { stdout, stdin } = setup([ - inputs.emptyDir, - '--template', - 'minimal', - '--install', - '0', - '--git', - '0', - ]); - stdout.on('data', (chunk) => { - onStdout(chunk); - if (!wrote && chunk.includes(PROMPT_MESSAGES.typescript)) { - // Enter (strict is default) - stdin.write('\n'); - wrote = true; - } - if (chunk.includes(PROMPT_MESSAGES.typescriptSucceed)) { - const tsConfigJson = getTsConfig(inputs.emptyDir); - expect(tsConfigJson).to.deep.equal({ extends: 'astro/tsconfigs/strict' }); - resolve(); - } - }); - }, - () => lastStdout - ); - }); - - it('should create tsconfig.json when missing', async function () { - return promiseWithTimeout( - (resolve, onStdout) => { - const { stdout } = setup([ - inputs.emptyDir, - '--template', - 'cassidoo/shopify-react-astro', - '--install', - '0', - '--git', - '0', - '--typescript', - 'base', - ]); - stdout.on('data', (chunk) => { - onStdout(chunk); - if (chunk.includes(PROMPT_MESSAGES.typescriptSucceed)) { - const tsConfigJson = getTsConfig(inputs.emptyDir); - expect(tsConfigJson).to.deep.equal({ extends: 'astro/tsconfigs/base' }); - resolve(); - } - }); - }, - () => lastStdout - ); - }); -}); diff --git a/packages/create-astro/test/typescript.test.js b/packages/create-astro/test/typescript.test.js new file mode 100644 index 000000000..599214dff --- /dev/null +++ b/packages/create-astro/test/typescript.test.js @@ -0,0 +1,79 @@ +import { expect } from 'chai'; + +import fs from 'node:fs' +import { fileURLToPath } from 'node:url' + +import { typescript, setupTypeScript } from '../dist/index.js'; +import { setup } from './utils.js'; + +describe('typescript', () => { + const fixture = setup(); + + it('none', async () => { + const context = { cwd: '', dryRun: true, prompt: (() => ({ ts: 'strict', useTs: true }))}; + await typescript(context); + + expect(fixture.hasMessage('Skipping TypeScript setup')).to.be.true; + }) + + it('use false', async () => { + const context = { cwd: '', dryRun: true, prompt: (() => ({ ts: 'strict', useTs: false }))}; + await typescript(context); + + expect(fixture.hasMessage('No worries')).to.be.true; + }) + + it('strict', async () => { + const context = { typescript: 'strict', cwd: '', dryRun: true, prompt: (() => ({ ts: 'strict' }))}; + await typescript(context); + + expect(fixture.hasMessage('Using strict TypeScript configuration')).to.be.true; + expect(fixture.hasMessage('Skipping TypeScript setup')).to.be.true; + }) + + it('default', async () => { + const context = { typescript: 'default', cwd: '', dryRun: true, prompt: (() => ({ ts: 'strict' }))}; + await typescript(context); + + expect(fixture.hasMessage('Using default TypeScript configuration')).to.be.true; + expect(fixture.hasMessage('Skipping TypeScript setup')).to.be.true; + }) + + it('relaxed', async () => { + const context = { typescript: 'relaxed', cwd: '', dryRun: true, prompt: (() => ({ ts: 'strict' }))}; + await typescript(context); + + expect(fixture.hasMessage('Using relaxed TypeScript configuration')).to.be.true; + expect(fixture.hasMessage('Skipping TypeScript setup')).to.be.true; + }) + + it('other', async () => { + const context = { typescript: 'other', cwd: '', dryRun: true, prompt: (() => ({ ts: 'strict' })), exit(code) { throw code }}; + let err = null; + try { + await typescript(context); + } catch (e) { + err = e; + } + expect(err).to.eq(1) + }) +}) + +describe('typescript: setup', () => { + it('none', async () => { + const root = new URL('./fixtures/empty/', import.meta.url); + const tsconfig = new URL('./tsconfig.json', root); + + await setupTypeScript('strict', { cwd: fileURLToPath(root) }) + expect(JSON.parse(fs.readFileSync(tsconfig, { encoding: 'utf-8' }))).to.deep.eq({ "extends": "astro/tsconfigs/strict" }); + fs.rmSync(tsconfig); + }) + + it('exists', async () => { + const root = new URL('./fixtures/not-empty/', import.meta.url); + const tsconfig = new URL('./tsconfig.json', root); + await setupTypeScript('strict', { cwd: fileURLToPath(root) }) + expect(JSON.parse(fs.readFileSync(tsconfig, { encoding: 'utf-8' }))).to.deep.eq({ "extends": "astro/tsconfigs/strict" }); + fs.writeFileSync(tsconfig, `{}`); + }) +}) diff --git a/packages/create-astro/test/utils.js b/packages/create-astro/test/utils.js index 1f437fa01..c2cbb7245 100644 --- a/packages/create-astro/test/utils.js +++ b/packages/create-astro/test/utils.js @@ -1,52 +1,29 @@ -import { execa } from 'execa'; -import { dirname } from 'path'; +import { setStdout } from '../dist/index.js'; import stripAnsi from 'strip-ansi'; -import { fileURLToPath } from 'url'; -const __filename = fileURLToPath(import.meta.url); -export const testDir = dirname(__filename); -export const timeout = 25000; - -const timeoutError = function (details) { - let errorMsg = 'Timed out waiting for create-astro to respond with expected output.'; - if (details) { - errorMsg += '\nLast output: "' + details + '"'; - } - return new Error(errorMsg); -}; - -export function promiseWithTimeout(testFn) { - return new Promise((resolve, reject) => { - let lastStdout; - function onStdout(chunk) { - lastStdout = stripAnsi(chunk.toString()).trim() || lastStdout; - } - - const timeoutEvent = setTimeout(() => { - reject(timeoutError(lastStdout)); - }, timeout); - function resolver() { - clearTimeout(timeoutEvent); - resolve(); - } - - testFn(resolver, onStdout); +export function setup() { + const ctx = { messages: [] }; + before(() => { + setStdout(Object.assign({}, process.stdout, { + write(buf) { + ctx.messages.push(stripAnsi(String(buf)).trim()) + return true; + } + })) }); -} + beforeEach(() => { + ctx.messages = []; + }) -export const PROMPT_MESSAGES = { - directory: 'Where would you like to create your new project?', - template: 'How would you like to setup your new project?', - typescript: 'How would you like to setup TypeScript?', - typescriptSucceed: 'next', -}; - -export function setup(args = []) { - const { stdout, stdin } = execa('../create-astro.mjs', [...args, '--skip-houston', '--dry-run'], { - cwd: testDir, - }); return { - stdin, - stdout, + messages() { + return ctx.messages + }, + length() { + return ctx.messages.length + }, + hasMessage(content) { + return !!ctx.messages.find(msg => msg.includes(content)) + } }; } diff --git a/packages/create-astro/tsconfig.json b/packages/create-astro/tsconfig.json index 8f0cdf74d..25bf60c24 100644 --- a/packages/create-astro/tsconfig.json +++ b/packages/create-astro/tsconfig.json @@ -3,6 +3,8 @@ "include": ["src", "index.d.ts"], "compilerOptions": { "allowJs": true, + "emitDeclarationOnly": false, + "noEmit": true, "target": "ES2020", "module": "ES2020", "outDir": "./dist", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d156cfaa..8a83bba42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2555,50 +2555,33 @@ importers: packages/create-astro: specifiers: - '@astrojs/cli-kit': ^0.1.6 - '@types/chai': ^4.3.1 - '@types/degit': ^2.8.3 - '@types/mocha': ^9.1.1 - '@types/prompts': ^2.0.14 + '@astrojs/cli-kit': ^0.2.2 '@types/which-pm-runs': ^1.0.0 - '@types/yargs-parser': ^21.0.0 + arg: ^5.0.2 astro-scripts: workspace:* chai: ^4.3.6 - chalk: ^5.0.1 - comment-json: ^4.2.3 execa: ^6.1.0 giget: ^1.0.0 - kleur: ^4.1.4 mocha: ^9.2.2 - ora: ^6.1.0 - prompts: ^2.4.2 strip-ansi: ^7.0.1 - uvu: ^0.5.3 + strip-json-comments: ^5.0.0 which-pm-runs: ^1.1.0 - yargs-parser: ^21.0.1 dependencies: - '@astrojs/cli-kit': 0.1.6 - chalk: 5.2.0 - comment-json: 4.2.3 + '@astrojs/cli-kit': 0.2.2 + chai: 4.3.7 execa: 6.1.0 giget: 1.0.0 - kleur: 4.1.5 - ora: 6.1.2 - prompts: 2.4.2 - strip-ansi: 7.0.1 - which-pm-runs: 1.1.0 - yargs-parser: 21.1.1 - devDependencies: - '@types/chai': 4.3.4 - '@types/degit': 2.8.3 - '@types/mocha': 9.1.1 - '@types/prompts': 2.4.2 - '@types/which-pm-runs': 1.0.0 - '@types/yargs-parser': 21.0.0 - astro-scripts: link:../../scripts - chai: 4.3.7 mocha: 9.2.2 - uvu: 0.5.6 + devDependencies: + '@types/which-pm-runs': 1.0.0 + arg: 5.0.2 + astro-scripts: link:../../scripts + strip-ansi: 7.0.1 + strip-json-comments: 5.0.0 + which-pm-runs: 1.1.0 + + packages/create-astro/test/fixtures/not-empty: + specifiers: {} packages/integrations/alpinejs: specifiers: @@ -3863,8 +3846,8 @@ packages: lite-youtube-embed: 0.2.0 dev: false - /@astrojs/cli-kit/0.1.6: - resolution: {integrity: sha512-hC0Z7kh4T5QdtfPJVyZ6qmNCqWFYg67zS64AxPm9Y8QVYfeXOdXfL3PaNPGbNtGmczmYJ7cBn/ImgXd/RTTc5g==} + /@astrojs/cli-kit/0.2.2: + resolution: {integrity: sha512-9AniGN+jib2QMRAg4J8WYQxNhDld0zegrb7lig5oNkh1ReDa7rBxaKF9Tor31sjhnGISqavPkKKcQrEm53mzWg==} dependencies: chalk: 5.2.0 log-update: 5.0.1 @@ -6895,10 +6878,6 @@ packages: dependencies: '@types/ms': 0.7.31 - /@types/degit/2.8.3: - resolution: {integrity: sha512-CL7y71j2zaDmtPLD5Xq5S1Gv2dFoHl0/GBZm6s39Mj/ls28L3NzAOqf7H4H0/2TNVMgMjMVf9CAFYSjmXhi3bw==} - dev: true - /@types/diff/5.0.2: resolution: {integrity: sha512-uw8eYMIReOwstQ0QKF0sICefSy8cNO/v7gOTiIy9SbwuHyEecJUm7qlgueOO5S1udZ5I/irVydHVwMchgzbKTg==} dev: true @@ -7329,7 +7308,6 @@ packages: /@ungap/promise-all-settled/1.1.2: resolution: {integrity: sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==} - dev: true /@ungap/structured-clone/0.3.4: resolution: {integrity: sha512-TSVh8CpnwNAsPC5wXcIyh92Bv1gq6E9cNDeeLu7Z4h8V4/qWtXJp7y42qljRkqcpmsve1iozwv1wr+3BNdILCg==} @@ -7735,7 +7713,6 @@ packages: /ansi-colors/4.1.1: resolution: {integrity: sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==} engines: {node: '>=6'} - dev: true /ansi-colors/4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} @@ -7756,7 +7733,6 @@ packages: /ansi-regex/6.0.1: resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} engines: {node: '>=12'} - dev: false /ansi-styles/3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} @@ -7804,16 +7780,11 @@ packages: /argparse/2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - dev: true /array-iterate/2.0.1: resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} dev: false - /array-timsort/1.0.3: - resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} - dev: false - /array-union/2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -8104,7 +8075,6 @@ packages: /browser-stdout/1.3.1: resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} - dev: true /browserslist/4.21.5: resolution: {integrity: sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==} @@ -8372,7 +8342,6 @@ packages: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - dev: true /cliui/8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} @@ -8440,17 +8409,6 @@ packages: /commander/2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - /comment-json/4.2.3: - resolution: {integrity: sha512-SsxdiOf064DWoZLH799Ata6u7iV658A11PlWtZATDlXPpKGJnbJZ5Z24ybixAi+LUUqJ/GKowAejtC5GFUG7Tw==} - engines: {node: '>= 6'} - dependencies: - array-timsort: 1.0.3 - core-util-is: 1.0.3 - esprima: 4.0.1 - has-own-prop: 2.0.0 - repeat-string: 1.6.1 - dev: false - /common-ancestor-path/1.0.1: resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} dev: false @@ -8516,6 +8474,7 @@ packages: /core-util-is/1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + dev: true /cron-schedule/3.0.6: resolution: {integrity: sha512-izfGgKyzzIyLaeb1EtZ3KbglkS6AKp9cv7LxmiyoOu+fXfol1tQDC0Cof0enVZGNtudTHW+3lfuW9ZkLQss4Wg==} @@ -8675,7 +8634,6 @@ packages: dependencies: ms: 2.1.2 supports-color: 8.1.1 - dev: true /debug/4.3.4: resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} @@ -8704,7 +8662,6 @@ packages: /decamelize/4.0.0: resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} engines: {node: '>=10'} - dev: true /decode-named-character-reference/1.0.2: resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} @@ -8853,7 +8810,6 @@ packages: /diff/5.0.0: resolution: {integrity: sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==} engines: {node: '>=0.3.1'} - dev: true /diff/5.1.0: resolution: {integrity: sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==} @@ -9952,7 +9908,6 @@ packages: /flat/5.0.2: resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} hasBin: true - dev: true /flatted/3.2.7: resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} @@ -10164,7 +10119,6 @@ packages: minimatch: 3.1.2 once: 1.4.0 path-is-absolute: 1.0.1 - dev: true /glob/7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} @@ -10297,7 +10251,6 @@ packages: /growl/1.10.5: resolution: {integrity: sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==} engines: {node: '>=4.x'} - dev: true /gzip-size/6.0.0: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} @@ -10322,11 +10275,6 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - /has-own-prop/2.0.0: - resolution: {integrity: sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==} - engines: {node: '>=8'} - dev: false - /has-package-exports/1.3.0: resolution: {integrity: sha512-e9OeXPQnmPhYoJ63lXC4wWe34TxEGZDZ3OQX9XRqp2VwsfLl3bQBy7VehLnd34g3ef8CmYlBLGqEMKXuz8YazQ==} dependencies: @@ -10522,7 +10470,6 @@ packages: /he/1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true - dev: true /hosted-git-info/2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -10863,7 +10810,6 @@ packages: /is-plain-obj/2.1.0: resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} engines: {node: '>=8'} - dev: true /is-plain-obj/4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} @@ -10947,7 +10893,6 @@ packages: /is-unicode-supported/0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} - dev: true /is-unicode-supported/1.3.0: resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} @@ -11021,7 +10966,6 @@ packages: hasBin: true dependencies: argparse: 2.0.1 - dev: true /jsesc/0.5.0: resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==} @@ -11235,7 +11179,6 @@ packages: dependencies: chalk: 4.1.2 is-unicode-supported: 0.1.0 - dev: true /log-symbols/5.1.0: resolution: {integrity: sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==} @@ -12000,7 +11943,6 @@ packages: engines: {node: '>=10'} dependencies: brace-expansion: 1.1.11 - dev: true /minimatch/5.1.6: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} @@ -12082,7 +12024,6 @@ packages: yargs: 16.2.0 yargs-parser: 20.2.4 yargs-unparser: 2.0.0 - dev: true /mri/1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} @@ -12112,7 +12053,6 @@ packages: resolution: {integrity: sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - dev: true /nanoid/3.3.4: resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==} @@ -13558,11 +13498,6 @@ packages: unified: 10.1.2 dev: true - /repeat-string/1.6.1: - resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} - engines: {node: '>=0.10'} - dev: false - /require-directory/2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -13859,7 +13794,6 @@ packages: resolution: {integrity: sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==} dependencies: randombytes: 2.1.0 - dev: true /server-destroy/1.0.1: resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==} @@ -14226,7 +14160,6 @@ packages: engines: {node: '>=12'} dependencies: ansi-regex: 6.0.1 - dev: false /strip-bom-string/1.0.0: resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} @@ -14269,6 +14202,10 @@ packages: /strip-json-comments/3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + + /strip-json-comments/5.0.0: + resolution: {integrity: sha512-V1LGY4UUo0jgwC+ELQ2BNWfPa17TIuwBLg+j1AA/9RPzKINl1lhxVEu2r+ZTTO8aetIsUzE5Qj6LMSBkoGYKKw==} + engines: {node: '>=14.16'} dev: true /strnum/1.0.5: @@ -15648,7 +15585,6 @@ packages: /workerpool/6.2.0: resolution: {integrity: sha512-Rsk5qQHJ9eowMH28Jwhe8HEbmdYDX4lwoMWshiCXugjtHqMD9ZbiqSDLxcsfdqsETPzVUtX5s1Z5kStiIM6l4A==} - dev: true /wrangler/2.9.0: resolution: {integrity: sha512-5nyyR4bXKG/Rwz0dH+nOx4SWvJWmTZVSbceLyTV+ZOH1sd2vvPnnW14NUzTNEjY3XaT93XH+28mc5+UNSYsFHw==} @@ -15785,7 +15721,6 @@ packages: /yargs-parser/20.2.4: resolution: {integrity: sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==} engines: {node: '>=10'} - dev: true /yargs-parser/21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} @@ -15799,7 +15734,6 @@ packages: decamelize: 4.0.0 flat: 5.0.2 is-plain-obj: 2.1.0 - dev: true /yargs/15.4.1: resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} @@ -15829,7 +15763,6 @@ packages: string-width: 4.2.3 y18n: 5.0.8 yargs-parser: 20.2.4 - dev: true /yargs/17.6.2: resolution: {integrity: sha512-1/9UrdHjDZc0eOU0HxOHoS78C69UD3JRMvzlJ7S79S2nTaWRA/whGCTV8o9e/N/1Va9YIV7Q4sOxD8VV4pCWOw==} diff --git a/scripts/cmd/build.js b/scripts/cmd/build.js index 1f543d70a..599d22d93 100644 --- a/scripts/cmd/build.js +++ b/scripts/cmd/build.js @@ -48,6 +48,7 @@ export default async function build(...args) { ); const noClean = args.includes('--no-clean-dist'); + const bundle = args.includes('--bundle'); const forceCJS = args.includes('--force-cjs'); const { @@ -68,7 +69,8 @@ export default async function build(...args) { if (!isDev) { await esbuild.build({ ...config, - bundle: false, + bundle, + external: bundle ? Object.keys(dependencies) : undefined, entryPoints, outdir, outExtension: forceCJS ? { '.js': '.cjs' } : {},