diff --git a/.changeset/intent-package-manager-next-step.md b/.changeset/intent-package-manager-next-step.md new file mode 100644 index 00000000..033ee7ec --- /dev/null +++ b/.changeset/intent-package-manager-next-step.md @@ -0,0 +1,5 @@ +--- +"@proofkit/cli": patch +--- + +Use package manager execute command in init next steps. diff --git a/.changeset/npm-pnpm-warning.md b/.changeset/npm-pnpm-warning.md new file mode 100644 index 00000000..192c88d7 --- /dev/null +++ b/.changeset/npm-pnpm-warning.md @@ -0,0 +1,5 @@ +--- +"@proofkit/cli": patch +--- + +Prefer pnpm when npm invokes scaffolding and warn npm fallback users to use pnpm 11+. diff --git a/.changeset/package-manager-dev-engines.md b/.changeset/package-manager-dev-engines.md new file mode 100644 index 00000000..1e7bdcd2 --- /dev/null +++ b/.changeset/package-manager-dev-engines.md @@ -0,0 +1,5 @@ +--- +"@proofkit/cli": patch +--- + +Use devEngines packageManager in generated apps. diff --git a/.changeset/typegen-exec-command.md b/.changeset/typegen-exec-command.md new file mode 100644 index 00000000..0b6357e5 --- /dev/null +++ b/.changeset/typegen-exec-command.md @@ -0,0 +1,5 @@ +--- +"@proofkit/cli": patch +--- + +Use package-manager exec command in generated typegen scripts. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06252379..f91c46ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -245,3 +245,4 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} NPM_CONFIG_PROVENANCE: true + PROOFKIT_BINARY_TARGETS: bun-linux-x64 diff --git a/packages/cli/src/cli/init.ts b/packages/cli/src/cli/init.ts index aa9de9e3..35c424c0 100644 --- a/packages/cli/src/cli/init.ts +++ b/packages/cli/src/cli/init.ts @@ -126,6 +126,13 @@ type ProofKitPackageJSON = PackageJson & { proofkitMetadata?: { initVersion: string; }; + devEngines?: { + packageManager: { + name: string; + version: string; + onFail: "download"; + }; + }; }; const missingTypegenCommandPatterns = [ @@ -286,7 +293,14 @@ export const runInit = async (name?: string, opts?: CliFlags) => { const { stdout } = await execa(pkgManager, ["-v"], { cwd: projectDir, }); - pkgJson.packageManager = `${pkgManager}@${stdout.trim()}`; + pkgJson.packageManager = undefined; + pkgJson.devEngines = { + packageManager: { + name: pkgManager, + version: `^${stdout.trim()}`, + onFail: "download", + }, + }; } fs.writeJSONSync(path.join(projectDir, "package.json"), pkgJson, { diff --git a/packages/cli/src/core/executeInitPlan.ts b/packages/cli/src/core/executeInitPlan.ts index b51d4f04..4f881588 100644 --- a/packages/cli/src/core/executeInitPlan.ts +++ b/packages/cli/src/core/executeInitPlan.ts @@ -30,6 +30,8 @@ const chalk = new Chalk({ level: 1 }); const formatCommand = (command: string) => chalk.cyan(command); const formatHeading = (heading: string) => chalk.bold(heading); const formatPath = (value: string) => chalk.yellow(value); +const NPM_PACKAGE_MANAGER_WARNING = + "Warning: We strongly suggest using PNPM 11 or greater as your package manager to better protect your computer and your app."; function renderNextSteps(plan: InitPlan, additionalSteps: string[] = []) { const lines = [ @@ -37,7 +39,7 @@ function renderNextSteps(plan: InitPlan, additionalSteps: string[] = []) { "", formatHeading("Agent setup:"), "Have your agent run this in the new project and complete the interactive prompt so it can load the right skills:", - ` ${formatCommand("npx @tanstack/intent@latest install")}`, + ` ${formatCommand(`${plan.packageManagerExecuteCommand} @tanstack/intent@latest install`)}`, ]; if (plan.request.noInstall) { @@ -48,6 +50,10 @@ function renderNextSteps(plan: InitPlan, additionalSteps: string[] = []) { ); } + if (plan.request.packageManager === "npm") { + lines.push("", chalk.yellow(NPM_PACKAGE_MANAGER_WARNING)); + } + lines.push("", formatHeading("Start the app:"), ` ${formatCommand(`${plan.packageManagerCommand} dev`)}`); if (plan.request.appType === "webviewer") { @@ -242,6 +248,22 @@ export const executeInitPlan = (plan: InitPlan) => cause, }), }); + yield* Effect.tryPromise({ + try: () => + replaceTextInFiles( + projectFilesFs, + plan.targetDir, + "__PNPM_EXECUTE_COMMAND__", + plan.packageManagerExecuteCommand, + ), + catch: (cause) => + new FileSystemError({ + message: "Unable to rewrite scaffold placeholders.", + operation: "replaceTextInFiles", + path: plan.targetDir, + cause, + }), + }); yield* Effect.tryPromise({ try: () => replaceTextInFiles(projectFilesFs, plan.targetDir, "__PACKAGE_MANAGER__", plan.request.packageManager), catch: (cause) => diff --git a/packages/cli/src/core/planInit.ts b/packages/cli/src/core/planInit.ts index bc34086f..793d7736 100644 --- a/packages/cli/src/core/planInit.ts +++ b/packages/cli/src/core/planInit.ts @@ -9,7 +9,12 @@ import { getTypegenVersion, getVersion, } from "~/utils/getProofKitVersion.js"; -import { formatPackageManagerCommand, getScaffoldVersion, getTemplatePackageCommand } from "~/utils/projectFiles.js"; +import { + formatPackageManagerCommand, + getScaffoldVersion, + getTemplatePackageCommand, + getTemplatePackageExecuteCommand, +} from "~/utils/projectFiles.js"; import { getNodeMajorVersion } from "~/utils/versioning.js"; const PNPM_BUILD_POLICY = { @@ -19,6 +24,8 @@ const PNPM_BUILD_POLICY = { msw: true, sharp: true, } as const; +const NPM_PACKAGE_MANAGER_WARNING = + "Warning: We strongly suggest using PNPM 11 or greater as your package manager to better protect your computer and your app."; function getPackageManagerMajorVersion(version?: string) { if (!version) { return undefined; @@ -84,13 +91,20 @@ export function planInit( const proofkitWebviewerVersion = getProofkitDependencyVersion(getProofkitWebviewerVersion()); const settings = createDefaultSettings(request); const packageManagerCommand = getTemplatePackageCommand(request.packageManager); + const packageManagerExecuteCommand = getTemplatePackageExecuteCommand(request.packageManager); const packageManagerMajorVersion = getPackageManagerMajorVersion(options.packageManagerVersion); const shouldWritePnpmWorkspaceFile = request.packageManager === "pnpm" && (packageManagerMajorVersion ?? 0) >= 11; const packageJson: InitPlan["packageJson"] = { name: request.scopedAppName, - packageManager: options.packageManagerVersion - ? `${request.packageManager}@${options.packageManagerVersion}` + devEngines: options.packageManagerVersion + ? { + packageManager: { + name: request.packageManager, + version: `^${options.packageManagerVersion}`, + onFail: "download", + }, + } : undefined, proofkitMetadata: { initVersion: getScaffoldVersion(), @@ -137,6 +151,7 @@ export function planInit( targetDir, templateDir: options.templateDir, packageManagerCommand, + packageManagerExecuteCommand, packageJson, settings, envFile: { @@ -171,8 +186,9 @@ export function planInit( }, nextSteps: [ `cd ${request.appDir}`, + ...(request.packageManager === "npm" ? [NPM_PACKAGE_MANAGER_WARNING] : []), ...(request.noInstall ? [request.packageManager === "yarn" ? "yarn" : `${request.packageManager} install`] : []), - "npx @tanstack/intent@latest install", + `${packageManagerExecuteCommand} @tanstack/intent@latest install`, formatPackageManagerCommand(request.packageManager, "dev"), ...(request.appType === "webviewer" ? [ @@ -192,8 +208,9 @@ export function applyPackageJsonMutations( ) { packageJson.name = mutations.name; packageJson.proofkitMetadata = mutations.proofkitMetadata as PackageJson["proofkitMetadata"]; - if (mutations.packageManager) { - packageJson.packageManager = mutations.packageManager; + if (mutations.devEngines) { + packageJson.devEngines = mutations.devEngines; + packageJson.packageManager = undefined; } if (!packageJson.dependencies) { diff --git a/packages/cli/src/core/resolveInitRequest.ts b/packages/cli/src/core/resolveInitRequest.ts index fc3e05e3..464daaf1 100644 --- a/packages/cli/src/core/resolveInitRequest.ts +++ b/packages/cli/src/core/resolveInitRequest.ts @@ -1,8 +1,14 @@ import { Effect } from "effect"; import { DEFAULT_APP_NAME } from "~/consts.js"; -import { CliContext, ConsoleService, FileMakerService, PromptService } from "~/core/context.js"; -import { CliValidationError, FileMakerSetupError, isCliError, NonInteractiveInputError } from "~/core/errors.js"; +import { CliContext, ConsoleService, FileMakerService, PackageManagerService, PromptService } from "~/core/context.js"; +import { + CliValidationError, + FileMakerSetupError, + isCliError, + NonInteractiveInputError, + UserCancelledError, +} from "~/core/errors.js"; import type { AppType, CliFlags, DataSourceType, FileMakerInputs, InitRequest } from "~/core/types.js"; import { createDataSourceEnvNames, getDefaultSchemaName } from "~/utils/projectFiles.js"; import { parseNameAndPath, validateAppName } from "~/utils/projectName.js"; @@ -80,6 +86,62 @@ function createMissingInputsMessage(scope: string, flags: string[]) { return `Missing required ${scope} inputs in non-interactive mode: ${flags.join(", ")}.`; } +function resolvePackageManager({ + cwd, + packageManager, + nonInteractive, +}: { + cwd: string; + packageManager: "npm" | "pnpm" | "yarn" | "bun"; + nonInteractive: boolean; +}) { + return Effect.gen(function* () { + if (packageManager !== "npm") { + return packageManager; + } + + const packageManagerService = yield* PackageManagerService; + const prompt = yield* PromptService; + const pnpmVersionResult = yield* Effect.either(packageManagerService.getVersion("pnpm", cwd)); + if (pnpmVersionResult._tag === "Right") { + return "pnpm" as const; + } + + if (nonInteractive) { + return packageManager; + } + + const packageManagerChoice = yield* promptEffect("Unable to choose package manager.", () => + prompt.select<"abort" | "continue">({ + message: + "We strongly suggest you use PNPM instead of NPM to better secure yourself and the apps you build. https://pnpm.io/installation", + options: [ + { + value: "abort", + label: "Abort", + hint: "Install PNPM first", + }, + { + value: "continue", + label: "Continue with NPM", + hint: "Ignore this warning", + }, + ], + }), + ); + + if (packageManagerChoice === "abort") { + return yield* Effect.fail( + new UserCancelledError({ + message: "User aborted to install pnpm first.", + }), + ); + } + + return packageManager; + }); +} + function resolveHostedFileMakerInputs({ prompt, fileMakerService, @@ -577,6 +639,11 @@ export const resolveInitRequest = (name?: string, rawFlags?: CliFlags) => const fileMakerService = yield* FileMakerService; const cliContext = yield* CliContext; const nonInteractive = cliContext.nonInteractive || flags.CI || flags.nonInteractive === true; + const packageManager = yield* resolvePackageManager({ + cwd: cliContext.cwd, + packageManager: cliContext.packageManager, + nonInteractive, + }); let projectName = name; if (!projectName) { @@ -700,7 +767,7 @@ export const resolveInitRequest = (name?: string, rawFlags?: CliFlags) => appType, ui: flags.ui ?? "shadcn", dataSource, - packageManager: cliContext.packageManager, + packageManager, noInstall: flags.noInstall, noGit: flags.noGit, force: flags.force, diff --git a/packages/cli/src/core/types.ts b/packages/cli/src/core/types.ts index 68bf7f07..bb8fb5a5 100644 --- a/packages/cli/src/core/types.ts +++ b/packages/cli/src/core/types.ts @@ -102,9 +102,16 @@ export interface InitPlan { templateDir: string; overwriteMode?: OverwriteMode; packageManagerCommand: string; + packageManagerExecuteCommand: string; packageJson: { name: string; - packageManager?: string; + devEngines?: { + packageManager: { + name: PackageManager; + version: string; + onFail: "download"; + }; + }; proofkitMetadata: { initVersion: string; scaffoldPackage: "@proofkit/cli"; diff --git a/packages/cli/src/utils/projectFiles.ts b/packages/cli/src/utils/projectFiles.ts index 4d3c817c..d5484d03 100644 --- a/packages/cli/src/utils/projectFiles.ts +++ b/packages/cli/src/utils/projectFiles.ts @@ -61,6 +61,19 @@ export function getTemplatePackageCommand(packageManager: PackageManager) { return packageManager; } +export function getTemplatePackageExecuteCommand(packageManager: PackageManager) { + if (packageManager === "npm") { + return "npx"; + } + if (packageManager === "pnpm") { + return "pnpx"; + } + if (packageManager === "bun") { + return "bunx"; + } + return `${packageManager} dlx`; +} + export function normalizeImportAlias(importAlias: string) { return importAlias.replace(/\*/g, "").replace(TRAILING_SLASH_REGEX, "$&/"); } diff --git a/packages/cli/src/utils/sortPackageJson.ts b/packages/cli/src/utils/sortPackageJson.ts index 08ee9eb7..f2f45980 100644 --- a/packages/cli/src/utils/sortPackageJson.ts +++ b/packages/cli/src/utils/sortPackageJson.ts @@ -13,6 +13,7 @@ const ROOT_KEY_ORDER = [ "funding", "type", "packageManager", + "devEngines", "engines", "bin", "exports", diff --git a/packages/cli/template/vite-wv/package.json b/packages/cli/template/vite-wv/package.json index 4e48b7af..ca23da65 100644 --- a/packages/cli/template/vite-wv/package.json +++ b/packages/cli/template/vite-wv/package.json @@ -10,8 +10,8 @@ "proofkit": "proofkit", "serve": "vite preview", "start": "vite", - "typegen": "npx @proofkit/typegen", - "typegen:ui": "npx @proofkit/typegen ui", + "typegen": "__PNPM_EXECUTE_COMMAND__ @proofkit/typegen", + "typegen:ui": "__PNPM_EXECUTE_COMMAND__ @proofkit/typegen ui", "upload": "node ./scripts/upload.js", "lint": "ultracite check .", "format": "ultracite fix ." diff --git a/packages/cli/tests/executor.test.ts b/packages/cli/tests/executor.test.ts index 1d929745..70355631 100644 --- a/packages/cli/tests/executor.test.ts +++ b/packages/cli/tests/executor.test.ts @@ -338,6 +338,68 @@ describe("executeInitPlan command paths", () => { ); }); + it("prints pnpm warning in npm next steps", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-new-npm-warning-")); + const console = { + info: [] as string[], + warn: [] as string[], + error: [] as string[], + success: [] as string[], + note: [] as Array<{ message: string; title?: string }>, + }; + const plan = planInit( + makeInitRequest({ + projectName: "npm-app", + scopedAppName: "npm-app", + appDir: "npm-app", + packageManager: "npm", + noInstall: true, + noGit: true, + cwd, + }), + { + templateDir: getSharedTemplateDir("nextjs-shadcn"), + packageManagerVersion: "10.0.0", + }, + ); + + await Effect.runPromise(executeInitPlan(plan).pipe(makeTestLayer({ cwd, packageManager: "npm", console }))); + + expect(console.info.join("\n")).toContain( + "Warning: We strongly suggest using PNPM 11 or greater as your package manager to better protect your computer and your app.", + ); + }); + + it("prints package manager execute command in agent setup next steps", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-new-pnpm-agent-setup-")); + const console = { + info: [] as string[], + warn: [] as string[], + error: [] as string[], + success: [] as string[], + note: [] as Array<{ message: string; title?: string }>, + }; + const plan = planInit( + makeInitRequest({ + projectName: "pnpm-app", + scopedAppName: "pnpm-app", + appDir: "pnpm-app", + packageManager: "pnpm", + noInstall: true, + noGit: true, + cwd, + }), + { + templateDir: getSharedTemplateDir("nextjs-shadcn"), + packageManagerVersion: "11.0.0", + }, + ); + + await Effect.runPromise(executeInitPlan(plan).pipe(makeTestLayer({ cwd, packageManager: "pnpm", console }))); + + expect(console.info.join("\n")).toContain("pnpx @tanstack/intent@latest install"); + }); + it("fails with a typed external command error when install fails", async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "proofkit-new-install-fail-")); const plan = planInit( diff --git a/packages/cli/tests/init-scaffold-contract.test.ts b/packages/cli/tests/init-scaffold-contract.test.ts index 576c5110..ce35226a 100644 --- a/packages/cli/tests/init-scaffold-contract.test.ts +++ b/packages/cli/tests/init-scaffold-contract.test.ts @@ -8,6 +8,13 @@ interface PackageJsonShape { version?: string; name?: string; packageManager?: string; + devEngines?: { + packageManager?: { + name?: string; + version?: string; + onFail?: string; + }; + }; scripts?: Record; dependencies?: Record; devDependencies?: Record; @@ -47,7 +54,7 @@ const expectedProofkitVersions = new Map([ `^${readJsonFile(join(__dirname, "..", "..", "webviewer", "package.json")).version}`, ], ]); -const packageManagerPattern = /^(npm|pnpm|yarn|bun)@/; +const packageManagerVersionPattern = /^\^?\d+\.\d+\.\d+/; const ansiStylePrefixPattern = /^[0-9;]*m/; function runInit({ appType, projectName }: { appType: "browser" | "webviewer"; projectName: string }): string { @@ -110,7 +117,7 @@ function checkNodeSyntax(projectDir: string, relativeFilePath: string): boolean } function getPackageManagerName(packageJson: PackageJsonShape): "npm" | "pnpm" | "yarn" | "bun" { - const raw = packageJson.packageManager?.split("@")[0]; + const raw = packageJson.devEngines?.packageManager?.name ?? packageJson.packageManager?.split("@")[0]; if (raw === "pnpm" || raw === "yarn" || raw === "bun") { return raw; } @@ -161,7 +168,10 @@ describe("Init scaffold contract tests", () => { expect(packageJson.scripts?.build).toBe("next build --turbopack"); expect(packageJson.scripts?.proofkit).toBe("proofkit"); expect(packageJson.proofkitMetadata?.initVersion).toBe(cliVersion); - expect(packageJson.packageManager).toMatch(packageManagerPattern); + expect(packageJson.packageManager).toBeUndefined(); + expect(packageJson.devEngines?.packageManager?.name).toBe("pnpm"); + expect(packageJson.devEngines?.packageManager?.version).toMatch(packageManagerVersionPattern); + expect(packageJson.devEngines?.packageManager?.onFail).toBe("download"); expect(allProofkitDependenciesUseCurrentVersions(packageJson)).toBe(true); expect(readFileSync(join(browserProjectDir, "CLAUDE.md"), "utf-8")).toBe("@AGENTS.md\n"); expect(readFileSync(join(browserProjectDir, ".cursorignore"), "utf-8")).toBe("CLAUDE.md\n"); @@ -209,11 +219,14 @@ describe("Init scaffold contract tests", () => { const packageJson = readJsonFile(join(webviewerProjectDir, "package.json")); expect(packageJson.name).toBe(webviewerProjectName); expect(packageJson.scripts?.build).toBe("vite build"); - expect(packageJson.scripts?.typegen).toBe("npx @proofkit/typegen"); - expect(packageJson.scripts?.["typegen:ui"]).toBe("npx @proofkit/typegen ui"); + expect(packageJson.scripts?.typegen).toBe("pnpx @proofkit/typegen"); + expect(packageJson.scripts?.["typegen:ui"]).toBe("pnpx @proofkit/typegen ui"); expect(packageJson.scripts?.proofkit).toBe("proofkit"); expect(packageJson.proofkitMetadata?.initVersion).toBe(cliVersion); - expect(packageJson.packageManager).toMatch(packageManagerPattern); + expect(packageJson.packageManager).toBeUndefined(); + expect(packageJson.devEngines?.packageManager?.name).toBe("pnpm"); + expect(packageJson.devEngines?.packageManager?.version).toMatch(packageManagerVersionPattern); + expect(packageJson.devEngines?.packageManager?.onFail).toBe("download"); expect(allProofkitDependenciesUseCurrentVersions(packageJson)).toBe(true); expect(readFileSync(join(webviewerProjectDir, "CLAUDE.md"), "utf-8")).toBe("@AGENTS.md\n"); expect(readFileSync(join(webviewerProjectDir, ".cursorignore"), "utf-8")).toBe("CLAUDE.md\n"); diff --git a/packages/cli/tests/integration.test.ts b/packages/cli/tests/integration.test.ts index 20d57b1d..b9824de5 100644 --- a/packages/cli/tests/integration.test.ts +++ b/packages/cli/tests/integration.test.ts @@ -66,7 +66,12 @@ describe("integration scaffold generation", () => { await readScaffoldArtifacts(projectDir); expect(packageJson.name).toBe("browser-app"); - expect(packageJson.packageManager).toBe("pnpm@11.1.0"); + expect(packageJson.packageManager).toBeUndefined(); + expect(packageJson.devEngines?.packageManager).toEqual({ + name: "pnpm", + version: "^11.1.0", + onFail: "download", + }); expect(packageJson.proofkitMetadata).toMatchObject({ scaffoldPackage: "@proofkit/cli", }); diff --git a/packages/cli/tests/planner.test.ts b/packages/cli/tests/planner.test.ts index dd4163b6..1f354550 100644 --- a/packages/cli/tests/planner.test.ts +++ b/packages/cli/tests/planner.test.ts @@ -14,6 +14,7 @@ const proofkitCliVersion = getProofkitDependencyVersion(getVersion()); const proofkitFmdapiVersion = getProofkitDependencyVersion(getFmdapiVersion()); const proofkitTypegenVersion = getProofkitDependencyVersion(getTypegenVersion()); const proofkitWebviewerVersion = getProofkitDependencyVersion(getProofkitWebviewerVersion()); +const pnpm11WarningPattern = /pnpm.*11/i; describe("planInit", () => { it("plans a browser scaffold", () => { @@ -25,6 +26,11 @@ describe("planInit", () => { expect(plan.targetDir).toBe(path.resolve("/tmp/workspace", "demo-app")); expect(plan.templateDir).toBe("/templates/browser"); expect(plan.packageJson.name).toBe("demo-app"); + expect(plan.packageJson.devEngines?.packageManager).toEqual({ + name: "pnpm", + version: "^11.0.0", + onFail: "download", + }); expect(plan.settings.appType).toBe("browser"); expect(plan.packageJson.devDependencies["@proofkit/cli"]).toBe(proofkitCliVersion); expect(plan.packageJson.devDependencies["@proofkit/typegen"]).toBe(proofkitTypegenVersion); @@ -114,6 +120,37 @@ describe("planInit", () => { expect(plan.writes.some((write) => write.path.endsWith("pnpm-workspace.yaml"))).toBe(false); }); + it("warns npm users to use pnpm 11 or greater", () => { + const plan = planInit( + makeInitRequest({ + packageManager: "npm", + }), + { + templateDir: "/templates/browser", + packageManagerVersion: "10.0.0", + }, + ); + + expect(plan.nextSteps).toEqual(expect.arrayContaining([expect.stringMatching(pnpm11WarningPattern)])); + }); + + it("uses package manager execute command for agent setup next step", () => { + const cases = [ + ["npm", "npx @tanstack/intent@latest install"], + ["pnpm", "pnpx @tanstack/intent@latest install"], + ["yarn", "yarn dlx @tanstack/intent@latest install"], + ["bun", "bunx @tanstack/intent@latest install"], + ] as const; + + for (const [packageManager, nextStep] of cases) { + const plan = planInit(makeInitRequest({ packageManager }), { + templateDir: "/templates/browser", + }); + + expect(plan.nextSteps).toContain(nextStep); + } + }); + it("adds fmdapi for browser filemaker scaffolds", () => { const plan = planInit( makeInitRequest({ diff --git a/packages/cli/tests/resolve-init.test.ts b/packages/cli/tests/resolve-init.test.ts index 1e9b7a5b..19f102f7 100644 --- a/packages/cli/tests/resolve-init.test.ts +++ b/packages/cli/tests/resolve-init.test.ts @@ -2,6 +2,7 @@ import { Effect } from "effect"; import { describe, expect, it } from "vitest"; import { CliValidationError, + ExternalCommandError, FileMakerSetupError, NonInteractiveInputError, UserCancelledError, @@ -11,6 +12,139 @@ import { getFailure } from "./effect-test-utils.js"; import { type ConsoleTranscript, makeTestLayer, type PromptTranscript } from "./test-layer.js"; describe("resolveInitRequest", () => { + it("uses pnpm when npm invoked and pnpm is installed", async () => { + const request = await Effect.runPromise( + resolveInitRequest("demo", { + noGit: true, + noInstall: true, + force: false, + default: true, + importAlias: "~/", + CI: true, + }).pipe( + makeTestLayer({ + cwd: "/tmp", + packageManager: "npm", + }), + ), + ); + + expect(request.packageManager).toBe("pnpm"); + }); + + it("aborts interactively when npm invoked and pnpm is missing", async () => { + expect( + await getFailure( + resolveInitRequest("demo", { + noGit: true, + noInstall: true, + force: false, + default: true, + importAlias: "~/", + CI: false, + }).pipe( + makeTestLayer({ + cwd: "/tmp", + packageManager: "npm", + nonInteractive: false, + failures: { + packageManagerGetVersion: { + pnpm: new ExternalCommandError({ + message: "pnpm not found", + command: "pnpm", + args: ["-v"], + cwd: "/tmp", + }), + }, + }, + }), + ), + ), + ).toMatchObject( + new UserCancelledError({ + message: "User aborted to install pnpm first.", + }), + ); + }); + + it("continues with npm when warning is ignored", async () => { + const promptTranscript: PromptTranscript = { + text: [], + password: [], + select: [], + searchSelect: [], + multiSearchSelect: [], + confirm: [], + }; + const request = await Effect.runPromise( + resolveInitRequest("demo", { + noGit: true, + noInstall: true, + force: false, + default: true, + importAlias: "~/", + CI: false, + appType: "browser", + dataSource: "none", + }).pipe( + makeTestLayer({ + cwd: "/tmp", + packageManager: "npm", + nonInteractive: false, + prompts: { + select: ["continue"], + }, + promptTranscript, + failures: { + packageManagerGetVersion: { + pnpm: new ExternalCommandError({ + message: "pnpm not found", + command: "pnpm", + args: ["-v"], + cwd: "/tmp", + }), + }, + }, + }), + ), + ); + + expect(request.packageManager).toBe("npm"); + expect(promptTranscript.select[0]?.message).toContain("https://pnpm.io/installation"); + }); + + it("continues with npm non-interactively when pnpm is missing", async () => { + const request = await Effect.runPromise( + resolveInitRequest("demo", { + noGit: true, + noInstall: true, + force: false, + default: true, + importAlias: "~/", + CI: true, + appType: "browser", + dataSource: "none", + }).pipe( + makeTestLayer({ + cwd: "/tmp", + packageManager: "npm", + failures: { + packageManagerGetVersion: { + pnpm: new ExternalCommandError({ + message: "pnpm not found", + command: "pnpm", + args: ["-v"], + cwd: "/tmp", + }), + }, + }, + }), + ), + ); + + expect(request.packageManager).toBe("npm"); + }); + it("fails for missing project name in non-interactive mode", async () => { expect( await getFailure( diff --git a/packages/cli/tests/test-layer.ts b/packages/cli/tests/test-layer.ts index 06ab91f2..303abc1f 100644 --- a/packages/cli/tests/test-layer.ts +++ b/packages/cli/tests/test-layer.ts @@ -85,6 +85,7 @@ export function makeTestLayer(options: { codegenRun?: unknown; validateHostedServerUrl?: unknown; deployDemoFile?: unknown; + packageManagerGetVersion?: Partial>; }; }) { const tracker = options.tracker; @@ -334,7 +335,13 @@ export function makeTestLayer(options: { }, }), Layer.succeed(PackageManagerService, { - getVersion: () => Effect.succeed("11.1.0"), + getVersion: (packageManager: PackageManager) => { + const failure = options.failures?.packageManagerGetVersion?.[packageManager]; + if (failure) { + return Effect.fail(failure as ExternalCommandError); + } + return Effect.succeed("11.1.0"); + }, }), Layer.succeed(ProcessService, { run: (command: string, args: string[]) => { diff --git a/packages/cli/tests/webviewer-apps.test.ts b/packages/cli/tests/webviewer-apps.test.ts index a3a222ea..9e870ce6 100644 --- a/packages/cli/tests/webviewer-apps.test.ts +++ b/packages/cli/tests/webviewer-apps.test.ts @@ -45,8 +45,8 @@ describe("Web Viewer CLI Tests", () => { expect(existsSync(join(projectDir, "proofkit-typegen.config.jsonc"))).toBe(true); const packageJson = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf-8")); - expect(packageJson.scripts.typegen).toBe("npx @proofkit/typegen"); - expect(packageJson.scripts["typegen:ui"]).toBe("npx @proofkit/typegen ui"); + expect(packageJson.scripts.typegen).toBe("pnpx @proofkit/typegen"); + expect(packageJson.scripts["typegen:ui"]).toBe("pnpx @proofkit/typegen ui"); expect(packageJson.devDependencies["@proofkit/typegen"]).toBe(expectedTypegenVersion); const proofkitConfig = JSON.parse(readFileSync(join(projectDir, "proofkit.json"), "utf-8"));