diff options
Diffstat (limited to 'packages/create-astro/src')
-rw-r--r-- | packages/create-astro/src/actions/context.ts | 124 | ||||
-rw-r--r-- | packages/create-astro/src/actions/dependencies.ts | 109 | ||||
-rw-r--r-- | packages/create-astro/src/actions/git.ts | 64 | ||||
-rw-r--r-- | packages/create-astro/src/actions/help.ts | 24 | ||||
-rw-r--r-- | packages/create-astro/src/actions/intro.ts | 29 | ||||
-rw-r--r-- | packages/create-astro/src/actions/next-steps.ts | 25 | ||||
-rw-r--r-- | packages/create-astro/src/actions/project-name.ts | 74 | ||||
-rw-r--r-- | packages/create-astro/src/actions/shared.ts | 59 | ||||
-rw-r--r-- | packages/create-astro/src/actions/template.ts | 155 | ||||
-rw-r--r-- | packages/create-astro/src/actions/verify.ts | 40 | ||||
-rw-r--r-- | packages/create-astro/src/data/seasonal.ts | 112 | ||||
-rw-r--r-- | packages/create-astro/src/index.ts | 62 | ||||
-rw-r--r-- | packages/create-astro/src/messages.ts | 201 | ||||
-rw-r--r-- | packages/create-astro/src/shell.ts | 51 |
14 files changed, 1129 insertions, 0 deletions
diff --git a/packages/create-astro/src/actions/context.ts b/packages/create-astro/src/actions/context.ts new file mode 100644 index 000000000..59f85f88a --- /dev/null +++ b/packages/create-astro/src/actions/context.ts @@ -0,0 +1,124 @@ +import os from 'node:os'; +import { type Task, prompt } from '@astrojs/cli-kit'; +import { random } from '@astrojs/cli-kit/utils'; +import arg from 'arg'; + +import getSeasonalData from '../data/seasonal.js'; +import { getName, getVersion } from '../messages.js'; + +export interface Context { + help: boolean; + prompt: typeof prompt; + cwd: string; + packageManager: string; + username: Promise<string>; + version: Promise<string>; + skipHouston: boolean; + fancy?: boolean; + add?: string[]; + 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; + welcome?: string; + hat?: string; + tie?: string; + tasks: Task[]; +} + +export async function getContext(argv: string[]): Promise<Context> { + const flags = arg( + { + '--template': String, + '--ref': String, + '--yes': Boolean, + '--no': Boolean, + '--install': Boolean, + '--no-install': Boolean, + '--git': Boolean, + '--no-git': Boolean, + '--skip-houston': Boolean, + '--dry-run': Boolean, + '--help': Boolean, + '--fancy': Boolean, + '--add': [String], + + '-y': '--yes', + '-n': '--no', + '-h': '--help', + }, + { argv, permissive: true }, + ); + + const packageManager = detectPackageManager() ?? 'npm'; + let cwd = flags['_'][0]; + let { + '--help': help = false, + '--template': template, + '--no': no, + '--yes': yes, + '--install': install, + '--no-install': noInstall, + '--git': git, + '--no-git': noGit, + '--fancy': fancy, + '--skip-houston': skipHouston, + '--dry-run': dryRun, + '--ref': ref, + '--add': add, + } = flags; + let projectName = cwd; + + if (no) { + yes = false; + if (install == undefined) install = false; + if (git == undefined) git = false; + } + + skipHouston = + ((os.platform() === 'win32' && !fancy) || skipHouston) ?? + [yes, no, install, git].some((v) => v !== undefined); + + const { messages, hats, ties } = getSeasonalData({ fancy }); + + const context: Context = { + help, + prompt, + packageManager, + username: getName(), + version: getVersion(packageManager, 'astro', process.env.ASTRO_VERSION), + skipHouston, + fancy, + add, + dryRun, + projectName, + template, + ref: ref ?? 'latest', + welcome: random(messages), + hat: hats ? random(hats) : undefined, + tie: ties ? random(ties) : undefined, + yes, + install: install ?? (noInstall ? false : undefined), + git: git ?? (noGit ? false : undefined), + cwd, + exit(code) { + process.exit(code); + }, + tasks: [], + }; + return context; +} + +function detectPackageManager() { + if (!process.env.npm_config_user_agent) return; + const specifier = process.env.npm_config_user_agent.split(' ')[0]; + const name = specifier.substring(0, specifier.lastIndexOf('/')); + return name === 'npminstall' ? 'cnpm' : name; +} diff --git a/packages/create-astro/src/actions/dependencies.ts b/packages/create-astro/src/actions/dependencies.ts new file mode 100644 index 000000000..d4990a8fb --- /dev/null +++ b/packages/create-astro/src/actions/dependencies.ts @@ -0,0 +1,109 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { color } from '@astrojs/cli-kit'; +import { error, info, title } from '../messages.js'; +import { shell } from '../shell.js'; +import type { Context } from './context.js'; + +export async function dependencies( + ctx: Pick< + Context, + 'install' | 'yes' | 'prompt' | 'packageManager' | 'cwd' | 'dryRun' | 'tasks' | 'add' + >, +) { + 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; + } + + ctx.add = ctx.add?.reduce<string[]>((acc, item) => acc.concat(item.split(',')), []); + + if (ctx.dryRun) { + await info( + '--dry-run', + `Skipping dependency installation${ctx.add ? ` and adding ${ctx.add.join(', ')}` : ''}`, + ); + } else if (deps) { + ctx.tasks.push({ + pending: 'Dependencies', + start: `Dependencies installing with ${ctx.packageManager}...`, + end: 'Dependencies installed', + onError: (e) => { + error('error', e); + error( + 'error', + `Dependencies failed to install, please run ${color.bold( + ctx.packageManager + ' install', + )} to install them manually after setup.`, + ); + }, + while: () => install({ packageManager: ctx.packageManager, cwd: ctx.cwd }), + }); + + let add = ctx.add; + + if (add) { + ctx.tasks.push({ + pending: 'Integrations', + start: `Adding integrations with astro add`, + end: 'Integrations added', + onError: (e) => { + error('error', e); + error( + 'error', + `Failed to add integrations, please run ${color.bold( + `astro add ${add.join(' ')}`, + )} to install them manually after setup.`, + ); + }, + while: () => + astroAdd({ integrations: add, packageManager: ctx.packageManager, cwd: ctx.cwd }), + }); + } + } else { + await info( + ctx.yes === false ? 'deps [skip]' : 'No problem!', + 'Remember to install dependencies after setup.', + ); + } +} + +async function astroAdd({ + integrations, + packageManager, + cwd, +}: { integrations: string[]; packageManager: string; cwd: string }) { + if (packageManager === 'yarn') await ensureYarnLock({ cwd }); + return shell( + packageManager === 'npm' ? 'npx' : `${packageManager} dlx`, + ['astro add', integrations.join(' '), '-y'], + { cwd, timeout: 90_000, stdio: 'ignore' }, + ); +} + +async function install({ packageManager, cwd }: { packageManager: string; cwd: string }) { + if (packageManager === 'yarn') await ensureYarnLock({ cwd }); + return shell(packageManager, ['install'], { cwd, timeout: 90_000, stdio: 'ignore' }); +} + +/** + * Yarn Berry (PnP) versions will throw an error if there isn't an existing `yarn.lock` file + * If a `yarn.lock` file doesn't exist, this function writes an empty `yarn.lock` one. + * Unfortunately this hack is required to run `yarn install`. + * + * The empty `yarn.lock` file is immediately overwritten by the installation process. + * See https://github.com/withastro/astro/pull/8028 + */ +async function ensureYarnLock({ cwd }: { cwd: string }) { + const yarnLock = path.join(cwd, 'yarn.lock'); + if (fs.existsSync(yarnLock)) return; + return fs.promises.writeFile(yarnLock, '', { encoding: 'utf-8' }); +} diff --git a/packages/create-astro/src/actions/git.ts b/packages/create-astro/src/actions/git.ts new file mode 100644 index 000000000..ebedb8701 --- /dev/null +++ b/packages/create-astro/src/actions/git.ts @@ -0,0 +1,64 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { Context } from './context.js'; + +import { color } from '@astrojs/cli-kit'; +import { error, info, title } from '../messages.js'; +import { shell } from '../shell.js'; + +export async function git( + ctx: Pick<Context, 'cwd' | 'git' | 'yes' | 'prompt' | 'dryRun' | 'tasks'>, +) { + 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) { + ctx.tasks.push({ + pending: 'Git', + start: 'Git initializing...', + end: 'Git initialized', + while: () => + init({ cwd: ctx.cwd }).catch((e) => { + error('error', e); + process.exit(1); + }), + }); + } 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 shell('git', ['init'], { cwd, stdio: 'ignore' }); + await shell('git', ['add', '-A'], { cwd, stdio: 'ignore' }); + await shell( + 'git', + [ + 'commit', + '-m', + '"Initial commit from Astro"', + '--author="houston[bot] <astrobot-houston@users.noreply.github.com>"', + ], + { cwd, stdio: 'ignore' }, + ); + } catch {} +} diff --git a/packages/create-astro/src/actions/help.ts b/packages/create-astro/src/actions/help.ts new file mode 100644 index 000000000..1d5c7f609 --- /dev/null +++ b/packages/create-astro/src/actions/help.ts @@ -0,0 +1,24 @@ +import { printHelp } from '../messages.js'; + +export function help() { + printHelp({ + commandName: 'create-astro', + usage: '[dir] [...flags]', + headline: 'Scaffold Astro projects.', + tables: { + Flags: [ + ['--help (-h)', 'See all available flags.'], + ['--template <name>', 'Specify your template.'], + ['--install / --no-install', 'Install dependencies (or not).'], + ['--add <integrations>', 'Add integrations.'], + ['--git / --no-git', 'Initialize git repo (or not).'], + ['--yes (-y)', 'Skip all prompts by accepting defaults.'], + ['--no (-n)', 'Skip all prompts by declining defaults.'], + ['--dry-run', 'Walk through steps without executing.'], + ['--skip-houston', 'Skip Houston animation.'], + ['--ref', 'Choose astro branch (default: latest).'], + ['--fancy', 'Enable full Unicode support for Windows.'], + ], + }, + }); +} diff --git a/packages/create-astro/src/actions/intro.ts b/packages/create-astro/src/actions/intro.ts new file mode 100644 index 000000000..0249e63c8 --- /dev/null +++ b/packages/create-astro/src/actions/intro.ts @@ -0,0 +1,29 @@ +import type { Context } from './context.js'; + +import { color, label } from '@astrojs/cli-kit'; +import { banner, say } from '../messages.js'; + +export async function intro( + ctx: Pick<Context, 'skipHouston' | 'welcome' | 'hat' | 'tie' | 'version' | 'username' | 'fancy'>, +) { + banner(); + + if (!ctx.skipHouston) { + const { welcome, hat, tie } = ctx; + await say( + [ + [ + 'Welcome', + 'to', + label('astro', color.bgGreen, color.black), + Promise.resolve(ctx.version).then( + (version) => (version ? color.green(`v${version}`) : '') + ',', + ), + Promise.resolve(ctx.username).then((username) => `${username}!`), + ], + welcome ?? "Let's build something awesome!", + ] as string[], + { clear: true, hat, tie }, + ); + } +} 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..91536cc46 --- /dev/null +++ b/packages/create-astro/src/actions/next-steps.ts @@ -0,0 +1,25 @@ +import path from 'node:path'; +import type { Context } from './context.js'; + +import { nextSteps, say } from '../messages.js'; + +export async function next( + ctx: Pick<Context, 'hat' | 'tie' | 'cwd' | 'packageManager' | 'skipHouston'>, +) { + let projectDir = path.relative(process.cwd(), ctx.cwd); + + const commandMap: { [key: string]: string } = { + npm: 'npm run dev', + bun: 'bun run dev', + yarn: 'yarn dev', + pnpm: 'pnpm dev', + }; + + const devCmd = commandMap[ctx.packageManager as keyof typeof commandMap] || 'npm run dev'; + await nextSteps({ projectDir, devCmd }); + + if (!ctx.skipHouston) { + await say(['Good luck out there, astronaut! ๐'], { hat: ctx.hat, tie: ctx.tie }); + } + 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..26938de80 --- /dev/null +++ b/packages/create-astro/src/actions/project-name.ts @@ -0,0 +1,74 @@ +import type { Context } from './context.js'; + +import path from 'node:path'; +import { color, generateProjectName } from '@astrojs/cli-kit'; +import { info, log, title } from '../messages.js'; + +import { isEmpty, toValidName } from './shared.js'; + +export async function projectName( + ctx: Pick<Context, 'cwd' | 'yes' | 'dryRun' | 'prompt' | 'projectName' | 'exit'>, +) { + 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!`)}`); + } + + if (ctx.yes) { + ctx.projectName = generateProjectName(); + ctx.cwd = `./${ctx.projectName}`; + await info('dir', `Project created at ./${ctx.projectName}`); + return; + } + + 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!`; + } + // Check for non-printable characters + if (value.match(/[^\x20-\x7E]/g) !== null) + return `Invalid non-printable character present!`; + return true; + }, + }); + + ctx.cwd = name!.trim(); + ctx.projectName = toValidName(name!); + if (ctx.dryRun) { + await info('--dry-run', 'Skipping project naming'); + return; + } + } 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..4bd852529 --- /dev/null +++ b/packages/create-astro/src/actions/shared.ts @@ -0,0 +1,59 @@ +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..512e1f921 --- /dev/null +++ b/packages/create-astro/src/actions/template.ts @@ -0,0 +1,155 @@ +import type { Context } from './context.js'; + +import fs from 'node:fs'; +import path from 'node:path'; +import { color } from '@astrojs/cli-kit'; +import { downloadTemplate } from '@bluwy/giget-core'; +import { error, info, title } from '../messages.js'; + +export async function template( + ctx: Pick<Context, 'template' | 'prompt' | 'yes' | 'dryRun' | 'exit' | 'tasks'>, +) { + if (!ctx.template && ctx.yes) ctx.template = 'basics'; + + if (ctx.template) { + await info('tmpl', `Using ${color.reset(ctx.template)}${color.dim(' as project template')}`); + } else { + 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: 'A basic, minimal starter', hint: '(recommended)' }, + { value: 'blog', label: 'Use blog template' }, + { value: 'starlight', label: 'Use docs (Starlight) template' }, + ], + }); + ctx.template = tmpl; + } + + if (ctx.dryRun) { + await info('--dry-run', `Skipping template copying`); + } else if (ctx.template) { + ctx.tasks.push({ + pending: 'Template', + start: 'Template copying...', + end: 'Template copied', + while: () => + copyTemplate(ctx.template!, ctx as Context).catch((e) => { + if (e instanceof Error) { + error('error', e.message); + process.exit(1); + } else { + error('error', 'Unable to clone template.'); + process.exit(1); + } + }), + }); + } 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 = ['CHANGELOG.md', '.codesandbox']; +const FILES_TO_UPDATE = { + 'package.json': (file: string, overrides: { name: string }) => + fs.promises.readFile(file, 'utf-8').then((value) => { + // Match first indent in the file or fallback to `\t` + const indent = /(^\s+)/m.exec(value)?.[1] ?? '\t'; + return fs.promises.writeFile( + file, + JSON.stringify( + Object.assign(JSON.parse(value), Object.assign(overrides, { private: undefined })), + null, + indent, + ), + 'utf-8', + ); + }), +}; + +export function getTemplateTarget(tmpl: string, ref = 'latest') { + // Handle Starlight templates + if (tmpl.startsWith('starlight')) { + const [, starter = 'basics'] = tmpl.split('/'); + return `github:withastro/starlight/examples/${starter}`; + } + + // Handle third-party templates + const isThirdParty = tmpl.includes('/'); + if (isThirdParty) return tmpl; + + // Handle Astro templates + if (ref === 'latest') { + // `latest` ref is specially handled to route to a branch specifically + // to allow faster downloads. Otherwise giget has to download the entire + // repo and only copy a sub directory + return `github:withastro/astro#examples/${tmpl}`; + } else { + return `github:withastro/astro/examples/${tmpl}#${ref}`; + } +} + +export default async function copyTemplate(tmpl: string, ctx: Context) { + const templateTarget = getTemplateTarget(tmpl, ctx.ref); + // Copy + if (!ctx.dryRun) { + try { + await downloadTemplate(templateTarget, { + force: true, + cwd: ctx.cwd, + dir: '.', + }); + } catch (err: any) { + // Only remove the directory if it's most likely created by us. + if (ctx.cwd !== '.' && ctx.cwd !== './' && !ctx.cwd.startsWith('../')) { + try { + fs.rmdirSync(ctx.cwd); + } catch (_) { + // Ignore any errors from removing the directory, + // make sure we throw and display the original error. + } + } + + if (err.message?.includes('404')) { + throw new Error(`Template ${color.reset(tmpl)} ${color.dim('does not exist!')}`); + } + + if (err.message) { + error('error', err.message); + } + try { + // The underlying error is often buried deep in the `cause` property + // This is in a try/catch block in case of weirdnesses in accessing the `cause` property + if ('cause' in err) { + // This is probably included in err.message, but we can log it just in case it has extra info + error('error', err.cause); + if ('cause' in err.cause) { + // Hopefully the actual fetch error message + error('error', err.cause?.cause); + } + } + } catch {} + throw new Error(`Unable to download template ${color.reset(tmpl)}`); + } + + // 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/verify.ts b/packages/create-astro/src/actions/verify.ts new file mode 100644 index 000000000..7f446c869 --- /dev/null +++ b/packages/create-astro/src/actions/verify.ts @@ -0,0 +1,40 @@ +import type { Context } from './context.js'; + +import dns from 'node:dns/promises'; +import { color } from '@astrojs/cli-kit'; +import { verifyTemplate } from '@bluwy/giget-core'; +import { bannerAbort, error, info, log } from '../messages.js'; +import { getTemplateTarget } from './template.js'; + +export async function verify( + ctx: Pick<Context, 'version' | 'dryRun' | 'template' | 'ref' | 'exit'>, +) { + if (!ctx.dryRun) { + const online = await isOnline(); + if (!online) { + bannerAbort(); + log(''); + error('error', `Unable to connect to the internet.`); + ctx.exit(1); + } + } + + if (ctx.template) { + const target = getTemplateTarget(ctx.template, ctx.ref); + const ok = await verifyTemplate(target); + if (!ok) { + bannerAbort(); + log(''); + error('error', `Template ${color.reset(ctx.template)} ${color.dim('could not be found!')}`); + await info('check', 'https://astro.build/examples'); + ctx.exit(1); + } + } +} + +function isOnline(): Promise<boolean> { + return dns.lookup('github.com').then( + () => true, + () => false, + ); +} diff --git a/packages/create-astro/src/data/seasonal.ts b/packages/create-astro/src/data/seasonal.ts new file mode 100644 index 000000000..c333affa0 --- /dev/null +++ b/packages/create-astro/src/data/seasonal.ts @@ -0,0 +1,112 @@ +interface SeasonalHouston { + hats?: string[]; + ties?: string[]; + messages: string[]; +} + +export default function getSeasonalHouston({ fancy }: { fancy?: boolean }): SeasonalHouston { + const season = getSeason(); + switch (season) { + case 'new-year': { + const year = new Date().getFullYear(); + return { + hats: rarity(0.5, ['๐ฉ']), + ties: rarity(0.25, ['๐', '๐', '๐']), + messages: [ + `New year, new Astro site!`, + `Kicking ${year} off with Astro?! What an honor!`, + `Happy ${year}! Let's make something cool.`, + `${year} is your year! Let's build something awesome.`, + `${year} is the year of Astro!`, + `${year} is clearly off to a great start!`, + `Thanks for starting ${year} with Astro!`, + ], + }; + } + case 'spooky': + return { + hats: rarity(0.5, ['๐', '๐ป', 'โ ๏ธ', '๐', '๐ท๏ธ', '๐ฎ']), + ties: rarity(0.25, ['๐ฆด', '๐ฌ', '๐ซ']), + messages: [ + `I'm afraid I can't help you... Just kidding!`, + `Boo! Just kidding. Let's make a website!`, + `Let's haunt the internet. OooOooOOoo!`, + `No tricks here. Seeing you is always treat!`, + `Spiders aren't the only ones building the web!`, + `Let's conjure up some web magic!`, + `Let's harness the power of Astro to build a frightful new site!`, + `We're conjuring up a spooktacular website!`, + `Prepare for a web of spooky wonders to be woven.`, + `Chills and thrills await you on your new project!`, + ], + }; + case 'holiday': + return { + hats: rarity(0.75, ['๐', '๐', '๐ฒ']), + ties: rarity(0.75, ['๐งฃ']), + messages: [ + `'Tis the season to code and create.`, + `Jingle all the way through your web creation journey!`, + `Bells are ringing, and so are your creative ideas!`, + `Let's make the internet our own winter wonderland!`, + `It's time to decorate a brand new website!`, + `Let's unwrap the magic of the web together!`, + `Hope you're enjoying the holiday season!`, + `I'm dreaming of a brand new website!`, + `No better holiday gift than a new site!`, + `Your creativity is the gift that keeps on giving!`, + ], + }; + case undefined: + default: + return { + hats: fancy ? ['๐ฉ', '๐ฉ', '๐ฉ', '๐ฉ', '๐', '๐', '๐งข', '๐ฆ'] : undefined, + ties: fancy ? rarity(0.33, ['๐', '๐งฃ']) : undefined, + messages: [ + `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.`, + ], + }; + } +} + +type Season = 'spooky' | 'holiday' | 'new-year'; +function getSeason(): Season | undefined { + const date = new Date(); + const month = date.getMonth() + 1; + const day = date.getDate() + 1; + + if (month === 1 && day <= 7) { + return 'new-year'; + } + if (month === 10 && day > 7) { + return 'spooky'; + } + if (month === 12 && day > 7 && day < 25) { + return 'holiday'; + } +} + +// Generates an array padded with empty strings to make decorations more rare +function rarity(frequency: number, emoji: string[]) { + if (frequency === 1) return emoji; + if (frequency === 0) return ['']; + const empty = Array.from({ length: Math.round(emoji.length * frequency) }, () => ''); + return [...emoji, ...empty]; +} diff --git a/packages/create-astro/src/index.ts b/packages/create-astro/src/index.ts new file mode 100644 index 000000000..60816f75d --- /dev/null +++ b/packages/create-astro/src/index.ts @@ -0,0 +1,62 @@ +import { getContext } from './actions/context.js'; + +import { tasks } from '@astrojs/cli-kit'; +import { dependencies } from './actions/dependencies.js'; +import { git } from './actions/git.js'; +import { help } from './actions/help.js'; +import { intro } from './actions/intro.js'; +import { next } from './actions/next-steps.js'; +import { projectName } from './actions/project-name.js'; +import { template } from './actions/template.js'; +import { verify } from './actions/verify.js'; +import { setStdout } from './messages.js'; + +const exit = () => process.exit(0); +process.on('SIGINT', exit); +process.on('SIGTERM', exit); + +export async function main() { + // Add some extra spacing from the noisy npm/pnpm init output + // biome-ignore lint/suspicious/noConsoleLog: allowed + console.log(''); + // 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; + } + + const steps = [ + verify, + intro, + projectName, + template, + dependencies, + + // Steps which write to files need to go above git + git, + ]; + + for (const step of steps) { + await step(ctx); + } + + // biome-ignore lint/suspicious/noConsoleLog: allowed + console.log(''); + + const labels = { + start: 'Project initializing...', + end: 'Project initialized!', + }; + await tasks(labels, ctx.tasks); + + await next(ctx); + + process.exit(0); +} + +export { dependencies, getContext, git, intro, next, projectName, setStdout, template, verify }; diff --git a/packages/create-astro/src/messages.ts b/packages/create-astro/src/messages.ts new file mode 100644 index 000000000..898c9c728 --- /dev/null +++ b/packages/create-astro/src/messages.ts @@ -0,0 +1,201 @@ +import { exec } from 'node:child_process'; +import { stripVTControlCharacters } from 'node:util'; +/* eslint no-console: 'off' */ +import { color, say as houston, label, spinner as load } from '@astrojs/cli-kit'; +import { align, sleep } from '@astrojs/cli-kit/utils'; +import { shell } from './shell.js'; + +// Users might lack access to the global npm registry, this function +// checks the user's project type and will return the proper npm registry +// +// A copy of this function also exists in the astro package +let _registry: string; +async function getRegistry(packageManager: string): Promise<string> { + if (_registry) return _registry; + const fallback = 'https://registry.npmjs.org'; + try { + const { stdout } = await shell(packageManager, ['config', 'get', 'registry']); + _registry = stdout?.trim()?.replace(/\/$/, '') || fallback; + // Detect cases where the shell command returned a non-URL (e.g. a warning) + if (!new URL(_registry).host) _registry = fallback; + } catch { + _registry = fallback; + } + return _registry; +} + +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 = '', tie = '' } = {}) { + return houston(messages, { clear, hat, tie, stdout }); +} + +export async function spinner(args: { + start: string; + end: string; + onError?: (error: any) => void; + while: (...args: any) => Promise<any>; +}) { + await load(args, { stdout }); +} + +export const title = (text: string) => align(label(text), 'end', 7) + ' '; + +export const getName = () => + new Promise<string>((resolve) => { + exec('git config user.name', { encoding: 'utf-8' }, (_1, gitName) => { + if (gitName.trim()) { + return resolve(gitName.split(' ')[0].trim()); + } + exec('whoami', { encoding: 'utf-8' }, (_3, whoami) => { + if (whoami.trim()) { + return resolve(whoami.split(' ')[0].trim()); + } + return resolve('astronaut'); + }); + }); + }); + +export const getVersion = (packageManager: string, packageName: string, fallback = '') => + new Promise<string>(async (resolve) => { + let registry = await getRegistry(packageManager); + const { version } = await fetch(`${registry}/${packageName}/latest`, { + redirect: 'follow', + }) + .then((res) => res.json()) + .catch(() => ({ version: fallback })); + return resolve(version); + }); + +export const log = (message: string) => stdout.write(message + '\n'); +export const banner = () => { + const prefix = `astro`; + const suffix = `Launch sequence initiated.`; + log(`${label(prefix, color.bgGreen, color.black)} ${suffix}`); +}; + +export const bannerAbort = () => + log(`\n${label('astro', color.bgRed)} ${color.bold('Launch sequence aborted.')}`); + +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 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 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 !== '') { + projectDir = projectDir.includes(' ') ? `"./${projectDir}"` : `./${projectDir}`; + const enter = [ + `\n${prefix}Enter your project directory using`, + color.cyan(`cd ${projectDir}`, ''), + ]; + const len = enter[0].length + stripVTControlCharacters(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<string, [command: string, help: string][]>; + 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}`, + ); + } + + if (usage) { + message.push(linebreak(), `${color.green(commandName)} ${color.bold(usage)}`); + } + + 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 })); + } + } + + if (description) { + message.push(linebreak(), `${description}`); + } + + log(message.join('\n') + '\n'); +} diff --git a/packages/create-astro/src/shell.ts b/packages/create-astro/src/shell.ts new file mode 100644 index 000000000..7c3e22622 --- /dev/null +++ b/packages/create-astro/src/shell.ts @@ -0,0 +1,51 @@ +// This is an extremely simplified version of [`execa`](https://github.com/sindresorhus/execa) +// intended to keep our dependency size down +import type { ChildProcess, StdioOptions } from 'node:child_process'; +import type { Readable } from 'node:stream'; + +import { spawn } from 'node:child_process'; +import { text as textFromStream } from 'node:stream/consumers'; + +export interface ExecaOptions { + cwd?: string | URL; + stdio?: StdioOptions; + timeout?: number; +} +export interface Output { + stdout: string; + stderr: string; + exitCode: number; +} +const text = (stream: NodeJS.ReadableStream | Readable | null) => + stream ? textFromStream(stream).then((t) => t.trimEnd()) : ''; + +export async function shell( + command: string, + flags: string[], + opts: ExecaOptions = {}, +): Promise<Output> { + let child: ChildProcess; + let stdout = ''; + let stderr = ''; + try { + child = spawn(command, flags, { + cwd: opts.cwd, + shell: true, + stdio: opts.stdio, + timeout: opts.timeout, + }); + const done = new Promise((resolve) => child.on('close', resolve)); + [stdout, stderr] = await Promise.all([text(child.stdout), text(child.stderr)]); + await done; + } catch { + throw { stdout, stderr, exitCode: 1 }; + } + const { exitCode } = child; + if (exitCode === null) { + throw new Error('Timeout'); + } + if (exitCode !== 0) { + throw new Error(stderr); + } + return { stdout, stderr, exitCode }; +} |