diff --git a/app/src/cli/commands/create/create.ts b/app/src/cli/commands/create/create.ts index d4ba370..8c73e6a 100644 --- a/app/src/cli/commands/create/create.ts +++ b/app/src/cli/commands/create/create.ts @@ -6,7 +6,7 @@ import { typewriter, wait } from "cli/utils/cli"; import { exec, getVersion } from "cli/utils/sys"; import { Option } from "commander"; import color from "picocolors"; -import { updateBkndPackages } from "./npm"; +import { overridePackageJson, updateBkndPackages } from "./npm"; import { type Template, templates } from "./templates"; const config = { @@ -36,16 +36,21 @@ export const create: CliCommand = (program) => { .action(action); }; +function errorOutro() { + $p.outro(color.red("Failed to create project.")); + console.log( + color.yellow("Sorry that this happened. If you think this is a bug, please report it at: ") + + color.cyan("https://github.com/bknd-io/bknd/issues") + ); + console.log(""); + process.exit(1); +} + async function action(options: { template?: string; dir?: string; integration?: string }) { - const _config = { - defaultDir: process.env.LOCAL ? "./.template" : "./", - speed: { - typewriter: process.env.LOCAL ? 0 : 20, - wait: process.env.LOCAL ? 0 : 250 - } - }; + console.log(""); + const downloadOpts = { - dir: options.dir || _config.defaultDir, + dir: options.dir || "./", clean: false }; @@ -56,12 +61,8 @@ async function action(options: { template?: string; dir?: string; integration?: await $p.stream.message( (async function* () { - yield* typewriter( - "Thanks for choosing to create a new project with bknd!", - _config.speed.typewriter, - color.dim - ); - await wait(_config.speed.wait); + yield* typewriter("Thanks for choosing to create a new project with bknd!", color.dim); + await wait(); })() ); @@ -90,6 +91,12 @@ async function action(options: { template?: string; dir?: string; integration?: downloadOpts.clean = clean; } + let name = downloadOpts.dir.includes("/") + ? downloadOpts.dir.split("/").pop() + : downloadOpts.dir.replace(/[./]/g, ""); + + if (!name || name.length === 0) name = "bknd"; + let template: Template | undefined; if (options.template) { template = templates.find((t) => t.key === options.template) as Template; @@ -102,14 +109,10 @@ async function action(options: { template?: string; dir?: string; integration?: if (!integration) { await $p.stream.info( (async function* () { - yield* typewriter("Ready? ", _config.speed.typewriter * 1.5, color.bold); - await wait(500); - yield* typewriter( - "Let's find the perfect template for you.", - _config.speed.typewriter, - color.dim - ); - await wait(500); + yield* typewriter("Ready? ", color.bold, 1.5); + await wait(2); + yield* typewriter("Let's find the perfect template for you.", color.dim); + await wait(2); })() ); @@ -169,12 +172,13 @@ async function action(options: { template?: string; dir?: string; integration?: process.exit(1); } - const ctx = { template, dir: downloadOpts.dir }; + const ctx = { template, dir: downloadOpts.dir, name }; { - // @todo: only while in PR - const ref = "feat/cli-starters"; - //const ref = `v${version}`; + const ref = process.env.BKND_CLI_CREATE_REF ?? `v${version}`; + if (process.env.BKND_CLI_CREATE_REF) { + $p.log.warn(color.dim("[DEV] Using local ref: ") + color.yellow(ref)); + } const prefix = template.ref === true @@ -187,11 +191,21 @@ async function action(options: { template?: string; dir?: string; integration?: //console.log("url", url); const s = $p.spinner(); s.start("Downloading template..."); - const result = await downloadTemplate(url, { - dir: ctx.dir, - force: downloadOpts.clean ? "clean" : true - }); - //console.log("result", result); + try { + await downloadTemplate(url, { + dir: ctx.dir, + force: downloadOpts.clean ? "clean" : true + }); + } catch (e) { + if (e instanceof Error) { + s.stop("Failed to download template: " + color.red(e.message), 1); + } else { + console.error(e); + s.stop("Failed to download template. Check logs above.", 1); + } + + errorOutro(); + } s.stop("Template downloaded."); await updateBkndPackages(ctx.dir); @@ -201,6 +215,16 @@ async function action(options: { template?: string; dir?: string; integration?: } } + // update package name + await overridePackageJson( + (pkg) => ({ + ...pkg, + name: ctx.name + }), + { dir: ctx.dir } + ); + $p.log.success(`Updated package name to ${color.cyan(ctx.name)}`); + { const install = await $p.confirm({ message: "Install dependencies?" @@ -209,9 +233,23 @@ async function action(options: { template?: string; dir?: string; integration?: if ($p.isCancel(install)) { process.exit(1); } else if (install) { + const install_cmd = template.scripts?.install || "npm install"; + const s = $p.spinner(); s.start("Installing dependencies..."); - exec(`cd ${ctx.dir} && npm install`, { silent: true }); + try { + exec(`cd ${ctx.dir} && ${install_cmd}`, { silent: true }); + } catch (e) { + if (e instanceof Error) { + s.stop("Failed to install: " + color.red(e.message), 1); + } else { + console.error(e); + s.stop("Failed to install. Check logs above.", 1); + } + + errorOutro(); + } + s.stop("Dependencies installed."); if (template!.postinstall) { @@ -220,12 +258,12 @@ async function action(options: { template?: string; dir?: string; integration?: } else { await $p.stream.warn( (async function* () { - yield* typewriter("Remember to run ", _config.speed.typewriter, color.dim); - await wait(_config.speed.typewriter); - yield* typewriter("npm install", _config.speed.typewriter, color.cyan); - await wait(_config.speed.typewriter); - yield* typewriter(" after setup", _config.speed.typewriter, color.dim); - await wait(_config.speed.wait / 2); + yield* typewriter( + color.dim("Remember to run ") + + color.cyan("npm install") + + color.dim(" after setup") + ); + await wait(); })() ); } @@ -237,17 +275,16 @@ async function action(options: { template?: string; dir?: string; integration?: await $p.stream.success( (async function* () { - yield* typewriter("That's it! ", _config.speed.typewriter); - await wait(_config.speed.wait / 2); + yield* typewriter("That's it! "); + await wait(0.5); yield "🎉"; - await wait(_config.speed.wait); + await wait(); yield "\n\n"; yield* typewriter( `Enter your project's directory using ${color.cyan("cd " + ctx.dir)} -If you need help, check ${color.cyan("https://docs.bknd.io")} or join our Discord!`, - _config.speed.typewriter +If you need help, check ${color.cyan("https://docs.bknd.io")} or join our Discord!` ); - await wait(_config.speed.wait * 2); + await wait(2); })() ); diff --git a/app/src/cli/commands/create/npm.ts b/app/src/cli/commands/create/npm.ts index e698683..97376ea 100644 --- a/app/src/cli/commands/create/npm.ts +++ b/app/src/cli/commands/create/npm.ts @@ -1,5 +1,6 @@ import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; +import { getVersion as sysGetVersion } from "cli/utils/sys"; export type TPackageJson = Partial<{ name: string; @@ -67,7 +68,7 @@ export async function replacePackageJsonVersions( export async function updateBkndPackages(dir?: string, map?: Record) { const versions = { - bknd: "^" + (await getVersion("bknd")), + bknd: "^" + (await sysGetVersion()), ...(map ?? {}) }; await replacePackageJsonVersions( diff --git a/app/src/cli/commands/create/templates/cloudflare.ts b/app/src/cli/commands/create/templates/cloudflare.ts index 0028535..8f0b0d9 100644 --- a/app/src/cli/commands/create/templates/cloudflare.ts +++ b/app/src/cli/commands/create/templates/cloudflare.ts @@ -4,6 +4,8 @@ import { uuid } from "core/utils"; import color from "picocolors"; import type { Template, TemplateSetupCtx } from "."; +const WRANGLER_FILE = "wrangler.json"; + export const cloudflare = { key: "cloudflare", title: "Cloudflare Basic", @@ -12,11 +14,12 @@ export const cloudflare = { path: "gh:bknd-io/bknd/examples/cloudflare-worker", ref: true, setup: async (ctx) => { - // overwrite assets directory + // overwrite assets directory & name await overrideJson( - "wrangler.json", + WRANGLER_FILE, (json) => ({ ...json, + name, assets: { directory: "node_modules/bknd/dist/static" } @@ -70,7 +73,7 @@ async function createD1(ctx: TemplateSetupCtx) { } await overrideJson( - "wrangler.json", + WRANGLER_FILE, (json) => ({ ...json, d1_databases: [ @@ -90,7 +93,7 @@ async function createD1(ctx: TemplateSetupCtx) { async function createLibsql(ctx: TemplateSetupCtx) { await overrideJson( - "wrangler.json", + WRANGLER_FILE, (json) => ({ ...json, vars: { diff --git a/app/src/cli/commands/create/templates/index.ts b/app/src/cli/commands/create/templates/index.ts index bf2a708..34b6b9f 100644 --- a/app/src/cli/commands/create/templates/index.ts +++ b/app/src/cli/commands/create/templates/index.ts @@ -3,10 +3,12 @@ import { cloudflare } from "./cloudflare"; export type TemplateSetupCtx = { template: Template; dir: string; + name: string; }; export type Integration = "node" | "bun" | "cloudflare" | "nextjs" | "remix" | "astro" | "custom"; +type TemplateScripts = "install" | "dev" | "build" | "start"; export type Template = { /** * unique key for the template @@ -23,12 +25,13 @@ export type Template = { * adds a ref "#{ref}" to the path. If "true", adds the current version of bknd */ ref?: true | string; + scripts?: Partial>; preinstall?: (ctx: TemplateSetupCtx) => Promise; postinstall?: (ctx: TemplateSetupCtx) => Promise; setup?: (ctx: TemplateSetupCtx) => Promise; }; -export const templates = [ +export const templates: Template[] = [ { key: "node", title: "Node.js Basic", @@ -45,5 +48,33 @@ export const templates = [ path: "gh:bknd-io/bknd/examples/bun", ref: true }, - cloudflare -] as const satisfies Template[]; + cloudflare, + { + key: "remix", + title: "Remix Basic", + integration: "remix", + description: "A basic bknd Remix starter", + path: "gh:bknd-io/bknd/examples/remix", + ref: true + }, + { + // @todo: add `concurrently`? + key: "nextjs", + title: "Next.js Basic", + integration: "nextjs", + description: "A basic bknd Next.js starter", + path: "gh:bknd-io/bknd/examples/nextjs", + scripts: { + install: "npm install --force" + }, + ref: true + }, + { + key: "astro", + title: "Astro Basic", + integration: "astro", + description: "A basic bknd Astro starter", + path: "gh:bknd-io/bknd/examples/astro", + ref: true + } +]; diff --git a/app/src/cli/index.ts b/app/src/cli/index.ts index d7c7ef5..d749df8 100644 --- a/app/src/cli/index.ts +++ b/app/src/cli/index.ts @@ -1,15 +1,17 @@ #!/usr/bin/env node import { Command } from "commander"; +import color from "picocolors"; import * as commands from "./commands"; import { getVersion } from "./utils/sys"; const program = new Command(); export async function main() { + const version = await getVersion(); program .name("bknd") - .description("bknd cli") - .version(await getVersion()); + .description(color.yellowBright("⚡") + " bknd cli " + color.bold(color.cyan(`v${version}`))) + .version(version); // register commands for (const command of Object.values(commands)) { diff --git a/app/src/cli/utils/cli.ts b/app/src/cli/utils/cli.ts index 7a866f1..5b0d8e7 100644 --- a/app/src/cli/utils/cli.ts +++ b/app/src/cli/utils/cli.ts @@ -1,14 +1,56 @@ -export async function wait(ms: number) { +const _SPEEDUP = process.env.LOCAL; + +const DEFAULT_WAIT = _SPEEDUP ? 0 : 250; +export async function wait(factor: number = 1, strict?: boolean) { + const ms = strict === true ? factor : DEFAULT_WAIT * factor; return new Promise((r) => setTimeout(r, ms)); } +// from https://github.com/chalk/ansi-regex/blob/main/index.js +export default function ansiRegex({ onlyFirst = false } = {}) { + // Valid string terminator sequences are BEL, ESC\, and 0x9c + const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)"; + const pattern = [ + `[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?${ST})`, + "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))" + ].join("|"); + + return new RegExp(pattern, onlyFirst ? undefined : "g"); +} + +const DEFAULT_WAIT_WRITER = _SPEEDUP ? 0 : 20; export async function* typewriter( text: string, - delay: number, - transform?: (char: string) => string + transform?: (char: string) => string, + _delay?: number ) { - for (const char of text) { - yield transform ? transform(char) : char; - await wait(delay); + const delay = DEFAULT_WAIT_WRITER * (_delay ?? 1); + const regex = ansiRegex(); + const parts: string[] = []; + let match: RegExpExecArray | null; + let lastIndex = 0; + + // Extract ANSI escape sequences as standalone units + // biome-ignore lint/suspicious/noAssignInExpressions: + while ((match = regex.exec(text)) !== null) { + if (lastIndex < match.index) { + parts.push(...text.slice(lastIndex, match.index).split("")); + } + parts.push(match[0]); // Add the ANSI escape sequence as a full chunk + lastIndex = regex.lastIndex; + } + + // Add any remaining characters after the last ANSI sequence + if (lastIndex < text.length) { + parts.push(...text.slice(lastIndex).split("")); + } + + // Yield characters or ANSI sequences in order + for (const chunk of parts) { + yield transform ? transform(chunk) : chunk; + // Delay only for normal characters, not ANSI codes + if (!regex.test(chunk)) { + await wait(delay, true); + } } } diff --git a/app/src/cli/utils/sys.ts b/app/src/cli/utils/sys.ts index 7e5fd50..4bedace 100644 --- a/app/src/cli/utils/sys.ts +++ b/app/src/cli/utils/sys.ts @@ -17,9 +17,9 @@ export function getRelativeDistPath() { return path.relative(process.cwd(), getDistPath()); } -export async function getVersion() { +export async function getVersion(_path: string = "") { try { - const resolved = path.resolve(getRootPath(), "package.json"); + const resolved = path.resolve(getRootPath(), path.join(_path, "package.json")); const pkg = await readFile(resolved, "utf-8"); if (pkg) { return JSON.parse(pkg).version ?? "preview"; diff --git a/examples/remix/package.json b/examples/remix/package.json index 9491091..af957ff 100644 --- a/examples/remix/package.json +++ b/examples/remix/package.json @@ -19,7 +19,7 @@ "isbot": "^5.1.18", "react": "^18.2.0", "react-dom": "^18.2.0", - "remix-utils": "^8.0.0" + "remix-utils": "^7.0.0" }, "devDependencies": { "@remix-run/dev": "^2.15.2",