init create cli, added node and partially cloudflare

This commit is contained in:
dswbx
2025-02-13 08:51:48 +01:00
parent d84731d89e
commit 8e72b5b615
15 changed files with 544 additions and 4 deletions

1
.gitignore vendored
View File

@@ -19,6 +19,7 @@ packages/media/.env
.history .history
**/*/.db/* **/*/.db/*
**/*/.configs/* **/*/.configs/*
**/*/.template/*
**/*/*.db **/*/*.db
**/*/*.db-shm **/*/*.db-shm
**/*/*.db-wal **/*/*.db-wal

View File

@@ -60,6 +60,7 @@
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.613.0", "@aws-sdk/client-s3": "^3.613.0",
"@bluwy/giget-core": "^0.1.2",
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@hono/typebox-validator": "^0.2.6", "@hono/typebox-validator": "^0.2.6",
"@hono/vite-dev-server": "^0.17.0", "@hono/vite-dev-server": "^0.17.0",

View File

@@ -0,0 +1,251 @@
import fs from "node:fs";
import { downloadTemplate } from "@bluwy/giget-core";
import * as $p from "@clack/prompts";
import type { CliCommand } from "cli/types";
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 { type Template, templates } from "./templates";
const config = {
types: {
runtime: "Runtime",
framework: "Framework"
},
runtime: {
node: "Node.js",
bun: "Bun",
cloudflare: "Cloudflare"
},
framework: {
nextjs: "Next.js",
remix: "Remix",
astro: "Astro"
}
} as const;
export const create: CliCommand = (program) => {
program
.command("create")
.addOption(new Option("-i, --integration <integration>", "integration to use"))
.addOption(new Option("-t, --template <template>", "template to use"))
.addOption(new Option("-d --dir <directory>", "directory to create in"))
.description("create a new project")
.action(action);
};
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
}
};
const downloadOpts = {
dir: options.dir || _config.defaultDir,
clean: false
};
const version = await getVersion();
$p.intro(
`👋 Welcome to the ${color.bold(color.cyan("bknd"))} create wizard ${color.bold(`v${version}`)}`
);
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);
})()
);
if (!options.dir) {
const dir = await $p.text({
message: "Where to create your project?",
placeholder: downloadOpts.dir,
initialValue: downloadOpts.dir
});
if ($p.isCancel(dir)) {
process.exit(1);
}
downloadOpts.dir = dir || "./";
}
if (fs.existsSync(downloadOpts.dir)) {
const clean = await $p.confirm({
message: `Directory ${color.cyan(downloadOpts.dir)} exists. Clean it?`,
initialValue: false
});
if ($p.isCancel(clean)) {
process.exit(1);
}
downloadOpts.clean = clean;
}
let template: Template | undefined;
if (options.template) {
template = templates.find((t) => t.key === options.template) as Template;
if (!template) {
$p.log.error(`Template ${color.cyan(options.template)} not found`);
process.exit(1);
}
} else {
let integration: string | undefined = options.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);
})()
);
const type = await $p.select({
message: "Pick an integration type",
options: Object.entries(config.types).map(([value, name]) => ({
value,
label: name,
hint: Object.values(config[value]).join(", ")
}))
});
if ($p.isCancel(type)) {
process.exit(1);
}
const _integration = await $p.select({
message: `Which ${color.cyan(config.types[type])} do you want to continue with?`,
options: Object.entries(config[type]).map(([value, name]) => ({
value,
label: name
})) as any
});
if ($p.isCancel(_integration)) {
process.exit(1);
}
integration = String(_integration);
}
if (!integration) {
$p.log.error("No integration selected");
process.exit(1);
}
//console.log("integration", { type, integration });
const choices = templates.filter((t) => t.integration === integration);
if (choices.length === 0) {
$p.log.error(`No templates found for "${color.cyan(String(integration))}"`);
process.exit(1);
} else if (choices.length > 1) {
const selected_template = await $p.select({
message: "Pick a template",
options: choices.map((t) => ({ value: t.key, label: t.title, hint: t.description }))
});
if ($p.isCancel(selected_template)) {
process.exit(1);
}
template = choices.find((t) => t.key === selected_template) as Template;
} else {
template = choices[0];
}
}
if (!template) {
$p.log.error("No template selected");
process.exit(1);
}
const ctx = { template, dir: downloadOpts.dir };
{
const prefix =
template.ref === true
? `#v${version}`
: typeof template.ref === "string"
? `#${template.ref}`
: "";
const url = `${template.path}${prefix}`;
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);
s.stop("Template downloaded.");
await updateBkndPackages({ dir: ctx.dir });
if (template.preinstall) {
await template.preinstall(ctx);
}
}
{
const install = await $p.confirm({
message: "Install dependencies?"
});
if ($p.isCancel(install)) {
process.exit(1);
} else if (install) {
const s = $p.spinner();
s.start("Installing dependencies...");
exec(`cd ${ctx.dir} && npm install`, { silent: true });
s.stop("Dependencies installed.");
if (template!.postinstall) {
await template.postinstall(ctx);
}
} 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);
})()
);
}
}
if (template.setup) {
await template.setup(ctx);
}
await $p.stream.success(
(async function* () {
yield* typewriter("That's it! ", _config.speed.typewriter);
await wait(_config.speed.wait / 2);
yield "🎉";
await wait(_config.speed.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
);
await wait(_config.speed.wait * 2);
})()
);
$p.outro(color.green("Setup complete."));
}

View File

@@ -0,0 +1 @@
export * from "./create";

View File

@@ -0,0 +1,75 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
export type TPackageJson = Partial<{
name: string;
main: string;
version: string;
scripts: Record<string, string>;
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
optionalDependencies: Record<string, string>;
[key: string]: any;
}>;
export async function overrideJson<File extends object = object>(
file: string,
fn: (pkg: File) => Promise<File> | File,
opts?: { dir?: string; indent?: number }
) {
const pkgPath = path.resolve(opts?.dir ?? process.cwd(), file);
const pkg = await readFile(pkgPath, "utf-8");
const newPkg = await fn(JSON.parse(pkg));
await writeFile(pkgPath, JSON.stringify(newPkg, null, opts?.indent || 2));
}
export async function overridePackageJson(
fn: (pkg: TPackageJson) => Promise<TPackageJson> | TPackageJson,
opts?: { dir?: string }
) {
return await overrideJson("package.json", fn, { dir: opts?.dir });
}
export async function getPackageInfo(pkg: string, version?: string): Promise<TPackageJson> {
const res = await fetch(`https://registry.npmjs.org/${pkg}${version ? `/${version}` : ""}`);
return await res.json();
}
export async function getVersion(pkg: string, version: string = "latest") {
const info = await getPackageInfo(pkg, version);
return info.version;
}
const _deps = ["dependencies", "devDependencies", "optionalDependencies"] as const;
export async function replacePackageJsonVersions(
fn: (pkg: string, version: string) => Promise<string | undefined> | string | undefined,
opts?: { include?: (keyof typeof _deps)[]; dir?: string }
) {
const deps = (opts?.include ?? _deps) as string[];
await overridePackageJson(
async (json) => {
for (const dep of deps) {
if (dep in json) {
for (const [pkg, version] of Object.entries(json[dep])) {
const newVersion = await fn(pkg, version as string);
if (newVersion) {
json[dep][pkg] = newVersion;
}
}
}
}
return json;
},
{ dir: opts?.dir }
);
}
export async function updateBkndPackages(opts?: { dir?: string }) {
await replacePackageJsonVersions(async (pkg) => {
if (pkg === "bknd") {
return "^" + (await getVersion(pkg));
}
return;
}, opts);
}

View File

@@ -0,0 +1,106 @@
import * as $p from "@clack/prompts";
import { overrideJson, overridePackageJson } from "cli/commands/create/npm";
import { uuid } from "core/utils";
import color from "picocolors";
import type { Template, TemplateSetupCtx } from ".";
export const cloudflare = {
key: "cloudflare",
title: "Cloudflare Basic",
integration: "cloudflare",
description: "A basic bknd Cloudflare worker",
path: "gh:bknd-io/bknd/examples/cloudflare-worker",
ref: true,
setup: async (ctx) => {
const db = await $p.select({
message: "What database do you want to use?",
options: [
{ label: "Cloudflare D1", value: "d1" },
{ label: "LibSQL", value: "libsql" }
]
});
if ($p.isCancel(db)) {
process.exit(1);
}
try {
switch (db) {
case "d1":
await createD1(ctx);
break;
case "libsql":
await createLibsql(ctx);
break;
default:
throw new Error("Invalid database");
}
} catch (e) {
const message = (e as any).message || "An error occurred";
$p.log.warn(
"Couldn't add database. You can add it manually later. Error: " + color.red(message)
);
}
}
} as const satisfies Template;
async function createD1(ctx: TemplateSetupCtx) {
const name = await $p.text({
message: "Enter database name",
validate: (v) => {
if (!v) {
return "Invalid name";
}
return;
}
});
if ($p.isCancel(name)) {
process.exit(1);
}
await overrideJson(
"wrangler.json",
(json) => ({
...json,
d1_databases: [
{
binding: "DB",
database_name: name,
database_id: uuid()
}
]
}),
{ dir: ctx.dir }
);
$p.log.info(
"Database created. Note that if you deploy, you have to create a real database using `npx wrangler d1 create <name>` and update your wrangler configuration."
);
}
async function createLibsql(ctx: TemplateSetupCtx) {
await overrideJson(
"wrangler.json",
(json) => ({
...json,
vars: {
DB_URL: "http://127.0.0.1:8080"
}
}),
{ dir: ctx.dir }
);
await overridePackageJson((pkg) => ({
...pkg,
scripts: {
...pkg.scripts,
db: "turso db",
dev: "npm run db && wrangler dev"
}
}));
$p.log.info(
"Database set to LibSQL. You can now run `npm run db` to start the database and `npm run dev` to start the worker."
);
$p.log.info(
`Make sure you have Turso's CLI installed. Check their docs on how to install at ${color.cyan("https://docs.turso.tech/cli/introduction")}`
);
}

View File

@@ -0,0 +1,49 @@
import { cloudflare } from "./cloudflare";
export type TemplateSetupCtx = {
template: Template;
dir: string;
};
export type Integration = "node" | "bun" | "cloudflare" | "nextjs" | "remix" | "astro" | "custom";
export type Template = {
/**
* unique key for the template
*/
key: string;
/**
* the integration this template is for
*/
integration: Integration;
title: string;
description?: string;
path: string;
/**
* adds a ref "#{ref}" to the path. If "true", adds the current version of bknd
*/
ref?: true | string;
preinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
postinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
setup?: (ctx: TemplateSetupCtx) => Promise<void>;
};
export const templates = [
{
key: "node",
title: "Node.js Basic",
integration: "node",
description: "A basic bknd Node.js server",
path: "gh:bknd-io/bknd/examples/node",
ref: true
},
{
key: "bun",
title: "Bun Basic",
integration: "bun",
description: "A basic bknd Bun server",
path: "gh:bknd-io/bknd/examples/bun",
ref: true
},
cloudflare
] as const satisfies Template[];

