alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
652 lines (588 loc) • 19.2 kB
text/typescript
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;
}
}
}