From 310f994ccbc1f4d73266626db988cf720eb3080e Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 29 Jul 2023 22:20:31 -0500 Subject: [PATCH] feat: `init` command for bootstrapping new projects (#65) --- .github/workflows/validate.yml | 2 +- README.md | 2 +- docs/get-started/installation.md | 28 ++--- e2e/tests/init.test.ts | 44 ++++++++ e2e/utils.ts | 3 +- package.json | 4 + pnpm-lock.yaml | 89 +++++++++++++++ src/cli/commands/init.ts | 180 ++++++++++++++++++++++++++++++- src/cli/index.ts | 2 + src/cli/utils/defineCommand.ts | 5 +- 10 files changed, 333 insertions(+), 26 deletions(-) create mode 100644 e2e/tests/init.test.ts diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 947fbbf3..b8b67204 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -49,7 +49,7 @@ jobs: working-directory: demo - name: Tests - run: pnpm test:coverage --reporter=default --reporter=hanging-process + run: pnpm test:coverage --reporter=verbose --reporter=hanging-process project-templates: name: Project Templates diff --git a/README.md b/README.md index 041332b3..739a0ada 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ - 🦾 Auto-imports - ⬇️ Download and bundle remote URL imports - 🎨 Frontend framework agnostic: works with Vue, React, Svelte, etc +- 🖍️ Quickly bootstrap a new project ### Todo -- 🖍️ Quickly bootstrap a new project - 📏 Bundle analysis - 🤖 Automated publishing diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md index 6b9b2db5..79877238 100644 --- a/docs/get-started/installation.md +++ b/docs/get-started/installation.md @@ -2,22 +2,22 @@ Bootstrap a new project or start from scratch. -## Bootstrap Project - -:::warning 🚧 The `wxt init` command is not implemented yet. - -See [From Scratch](#from-scratch) or reference one of the templates below. +:::warning 🚧 WSL Support +**_WXT does not support [Windows Subsystem for Linux](https://learn.microsoft.com/en-us/windows/wsl/) yet_**. See [Issue #55](https://github.com/aklinker1/wxt/issues/55) to track progress. +In the meantime, you can use `cmd` instead. ::: +## Bootstrap Project + :::code-group ```sh [pnpm] -pnpx wxt@latest init +pnpx wxt@latest init ``` ```sh [npm] -npx wxt@latest init +npx wxt@latest init ``` ::: @@ -66,15 +66,15 @@ Then install `wxt`: :::code-group ```sh [pnpm] -pnpm add wxt +pnpm add -D wxt ``` ```sh [npm] -npm i --save wxt +npm i --save-dev wxt ``` ```sh [yarn] -yarn add wxt +yarn add --dev wxt ``` ::: @@ -104,14 +104,6 @@ Finally, add scripts to your `package.json`: } ``` -> You can skip `*:firefox` scripts if you don't want to support Firefox - -## 🚧 WSL Support - -**_WXT does not support [Windows Subsystem for Linux](https://learn.microsoft.com/en-us/windows/wsl/) yet_**. See [Issue #55](https://github.com/aklinker1/wxt/issues/55) to track progress. - -In the meantime, you can use `cmd` instead. - ## Development Once you've installed WXT, you can start the development server using the `dev` script. diff --git a/e2e/tests/init.test.ts b/e2e/tests/init.test.ts new file mode 100644 index 00000000..6e3d5489 --- /dev/null +++ b/e2e/tests/init.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { TestProject } from '../utils'; +import { execaCommand } from 'execa'; +import glob from 'fast-glob'; + +describe('Init command', () => { + it('should download and create a template', async () => { + const project = new TestProject(); + + await execaCommand(`pnpm -s wxt init ${project.root} -t vue --pm npm`, { + env: { ...process.env, CI: 'true' }, + stdio: 'ignore', + }); + const files = await glob('**/*', { + cwd: project.root, + onlyFiles: true, + dot: true, + }); + + expect(files.sort()).toMatchInlineSnapshot(` + [ + ".gitignore", + ".vscode/extensions.json", + "README.md", + "assets/vue.svg", + "components/HelloWorld.vue", + "entrypoints/background.ts", + "entrypoints/popup/App.vue", + "entrypoints/popup/index.html", + "entrypoints/popup/main.ts", + "entrypoints/popup/style.css", + "package.json", + "public/icon/128.png", + "public/icon/16.png", + "public/icon/32.png", + "public/icon/48.png", + "public/icon/96.png", + "public/wxt.svg", + "tsconfig.json", + "wxt.config.ts", + ] + `); + }, 30e3); +}); diff --git a/e2e/utils.ts b/e2e/utils.ts index e5e1c464..8589cdd1 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -8,7 +8,7 @@ import { normalizePath } from '../src/core/utils/paths'; export class TestProject { files: Array<[string, string]> = []; config: UserConfig | undefined; - private readonly root: string; + readonly root: string; constructor(root = 'e2e/project') { // We can't put each test's project inside e2e/project directly, otherwise the wxt.config.ts @@ -98,6 +98,7 @@ export class TestProject { private async serializeDir(dir: string): Promise { const outputFiles = await glob('**/*', { cwd: resolve(this.root, dir), + ignore: ['**/node_modules', '**/.output'], }); outputFiles.sort(); const fileContents = []; diff --git a/package.json b/package.json index d0e601a0..8deb7cf8 100644 --- a/package.json +++ b/package.json @@ -74,11 +74,13 @@ "filesize": "^10.0.7", "fs-extra": "^11.1.1", "get-port": "^7.0.0", + "giget": "^1.1.2", "jiti": "^1.18.2", "json5": "^2.2.3", "linkedom": "^0.14.26", "minimatch": "^9.0.3", "picocolors": "^1.0.0", + "prompts": "^2.4.2", "unimport": "^3.0.8", "vite": "^4.3.9", "vite-tsconfig-paths": "^4.2.0", @@ -91,7 +93,9 @@ "@types/fs-extra": "^11.0.1", "@types/lodash.merge": "^4.6.7", "@types/node": "^20.3.1", + "@types/prompts": "^2.4.4", "@vitest/coverage-v8": "^0.32.2", + "execa": "^7.2.0", "lodash.merge": "^4.6.2", "npm-run-all": "^4.1.5", "ora": "^6.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc7664b1..77cba82d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: get-port: specifier: ^7.0.0 version: 7.0.0 + giget: + specifier: ^1.1.2 + version: 1.1.2 jiti: specifier: ^1.18.2 version: 1.18.2 @@ -56,6 +59,9 @@ importers: picocolors: specifier: ^1.0.0 version: 1.0.0 + prompts: + specifier: ^2.4.2 + version: 2.4.2 unimport: specifier: ^3.0.8 version: 3.0.8 @@ -87,9 +93,15 @@ importers: '@types/node': specifier: ^20.3.1 version: 20.3.1 + '@types/prompts': + specifier: ^2.4.4 + version: 2.4.4 '@vitest/coverage-v8': specifier: ^0.32.2 version: 0.32.2(vitest@0.32.4) + execa: + specifier: ^7.2.0 + version: 7.2.0 lodash.merge: specifier: ^4.6.2 version: 4.6.2 @@ -1043,6 +1055,13 @@ packages: /@types/node@20.3.1: resolution: {integrity: sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg==} + /@types/prompts@2.4.4: + resolution: {integrity: sha512-p5N9uoTH76lLvSAaYSZtBCdEXzpOOufsRjnhjVSrZGXikVGHX9+cc9ERtHRV4hvBKHyZb1bg4K+56Bd2TqUn4A==} + dependencies: + '@types/node': 20.3.1 + kleur: 3.0.3 + dev: true + /@types/web-bluetooth@0.0.17: resolution: {integrity: sha512-4p9vcSmxAayx72yn70joFoL44c9MO/0+iVEBIQXe3v2h2SiAsEIo/G5v6ObFWvNKRFjbrVadNf9LqEEZeQPzdA==} dev: true @@ -2591,6 +2610,21 @@ packages: strip-final-newline: 2.0.0 dev: true + /execa@7.2.0: + resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==} + engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0} + dependencies: + cross-spawn: 7.0.3 + get-stream: 6.0.1 + human-signals: 4.3.1 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.1.0 + onetime: 6.0.0 + signal-exit: 3.0.7 + strip-final-newline: 3.0.0 + dev: true + /extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} dev: false @@ -3146,6 +3180,11 @@ packages: engines: {node: '>=10.17.0'} dev: true + /human-signals@4.3.1: + resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==} + engines: {node: '>=14.18.0'} + dev: true + /ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -3374,6 +3413,11 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + /is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: true + /is-string@1.0.7: resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} engines: {node: '>= 0.4'} @@ -3620,6 +3664,10 @@ packages: json-buffer: 3.0.1 dev: false + /kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + /latest-version@7.0.0: resolution: {integrity: sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg==} engines: {node: '>=14.16'} @@ -3832,6 +3880,11 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + /mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + dev: true + /mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -4075,6 +4128,13 @@ packages: dependencies: path-key: 3.1.1 + /npm-run-path@5.1.0: + resolution: {integrity: sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + path-key: 4.0.0 + dev: true + /nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} dependencies: @@ -4127,6 +4187,13 @@ packages: dependencies: mimic-fn: 2.1.0 + /onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + dependencies: + mimic-fn: 4.0.0 + dev: true + /open@8.4.2: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} @@ -4301,6 +4368,11 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + /path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + dev: true + /path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} dev: true @@ -4495,6 +4567,14 @@ packages: make-error: 1.3.6 dev: false + /prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + dev: false + /proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} dev: false @@ -4942,6 +5022,10 @@ packages: requiresBuild: true dev: true + /sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + dev: false + /slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -5160,6 +5244,11 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + /strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + dev: true + /strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 6ac0dd8c..34744b8e 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -1,6 +1,178 @@ -import { consola } from 'consola'; import { defineCommand } from '../utils/defineCommand'; +import prompts from 'prompts'; +import ora from 'ora'; +import { consola } from 'consola'; +import { downloadTemplate } from 'giget'; +import fs from 'fs-extra'; +import path from 'node:path'; +import pc from 'picocolors'; +import { Formatter } from 'picocolors/types'; -export const init = defineCommand<[directory?: string]>(async (directory) => { - consola.warn('wxt init: Not implemented'); -}); +export const init = defineCommand< + [directory: string | undefined, options: { template?: string; pm?: string }] +>( + async (userDirectory, flags) => { + consola.info('Initalizing new project'); + + const templates = await listTemplates(); + const defaultTemplate = templates.find( + (template) => template.name === flags.template?.toLowerCase().trim(), + ); + + const input = await prompts( + [ + { + name: 'directory', + type: () => (userDirectory == null ? 'text' : undefined), + message: 'Project Directory', + initial: userDirectory, + }, + { + name: 'template', + type: () => (defaultTemplate == null ? 'select' : undefined), + message: 'Choose a template', + choices: templates.map((template) => ({ + title: + TEMPLATE_COLORS[template.name]?.(template.name) ?? template.name, + value: template, + })), + }, + { + name: 'packageManager', + type: () => (flags.pm == null ? 'select' : undefined), + message: 'Package Manager', + choices: [ + { title: 'npm', value: 'npm' }, + { title: 'pnpm', value: 'pnpm' }, + { title: 'yarn', value: 'yarn' }, + ], + }, + ], + { + onCancel: () => process.exit(1), + }, + ); + input.directory ??= userDirectory; + input.template ??= defaultTemplate; + input.packageManager ??= flags.pm; + + await cloneProject(input); + + const cdPath = path.relative(process.cwd(), path.resolve(input.directory)); + console.log(); + consola.log( + `✨ WXT project created with the ${ + TEMPLATE_COLORS[input.template.name]?.(input.template.name) ?? + input.template.name + } template.`, + ); + console.log(); + consola.log('Next steps:'); + let step = 0; + if (cdPath !== '.') consola.log(` ${++step}.`, pc.cyan(`cd ${cdPath}`)); + consola.log(` ${++step}.`, pc.cyan(`${input.packageManager} install`)); + console.log(); + }, + { disableFinishedLog: true }, +); + +interface Template { + /** + * Template's name. + */ + name: string; + /** + * Path to template directory in github repo. + */ + path: string; +} + +async function listTemplates(): Promise { + try { + const res = await fetch( + 'https://api.github.com/repos/aklinker1/wxt/contents/templates', + { + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }, + ); + if (res.status >= 300) + throw Error(`Request failed with status ${res.status} ${res.statusText}`); + + const data = (await res.json()) as Array<{ + type: 'file' | 'dir'; + name: string; + path: string; + }>; + return data + .filter((item: any) => item.type === 'dir') + .map((item) => ({ name: item.name, path: item.path })) + .sort((l, r) => { + const lWeight = TEMPLATE_SORT_WEIGHT[l.name] ?? Number.MAX_SAFE_INTEGER; + const rWeight = TEMPLATE_SORT_WEIGHT[r.name] ?? Number.MAX_SAFE_INTEGER; + const diff = lWeight - rWeight; + if (diff !== 0) return diff; + return l.name.localeCompare(r.name); + }); + } catch (err) { + throw Error(`Cannot load templates: ${JSON.stringify(err, null, 2)}`); + } +} + +async function cloneProject({ + directory, + template, + packageManager, +}: { + directory: string; + template: Template; + packageManager: string; +}) { + const spinner = ora('Downloading template').start(); + try { + // 1. Clone repo + await downloadTemplate(`gh:aklinker1/wxt/${template.path}`, { + dir: directory, + force: true, + }); + + // 2. Move _gitignore -> .gitignore + await fs + .move( + path.join(directory, '_gitignore'), + path.join(directory, '.gitignore'), + ) + .catch((err) => + consola.warn('Failed to move _gitignore to .gitignore:', err), + ); + + // 3. Add .npmrc for pnpm + if (packageManager === 'pnpm') { + await fs.writeFile( + path.join(directory, '.npmrc'), + 'shamefully-hoist=true\n', + ); + } + + spinner.succeed(); + } catch (err) { + spinner.fail(); + throw Error(`Failed to setup new project: ${JSON.stringify(err, null, 2)}`); + } +} + +const TEMPLATE_COLORS: Record = { + vanilla: pc.blue, + vue: pc.green, + react: pc.cyan, + svelte: pc.red, + solid: pc.blue, +}; + +const TEMPLATE_SORT_WEIGHT: Record = { + vanilla: 0, + vue: 1, + react: 2, +}; diff --git a/src/cli/index.ts b/src/cli/index.ts index 4425c5a3..0b3939c4 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -48,6 +48,8 @@ cli.command('publish [root]', 'publish to stores').action(commands.publish); // INIT cli .command('init [directory]', 'initialize a new project') + .option('-t, --template