alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
457 lines (398 loc) • 13.6 kB
text/typescript
import { basename } from "node:path";
import { $inject, Alepha } from "alepha";
import type { RunnerMethod } from "alepha/command";
import { $logger } from "alepha/logger";
import { FileSystemProvider } from "alepha/system";
import { alephaPackageJson, version } from "../alephaPackageJson.ts";
/**
* Context information about a workspace root.
* Used when initializing a package inside a monorepo.
*/
export interface WorkspaceContext {
/**
* Whether we're inside a workspace package.
*/
isPackage: boolean;
/**
* The workspace root directory (e.g., ../.. from packages/my-pkg).
*/
workspaceRoot: string | null;
/**
* Package manager detected at workspace root.
*/
packageManager: "yarn" | "pnpm" | "npm" | "bun" | null;
/**
* Config files present at workspace root.
*/
config: {
biomeJson: boolean;
editorconfig: boolean;
tsconfigJson: boolean;
};
}
/**
* Utility service for package manager operations.
*
* Handles detection, installation, and cleanup for:
* - Yarn
* - npm
* - pnpm
* - Bun
*/
export class PackageManagerUtils {
protected readonly log = $logger();
protected readonly fs = $inject(FileSystemProvider);
protected readonly alepha = $inject(Alepha);
/**
* Detect the package manager used in the project.
* Checks current directory first, then workspace root if in a monorepo.
*/
public async getPackageManager(
root: string,
pm?: "yarn" | "pnpm" | "npm" | "bun",
): Promise<"yarn" | "pnpm" | "npm" | "bun"> {
if (pm) return pm;
if (this.alepha.isBun()) return "bun";
// Check current directory first
if (await this.fs.exists(this.fs.join(root, "bun.lock"))) return "bun";
if (await this.fs.exists(this.fs.join(root, "yarn.lock"))) return "yarn";
if (await this.fs.exists(this.fs.join(root, "pnpm-lock.yaml")))
return "pnpm";
if (await this.fs.exists(this.fs.join(root, "package-lock.json")))
return "npm";
// Check workspace root (for monorepo packages like apps/blog)
const workspace = await this.getWorkspaceContext(root);
if (workspace.packageManager) {
return workspace.packageManager;
}
return "npm";
}
/**
* Detect workspace context when inside a monorepo package.
*
* Checks if we're inside a workspace package by walking up to 3 levels
* for workspace indicators like lockfiles and config files.
* This covers both standard layouts (packages/my-pkg) and deeper nesting
* (packages/scope/my-pkg).
*
* @param root - The current package directory
* @returns Workspace context with root path, PM, and config presence
*/
public async getWorkspaceContext(root: string): Promise<WorkspaceContext> {
const noContext: WorkspaceContext = {
isPackage: false,
workspaceRoot: null,
packageManager: null,
config: { biomeJson: false, editorconfig: false, tsconfigJson: false },
};
// Walk up 2–3 levels (covers packages/pkg and packages/scope/pkg)
for (let depth = 2; depth <= 3; depth++) {
const segments = Array.from({ length: depth }, () => "..");
const candidate = this.fs.join(root, ...segments);
// Don't check above filesystem root
if (candidate === root) break;
const result = await this.checkWorkspaceRoot(candidate);
if (result) return result;
}
return noContext;
}
protected async checkWorkspaceRoot(
candidate: string,
): Promise<WorkspaceContext | null> {
const [hasYarnLock, hasPnpmLock, hasNpmLock, hasBunLock] =
await Promise.all([
this.fs.exists(this.fs.join(candidate, "yarn.lock")),
this.fs.exists(this.fs.join(candidate, "pnpm-lock.yaml")),
this.fs.exists(this.fs.join(candidate, "package-lock.json")),
this.fs.exists(this.fs.join(candidate, "bun.lock")),
]);
const hasLockfile = hasYarnLock || hasPnpmLock || hasNpmLock || hasBunLock;
if (!hasLockfile) return null;
const [hasBiome, hasEditorConfig, hasTsConfig, hasPackageJson] =
await Promise.all([
this.fs.exists(this.fs.join(candidate, "biome.json")),
this.fs.exists(this.fs.join(candidate, ".editorconfig")),
this.fs.exists(this.fs.join(candidate, "tsconfig.json")),
this.fs.exists(this.fs.join(candidate, "package.json")),
]);
if (!hasPackageJson) return null;
let packageManager: "yarn" | "pnpm" | "npm" | "bun" | null = null;
if (hasYarnLock) packageManager = "yarn";
else if (hasPnpmLock) packageManager = "pnpm";
else if (hasBunLock) packageManager = "bun";
else if (hasNpmLock) packageManager = "npm";
return {
isPackage: true,
workspaceRoot: candidate,
packageManager,
config: {
biomeJson: hasBiome,
editorconfig: hasEditorConfig,
tsconfigJson: hasTsConfig,
},
};
}
/**
* Get the install command for a package.
*/
public async getInstallCommand(
root: string,
packageName: string,
dev = true,
): Promise<string> {
const pm = await this.getPackageManager(root);
let cmd: string;
switch (pm) {
case "yarn":
cmd = `yarn add ${dev ? "-D" : ""} ${packageName}`;
break;
case "pnpm":
cmd = `pnpm add ${dev ? "-D" : ""} ${packageName}`;
break;
case "bun":
cmd = `bun add ${dev ? "-d" : ""} ${packageName}`;
break;
default:
cmd = `npm install ${dev ? "--save-dev" : ""} ${packageName}`;
}
return cmd.replace(/\s+/g, " ").trim();
}
/**
* Check if a dependency is installed in the project.
*/
public async hasDependency(
root: string,
packageName: string,
): Promise<boolean> {
try {
const pkg = await this.readPackageJson(root);
return !!(
pkg.dependencies?.[packageName] || pkg.devDependencies?.[packageName]
);
} catch {
return false;
}
}
/**
* Check if Expo is present in the project.
*/
public async hasExpo(root: string): Promise<boolean> {
return this.hasDependency(root, "expo");
}
/**
* Check if React is present in the project.
*/
public async hasReact(root: string): Promise<boolean> {
return this.hasDependency(root, "react");
}
/**
* Install a dependency if it's missing from the project.
* Optionally checks workspace root for the dependency in monorepo setups.
*/
public async ensureDependency(
root: string,
packageName: string,
options: {
dev?: boolean;
/**
* Also check workspace root for the dependency (for monorepo setups).
*/
checkWorkspace?: boolean;
run?: RunnerMethod;
exec?: (
cmd: string,
opts?: { global?: boolean; root?: string },
) => Promise<void>;
} = {},
): Promise<void> {
const { dev = true, checkWorkspace = false } = options;
// Check current package
if (await this.hasDependency(root, packageName)) {
this.log.debug(`Dependency '${packageName}' is already installed`);
return;
}
// Check workspace root (for monorepo setups)
if (checkWorkspace) {
const workspace = await this.getWorkspaceContext(root);
if (workspace.workspaceRoot) {
if (await this.hasDependency(workspace.workspaceRoot, packageName)) {
this.log.debug(
`Dependency '${packageName}' is already installed in workspace root`,
);
return;
}
}
}
const cmd = await this.getInstallCommand(root, packageName, dev);
if (options.run) {
await options.run(cmd, { alias: `add ${packageName}`, root });
} else if (options.exec) {
this.log.debug(`Installing ${packageName}`);
await options.exec(cmd, { global: true, root });
}
}
// ===========================================
// Package Manager Setup & Cleanup
// ===========================================
public async ensureYarn(root: string): Promise<void> {
const yarnrcPath = this.fs.join(root, ".yarnrc.yml");
if (!(await this.fs.exists(yarnrcPath))) {
await this.fs.writeFile(yarnrcPath, "nodeLinker: node-modules");
}
await this.removeAllPmFilesExcept(root, "yarn");
}
public async ensureBun(root: string): Promise<void> {
await this.removeAllPmFilesExcept(root, "bun");
}
public async ensurePnpm(root: string): Promise<void> {
await this.removeAllPmFilesExcept(root, "pnpm");
}
public async ensureNpm(root: string): Promise<void> {
await this.removeAllPmFilesExcept(root, "npm");
}
public async removeAllPmFilesExcept(
root: string,
except: string,
): Promise<void> {
if (except !== "yarn") await this.removeYarn(root);
if (except !== "pnpm") await this.removePnpm(root);
if (except !== "npm") await this.removeNpm(root);
if (except !== "bun") await this.removeBun(root);
}
public async removeYarn(root: string): Promise<void> {
await this.removeFiles(root, [".yarn", ".yarnrc.yml", "yarn.lock"]);
await this.editPackageJson(root, (pkg) => {
pkg.packageManager = undefined;
return pkg;
});
}
public async removePnpm(root: string): Promise<void> {
await this.removeFiles(root, ["pnpm-lock.yaml", "pnpm-workspace.yaml"]);
await this.editPackageJson(root, (pkg) => {
pkg.packageManager = undefined;
return pkg;
});
}
public async removeNpm(root: string): Promise<void> {
await this.removeFiles(root, ["package-lock.json"]);
}
public async removeBun(root: string): Promise<void> {
await this.removeFiles(root, ["bun.lockb", "bun.lock"]);
}
// ===========================================
// Package.json utilities
// ===========================================
public async readPackageJson(root: string): Promise<Record<string, any>> {
const content = await this.fs
.createFile({ path: this.fs.join(root, "package.json") })
.text();
return JSON.parse(content);
}
public async writePackageJson(
root: string,
content: Record<string, any>,
): Promise<void> {
await this.fs.writeFile(
this.fs.join(root, "package.json"),
JSON.stringify(content, null, 2),
);
}
public async editPackageJson(
root: string,
editFn: (pkg: Record<string, any>) => Record<string, any>,
): Promise<void> {
try {
const pkg = await this.readPackageJson(root);
const updated = editFn(pkg);
await this.writePackageJson(root, updated);
} catch {
// package.json doesn't exist, skip
}
}
public async ensurePackageJson(
root: string,
modes: DependencyModes,
): Promise<Record<string, any>> {
const packageJsonPath = this.fs.join(root, "package.json");
if (!(await this.fs.exists(packageJsonPath))) {
const dirName = basename(root) || "app";
const content = {
name: dirName,
private: true,
...this.generatePackageJsonContent(modes),
};
await this.writePackageJson(root, content);
return content;
}
const packageJson = await this.readPackageJson(root);
const newContent = this.generatePackageJsonContent(modes);
packageJson.type = "module";
packageJson.dependencies ??= {};
packageJson.devDependencies ??= {};
packageJson.scripts ??= {};
Object.assign(packageJson.dependencies, newContent.dependencies);
Object.assign(packageJson.devDependencies, newContent.devDependencies);
Object.assign(packageJson.scripts, newContent.scripts);
await this.writePackageJson(root, packageJson);
return packageJson;
}
public generatePackageJsonContent(modes: DependencyModes): {
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
scripts: Record<string, string>;
type: "module";
} {
const alephaDeps = alephaPackageJson.devDependencies;
const dependencies: Record<string, string> = {
alepha: `^${version}`,
};
// The toolchain (typescript, vite, vitest, biome, drizzle-kit) is NOT
// pinned here — it ships embedded as `dependencies` of `alepha`, so the
// `alepha` CLI resolves and runs it from its own install. The project
// never declares those versions; upgrading `alepha` moves the whole
// toolchain atomically. See `AlephaCliUtils.resolveBin`.
const devDependencies: Record<string, string> = {};
const scripts: Record<string, string> = {
dev: "alepha dev",
build: "alepha build",
test: "alepha test",
lint: "alepha lint",
typecheck: "alepha typecheck",
verify: "alepha verify",
};
if (modes.tailwind) {
devDependencies.tailwindcss = alephaDeps.tailwindcss;
devDependencies["@tailwindcss/vite"] = alephaDeps["@tailwindcss/vite"];
}
if (modes.react) {
dependencies.react = alephaDeps.react;
dependencies["react-dom"] = alephaDeps["react-dom"];
devDependencies["@types/react"] = alephaDeps["@types/react"];
}
return {
type: "module",
dependencies,
devDependencies,
scripts,
};
}
// ===========================================
// Helper methods
// ===========================================
protected async removeFiles(root: string, files: string[]): Promise<void> {
await Promise.all(
files.map((file) =>
this.fs.rm(this.fs.join(root, file), { force: true, recursive: true }),
),
);
}
}
export interface DependencyModes {
react?: boolean;
expo?: boolean;
tailwind?: boolean;
/**
* Whether the project is a workspace package inside a monorepo.
*/
isPackage?: boolean;
}