diff --git a/.changeset/strong-seals-grow.md b/.changeset/strong-seals-grow.md new file mode 100644 index 000000000..f695cfad3 --- /dev/null +++ b/.changeset/strong-seals-grow.md @@ -0,0 +1,5 @@ +--- +'create-astro': minor +--- + +Feat: add option to install dependencies during setup. This respects the package manager used to run create-astro (ex. "yarn create astro" vs "pnpm create astro@latest"). diff --git a/packages/create-astro/package.json b/packages/create-astro/package.json index 1b29d392f..bd7802737 100644 --- a/packages/create-astro/package.json +++ b/packages/create-astro/package.json @@ -31,6 +31,7 @@ "@types/degit": "^2.8.3", "@types/prompts": "^2.0.14", "degit": "^2.8.4", + "execa": "^6.1.0", "kleur": "^4.1.4", "node-fetch": "^3.2.3", "ora": "^6.1.0", diff --git a/packages/create-astro/src/index.ts b/packages/create-astro/src/index.ts index 9c102c37e..80b3b4fd1 100644 --- a/packages/create-astro/src/index.ts +++ b/packages/create-astro/src/index.ts @@ -10,6 +10,7 @@ import { FRAMEWORKS, COUNTER_COMPONENTS, Integration } from './frameworks.js'; import { TEMPLATES } from './templates.js'; import { createConfig } from './config.js'; import { logger, defaultLogLevel } from './logger.js'; +import { execa } from 'execa'; // 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 @@ -40,6 +41,8 @@ const FILES_TO_REMOVE = ['.stackblitzrc', 'sandbox.config.json']; // some files const POSTPROCESS_FILES = ['package.json', 'astro.config.mjs', 'CHANGELOG.md']; // some files need processing after copying. export async function main() { + const pkgManager = pkgManagerFromUserAgent(process.env.npm_config_user_agent); + logger.debug('Verbose logging turned on'); console.log(`\n${bold('Welcome to Astro!')} ${gray(`(create-astro v${version})`)}`); console.log( @@ -138,140 +141,169 @@ export async function main() { spinner = ora({ color: 'green', text: 'Copying project files...' }).start(); // Copy - try { - emitter.on('info', (info) => { - logger.debug(info.message); - }); - await emitter.clone(cwd); - } catch (err: any) { - // degit is compiled, so the stacktrace is pretty noisy. Only report the stacktrace when using verbose mode. - logger.debug(err); - console.error(red(err.message)); + if (!args.dryrun) { + try { + emitter.on('info', (info) => { + logger.debug(info.message); + }); + await emitter.clone(cwd); + } catch (err: any) { + // degit is compiled, so the stacktrace is pretty noisy. Only report the stacktrace when using verbose mode. + logger.debug(err); + console.error(red(err.message)); - // Warning for issue #655 - if (err.message === 'zlib: unexpected end of file') { - console.log( - yellow( - "This seems to be a cache related problem. Remove the folder '~/.degit/github/withastro' to fix this error." - ) - ); - console.log( - yellow( - 'For more information check out this issue: https://github.com/withastro/astro/issues/655' - ) - ); + // Warning for issue #655 + if (err.message === 'zlib: unexpected end of file') { + console.log( + yellow( + "This seems to be a cache related problem. Remove the folder '~/.degit/github/withastro' to fix this error." + ) + ); + console.log( + yellow( + 'For more information check out this issue: https://github.com/withastro/astro/issues/655' + ) + ); + } + + // Helpful message when encountering the "could not find commit hash for ..." error + if (err.code === 'MISSING_REF') { + console.log( + yellow( + "This seems to be an issue with degit. Please check if you have 'git' installed on your system, and install it if you don't have (https://git-scm.com)." + ) + ); + console.log( + yellow( + "If you do have 'git' installed, please run this command with the --verbose flag and file a new issue with the command output here: https://github.com/withastro/astro/issues" + ) + ); + } + spinner.fail(); + process.exit(1); } - // Helpful message when encountering the "could not find commit hash for ..." error - if (err.code === 'MISSING_REF') { - console.log( - yellow( - "This seems to be an issue with degit. Please check if you have 'git' installed on your system, and install it if you don't have (https://git-scm.com)." - ) - ); - console.log( - yellow( - "If you do have 'git' installed, please run this command with the --verbose flag and file a new issue with the command output here: https://github.com/withastro/astro/issues" - ) - ); - } - spinner.fail(); - process.exit(1); - } + // Post-process in parallel + await Promise.all([ + ...FILES_TO_REMOVE.map(async (file) => { + const fileLoc = path.resolve(path.join(cwd, file)); + return fs.promises.rm(fileLoc); + }), + ...POSTPROCESS_FILES.map(async (file) => { + const fileLoc = path.resolve(path.join(cwd, file)); - // Post-process in parallel - await Promise.all([ - ...FILES_TO_REMOVE.map(async (file) => { - const fileLoc = path.resolve(path.join(cwd, file)); - return fs.promises.rm(fileLoc); - }), - ...POSTPROCESS_FILES.map(async (file) => { - const fileLoc = path.resolve(path.join(cwd, file)); - - switch (file) { - case 'CHANGELOG.md': { - if (fs.existsSync(fileLoc)) { - await fs.promises.unlink(fileLoc); - } - break; - } - case 'astro.config.mjs': { - if (selectedTemplate?.integrations !== true) { + switch (file) { + case 'CHANGELOG.md': { + if (fs.existsSync(fileLoc)) { + await fs.promises.unlink(fileLoc); + } break; } - await fs.promises.writeFile(fileLoc, createConfig({ integrations })); - break; - } - case 'package.json': { - const packageJSON = JSON.parse(await fs.promises.readFile(fileLoc, 'utf8')); - delete packageJSON.snowpack; // delete snowpack config only needed in monorepo (can mess up projects) - // Fetch latest versions of selected integrations - const integrationEntries = ( - await Promise.all( - integrations.map((integration) => - fetch(`https://registry.npmjs.org/${integration.packageName}/latest`) - .then((res) => res.json()) - .then((res: any) => { - let dependencies: [string, string][] = [[res['name'], `^${res['version']}`]]; + case 'astro.config.mjs': { + if (selectedTemplate?.integrations !== true) { + break; + } + await fs.promises.writeFile(fileLoc, createConfig({ integrations })); + break; + } + case 'package.json': { + const packageJSON = JSON.parse(await fs.promises.readFile(fileLoc, 'utf8')); + delete packageJSON.snowpack; // delete snowpack config only needed in monorepo (can mess up projects) + // Fetch latest versions of selected integrations + const integrationEntries = ( + await Promise.all( + integrations.map((integration) => + fetch(`https://registry.npmjs.org/${integration.packageName}/latest`) + .then((res) => res.json()) + .then((res: any) => { + let dependencies: [string, string][] = [[res['name'], `^${res['version']}`]]; - if (res['peerDependencies']) { - for (const peer in res['peerDependencies']) { - dependencies.push([peer, res['peerDependencies'][peer]]); + if (res['peerDependencies']) { + for (const peer in res['peerDependencies']) { + dependencies.push([peer, res['peerDependencies'][peer]]); + } } - } - return dependencies; - }) + return dependencies; + }) + ) ) - ) - ).flat(1); - // merge and sort dependencies - packageJSON.devDependencies = { - ...(packageJSON.devDependencies ?? {}), - ...Object.fromEntries(integrationEntries), - }; - packageJSON.devDependencies = Object.fromEntries( - Object.entries(packageJSON.devDependencies).sort((a, b) => a[0].localeCompare(b[0])) - ); - await fs.promises.writeFile(fileLoc, JSON.stringify(packageJSON, undefined, 2)); - break; + ).flat(1); + // merge and sort dependencies + packageJSON.devDependencies = { + ...(packageJSON.devDependencies ?? {}), + ...Object.fromEntries(integrationEntries), + }; + packageJSON.devDependencies = Object.fromEntries( + Object.entries(packageJSON.devDependencies).sort((a, b) => a[0].localeCompare(b[0])) + ); + await fs.promises.writeFile(fileLoc, JSON.stringify(packageJSON, undefined, 2)); + break; + } } - } - }), - ]); + }), + ]); - // Inject framework components into starter template - if (selectedTemplate?.value === 'starter') { - let importStatements: string[] = []; - let components: string[] = []; - await Promise.all( - integrations.map(async (integration) => { - const component = COUNTER_COMPONENTS[integration.id as keyof typeof COUNTER_COMPONENTS]; - const componentName = path.basename(component.filename, path.extname(component.filename)); - const absFileLoc = path.resolve(cwd, component.filename); - importStatements.push( - `import ${componentName} from '${component.filename.replace(/^src/, '..')}';` - ); - components.push(`<${componentName} client:visible />`); - await fs.promises.writeFile(absFileLoc, component.content); - }) - ); + // Inject framework components into starter template + if (selectedTemplate?.value === 'starter') { + let importStatements: string[] = []; + let components: string[] = []; + await Promise.all( + integrations.map(async (integration) => { + const component = COUNTER_COMPONENTS[integration.id as keyof typeof COUNTER_COMPONENTS]; + const componentName = path.basename(component.filename, path.extname(component.filename)); + const absFileLoc = path.resolve(cwd, component.filename); + importStatements.push( + `import ${componentName} from '${component.filename.replace(/^src/, '..')}';` + ); + components.push(`<${componentName} client:visible />`); + await fs.promises.writeFile(absFileLoc, component.content); + }) + ); - const pageFileLoc = path.resolve(path.join(cwd, 'src', 'pages', 'index.astro')); - const content = (await fs.promises.readFile(pageFileLoc)).toString(); - const newContent = content - .replace(/^(\s*)\/\* ASTRO\:COMPONENT_IMPORTS \*\//gm, (_, indent) => { - return indent + importStatements.join('\n'); - }) - .replace(/^(\s*)/gm, (_, indent) => { - return components.map((ln) => indent + ln).join('\n'); - }); - await fs.promises.writeFile(pageFileLoc, newContent); + const pageFileLoc = path.resolve(path.join(cwd, 'src', 'pages', 'index.astro')); + const content = (await fs.promises.readFile(pageFileLoc)).toString(); + const newContent = content + .replace(/^(\s*)\/\* ASTRO\:COMPONENT_IMPORTS \*\//gm, (_, indent) => { + return indent + importStatements.join('\n'); + }) + .replace(/^(\s*)/gm, (_, indent) => { + return components.map((ln) => indent + ln).join('\n'); + }); + await fs.promises.writeFile(pageFileLoc, newContent); + } } spinner.succeed(); console.log(bold(green('✔') + ' Done!')); + const installResponse = await prompts({ + type: 'confirm', + name: 'install', + message: `Would you like us to run "${pkgManager} install?"`, + initial: true, + }); + + if (!installResponse) { + process.exit(0); + } + + if (installResponse.install) { + const installExec = execa(pkgManager, ['install'], { cwd }); + const installingPackagesMsg = `Installing packages${emojiWithFallback(' 📦', '...')}`; + spinner = ora({ color: 'green', text: installingPackagesMsg }).start(); + if (!args.dryrun) { + await new Promise((resolve, reject) => { + installExec.stdout?.on('data', function (data) { + spinner.text = `${installingPackagesMsg}\n${bold(`[${pkgManager}]`)} ${data}`; + }); + installExec.on('error', (error) => reject(error)); + installExec.on('close', () => resolve()); + }); + } + spinner.succeed(); + } + console.log('\nNext steps:'); let i = 1; const relative = path.relative(process.cwd(), cwd); @@ -279,14 +311,28 @@ export async function main() { console.log(` ${i++}: ${bold(cyan(`cd ${relative}`))}`); } - console.log(` ${i++}: ${bold(cyan('npm install'))} (or pnpm install, yarn, etc)`); + if (!installResponse.install) { + console.log(` ${i++}: ${bold(cyan(`${pkgManager} install`))}`); + } console.log( ` ${i++}: ${bold( cyan('git init && git add -A && git commit -m "Initial commit"') )} (optional step)` ); - console.log(` ${i++}: ${bold(cyan('npm run dev'))} (or pnpm, yarn, etc)`); + const runCommand = pkgManager === 'npm' ? 'npm run dev' : `${pkgManager} dev`; + console.log(` ${i++}: ${bold(cyan(runCommand))}`); console.log(`\nTo close the dev server, hit ${bold(cyan('Ctrl-C'))}`); console.log(`\nStuck? Visit us at ${cyan('https://astro.build/chat')}\n`); } + +function emojiWithFallback(char: string, fallback: string) { + return process.platform !== 'win32' ? char : fallback; +} + +function pkgManagerFromUserAgent(userAgent?: string) { + if (!userAgent) return 'npm'; + const pkgSpec = userAgent.split(' ')[0]; + const pkgSpecArr = pkgSpec.split('/'); + return pkgSpecArr[0]; +} diff --git a/packages/create-astro/test/directory-step.test.js b/packages/create-astro/test/directory-step.test.js index 122bd3f8f..02ddc672a 100644 --- a/packages/create-astro/test/directory-step.test.js +++ b/packages/create-astro/test/directory-step.test.js @@ -1,54 +1,20 @@ -import { execa } from 'execa'; -import { fileURLToPath } from 'url'; -import { dirname, resolve } from 'path'; +import { resolve } from 'path'; import { promises, existsSync } from 'fs'; +import { PROMPT_MESSAGES, testDir, setup, promiseWithTimeout, timeout } from './utils.js'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const createAstroError = new Error( - 'Timed out waiting for create-astro to respond with expected output.' -); -const timeout = 5000; - -const instructions = { - directory: 'Where would you like to create your app?', - template: 'Which app template would you like to use?', -}; const inputs = { nonEmptyDir: './fixtures/select-directory/nonempty-dir', emptyDir: './fixtures/select-directory/empty-dir', nonexistentDir: './fixtures/select-directory/banana-dir', }; -function promiseWithTimeout(testFn) { - return new Promise((resolve, reject) => { - const timeoutEvent = setTimeout(() => { - reject(createAstroError); - }, timeout); - function resolver() { - clearTimeout(timeoutEvent); - resolve(); - } - testFn(resolver); - }); -} - -function setup(args = []) { - const { stdout, stdin } = execa('../create-astro.mjs', args, { cwd: __dirname }); - return { - stdin, - stdout, - }; -} - describe('[create-astro] select directory', function () { this.timeout(timeout); it('should prompt for directory when none is provided', function () { return promiseWithTimeout((resolve) => { const { stdout } = setup(); stdout.on('data', (chunk) => { - if (chunk.includes(instructions.directory)) { + if (chunk.includes(PROMPT_MESSAGES.directory)) { resolve(); } }); @@ -58,21 +24,21 @@ describe('[create-astro] select directory', function () { return promiseWithTimeout((resolve) => { const { stdout } = setup([inputs.nonEmptyDir]); stdout.on('data', (chunk) => { - if (chunk.includes(instructions.directory)) { + if (chunk.includes(PROMPT_MESSAGES.directory)) { resolve(); } }); }); }); it('should proceed on an empty directory', async function () { - const resolvedEmptyDirPath = resolve(__dirname, inputs.emptyDir); + const resolvedEmptyDirPath = resolve(testDir, inputs.emptyDir); if (!existsSync(resolvedEmptyDirPath)) { await promises.mkdir(resolvedEmptyDirPath); } return promiseWithTimeout((resolve) => { const { stdout } = setup([inputs.emptyDir]); stdout.on('data', (chunk) => { - if (chunk.includes(instructions.template)) { + if (chunk.includes(PROMPT_MESSAGES.template)) { resolve(); } }); @@ -82,7 +48,7 @@ describe('[create-astro] select directory', function () { return promiseWithTimeout((resolve) => { const { stdout } = setup([inputs.nonexistentDir]); stdout.on('data', (chunk) => { - if (chunk.includes(instructions.template)) { + if (chunk.includes(PROMPT_MESSAGES.template)) { resolve(); } }); @@ -95,7 +61,7 @@ describe('[create-astro] select directory', function () { if (chunk.includes('Please clear contents or choose a different path.')) { resolve(); } - if (chunk.includes(instructions.directory)) { + if (chunk.includes(PROMPT_MESSAGES.directory)) { stdin.write(`${inputs.nonEmptyDir}\x0D`); } }); diff --git a/packages/create-astro/test/install-step.test.js b/packages/create-astro/test/install-step.test.js new file mode 100644 index 000000000..91e3bc66c --- /dev/null +++ b/packages/create-astro/test/install-step.test.js @@ -0,0 +1,71 @@ +import { setup, promiseWithTimeout, timeout, PROMPT_MESSAGES } from './utils.js'; +import { sep } from 'path'; +import fs from 'fs'; +import os from 'os'; + +const FAKE_PACKAGE_MANAGER = 'banana'; +let initialEnvValue = null; + +describe('[create-astro] install', function () { + this.timeout(timeout); + let tempDir = ''; + beforeEach(async () => { + tempDir = await fs.promises.mkdtemp(`${os.tmpdir()}${sep}`); + }); + this.beforeAll(() => { + initialEnvValue = process.env.npm_config_user_agent; + process.env.npm_config_user_agent = FAKE_PACKAGE_MANAGER; + }) + this.afterAll(() => { + process.env.npm_config_user_agent = initialEnvValue; + }) + + it('should respect package manager in prompt', function() { + const { stdout, stdin } = setup([tempDir, '--dryrun']); + return promiseWithTimeout((resolve) => { + const seen = new Set(); + const installPrompt = PROMPT_MESSAGES.install(FAKE_PACKAGE_MANAGER); + stdout.on('data', (chunk) => { + if (!seen.has(PROMPT_MESSAGES.template) && chunk.includes(PROMPT_MESSAGES.template)) { + seen.add(PROMPT_MESSAGES.template); + stdin.write('\x0D'); + } + if (!seen.has(PROMPT_MESSAGES.frameworks) && chunk.includes(PROMPT_MESSAGES.frameworks)) { + seen.add(PROMPT_MESSAGES.frameworks); + stdin.write('\x0D'); + } + + if (!seen.has(installPrompt) && chunk.includes(installPrompt)) { + seen.add(installPrompt); + resolve(); + } + }); + }); + }); + + it('should respect package manager in next steps', function() { + const { stdout, stdin } = setup([tempDir, '--dryrun']); + return promiseWithTimeout((resolve) => { + const seen = new Set(); + const installPrompt = PROMPT_MESSAGES.install(FAKE_PACKAGE_MANAGER); + stdout.on('data', (chunk) => { + if (!seen.has(PROMPT_MESSAGES.template) && chunk.includes(PROMPT_MESSAGES.template)) { + seen.add(PROMPT_MESSAGES.template); + stdin.write('\x0D'); + } + if (!seen.has(PROMPT_MESSAGES.frameworks) && chunk.includes(PROMPT_MESSAGES.frameworks)) { + seen.add(PROMPT_MESSAGES.frameworks); + stdin.write('\x0D'); + } + + if (!seen.has(installPrompt) && chunk.includes(installPrompt)) { + seen.add(installPrompt) + stdin.write('n\x0D'); + } + if (chunk.includes('banana dev')) { + resolve(); + } + }); + }); + }); +}) diff --git a/packages/create-astro/test/utils.js b/packages/create-astro/test/utils.js new file mode 100644 index 000000000..c5519c7bf --- /dev/null +++ b/packages/create-astro/test/utils.js @@ -0,0 +1,40 @@ +import { execa } from 'execa' +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +export const testDir = dirname(__filename); +export const timeout = 5000; + +const createAstroError = new Error( + 'Timed out waiting for create-astro to respond with expected output.' +); + +export function promiseWithTimeout(testFn) { + return new Promise((resolve, reject) => { + const timeoutEvent = setTimeout(() => { + reject(createAstroError); + }, timeout); + function resolver() { + clearTimeout(timeoutEvent); + resolve(); + } + testFn(resolver); + }); +} + +export const PROMPT_MESSAGES = { + directory: 'Where would you like to create your app?', + template: 'Which app template would you like to use?', + // TODO: remove when framework selector is removed + frameworks: 'Which frameworks would you like to use?', + install: (pkgManager) => `Would you like us to run "${pkgManager} install?"`, +}; + +export function setup(args = []) { + const { stdout, stdin } = execa('../create-astro.mjs', args, { cwd: testDir }); + return { + stdin, + stdout, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index baa7a59ca..835892abd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1216,6 +1216,7 @@ importers: astro-scripts: workspace:* chai: ^4.3.6 degit: ^2.8.4 + execa: ^6.1.0 kleur: ^4.1.4 mocha: ^9.2.2 node-fetch: ^3.2.3 @@ -1227,6 +1228,7 @@ importers: '@types/degit': 2.8.3 '@types/prompts': 2.0.14 degit: 2.8.4 + execa: 6.1.0 kleur: 4.1.4 node-fetch: 3.2.3 ora: 6.1.0 @@ -1241,6 +1243,12 @@ importers: mocha: 9.2.2 uvu: 0.5.3 + packages/create-astro/test/fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir./fixtures/select-directory/nonempty-dir: + specifiers: + astro: ^1.0.0-beta.17 + devDependencies: + astro: link:../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../astro + packages/integrations/deno: specifiers: astro: workspace:*