Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/auto-install-build-deps.md
Original file line number Diff line number Diff line change
@@ -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 <framework> 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`).
75 changes: 75 additions & 0 deletions src/utils/build/detect.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import {
detectBuildConfig,
detectInstallConfig,
detectPackageManager,
BuildDetectError,
type DetectInput,
Expand All @@ -11,6 +12,7 @@ function input(overrides: Partial<DetectInput> = {}): DetectInput {
packageJson: null,
lockfiles: new Set(),
configFiles: new Set(),
hasNodeModules: true,
...overrides,
};
}
Expand Down Expand Up @@ -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" });
});
});
38 changes: 38 additions & 0 deletions src/utils/build/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -38,6 +46,8 @@ export interface DetectInput {
lockfiles: Set<string>;
/** Set of additional config-file basenames (e.g. vite.config.ts). */
configFiles: Set<string>;
/** Whether a node_modules/ directory exists at the project root. */
hasNodeModules: boolean;
}

export class BuildDetectError extends Error {
Expand Down Expand Up @@ -110,6 +120,34 @@ const PM_EXEC: Record<PackageManager, string[]> = {
npm: ["npx"],
};

const PM_INSTALL: Record<PackageManager, InstallConfig> = {
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.
*
Expand Down
2 changes: 2 additions & 0 deletions src/utils/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
75 changes: 63 additions & 12 deletions src/utils/build/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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 {
Expand All @@ -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<RunBuildResult> {
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<void> {
await new Promise<void>((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" },
});
Expand All @@ -80,15 +100,15 @@ export async function runBuild(options: RunBuildOptions): Promise<RunBuildResult
if (line.length === 0) continue;
tail.push(line);
if (tail.length > MAX_TAIL) tail.shift();
options.onData?.(line);
opts.onData?.(line);
}
};

child.stdout.on("data", forward);
child.stderr.on("data", forward);
child.on("error", (err) =>
rejectPromise(
new Error(`Failed to spawn "${config.description}": ${err.message}`, {
new Error(`Failed to spawn "${opts.description}": ${err.message}`, {
cause: err,
}),
),
Expand All @@ -100,12 +120,43 @@ export async function runBuild(options: RunBuildOptions): Promise<RunBuildResult
const snippet = tail.slice(-10).join("\n") || "(no output)";
rejectPromise(
new Error(
`Build failed (${config.description}) with exit code ${code}.\n${snippet}`,
`${opts.failurePrefix} (${opts.description}) with exit code ${code}.\n${snippet}`,
),
);
}
});
});
}

/**
* Run the detected build command. Auto-installs dependencies first when
* node_modules/ is missing — without this, falling through to `npx <framework>
* 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<RunBuildResult> {
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,
Expand Down
1 change: 1 addition & 0 deletions src/utils/deploy/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const {
packageJson: { scripts: { build: "vite build" } },
lockfiles: new Set<string>(),
configFiles: new Set<string>(),
hasNodeModules: true,
})),
}));

Expand Down
Loading