UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

652 lines (588 loc) 19.2 kB
import { basename, dirname } from "node:path"; import { $inject, AlephaError } from "alepha"; import type { RunnerMethod } from "alepha/command"; import { $logger, ConsoleColorProvider } from "alepha/logger"; import { FileSystemProvider } from "alepha/system"; import { agentMd } from "../templates/agentMd.ts"; import { alephaConfigTs } from "../templates/alephaConfigTs.ts"; import { apiHelloControllerTs } from "../templates/apiHelloControllerTs.ts"; import { apiHelloResponseSchemaTs } from "../templates/apiHelloResponseSchemaTs.ts"; import { apiIndexTs } from "../templates/apiIndexTs.ts"; import { biomeJson } from "../templates/biomeJson.ts"; import { dummySpecTs } from "../templates/dummySpecTs.ts"; import { editorconfig } from "../templates/editorconfig.ts"; import { gitignore } from "../templates/gitignore.ts"; import { logoSvg } from "../templates/logoSvg.ts"; import { mainBrowserTs } from "../templates/mainBrowserTs.ts"; import { mainCss } from "../templates/mainCss.ts"; import { mainServerTs } from "../templates/mainServerTs.ts"; import { tsconfigJson } from "../templates/tsconfigJson.ts"; import { viteConfigTs } from "../templates/viteConfigTs.ts"; import { vitestConfigTs } from "../templates/vitestConfigTs.ts"; import { vscodeSettingsJson } from "../templates/vscodeSettingsJson.ts"; import { webAppRouterTs } from "../templates/webAppRouterTs.ts"; import { webHomeComponentTsx } from "../templates/webHomeComponentTsx.ts"; import { webIndexTs } from "../templates/webIndexTs.ts"; import { AlephaCliUtils } from "./AlephaCliUtils.ts"; import { type DependencyModes, PackageManagerUtils, } from "./PackageManagerUtils.ts"; /** * Service for scaffolding new Alepha projects. * * Handles creation of: * - Project structure (src/api, src/web) * - Configuration files (tsconfig, biome, editorconfig) * - Entry points (main.server.ts, main.browser.ts) * - Example code (HelloController, Home component) */ export class ProjectScaffolder { protected readonly log = $logger(); protected readonly colors = $inject(ConsoleColorProvider); protected readonly fs = $inject(FileSystemProvider); protected readonly pm = $inject(PackageManagerUtils); protected readonly utils = $inject(AlephaCliUtils); /** * Get the app name from the directory name. * * Converts the directory name to a valid module name: * - Converts to lowercase * - Replaces spaces, dashes, underscores with nothing * - Falls back to "app" if empty */ public getAppName(root: string): string { const dirName = basename(root); const appName = dirName.toLowerCase().replace(/[\s\-_.\d]/g, ""); return appName || "app"; } /** * Ensure all configuration files exist. */ public async ensureConfig( root: string, opts: { force?: boolean; /** * Check workspace root for existing config files. */ checkWorkspace?: boolean; packageJson?: boolean | DependencyModes; tsconfigJson?: boolean; biomeJson?: boolean; editorconfig?: boolean; agentMd?: boolean; /** * Write `.vscode/settings.json` pointing the editor's TypeScript * server at the `typescript` copy embedded in `alepha`. */ vscodeSettings?: boolean; }, ): Promise<void> { const tasks: Promise<void>[] = []; const force = opts.force ?? false; const checkWorkspace = opts.checkWorkspace ?? false; if (opts.packageJson) { tasks.push( this.pm .ensurePackageJson( root, typeof opts.packageJson === "boolean" ? {} : opts.packageJson, ) .then(() => {}), ); } if (opts.tsconfigJson) { tasks.push(this.ensureTsConfig(root, { force })); } if (opts.biomeJson) { tasks.push(this.ensureBiomeConfig(root, { force, checkWorkspace })); } if (opts.editorconfig) { tasks.push(this.ensureEditorConfig(root, { force, checkWorkspace })); } if (opts.agentMd) { tasks.push(this.ensureAgentMd(root, { force })); } if (opts.vscodeSettings) { tasks.push(this.ensureVscodeSettings(root, { force, checkWorkspace })); } await Promise.all(tasks); } // =========================================== // Config Files // =========================================== public async ensureTsConfig( root: string, opts: { force?: boolean } = {}, ): Promise<void> { const exists = await this.existsInParents(root, "tsconfig.json"); if (!opts.force && exists) { return; } await this.fs.writeFile( this.fs.join(root, "tsconfig.json"), tsconfigJson(), ); } public async ensureBiomeConfig( root: string, opts: { force?: boolean; checkWorkspace?: boolean } = {}, ): Promise<void> { if ( !opts.force && opts.checkWorkspace && (await this.existsInParents(root, "biome.json")) ) { return; } await this.ensureFile(root, "biome.json", biomeJson(), opts.force); } public async ensureEditorConfig( root: string, opts: { force?: boolean; checkWorkspace?: boolean } = {}, ): Promise<void> { if ( !opts.force && opts.checkWorkspace && (await this.existsInParents(root, ".editorconfig")) ) { return; } await this.ensureFile(root, ".editorconfig", editorconfig(), opts.force); } /** * Ensure `.vscode/settings.json` exists, pointing the editor's TypeScript * language server at the `typescript` copy embedded in `alepha`. Keeps the * IDE on the same compiler version as `alepha typecheck` — see * `vscodeSettingsJson`. */ public async ensureVscodeSettings( root: string, opts: { force?: boolean; checkWorkspace?: boolean } = {}, ): Promise<void> { if ( !opts.force && opts.checkWorkspace && (await this.existsInParents(root, ".vscode")) ) { return; } const target = this.fs.join(root, ".vscode", "settings.json"); if (!opts.force && (await this.fs.exists(target))) { return; } await this.fs.mkdir(this.fs.join(root, ".vscode"), { recursive: true }); await this.fs.writeFile(target, vscodeSettingsJson()); } /** * Ensure git repository is initialized with .gitignore. * * @returns true if git was initialized, false if already exists or git unavailable */ public async ensureGitRepo( root: string, opts: { force?: boolean } = {}, ): Promise<boolean> { const gitDir = this.fs.join(root, ".git"); // Skip if .git already exists if (!opts.force && (await this.fs.exists(gitDir))) { return false; } // Check if git is available const hasGit = await this.utils.isInstalledAsync("git"); if (!hasGit) { return false; } // Initialize git repository await this.utils.exec("git init", { root, global: true }); // Write .gitignore await this.ensureFile(root, ".gitignore", gitignore(), opts.force); return true; } /** * Ensure AGENTS.md (cross-tool standard, canonical source) exists, with a * CLAUDE.md stub that imports it via Claude Code's `@` syntax. Single * source of truth, cross-platform, no symlink needed. */ public async ensureAgentMd( root: string, options: { force?: boolean } = {}, ): Promise<void> { await Promise.all([ this.ensureFile(root, "AGENTS.md", agentMd(), options.force), this.ensureFile(root, "CLAUDE.md", "@AGENTS.md\n", options.force), ]); } /** * Ensure alepha.config.ts exists with documented options. */ public async ensureAlephaConfig( root: string, opts: { force?: boolean } = {}, ): Promise<void> { await this.ensureFile( root, "alepha.config.ts", alephaConfigTs(), opts.force, ); } // =========================================== // Minimal Project Structure // =========================================== /** * Ensure src/main.server.ts exists with correct module imports. */ public async ensureMainServerTs( root: string, opts: { api?: boolean; react?: boolean; force?: boolean } = {}, ): Promise<void> { const srcDir = this.fs.join(root, "src"); await this.fs.mkdir(srcDir, { recursive: true }); await this.ensureFile( srcDir, "main.server.ts", mainServerTs({ api: opts.api, react: opts.react }), opts.force, ); } // =========================================== // API Project Structure // =========================================== /** * Ensure API module structure exists. * * Creates: * - src/api/index.ts (API module) * - src/api/controllers/HelloController.ts (example controller) */ public async ensureApiProject( root: string, opts: { force?: boolean } = {}, ): Promise<void> { const appName = this.getAppName(root); // Create directories await this.fs.mkdir(this.fs.join(root, "src/api/controllers"), { recursive: true, }); await this.fs.mkdir(this.fs.join(root, "src/api/schemas"), { recursive: true, }); // Create files await this.ensureFile( root, "src/api/index.ts", apiIndexTs({ appName }), opts.force, ); await this.ensureFile( root, "src/api/controllers/HelloController.ts", apiHelloControllerTs({ appName }), opts.force, ); await this.ensureFile( root, "src/api/schemas/helloResponseSchema.ts", apiHelloResponseSchemaTs(), opts.force, ); } // =========================================== // Web Project Structure // =========================================== /** * Ensure web/React project structure exists. * * Creates: * - src/main.browser.ts * - src/main.css * - src/web/index.ts, src/web/AppRouter.ts, src/web/components/Home.tsx */ public async ensureWebProject( root: string, opts: { api?: boolean; tailwind?: boolean; force?: boolean; } = {}, ): Promise<void> { const appName = this.getAppName(root); // Create directories await this.fs.mkdir(this.fs.join(root, "src/web/components"), { recursive: true, }); // public/favicon.svg await this.fs.mkdir(this.fs.join(root, "public"), { recursive: true }); await this.ensureFile(root, "public/favicon.svg", logoSvg, opts.force); // src/main.css await this.ensureFile( root, "src/main.css", mainCss({ tailwind: opts.tailwind }), opts.force, ); // vite.config.ts (Tailwind CSS plugin) if (opts.tailwind) { await this.ensureFile(root, "vite.config.ts", viteConfigTs(), opts.force); } // Web structure await this.ensureFile( root, "src/web/index.ts", webIndexTs({ appName }), opts.force, ); await this.ensureFile( root, "src/web/AppRouter.ts", webAppRouterTs({ api: opts.api }), opts.force, ); await this.ensureFile( root, "src/web/components/Home.tsx", webHomeComponentTsx({ api: opts.api }), opts.force, ); await this.ensureFile( root, "src/main.browser.ts", mainBrowserTs(), opts.force, ); } // =========================================== // Test Directory // =========================================== /** * Ensure test directory exists with a dummy test file + a self-contained * `vitest.config.ts`. Pinning `test.root` prevents Vitest from walking up * to a parent monorepo config (e.g. one that boots a Postgres container). */ public async ensureTestDir(root: string): Promise<void> { const testDir = this.fs.join(root, "test"); const dummyPath = this.fs.join(testDir, "dummy.spec.ts"); const vitestConfigPath = this.fs.join(root, "vitest.config.ts"); if (!(await this.fs.exists(vitestConfigPath))) { await this.fs.writeFile(vitestConfigPath, vitestConfigTs()); } if (!(await this.fs.exists(testDir))) { await this.fs.mkdir(testDir, { recursive: true }); await this.fs.writeFile(dummyPath, dummySpecTs()); return; } const files = await this.fs.ls(testDir); if (files.length === 0) { await this.fs.writeFile(dummyPath, dummySpecTs()); } } // =========================================== // Full Init Orchestration // =========================================== /** * Full project init — scaffolds files, installs deps, sets up PM and git. */ async init({ run, root, flags, args, }: { run: RunnerMethod; root: string; flags: { pm?: "yarn" | "npm" | "pnpm" | "bun"; api?: boolean; react?: boolean; tailwind?: boolean; force?: boolean; }; args?: string; }) { if (!args) { // If the current directory doesn't look like an existing project // (no package.json), default to creating a `my-app/` subdirectory // rather than scaffolding into a random cwd. const hasPackageJson = await this.fs.exists( this.fs.join(root, "package.json"), ); if (!hasPackageJson) { args = "my-app"; } } if (args) { root = this.fs.join(root, args); await this.fs.mkdir(root, { force: true }); } // Flag cascading: --tailwind → --react if (flags.tailwind) { flags.react = true; } const f = flags; // When codegen flags are set, target directory must be empty (unless --force) const hasCodegenFlags = flags.api || flags.react || flags.tailwind; if (hasCodegenFlags && !flags.force) { const files = await this.fs.ls(root); // Allow a directory that only has package.json (common for monorepo packages) const meaningful = files.filter((f) => f !== "package.json"); if (meaningful.length > 0) { throw new AlephaError( `Target directory is not empty (${root}). Use --force to overwrite existing files.`, ); } } // Detect workspace context (are we inside packages/ or apps/ of a monorepo?) const workspace = await this.pm.getWorkspaceContext(root); // Always emit both AGENTS.md and CLAUDE.md at project roots (skip for // monorepo sub-packages where agent files live at workspace root). const writeAgentMd = !workspace.isPackage; const isExpo = await this.pm.hasExpo(root); const force = !!flags.force; await run({ name: "ensuring configuration files", handler: async () => { await this.ensureConfig(root, { force, packageJson: { ...f, isPackage: workspace.isPackage }, tsconfigJson: !workspace.config.tsconfigJson, biomeJson: true, editorconfig: !workspace.config.editorconfig, agentMd: writeAgentMd, // Editor TS-server pointer at a project root only; monorepo // sub-packages inherit the workspace-root `.vscode/`. vscodeSettings: writeAgentMd, }); // Create alepha.config.ts with documented options await this.ensureAlephaConfig(root, { force }); // Create project structure based on flags await this.ensureMainServerTs(root, { api: !!flags.api, react: !!flags.react && !isExpo, force, }); if (flags.api) { await this.ensureApiProject(root, { force }); } if (flags.react && !isExpo) { await this.ensureWebProject(root, { api: !!flags.api, tailwind: !!flags.tailwind, force, }); } }, }); // Use workspace PM if detected, otherwise detect from current root const pmName = await this.pm.getPackageManager( workspace.workspaceRoot ?? root, flags.pm ?? workspace.packageManager ?? undefined, ); // Only setup PM files if not in a workspace package if (!workspace.isPackage) { if (pmName === "yarn") { await this.pm.ensureYarn(root); await run("yarn set version stable", { root }); } else if (pmName === "bun") { await this.pm.ensureBun(root); } else if (pmName === "pnpm") { await this.pm.ensurePnpm(root); } else { await this.pm.ensureNpm(root); } } // Run install from workspace root if in a package, otherwise from current root const installRoot = workspace.workspaceRoot ?? root; await run(`${pmName} install`, { alias: `installing dependencies with ${pmName}`, root: installRoot, }); // Always scaffold the test setup — Vitest ships embedded in `alepha`, so // `alepha test` works in every project. The dummy spec doubles as a // worked example for both humans and AI agents. await this.ensureTestDir(root); // Best-effort lint pass — don't block init if it fails. The user can // fix or silence issues later. try { await run(`${pmName} run lint`, { alias: "running linter", root, }); } catch (err) { this.log.warn( "Linter reported issues during init — continuing. Run `lint` again later to inspect.", { error: err instanceof Error ? err.message : String(err) }, ); } // Initialize git repository if not in a workspace package if (!workspace.isPackage) { const gitInitialized = await this.ensureGitRepo(root, { force, }); if (gitInitialized) { await run("git add .", { alias: "staging generated files", root, }); } } // Don't show success message if no path arg, e.g. just "alepha init" to re-configure current dir if (!args) { return; } // We must end the run context in order to log success message run.end(); // Success message const projectName = args || "."; const pmRun = pmName === "npm" ? "npm run" : pmName; const c = this.colors; this.log.info(""); this.log.info(` ${c.set("GREEN", "✓")} Project ready!`); this.log.info(""); this.log.info( ` ${c.set("GREY_DARK", "$")} cd ${c.set("CYAN", projectName)}`, ); this.log.info( ` ${c.set("GREY_DARK", "$")} ${c.set("CYAN", `${pmRun} dev`)}`, ); this.log.info(""); } // =========================================== // Helpers // =========================================== /** * Write a file, optionally overriding if it exists. */ protected async ensureFile( root: string, relativePath: string, content: string, force?: boolean, ): Promise<void> { const fullPath = this.fs.join(root, relativePath); if (force || !(await this.fs.exists(fullPath))) { await this.fs.writeFile(fullPath, content); } } /** * Check if a file exists in the given directory or any parent directory. */ protected async existsInParents( root: string, filename: string, ): Promise<boolean> { let current = root; while (true) { if (await this.fs.exists(this.fs.join(current, filename))) { return true; } const parent = dirname(current); if (parent === current) { // Reached filesystem root return false; } current = parent; } } }