summaryrefslogtreecommitdiff
path: root/packages/create-astro/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/create-astro/src')
-rw-r--r--packages/create-astro/src/actions/context.ts124
-rw-r--r--packages/create-astro/src/actions/dependencies.ts109
-rw-r--r--packages/create-astro/src/actions/git.ts64
-rw-r--r--packages/create-astro/src/actions/help.ts24
-rw-r--r--packages/create-astro/src/actions/intro.ts29
-rw-r--r--packages/create-astro/src/actions/next-steps.ts25
-rw-r--r--packages/create-astro/src/actions/project-name.ts74
-rw-r--r--packages/create-astro/src/actions/shared.ts59
-rw-r--r--packages/create-astro/src/actions/template.ts155
-rw-r--r--packages/create-astro/src/actions/verify.ts40
-rw-r--r--packages/create-astro/src/data/seasonal.ts112
-rw-r--r--packages/create-astro/src/index.ts62
-rw-r--r--packages/create-astro/src/messages.ts201
-rw-r--r--packages/create-astro/src/shell.ts51
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 };
+}