View File

@@ -3,3 +3,4 @@ export { schema } from "./schema";
export { run } from "./run"; export { run } from "./run";
export { debug } from "./debug"; export { debug } from "./debug";
export { user } from "./user"; export { user } from "./user";
export { create } from "./create";

14
app/src/cli/utils/cli.ts Normal file
View File

@@ -0,0 +1,14 @@
export async function wait(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
export async function* typewriter(
text: string,
delay: number,
transform?: (char: string) => string
) {
for (const char of text) {
yield transform ? transform(char) : char;
await wait(delay);
}
}

View File

@@ -1,3 +1,4 @@
import { execSync } from "node:child_process";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import url from "node:url"; import url from "node:url";
@@ -38,3 +39,15 @@ export async function fileExists(filePath: string) {
return false; return false;
} }
} }
export function exec(command: string, opts?: { silent?: boolean; env?: Record<string, string> }) {
const stdio = opts?.silent ? "pipe" : "inherit";
const output = execSync(command, {
stdio: ["inherit", stdio, stdio],
env: { ...process.env, ...opts?.env }
});
if (!opts?.silent) {
return;
}
return output.toString();
}

BIN
bun.lockb

Binary file not shown.

View File

@@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"deploy": "wrangler deploy", "deploy": "wrangler deploy",
"db": "turso dev --db-file test.db", "db": "turso dev",
"dev": "wrangler dev", "dev": "wrangler dev",
"start": "wrangler dev", "start": "wrangler dev",
"test": "vitest", "test": "vitest",

