From 7093acd31e88ba3e635c2c948a2c397b2f53de1e Mon Sep 17 00:00:00 2001 From: Oskar Sommer Date: Fri, 19 Jul 2024 12:29:57 +0200 Subject: [PATCH] replicate create-vite behavior --- .gitignore | 175 +--------------------------- biome.json | 8 ++ package.json | 8 +- scripts/build.ts | 16 +-- scripts/vite-templates.ts | 43 +++++++ src/constants.ts | 67 ++++++----- src/create-vite.ts | 113 ++++++++++++++++++ src/index.ts | 239 +++++++++++++++++++++----------------- src/template-prompt.ts | 16 --- src/util.ts | 12 +- tsconfig.json | 55 ++++----- 11 files changed, 380 insertions(+), 372 deletions(-) create mode 100644 scripts/vite-templates.ts create mode 100644 src/create-vite.ts delete mode 100644 src/template-prompt.ts diff --git a/.gitignore b/.gitignore index 9b1ee42..62f3aef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,175 +1,6 @@ -# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore - -# Logs - -logs -_.log -npm-debug.log_ -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Caches - -.cache - -# Diagnostic reports (https://nodejs.org/api/report.html) - -report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json - -# Runtime data - -pids -_.pid -_.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover - -lib-cov - -# Coverage directory used by tools like istanbul - -coverage -*.lcov - -# nyc test coverage - -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) - -.grunt - -# Bower dependency directory (https://bower.io/) - -bower_components - -# node-waf configuration - -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) - -build/Release - -# Dependency directories - +.DS_Store node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) - -web_modules/ - -# TypeScript cache - -*.tsbuildinfo - -# Optional npm cache directory - -.npm - -# Optional eslint cache - -.eslintcache - -# Optional stylelint cache - -.stylelintcache - -# Microbundle cache - -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history - -.node_repl_history - -# Output of 'npm pack' - -*.tgz - -# Yarn Integrity file - -.yarn-integrity - -# dotenv environment variable files - -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) - -.parcel-cache - -# Next.js build output - -.next -out - -# Nuxt.js build / generate output - -.nuxt -dist - -# Gatsby files - -# Comment in the public line in if your project uses Gatsby and not Next.js - -# https://nextjs.org/blog/next-9-1#public-directory-support - -# public - -# vuepress build output - -.vuepress/dist - -# vuepress v2.x temp and cache directory - .temp +dist -# Docusaurus cache and generated files - -.docusaurus - -# Serverless directories - -.serverless/ - -# FuseBox cache - -.fusebox/ - -# DynamoDB Local files - -.dynamodb/ - -# TernJS port file - -.tern-port - -# Stores VSCode versions used for testing VSCode extensions - -.vscode-test - -# yarn v2 - -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* - -# IntelliJ based IDEs -.idea - -# Finder (MacOS) folder config -.DS_Store +templates/vite \ No newline at end of file diff --git a/biome.json b/biome.json index 3867749..0328817 100644 --- a/biome.json +++ b/biome.json @@ -8,5 +8,13 @@ "rules": { "recommended": true } + }, + "formatter": { + "indentStyle": "tab" + }, + "javascript": { + "formatter": { + "semicolons": "asNeeded" + } } } diff --git a/package.json b/package.json index b2c3081..bcd76ae 100644 --- a/package.json +++ b/package.json @@ -7,15 +7,13 @@ "description": "Quickly create a local development environment for your TypeScript project.", "author": "vaaski ", "license": "MIT", - "files": [ - "dist", - "templates" - ], + "files": ["dist", "templates"], "scripts": { "start": "bun run src/index.ts", "dev": "bun run --watch src/index.ts", "build": "bun run scripts/build.ts", - "test": "echo no tests yet" + "test": "echo no tests yet", + "format": "biome format --write src scripts" }, "devDependencies": { "@biomejs/biome": "1.8.3", diff --git a/scripts/build.ts b/scripts/build.ts index 18e2dd5..e3256fc 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -4,19 +4,19 @@ import { platform } from "node:os" const BANNER = "#!/usr/bin/env bun\n" try { - await unlink("./dist/index.js") + await unlink("./dist/index.js") } catch {} const result = await Bun.build({ - entrypoints: ["./src/index.ts"], - outdir: "./dist", - minify: true, - target: "bun", + entrypoints: ["./src/index.ts"], + outdir: "./dist", + minify: true, + target: "bun", }) if (!result.success) { - console.log(result.logs) - process.exit(1) + console.log(result.logs) + process.exit(1) } const file = Bun.file("./dist/index.js") @@ -25,5 +25,5 @@ const output = BANNER + text await Bun.write("./dist/index.js", output) if (platform() !== "win32") { - Bun.spawn(["chmod", "+x", "./dist/index.js"]) + Bun.spawn(["chmod", "+x", "./dist/index.js"]) } diff --git a/scripts/vite-templates.ts b/scripts/vite-templates.ts new file mode 100644 index 0000000..39a0edc --- /dev/null +++ b/scripts/vite-templates.ts @@ -0,0 +1,43 @@ +import { mkdir, readdir, rename, rm } from "node:fs/promises" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +import { spawnerInstance } from "../src/util" + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +const OUTPUT_FOLDER = join(__dirname, "../templates/vite") +const tempFolder = join(__dirname, "../.temp") + +await rm(tempFolder, { recursive: true, force: true }) +await mkdir(tempFolder, { recursive: true }) + +const spawner = spawnerInstance({ + cwd: tempFolder, + stdio: ["inherit", "inherit", "inherit"], +}) + +await spawner(["bunx", "degit", "vitejs/vite/packages/create-vite"]).exited + +const content = await readdir(tempFolder) +const nonTemplates = content.filter((file) => !file.startsWith("template-")) +const templates = content.filter((file) => file.startsWith("template-")) + +for (const item of nonTemplates) { + const path = join(tempFolder, item) + await rm(path, { recursive: true, force: true }) +} + +await rm(OUTPUT_FOLDER, { recursive: true, force: true }) +await mkdir(OUTPUT_FOLDER, { recursive: true }) + +for (const item of templates) { + const path = join(tempFolder, item) + const newName = item.replace("template-", "") + const newPath = join(OUTPUT_FOLDER, newName) + + console.log({ path, item, newName, newPath }) + await rename(path, newPath) +} + +await rm(tempFolder, { recursive: true, force: true }) diff --git a/src/constants.ts b/src/constants.ts index 6e7d8b7..03d1fcc 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,42 +1,45 @@ import { blue, bold, gray, magenta, yellow } from "kolorist" export type TemplateBuiltin = { - name: string - folder: string - color: (str: string | number) => string - editorEntry: string - entry: string[] - alias?: string + name: string + folder: string + color: (str: string | number) => string + editorEntry: string + entry: string[] + alias?: string } -export type TemplateVite = Omit & { - vite: true +export type TemplateVite = Omit< + TemplateBuiltin, + "folder" | "entry" | "editorEntry" +> & { + vite: true } export type Template = TemplateBuiltin | TemplateVite export const templates: Template[] = [ - { - name: "typescript", - alias: "ts", - folder: "typescript", - color: blue, - editorEntry: "index.ts", - entry: ["bun", "run", "--watch", "index.ts"], - }, - { - name: "javascript", - alias: "js", - folder: "javascript", - color: yellow, - editorEntry: "index.js", - entry: ["bun", "run", "--watch", "index.js"], - }, - { - name: "vite", - alias: "v", - color: magenta, - vite: true, - }, + { + name: "TypeScript", + alias: "ts", + folder: "typescript", + color: blue, + editorEntry: "index.ts", + entry: ["bun", "run", "--watch", "index.ts"], + }, + { + name: "JavaScript", + alias: "js", + folder: "javascript", + color: yellow, + editorEntry: "index.js", + entry: ["bun", "run", "--watch", "index.js"], + }, + { + name: "vite", + alias: "v", + color: magenta, + vite: true, + }, ] export const helpMessage = ` @@ -52,5 +55,5 @@ ${gray("Deletion options, pick either or get prompted:")} Available templates: ${templates - .map((t) => ` - ${t.color(t.name)}${t.alias ? `/${t.color(t.alias)}` : ""}`) - .join("\n")}` + .map((t) => ` - ${t.color(t.name)}${t.alias ? `/${t.color(t.alias)}` : ""}`) + .join("\n")}` diff --git a/src/create-vite.ts b/src/create-vite.ts new file mode 100644 index 0000000..7263ff2 --- /dev/null +++ b/src/create-vite.ts @@ -0,0 +1,113 @@ +// the reason the create-vite behavior is duplicated here is because +// prompts seem to mess up the stdin for the create-vite child process + +import * as colors from "kolorist" +import { cp, readdir, rename } from "node:fs/promises" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import prompts from "prompts" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const VITE_TEMPLATES_FOLDER = join(__dirname, "../templates/vite") + +const COLOR_STARTS_WITH = Object.entries({ + vanilla: colors.yellow, + vue: colors.green, + react: colors.cyan, + preact: colors.magenta, + lit: colors.lightRed, + svelte: colors.red, + solid: colors.blue, + qwik: colors.lightBlue, +}) as [string, (str: string | number) => string][] + +export type ViteFlavor = { + color: typeof colors.blue + variants: ["TypeScript" | "JavaScript", typeof colors.blue][] +} +export type ViteTemplateMap = { [name: string]: ViteFlavor } +export type ViteTemplateEntry = [string, ViteFlavor] + +export const listTemplates = async (): Promise => { + const contents = await readdir(VITE_TEMPLATES_FOLDER) + const map: ViteTemplateMap = {} + + for (const viteTemplate of contents) { + const colorMatch = COLOR_STARTS_WITH.find(([color]) => + viteTemplate.startsWith(color), + ) + const color = colorMatch ? colorMatch[1] : colors.gray + + const [name, variantName] = viteTemplate.split("-") + if (!name) throw new Error(`Invalid template name: ${viteTemplate}`) + + if (!map[name]) map[name] = { color, variants: [] } + + const variant = variantName === "ts" ? "TypeScript" : "JavaScript" + const variantColor = variant === "TypeScript" ? colors.blue : colors.yellow + map[name].variants.push([variant, variantColor]) + } + + const sorted = Object.entries(map).sort((a, b) => a[0].localeCompare(b[0])) + return sorted +} + +const renameFiles = { + _gitignore: ".gitignore", +} +const copyViteTemplate = async ( + templateFolder: string, + outputFolder: string, +) => { + const templatePath = join(VITE_TEMPLATES_FOLDER, templateFolder) + + await cp(templatePath, outputFolder, { recursive: true }) + + const outputFolderContents = await readdir(outputFolder) + for (const [current, target] of Object.entries(renameFiles)) { + if (outputFolderContents.includes(current)) { + await rename(join(outputFolder, current), join(outputFolder, target)) + } + } +} + +export const createVite = async (folder: string) => { + const viteTemplates = await listTemplates() + + const templatePrompt = await prompts({ + type: "select", + name: "template", + message: "Select a create-vite template", + choices: viteTemplates.map((template) => ({ + title: template[1].color(template[0]), + value: template, + })), + }) + + const template = templatePrompt.template as ViteTemplateEntry + if (!template) throw new Error("No template selected") + + if (template[1].variants.length > 1) { + const variantPrompt = await prompts({ + type: "select", + name: "variant", + message: "Select a variant", + choices: template[1].variants + .map((variant) => ({ + title: variant[1](variant[0]), + value: variant[0] === "TypeScript" ? "-ts" : "", + })) + .sort((a, b) => b.title.localeCompare(a.title)), + }) + + if (variantPrompt.variant === undefined) { + throw new Error("No variant selected") + } + + const templateFolder = template[0] + variantPrompt.variant + await copyViteTemplate(templateFolder, folder) + } else { + const templateFolder = template[0] + await copyViteTemplate(templateFolder, folder) + } +} diff --git a/src/index.ts b/src/index.ts index c9d2036..b0de3d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { black, blue, bold, gray, magenta, red, white } from "kolorist" +import { black, blue, bold, gray, red, white } from "kolorist" import minimist from "minimist" import { cp, mkdir, rm } from "node:fs/promises" import { dirname, join } from "node:path" @@ -7,148 +7,175 @@ import temporaryPath from "temporary-path" import prompts from "prompts" import { type Template, helpMessage, templates } from "./constants" -import { templatePrompt } from "./template-prompt" +import { createVite } from "./create-vite" import { separator, spawnerInstance } from "./util" const __dirname = dirname(fileURLToPath(import.meta.url)) const argv = minimist<{ - template?: string - help?: boolean - keep?: boolean - delete?: boolean + template?: string + help?: boolean + keep?: boolean + delete?: boolean }>(process.argv.slice(2), { - default: { - help: false, - delete: false, - keep: false, - }, - alias: { - h: "help", - t: "template", - k: "keep", - d: "delete", - }, - string: ["_"], + default: { + help: false, + delete: false, + keep: false, + }, + alias: { + h: "help", + t: "template", + k: "keep", + d: "delete", + }, + string: ["_"], }) if (argv.help) { - console.log(helpMessage) - process.exit(0) + console.log(helpMessage) + process.exit(0) } if (argv.delete && argv.keep) { - console.log(red(bold("Cannot use both --delete and --keep"))) - process.exit(1) + console.log(red(bold("Cannot use both --delete and --keep"))) + process.exit(1) } let template: Template | undefined if (argv.template) { - template = templates.find((t) => t.name === argv.template || t.alias === argv.template) + template = templates.find((t) => { + return ( + t.name.toLowerCase() === argv.template?.toLowerCase() || + t.alias?.toLowerCase() === argv.template?.toLowerCase() + ) + }) } else { - template = await templatePrompt() + const templatePrompt = await prompts({ + type: "select", + name: "template", + message: "Select a template", + choices: templates.map((t) => ({ + title: t.color(t.name), + value: t, + })), + }) + + template = templatePrompt.template as Template + + if (!template) { + console.log(gray("Aborted.")) + process.exit(0) + } } if (!template) { - console.log(red(bold(`Template ${black(argv.template ?? "")} not found`))) - console.log(`${helpMessage}`) - process.exit(1) + console.log(red(bold(`Template ${black(argv.template ?? "")} not found`))) + console.log(`${helpMessage}`) + process.exit(1) } const tempFolder = temporaryPath() const spawner = spawnerInstance({ - cwd: tempFolder, - stdio: ["inherit", "inherit", "inherit"], + cwd: tempFolder, + stdio: ["inherit", "inherit", "inherit"], }) if ("vite" in template) { - console.log(gray(`Launching ${magenta("create-vite")}...`)) - - const projectPath = join(tempFolder, "localpen-vite") - await mkdir(tempFolder, { recursive: true }) - - const createVite = spawner(["bunx", "create-vite", "localpen-vite"]) - const result = await createVite.exited - if (result !== 0) { - console.log(red(bold("Failed to create Vite project"))) - process.exit(1) - } - - console.log(gray("Installing dependencies...")) - await spawner(["bun", "install"], { cwd: projectPath }).exited - - console.log(gray("Opening project in VS Code...")) - const codeInstance = spawner(["code", "-n", "-w", projectPath]) - - console.clear() - console.log(bold(blue(`Close the VS Code tab or press ${white("q + enter")} to exit`))) - console.log(gray(`${separator()}\n`)) - - console.log(gray("Running project...")) - const runInstance = spawner(["bunx", "vite", "--open", "--clearScreen", "false"], { - cwd: projectPath, - }) - - await Promise.any([codeInstance.exited, runInstance.exited]) - - runInstance.kill() - codeInstance.kill() - await Promise.all([codeInstance.exited, runInstance.exited]) + const projectPath = join(tempFolder, "localpen-vite") + await mkdir(tempFolder, { recursive: true }) + + try { + await createVite(projectPath) + } catch (error) { + if (error instanceof Error) { + console.log(red(bold(error.message))) + } + process.exit(1) + } + + console.log(gray("Installing dependencies...")) + await spawner(["bun", "install"], { cwd: projectPath }).exited + + console.log(gray("Opening project in VS Code...")) + const codeInstance = spawner(["code", "-n", "-w", projectPath]) + + console.clear() + console.log( + bold(blue(`Close the VS Code tab or press ${white("q + enter")} to exit`)), + ) + console.log(gray(`${separator()}\n`)) + + console.log(gray("Running project...")) + const runInstance = spawner( + ["bunx", "vite", "--open", "--clearScreen", "false"], + { + cwd: projectPath, + }, + ) + + await Promise.any([codeInstance.exited, runInstance.exited]) + + runInstance.kill() + codeInstance.kill() + await Promise.all([codeInstance.exited, runInstance.exited]) } else { - const templatePath = join(__dirname, "../templates", template.folder) - - await cp(templatePath, tempFolder, { recursive: true }) - - console.log(gray("Installing dependencies...")) - await spawner(["bun", "install", "--frozen-lockfile"]).exited - - console.log(gray("Opening project in VS Code...")) - const codeInstance = spawner(["code", "-r", "-w", template.editorEntry]) - - console.log(gray("Running project...")) - const runInstance = spawner(template.entry) - - process.stdin.setRawMode(true) - process.stdin.resume() - process.stdin.on("data", async (key) => { - switch (key.toString()) { - case "q": - case "\u0003": // ctrl-c - codeInstance.kill() - break - default: - console.log(key.toString()) - break - } - }) - - console.clear() - console.log(bold(blue(`Close the VS Code tab or press ${white("q")} to exit`))) - console.log(gray(`${separator()}\n`)) - - await codeInstance.exited - runInstance.kill() - process.stdin.pause() + const templatePath = join(__dirname, "../templates", template.folder) + + await cp(templatePath, tempFolder, { recursive: true }) + + console.log(gray("Installing dependencies...")) + await spawner(["bun", "install", "--frozen-lockfile"]).exited + + console.log(gray("Opening project in VS Code...")) + const codeInstance = spawner(["code", "-r", "-w", template.editorEntry]) + + console.log(gray("Running project...")) + const runInstance = spawner(template.entry) + + process.stdin.setRawMode(true) + process.stdin.resume() + process.stdin.on("data", async (key) => { + switch (key.toString()) { + case "q": + case "\u0003": // ctrl-c + codeInstance.kill() + break + default: + console.log(key.toString()) + break + } + }) + + console.clear() + console.log( + bold(blue(`Close the VS Code tab or press ${white("q")} to exit`)), + ) + console.log(gray(`${separator()}\n`)) + + await codeInstance.exited + runInstance.kill() + process.stdin.pause() } console.clear() let keep = argv.keep && !argv.delete if (!argv.delete && !argv.keep) { - const keepPrompt = await prompts({ - type: "confirm", - name: "keep", - message: "Keep the project folder?", - initial: false, - }) - - keep = keepPrompt.keep + const keepPrompt = await prompts({ + type: "confirm", + name: "keep", + message: "Keep the project folder?", + initial: false, + }) + + keep = keepPrompt.keep } if (keep) { - console.log(gray(`The folder is kept at ${tempFolder}`)) + console.log(gray(`The folder is kept at ${tempFolder}`)) } else { - await rm(tempFolder, { recursive: true, force: true }) - console.log(gray(`Deleted ${tempFolder}`)) + await rm(tempFolder, { recursive: true, force: true }) + console.log(gray(`Deleted ${tempFolder}`)) } diff --git a/src/template-prompt.ts b/src/template-prompt.ts deleted file mode 100644 index b03a592..0000000 --- a/src/template-prompt.ts +++ /dev/null @@ -1,16 +0,0 @@ -import prompts from "prompts" -import { type Template, templates } from "./constants" - -export const templatePrompt = async () => { - const prompt = await prompts({ - type: "select", - name: "template", - message: "Select a template", - choices: templates.map((t) => ({ - title: t.color(t.name), - value: t, - })), - }) - - return prompt.template as Template -} diff --git a/src/util.ts b/src/util.ts index 2b5b127..fd393de 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,11 +1,11 @@ export const spawnerInstance = (options?: Parameters[1]) => { - return ( - command: Parameters[0], - overrides?: Parameters[1], - ) => Bun.spawn(command, { ...options, ...overrides }) + return ( + command: Parameters[0], + overrides?: Parameters[1], + ) => Bun.spawn(command, { ...options, ...overrides }) } export const separator = () => { - const width = process.stdout.columns - return "─".repeat(width) + const width = process.stdout.columns + return "─".repeat(width) } diff --git a/tsconfig.json b/tsconfig.json index 238655f..564fec8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,27 +1,28 @@ -{ - "compilerOptions": { - // Enable latest features - "lib": ["ESNext", "DOM"], - "target": "ESNext", - "module": "ESNext", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } -} +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}