muddle/cli.ts
2026-10-23 08:53:07 -04:00

123 lines
5.8 KiB
TypeScript
Executable file

#!/usr/bin/env bun
import { existsSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, relative, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { generate, collectTables } from "./contracts/typegen/generator.ts";
import { format } from "./core/format.ts";
import { loadConfig } from "./core/config.ts";
import { setupWrangler } from "./adapters/cloudflare-wrangler.ts";
import { setupVercel } from "./adapters/vercel-vercel.ts";
import { backportProject } from "./core/backport.ts";
import { createTemplate, muddleTemplates, type MuddleTemplate } from "./core/templates.ts";
function progress(event: { current: number; total: number; route: string }): void {
const width = 20;
const filled = Math.round((event.current / Math.max(1, event.total)) * width);
console.log(`[${"#".repeat(filled)}${".".repeat(width - filled)}] ${event.current}/${event.total} ${event.route}`);
}
async function dev(entry: string, root: string, port: number): Promise<void> {
const moduleUrl = pathToFileURL(entry).href;
const source = `const project = await import(${JSON.stringify(moduleUrl)}); if (!project.app || typeof project.app.fetch !== "function") throw new Error("Project entry must export an app with fetch(request)"); const server = Bun.serve({ port: ${port}, fetch: (request) => project.app.fetch(request) }); console.log(\`muddle dev listening on \${server.url}\`); await new Promise(() => {});`;
const child = Bun.spawn([process.execPath, "--watch", "--eval", source], {
cwd: root,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
});
const stop = () => child.kill();
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
await child.exited;
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
const command = args[0] ?? "help";
const portFlag = args.indexOf("--port");
const requestedPort = portFlag === -1 ? undefined : Number(args[portFlag + 1]);
const root = args.slice(1).find((argument, index, values) => {
if (argument.startsWith("--")) return false;
return values[index - 1] !== "--port";
}) ?? process.cwd();
if (requestedPort !== undefined && (!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65_535)) {
throw new Error("--port must be an integer from 1 through 65535");
}
if (command === "typegen") {
const result = await generate({ root });
for (const file of result.written) console.log(`muddle generated ${relative(process.cwd(), file)}`);
return;
}
if (command === "backport") {
const source = args[1] ? resolve(args[1]) : process.cwd();
const destination = args[2] ? resolve(args[2]) : resolve(source, "muddle");
const result = await backportProject(source, destination);
console.log(`muddle backported ${result.source} project to ${result.output} (${result.files.length} files)`);
return;
}
if (command === "templates") {
const name = args[1] as MuddleTemplate | undefined;
if (!name) {
console.log(`Available templates: ${muddleTemplates.join(", ")}`);
console.log("Usage: muddle templates <docs|minimal> [destination]");
return;
}
if (!muddleTemplates.includes(name)) throw new Error(`Unknown template ${name}. Choose one of: ${muddleTemplates.join(", ")}`);
const result = await createTemplate(name, args[2] ? resolve(args[2]) : resolve(process.cwd(), name));
console.log(`muddle created ${result.name} template at ${result.output} (${result.files.length} files)`);
return;
}
if (command === "repo") {
const target = resolve(root);
const tables = await collectTables(target, ["**/*.table.ts"]);
const out = join(target, "repositories");
await mkdir(out, { recursive: true });
for (const table of tables) {
const rowType = table.fields
.map((field) => {
const tsType = field.type;
const optional = field.nullable ? "?" : "";
return `\t${field.property}${optional}: ${tsType};`;
})
.join("\n");
const typeName = `${table.name[0]?.toUpperCase() ?? ""}${table.name.slice(1)}Row`;
const source = `import { Repository } from "@muddle/database";\n\nexport type ${typeName} = {\n${rowType}\n};\n\nexport class ${table.name[0]?.toUpperCase() ?? ""}${table.name.slice(1)}Repository extends Repository<${typeName}> {}\n`;
const file = join(out, `${table.name}.repository.ts`);
await writeFile(file, source);
console.log(`muddle generated ${relative(target, file)}`);
}
return;
}
if (command === "fmt") {
const check = process.argv.includes("--check");
const changed = await format(resolve(root === "--check" ? process.cwd() : root), { check });
for (const file of changed) console.log(`${check ? "Unformatted" : "Formatted"} ${file}`);
if (check && changed.length) process.exitCode = 1;
return;
}
if (command === "build" || command === "dev") {
const target = resolve(root);
const config = await loadConfig(target);
const entry = resolve(target, config.entry ?? "index.ts");
if (!existsSync(entry)) throw new Error(`No index.ts found in ${root}`);
if (config.adapter?.name === "cloudflare-wrangler") await setupWrangler(target);
if (config.adapter?.name === "vercel-vercel") await setupVercel(target);
const project = await import(entry) as { build?: (options?: { progress?: typeof progress }) => Promise<void>; app?: unknown };
if (command === "build") {
if (!project.build) throw new Error("Project must export build() for muddle build");
await project.build({ progress });
return;
} else if (command === "dev") {
if (!project.app || typeof (project.app as { fetch?: unknown }).fetch !== "function") {
throw new Error("Project entry must export an app with fetch(request) for muddle dev");
}
await dev(entry, target, requestedPort ?? config.adapter?.port ?? 3000);
return;
}
return;
}
console.log("muddle <typegen|repo|fmt|build|dev|backport|templates> [project-root] [--port number]");
}
await main();