muddle/cli.ts
2026-10-23 09:12:49 -04:00

167 lines
6 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();