diff --git a/.changeset/auto-install-build-deps.md b/.changeset/auto-install-build-deps.md new file mode 100644 index 0000000..bdeedcb --- /dev/null +++ b/.changeset/auto-install-build-deps.md @@ -0,0 +1,5 @@ +--- +"playground-cli": minor +--- + +`dot build` (and the build phase of `dot deploy`) now auto-installs the project's dependencies when `node_modules/` is missing. The package manager is inferred from the lockfile (`pnpm`/`yarn`/`bun`), falling back to `npm`. Previously, an uninstalled project fell through to `npx build`, which ephemerally downloaded the framework binary but then failed with a confusing `ERR_MODULE_NOT_FOUND` while loading the project's own config file (e.g. `vite.config.ts` importing `vite`). diff --git a/src/utils/build/detect.test.ts b/src/utils/build/detect.test.ts index b105c56..75f1858 100644 --- a/src/utils/build/detect.test.ts +++ b/src/utils/build/detect.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { detectBuildConfig, + detectInstallConfig, detectPackageManager, BuildDetectError, type DetectInput, @@ -11,6 +12,7 @@ function input(overrides: Partial = {}): DetectInput { packageJson: null, lockfiles: new Set(), configFiles: new Set(), + hasNodeModules: true, ...overrides, }; } @@ -126,3 +128,76 @@ describe("detectBuildConfig", () => { ).toThrow(BuildDetectError); }); }); + +describe("detectInstallConfig", () => { + it("returns null when node_modules is already present", () => { + expect( + detectInstallConfig( + input({ + packageJson: { dependencies: { vite: "^5.0.0" } }, + hasNodeModules: true, + }), + ), + ).toBeNull(); + }); + + it("returns null when package.json is missing", () => { + expect(detectInstallConfig(input({ hasNodeModules: false }))).toBeNull(); + }); + + it("returns null when the project declares no dependencies", () => { + // A package.json with scripts but no deps has nothing to install; we + // shouldn't pointlessly spawn `npm install`. + expect( + detectInstallConfig( + input({ + packageJson: { scripts: { build: "echo hi" } }, + hasNodeModules: false, + }), + ), + ).toBeNull(); + }); + + it("returns the npm install command when no lockfile is present", () => { + expect( + detectInstallConfig( + input({ + packageJson: { devDependencies: { vite: "^7.0.0" } }, + hasNodeModules: false, + }), + ), + ).toEqual({ cmd: "npm", args: ["install"], description: "npm install" }); + }); + + it("picks the install command matching the detected lockfile", () => { + expect( + detectInstallConfig( + input({ + packageJson: { dependencies: { react: "^19.0.0" } }, + lockfiles: new Set(["pnpm-lock.yaml"]), + hasNodeModules: false, + }), + ), + ).toEqual({ cmd: "pnpm", args: ["install"], description: "pnpm install" }); + + expect( + detectInstallConfig( + input({ + packageJson: { dependencies: { react: "^19.0.0" } }, + lockfiles: new Set(["bun.lockb"]), + hasNodeModules: false, + }), + ), + ).toEqual({ cmd: "bun", args: ["install"], description: "bun install" }); + + expect( + detectInstallConfig( + input({ + packageJson: { dependencies: { react: "^19.0.0" } }, + lockfiles: new Set(["yarn.lock"]), + hasNodeModules: false, + }), + ), + ).toEqual({ cmd: "yarn", args: ["install"], description: "yarn install" }); + }); +}); diff --git a/src/utils/build/detect.ts b/src/utils/build/detect.ts index 273e4fd..4674187 100644 --- a/src/utils/build/detect.ts +++ b/src/utils/build/detect.ts @@ -27,6 +27,14 @@ export interface BuildConfig { defaultOutputDir: string; } +export interface InstallConfig { + /** Binary + args to spawn. */ + cmd: string; + args: string[]; + /** Human-readable description ("npm install", "pnpm install", …). */ + description: string; +} + export interface DetectInput { /** Parsed package.json contents (object after JSON.parse), or null if missing. */ packageJson: { @@ -38,6 +46,8 @@ export interface DetectInput { lockfiles: Set; /** Set of additional config-file basenames (e.g. vite.config.ts). */ configFiles: Set; + /** Whether a node_modules/ directory exists at the project root. */ + hasNodeModules: boolean; } export class BuildDetectError extends Error { @@ -110,6 +120,34 @@ const PM_EXEC: Record = { npm: ["npx"], }; +const PM_INSTALL: Record = { + pnpm: { cmd: "pnpm", args: ["install"], description: "pnpm install" }, + yarn: { cmd: "yarn", args: ["install"], description: "yarn install" }, + bun: { cmd: "bun", args: ["install"], description: "bun install" }, + npm: { cmd: "npm", args: ["install"], description: "npm install" }, +}; + +/** + * Decide whether we need to run an install step before building. Returns the + * install command when the project has dependencies declared but no + * node_modules/ directory, otherwise null. + * + * Rationale: without this check, `dot build` for an uninstalled project falls + * through to `npx vite build` (or similar), which ephemerally downloads the + * framework binary but can't resolve the project's own `vite.config.ts` + * imports — yielding a confusing ERR_MODULE_NOT_FOUND deep in the config + * loader. Auto-installing first eliminates the footgun. + */ +export function detectInstallConfig(input: DetectInput): InstallConfig | null { + if (input.hasNodeModules) return null; + const pkg = input.packageJson; + if (!pkg) return null; + const depCount = + Object.keys(pkg.dependencies ?? {}).length + Object.keys(pkg.devDependencies ?? {}).length; + if (depCount === 0) return null; + return PM_INSTALL[detectPackageManager(input.lockfiles)]; +} + /** * Pick a build command given the detected project state. * diff --git a/src/utils/build/index.ts b/src/utils/build/index.ts index d819ce3..a4e8c71 100644 --- a/src/utils/build/index.ts +++ b/src/utils/build/index.ts @@ -7,11 +7,13 @@ export { detectBuildConfig, + detectInstallConfig, detectPackageManager, BuildDetectError, PM_LOCKFILES, type BuildConfig, type DetectInput, + type InstallConfig, type PackageManager, } from "./detect.js"; export { loadDetectInput, runBuild, type RunBuildOptions, type RunBuildResult } from "./runner.js"; diff --git a/src/utils/build/runner.ts b/src/utils/build/runner.ts index 42346c6..494ea72 100644 --- a/src/utils/build/runner.ts +++ b/src/utils/build/runner.ts @@ -6,7 +6,14 @@ import { readFileSync, statSync, existsSync } from "node:fs"; import { join, resolve } from "node:path"; import { spawn } from "node:child_process"; -import { detectBuildConfig, PM_LOCKFILES, type BuildConfig, type DetectInput } from "./detect.js"; +import { + detectBuildConfig, + detectInstallConfig, + PM_LOCKFILES, + type BuildConfig, + type DetectInput, + type InstallConfig, +} from "./detect.js"; /** Files whose presence alters build strategy (read once at detect time). */ const CONFIG_PROBES = [ @@ -42,7 +49,12 @@ export function loadDetectInput(projectDir: string): DetectInput { if (existsSync(join(root, name))) configFiles.add(name); } - return { packageJson, lockfiles, configFiles }; + return { + packageJson, + lockfiles, + configFiles, + hasNodeModules: existsSync(join(root, "node_modules")), + }; } export interface RunBuildOptions { @@ -60,14 +72,22 @@ export interface RunBuildResult { outputDir: string; } -/** Run the detected build command; reject on non-zero exit with captured output. */ -export async function runBuild(options: RunBuildOptions): Promise { - const cwd = resolve(options.cwd); - const config = options.config ?? detectBuildConfig(loadDetectInput(cwd)); - +/** + * Spawn a streamed child process and resolve/reject based on exit code. + * Forwards every non-empty line through `onData` and includes the last few + * lines in the rejection error when the process exits non-zero. + */ +async function runStreamed(opts: { + cmd: string; + args: string[]; + cwd: string; + description: string; + failurePrefix: string; + onData?: (line: string) => void; +}): Promise { await new Promise((resolvePromise, rejectPromise) => { - const child = spawn(config.cmd, config.args, { - cwd, + const child = spawn(opts.cmd, opts.args, { + cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, FORCE_COLOR: process.env.FORCE_COLOR ?? "1" }, }); @@ -80,7 +100,7 @@ export async function runBuild(options: RunBuildOptions): Promise MAX_TAIL) tail.shift(); - options.onData?.(line); + opts.onData?.(line); } }; @@ -88,7 +108,7 @@ export async function runBuild(options: RunBuildOptions): Promise rejectPromise( - new Error(`Failed to spawn "${config.description}": ${err.message}`, { + new Error(`Failed to spawn "${opts.description}": ${err.message}`, { cause: err, }), ), @@ -100,12 +120,43 @@ export async function runBuild(options: RunBuildOptions): Promise + * build` ephemerally downloads the framework binary but can't resolve the + * project's own config-file imports. Rejects on non-zero exit with captured + * output. + */ +export async function runBuild(options: RunBuildOptions): Promise { + const cwd = resolve(options.cwd); + const input = loadDetectInput(cwd); + const config = options.config ?? detectBuildConfig(input); + + const install: InstallConfig | null = detectInstallConfig(input); + if (install) { + options.onData?.(`> ${install.description}`); + await runStreamed({ + ...install, + cwd, + failurePrefix: "Install failed", + onData: options.onData, + }); + } + + await runStreamed({ + ...config, + cwd, + failurePrefix: "Build failed", + onData: options.onData, + }); return { config, diff --git a/src/utils/deploy/run.test.ts b/src/utils/deploy/run.test.ts index bc749e2..8f877ef 100644 --- a/src/utils/deploy/run.test.ts +++ b/src/utils/deploy/run.test.ts @@ -39,6 +39,7 @@ const { packageJson: { scripts: { build: "vite build" } }, lockfiles: new Set(), configFiles: new Set(), + hasNodeModules: true, })), }));