View File

@@ -0,0 +1,28 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "bknd-cf-worker-example",
"main": "src/index.ts",
"compatibility_date": "2025-02-04",
"compatibility_flags": ["nodejs_compat"],
"workers_dev": true,
"minify": true,
"assets": {
"directory": "../../app/dist/static"
},
"observability": {
"enabled": true
},
"d1_databases": [
{
"binding": "DB",
"database_name": "bknd-cf-example",
"database_id": "7ad67953-2bbf-47fc-8696-f4517dbfe674"
}
],
"r2_buckets": [
{
"binding": "BUCKET",
"bucket_name": "bknd-cf-example"
}
]
}

View File

@@ -19,7 +19,7 @@
"dependencies": {}, "dependencies": {},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
"@clack/prompts": "^0.7.0", "@clack/prompts": "^0.10.0",
"@cloudflare/workers-types": "^4.20240620.0", "@cloudflare/workers-types": "^4.20240620.0",
"@tsconfig/strictest": "^2.0.5", "@tsconfig/strictest": "^2.0.5",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
@@ -36,7 +36,7 @@
"tsup": "^8.1.0", "tsup": "^8.1.0",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"verdaccio": "^5.32.1", "verdaccio": "^5.32.1",
"wrangler": "^3.71.0" "wrangler": "^3.108.1"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"