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
7 changes: 7 additions & 0 deletions .changeset/always-install-before-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"playground-cli": patch
---

`dot deploy` and `dot build` now run `pnpm install` (or the project's package manager equivalent) before every build, not just when `node_modules/` is missing. A stale `node_modules/` left over from a branch switch or a lockfile bump used to slip past the missing-folder guard and produce opaque Vite/Rollup errors like `"X is not exported by ..."`; the only fix was to re-run `pnpm install` by hand. The install step is idempotent (~1s when nothing has changed), so the happy path is essentially unaffected.

Also surfaces more of the failing build's output in the CLI error message (40 lines instead of 10), so when a build does fail the actual error line — not just the trailing stack trace — makes it into the rendered output. And the same error no longer renders twice in the deploy TUI: the per-section row marks which step failed with `✕`, and the bottom `deploy failed` row carries the message once.
3 changes: 0 additions & 3 deletions src/commands/deploy/DeployScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -997,7 +997,6 @@ function RunningStage({
<Row
mark={stepMark(playgroundState.status)}
label="publish to playground"
value={playgroundState.error}
tone={playgroundState.status === "error" ? "danger" : "muted"}
/>
</Box>
Expand Down Expand Up @@ -1030,7 +1029,6 @@ function ContractsSectionView({ state }: { state: ContractsSectionState }) {
<Row
mark={stepMark(state.deployStatus)}
label="deploy"
value={state.error}
tone={state.deployStatus === "error" ? "danger" : "muted"}
/>
{state.contracts.length > 0 && (
Expand Down Expand Up @@ -1072,7 +1070,6 @@ function FrontendSectionView({ state }: { state: FrontendSectionState }) {
<Row
mark={stepMark(state.uploadStatus)}
label="upload + dotns"
value={state.error}
tone={state.uploadStatus === "error" ? "danger" : "muted"}
/>
{running && state.latestLog && <Hint indent={2}>{truncate(state.latestLog, 120)}</Hint>}
Expand Down
18 changes: 8 additions & 10 deletions src/utils/build/detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ function input(overrides: Partial<DetectInput> = {}): DetectInput {
packageJson: null,
lockfiles: new Set(),
configFiles: new Set(),
hasNodeModules: true,
cargoToml: null,
...overrides,
};
Expand Down Expand Up @@ -147,19 +146,23 @@ describe("detectBuildConfig", () => {
});

describe("detectInstallConfig", () => {
it("returns null when node_modules is already present", () => {
it("returns an install command even when node_modules already exists (idempotent reconcile)", () => {
// A stale node_modules/ — e.g. after a branch switch — bypasses any
// missing-folder guard and lets the build run against package versions
// that don't match the lockfile. We install unconditionally so that
// case can't reach the build step.
expect(
detectInstallConfig(
input({
packageJson: { dependencies: { vite: "^5.0.0" } },
hasNodeModules: true,
lockfiles: new Set(["pnpm-lock.yaml"]),
}),
),
).toBeNull();
).toEqual({ cmd: "pnpm", args: ["install"], description: "pnpm install" });
});

it("returns null when package.json is missing", () => {
expect(detectInstallConfig(input({ hasNodeModules: false }))).toBeNull();
expect(detectInstallConfig(input())).toBeNull();
});

it("returns null when the project declares no dependencies", () => {
Expand All @@ -169,7 +172,6 @@ describe("detectInstallConfig", () => {
detectInstallConfig(
input({
packageJson: { scripts: { build: "echo hi" } },
hasNodeModules: false,
}),
),
).toBeNull();
Expand All @@ -180,7 +182,6 @@ describe("detectInstallConfig", () => {
detectInstallConfig(
input({
packageJson: { devDependencies: { vite: "^7.0.0" } },
hasNodeModules: false,
}),
),
).toEqual({ cmd: "npm", args: ["install"], description: "npm install" });
Expand All @@ -192,7 +193,6 @@ describe("detectInstallConfig", () => {
input({
packageJson: { dependencies: { react: "^19.0.0" } },
lockfiles: new Set(["pnpm-lock.yaml"]),
hasNodeModules: false,
}),
),
).toEqual({ cmd: "pnpm", args: ["install"], description: "pnpm install" });
Expand All @@ -202,7 +202,6 @@ describe("detectInstallConfig", () => {
input({
packageJson: { dependencies: { react: "^19.0.0" } },
lockfiles: new Set(["bun.lockb"]),
hasNodeModules: false,
}),
),
).toEqual({ cmd: "bun", args: ["install"], description: "bun install" });
Expand All @@ -212,7 +211,6 @@ describe("detectInstallConfig", () => {
input({
packageJson: { dependencies: { react: "^19.0.0" } },
lockfiles: new Set(["yarn.lock"]),
hasNodeModules: false,
}),
),
).toEqual({ cmd: "yarn", args: ["install"], description: "yarn install" });
Expand Down
20 changes: 10 additions & 10 deletions src/utils/build/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,6 @@ 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;
/** Raw Cargo.toml contents (used to gate the cdm contract flow). Null when absent. */
cargoToml: string | null;
}
Expand Down Expand Up @@ -172,17 +170,19 @@ export function detectContractsType(input: DetectInput): ContractsType | null {

/**
* 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.
* install command whenever the project declares any dependencies, 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.
* We install unconditionally (not just when node_modules/ is missing) because
* a stale node_modules/ — e.g. after a branch switch or a teammate bumping the
* lockfile — bypasses the missing-folder guard and lets the build run against
* package versions that don't match the lockfile. The resulting "X is not
* exported by Y" error from Vite/Rollup is opaque and the user has no signal
* that the fix is a re-install. pnpm/yarn/npm are all idempotent when in sync
* (~1s no-op), so the cost is negligible. Skipping install for a deps-free
* package.json is still safe: there's nothing to install.
*/
export function detectInstallConfig(input: DetectInput): InstallConfig | null {
if (input.hasNodeModules) return null;
const pkg = input.packageJson;
if (!pkg) return null;
const depCount =
Expand Down
1 change: 0 additions & 1 deletion src/utils/build/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ export function loadDetectInput(projectDir: string): DetectInput {
packageJson,
lockfiles,
configFiles,
hasNodeModules: existsSync(join(root, "node_modules")),
cargoToml,
};
}
Expand Down
2 changes: 1 addition & 1 deletion src/utils/deploy/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const {
packageJson: { scripts: { build: "vite build" } },
lockfiles: new Set<string>(),
configFiles: new Set<string>(),
hasNodeModules: true,
cargoToml: null,
})),
detectContractsTypeMock: vi.fn<() => "foundry" | "hardhat" | "cdm" | null>(() => null),
runContractsPhaseMock: vi.fn<
Expand Down
49 changes: 42 additions & 7 deletions src/utils/process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@ describe("runStreamed", () => {
).rejects.toThrow(/\(no output\)/);
});

it("caps the captured tail and reports only the LAST 10 lines on failure (not the first)", async () => {
it("caps the captured tail and reports only the LAST 40 lines on failure (not the first)", async () => {
// Generate 60 numbered lines, then exit 1. Tail buffer holds the last
// 50; the error message shows the last 10 of those — so 51..60.
// 40, which is what the error message reports — so 21..60.
try {
await runStreamed({
cmd: "/bin/sh",
Expand All @@ -99,15 +99,50 @@ describe("runStreamed", () => {
expect.fail("expected rejection");
} catch (err) {
const msg = (err as Error).message;
// Last 10 lines must appear.
for (let i = 51; i <= 60; i++) {
// Last 40 lines must appear.
for (let i = 21; i <= 60; i++) {
expect(msg).toContain(`line-${i}`);
}
// Anything earlier must NOT: confirms we're taking the tail, not
// the head, and that the slice is capped at 10.
// the head, and that the buffer is capped at 40.
expect(msg).not.toContain("line-1\n");
expect(msg).not.toContain("line-50\n");
expect(msg).not.toContain("line-10\n");
expect(msg).not.toContain("line-20\n");
}
});

it("preserves a Vite/Rollup-style error message that precedes a long stack trace", async () => {
// Realistic shape: vite prints the build banner, a single descriptive
// error line, a code snippet, and finally a ~12-frame stack trace.
// With the older 10-line snippet the actual error was pushed off the
// window by the trailing trace; the wider window keeps it visible.
const script = [
"echo 'vite v7.3.2 building client environment for production...'",
"echo 'transforming...'",
"echo '✓ 1544 modules transformed.'",
"echo '✗ Build failed in 843ms'",
"echo 'error during build:'",
`echo ' src/utils/contracts.ts (40:9): \"createContractRuntimeFromClient\" is not exported by node_modules/@parity/product-sdk-contracts/dist/index.js'`,
"echo 'file: /tmp/contracts.ts:40:9'",
"echo '38: import type { PolkadotSigner } from \"polkadot-api\";'",
"echo '39: import { keccak256 } from \"@parity/product-sdk-utils\";'",
"echo '40: import { createContractRuntimeFromClient } from \"@parity/product-sdk-contracts\";'",
"echo ' ^'",
"echo '41: import { paseo_asset_hub } from \"@parity/product-sdk-descriptors\";'",
"for i in $(seq 1 12); do echo ' at frame-'$i' (file:///.../rollup/dist/es/shared/node-entry.js:1234:5)'; done",
"exit 1",
].join("; ");

try {
await runStreamed({
cmd: "/bin/sh",
args: ["-c", script],
description: "vite-like-failure",
});
expect.fail("expected rejection");
} catch (err) {
const msg = (err as Error).message;
expect(msg).toContain('"createContractRuntimeFromClient" is not exported');
expect(msg).toContain("at frame-12");
}
});
});
Expand Down
10 changes: 7 additions & 3 deletions src/utils/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,12 @@ export interface RunStreamedOptions {

/**
* Spawn a child process, stream every non-empty stdout/stderr line through
* `onData`, and resolve/reject based on exit code. Includes the last ~10
* `onData`, and resolve/reject based on exit code. Includes the last ~40
* lines of output in the rejection message so failures are diagnosable.
*
* The window is 40 (not 10) because Vite/Rollup and many bundlers print the
* meaningful error first and then a 10–20 line stack trace; a smaller window
* keeps the trace and drops the message that explains what actually broke.
*/
export async function runStreamed(opts: RunStreamedOptions): Promise<void> {
const description = opts.description ?? `${opts.cmd} ${opts.args.join(" ")}`;
Expand All @@ -49,7 +53,7 @@ export async function runStreamed(opts: RunStreamedOptions): Promise<void> {
});

const tail: string[] = [];
const MAX_TAIL = 50;
const MAX_TAIL = 40;

const forward = (chunk: Buffer) => {
for (const line of chunk.toString().split("\n")) {
Expand All @@ -71,7 +75,7 @@ export async function runStreamed(opts: RunStreamedOptions): Promise<void> {
if (code === 0) {
resolvePromise();
} else {
const snippet = tail.slice(-10).join("\n") || "(no output)";
const snippet = tail.join("\n") || "(no output)";
rejectPromise(
new Error(
`${failurePrefix} (${description}) with exit code ${code}.\n${snippet}`,
Expand Down
Loading