diff --git a/.changeset/quiet-bikes-validate.md b/.changeset/quiet-bikes-validate.md new file mode 100644 index 00000000..bb17d00d --- /dev/null +++ b/.changeset/quiet-bikes-validate.md @@ -0,0 +1,5 @@ +--- +"@proofkit/cli": patch +--- + +Fix webviewer scaffold installs by validating Vite native deps and repairing missing Rolldown bindings. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f91c46ab..34c75c4d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,6 +109,8 @@ jobs: - name: Build run: pnpm ci:build + env: + PROOFKIT_MANIFEST_REVALIDATE_SECRET: ci-build-placeholder cli-smoke: name: CLI External Integration Smoke Tests @@ -182,6 +184,8 @@ jobs: - name: Build run: pnpm ci:build + env: + PROOFKIT_MANIFEST_REVALIDATE_SECRET: ci-build-placeholder - name: Publish preview packages run: | diff --git a/packages/cli/src/core/executeInitPlan.ts b/packages/cli/src/core/executeInitPlan.ts index 8a877119..7dfdadb5 100644 --- a/packages/cli/src/core/executeInitPlan.ts +++ b/packages/cli/src/core/executeInitPlan.ts @@ -16,7 +16,13 @@ import { PromptService, SettingsService, } from "~/core/context.js"; -import { DirectoryConflictError, FileSystemError, isCliError, UserCancelledError } from "~/core/errors.js"; +import { + DirectoryConflictError, + ExternalCommandError, + FileSystemError, + isCliError, + UserCancelledError, +} from "~/core/errors.js"; import { applyPackageJsonMutations } from "~/core/planInit.js"; import type { InitPlan } from "~/core/types.js"; import { getIntentInstallCommand } from "~/helpers/intent.js"; @@ -33,6 +39,9 @@ import { sortPackageJson } from "~/utils/sortPackageJson.js"; const AGENT_METADATA_DIRS = new Set([".agents", ".claude", ".clawed", ".clinerules", ".cursor", ".windsurf"]); const IMPORT_ALIAS_WILDCARD_REGEX = /\*/g; const IMPORT_ALIAS_TRAILING_SLASH_REGEX = /\/?$/; +const ROLLDOWN_NATIVE_BINDING_REGEX = + /Cannot find native binding|@rolldown[/+]binding-[\w-]+|rolldown[\s\S]*native binding/i; +const PNPM_ROLLDOWN_REPAIR_INSTRUCTION = "Delete node_modules and pnpm-lock.yaml, then run: pnpm install --force"; const chalk = new Chalk({ level: 1 }); const formatCommand = (command: string) => chalk.cyan(command); @@ -94,6 +103,36 @@ function getPackageScriptCommand(plan: InitPlan, scriptName: string) { return { command, args }; } +function getErrorDetails(error: unknown): string { + const parts: string[] = []; + const add = (value: unknown) => { + if (typeof value === "string" && value.trim()) { + parts.push(value.trim()); + } + }; + if (error instanceof Error) { + add(error.message); + } else if (typeof error === "string") { + add(error); + } else if (typeof error === "object" && error !== null) { + add("message" in error ? error.message : undefined); + } + const cause = typeof error === "object" && error !== null && "cause" in error ? error.cause : undefined; + if (typeof cause === "object" && cause !== null) { + add("message" in cause ? cause.message : undefined); + add("stdout" in cause ? cause.stdout : undefined); + add("stderr" in cause ? cause.stderr : undefined); + add("shortMessage" in cause ? cause.shortMessage : undefined); + } else { + add(cause); + } + return Array.from(new Set(parts)).join("\n"); +} + +function isRolldownNativeBindingError(error: unknown): boolean { + return ROLLDOWN_NATIVE_BINDING_REGEX.test(getErrorDetails(error)); +} + function getMeaningfulDirectoryEntries(entries: string[]) { return entries.filter((entry) => { if (AGENT_METADATA_DIRS.has(entry)) { @@ -405,6 +444,91 @@ export const executeInitPlan = (plan: InitPlan) => stdout: "pipe", stderr: "pipe", }); + + if (plan.request.appType === "webviewer" && plan.request.packageManager === "pnpm") { + consoleService.info("Validating Vite native dependencies..."); + const validateVite = processService.run("pnpm", ["exec", "vite", "--version"], { + cwd: plan.targetDir, + stdout: "pipe", + stderr: "pipe", + }); + const validationResult = yield* Effect.either(validateVite); + + if (validationResult._tag === "Left") { + if (!isRolldownNativeBindingError(validationResult.left)) { + return yield* Effect.fail(validationResult.left); + } + + const validationDetails = getErrorDetails(validationResult.left); + consoleService.warn( + [ + "Vite native dependency validation failed because Rolldown native bindings are missing.", + validationDetails ? `Validation output:\n${validationDetails}` : undefined, + `Repairing install: ${PNPM_ROLLDOWN_REPAIR_INSTRUCTION}`, + ] + .filter(Boolean) + .join("\n"), + ); + + yield* fs.remove(path.join(plan.targetDir, "node_modules")); + yield* fs.remove(path.join(plan.targetDir, "pnpm-lock.yaml")); + const repairResult = yield* Effect.either( + processService.run("pnpm", ["install", "--force"], { + cwd: plan.targetDir, + stdout: "pipe", + stderr: "pipe", + }), + ); + + if (repairResult._tag === "Left") { + const repairDetails = getErrorDetails(repairResult.left); + return yield* Effect.fail( + new ExternalCommandError({ + message: [ + "Vite native dependency repair failed.", + "Repair command: pnpm install --force", + repairDetails ? `Repair output:\n${repairDetails}` : undefined, + `Manual recovery: ${PNPM_ROLLDOWN_REPAIR_INSTRUCTION}`, + ] + .filter(Boolean) + .join("\n"), + command: "pnpm", + args: ["install", "--force"], + cwd: plan.targetDir, + cause: repairResult.left, + }), + ); + } + + const repairedValidation = yield* Effect.either( + processService.run("pnpm", ["exec", "vite", "--version"], { + cwd: plan.targetDir, + stdout: "pipe", + stderr: "pipe", + }), + ); + + if (repairedValidation._tag === "Left") { + const repairedValidationDetails = getErrorDetails(repairedValidation.left); + return yield* Effect.fail( + new ExternalCommandError({ + message: [ + "Vite native dependency validation still failed after repair.", + "Validation command: pnpm exec vite --version", + repairedValidationDetails ? `Validation output:\n${repairedValidationDetails}` : undefined, + `Manual recovery: ${PNPM_ROLLDOWN_REPAIR_INSTRUCTION}`, + ] + .filter(Boolean) + .join("\n"), + command: "pnpm", + args: ["exec", "vite", "--version"], + cwd: plan.targetDir, + cause: repairedValidation.left, + }), + ); + } + } + } } if (plan.tasks.runUltraciteInit) { diff --git a/packages/cli/tests/executor.test.ts b/packages/cli/tests/executor.test.ts index b0ca09bc..a9e2158f 100644 --- a/packages/cli/tests/executor.test.ts +++ b/packages/cli/tests/executor.test.ts @@ -63,6 +63,7 @@ describe("executeInitPlan command paths", () => { expect(tracker.commands).toEqual([ "pnpm install", + "pnpm exec vite --version", [ "pnpx ultracite init --quiet --linter oxlint --pm pnpm --frameworks react --editors universal cursor", "--agents universal claude codex --hooks cursor windsurf codebuddy claude --integrations husky lint-staged", @@ -87,6 +88,170 @@ describe("executeInitPlan command paths", () => { expect(pnpmWorkspaceFile).toContain("blockExoticSubdeps: true"); }); + it("validates Vite native dependencies after webviewer pnpm install", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-vite-validate-ok-")); + const tracker = { + commands: [] as string[], + gitInits: 0, + codegens: 0, + filemakerBootstraps: 0, + }; + + const plan = planInit( + makeInitRequest({ + appType: "webviewer", + dataSource: "none", + packageManager: "pnpm", + noInstall: false, + noGit: true, + cwd, + }), + { + templateDir: getSharedTemplateDir("vite-wv"), + packageManagerVersion: "11.0.0", + }, + ); + + await Effect.runPromise(executeInitPlan(plan).pipe(makeTestLayer({ cwd, packageManager: "pnpm", tracker }))); + + expect(tracker.commands).toContain("pnpm install"); + expect(tracker.commands).toContain("pnpm exec vite --version"); + expect(tracker.commands).not.toContain("pnpm install --force"); + }); + + it("repairs pnpm webviewer install when Rolldown native binding is missing", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-vite-validate-repair-")); + const projectDir = path.join(cwd, "demo-app"); + const console = { + error: [] as string[], + info: [] as string[], + note: [] as Array<{ message: string; title?: string }>, + success: [] as string[], + warn: [] as string[], + }; + const tracker = { + commands: [] as string[], + gitInits: 0, + codegens: 0, + filemakerBootstraps: 0, + }; + + const plan = planInit( + makeInitRequest({ + appType: "webviewer", + dataSource: "none", + packageManager: "pnpm", + noInstall: false, + noGit: true, + cwd, + }), + { + templateDir: getSharedTemplateDir("vite-wv"), + packageManagerVersion: "11.0.0", + }, + ); + + await Effect.runPromise( + executeInitPlan(plan).pipe( + makeTestLayer({ + console, + cwd, + packageManager: "pnpm", + processRuns: { + "pnpm exec vite --version": [ + new ExternalCommandError({ + message: "Cannot find native binding. Missing @rolldown/binding-darwin-arm64", + command: "pnpm", + args: ["exec", "vite", "--version"], + cwd: projectDir, + }), + { stdout: "vite/8.0.13", stderr: "" }, + ], + }, + tracker, + }), + ), + ); + + expect(tracker.commands).toContain("pnpm install --force"); + expect(tracker.commands.filter((command) => command === "pnpm exec vite --version")).toHaveLength(2); + expect(console.warn.join("\n")).toContain("Rolldown native bindings are missing"); + expect(console.warn.join("\n")).toContain("Delete node_modules and pnpm-lock.yaml, then run: pnpm install --force"); + }); + + it("returns actionable error when Vite validation still fails after repair", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-vite-validate-repair-fail-")); + const projectDir = path.join(cwd, "demo-app"); + const console = { + error: [] as string[], + info: [] as string[], + note: [] as Array<{ message: string; title?: string }>, + success: [] as string[], + warn: [] as string[], + }; + const tracker = { + commands: [] as string[], + gitInits: 0, + codegens: 0, + filemakerBootstraps: 0, + }; + + const plan = planInit( + makeInitRequest({ + appType: "webviewer", + dataSource: "none", + packageManager: "pnpm", + noInstall: false, + noGit: true, + cwd, + }), + { + templateDir: getSharedTemplateDir("vite-wv"), + packageManagerVersion: "11.0.0", + }, + ); + + const failure = await getFailure( + executeInitPlan(plan).pipe( + makeTestLayer({ + console, + cwd, + packageManager: "pnpm", + processRuns: { + "pnpm exec vite --version": [ + new ExternalCommandError({ + message: "Cannot find native binding. Missing @rolldown/binding-darwin-arm64", + command: "pnpm", + args: ["exec", "vite", "--version"], + cwd: projectDir, + }), + new ExternalCommandError({ + message: "Cannot find native binding. Missing @rolldown/binding-darwin-arm64", + command: "pnpm", + args: ["exec", "vite", "--version"], + cwd: projectDir, + }), + ], + "pnpm install --force": [{ stdout: "reinstalled", stderr: "" }], + }, + tracker, + }), + ), + ); + + expect(failure).toMatchObject({ + _tag: "ExternalCommandError", + message: expect.stringContaining("Vite native dependency validation still failed after repair"), + }); + expect(failure).toMatchObject({ + message: expect.stringContaining( + "Manual recovery: Delete node_modules and pnpm-lock.yaml, then run: pnpm install --force", + ), + }); + expect(tracker.commands).toContain("pnpm install --force"); + expect(console.warn.join("\n")).toContain("Validation output"); + }); + it("runs Ultracite with browser framework presets", async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-ultracite-browser-")); const tracker = { diff --git a/packages/cli/tests/test-layer.ts b/packages/cli/tests/test-layer.ts index e3d7d42b..6e800b07 100644 --- a/packages/cli/tests/test-layer.ts +++ b/packages/cli/tests/test-layer.ts @@ -88,6 +88,7 @@ export function makeTestLayer(options: { deployDemoFile?: unknown; packageManagerGetVersion?: Partial>; }; + processRuns?: Record>; }) { const tracker = options.tracker; const promptScript = { @@ -348,6 +349,18 @@ export function makeTestLayer(options: { run: (command: string, args: string[]) => { const processCommand = [command, ...args].join(" "); tracker?.commands.push(processCommand); + const scriptedResult = options.processRuns?.[processCommand]?.shift(); + if (scriptedResult !== undefined) { + if ( + typeof scriptedResult === "object" && + scriptedResult !== null && + "stdout" in scriptedResult && + "stderr" in scriptedResult + ) { + return Effect.succeed(scriptedResult as { stdout: string; stderr: string }); + } + return Effect.fail(scriptedResult as ExternalCommandError); + } const processRunFailure = options.failures?.processRun; if (options.failProcessCommand === processCommand) { if (!processRunFailure) {