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
63 changes: 41 additions & 22 deletions src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import ora from "ora";
import path from "path";
import { generateProject } from "../generators/project-generator.js";
import { validateProjectName } from "../utils/validation.js";
import {
forcedWalletProvider,
walletOverrideReason,
} from "../utils/templates.js";

interface CreateOptions {
description?: string;
Expand Down Expand Up @@ -67,18 +71,14 @@ export async function createCommand(
const autoTemplateType =
cliTemplateType || "basic";
// FORCED, not defaulted. The plopfile guards skip the wallet
// components for these template types unconditionally, so a flag
// saying otherwise cannot be honoured — it just produces a navbar
// importing a connect-button nothing writes. Farcaster and Minipay
// survive that because they ship their own replacement; x402 and
// ai-chat ship none, which is where it fails silently.
const scaffoldsOwnWallet =
autoTemplateType === "farcaster-miniapp" ||
autoTemplateType === "ai-chat" ||
autoTemplateType === "x402";
const autoWallet = scaffoldsOwnWallet
? "none"
: options.walletProvider ?? "rainbowkit";
// actions for these template types unconditionally, so a flag
// saying otherwise cannot be honoured. The list and the two
// distinct reasons live in utils/templates.ts — they used to be
// restated here and in the skip strings, and had drifted.
const autoWallet =
forcedWalletProvider(autoTemplateType) ??
options.walletProvider ??
"rainbowkit";
// NOT forced, unlike the wallet above — the two guards are not
// symmetric. The wallet actions in plopfile.ts check the template
// type as well ("This template uses its own wallet components"),
Expand Down Expand Up @@ -264,17 +264,36 @@ export async function createCommand(
// Forced here, and it has to be HERE. Computing it in the auto-mode branch
// above is not enough — this is the value that reaches plop, and it read
// `options.walletProvider` first, so an explicit flag won again regardless.
//
// plopfile.ts skips the rainbowkit and thirdweb component actions for these
// template types unconditionally, so the flag cannot be honoured; it only
// produces a navbar importing a component nothing writes. Farcaster and
// Minipay ship their own replacement, x402 and ai-chat ship none.
// NOT always "none". minipay is forced to rainbowkit, because its own
// components import RainbowKitProvider/WagmiProvider and the base manifest
// gates those packages on this value — forcing "none" shipped a scaffold
// with the components and none of what they import. See utils/templates.ts.
const forcedWallet = forcedWalletProvider(finalTemplateType);
const finalWalletProvider =
finalTemplateType === "farcaster-miniapp" ||
finalTemplateType === "ai-chat" ||
finalTemplateType === "x402"
? "none"
: options.walletProvider || answers.walletProvider || "rainbowkit";
forcedWallet ?? (options.walletProvider || answers.walletProvider || "rainbowkit");

// The flag was passed and then dropped, so say so. Until now `create app
// -t x402 --wallet-provider thirdweb` exited 0 having produced a project
// with no thirdweb in it, and nothing told the user their flag did
// nothing. `--wallet-provider none` is what the forcing does anyway, so
// it is agreement rather than a conflict and stays quiet.
// Warn when the flag was passed and does not survive — whatever it was
// overridden TO. A flag that agrees with the forced value (`--wallet-provider
// rainbowkit` on minipay, or `none` on x402) is agreement, not a conflict,
// and stays quiet.
if (
options.walletProvider &&
forcedWallet !== null &&
options.walletProvider !== forcedWallet
) {
console.warn(
chalk.yellow(
`⚠️ Ignoring --wallet-provider ${options.walletProvider}: ${walletOverrideReason(
finalTemplateType
)}. Using ${forcedWallet} instead.`
)
);
}
let finalContractFramework = options.contracts || answers.contractFramework;
if (!finalContractFramework) {
finalContractFramework =
Expand Down
158 changes: 153 additions & 5 deletions src/generators/template-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,39 +9,60 @@
* so they fail on a missed edit rather than on a changed line number.
*/

import { execFileSync } from "child_process";
import { spawnSync } from "child_process";
import fs from "fs-extra";
import os from "os";
import path from "path";

const repoRoot = path.resolve(__dirname, "..", "..");

interface Generated {
projectPath: string;
stderr: string;
}

/**
* Drive the CLI the way a user does, as a subprocess, rather than importing the
* generator. The source is ESM and several of its dependencies (chalk, ora) are
* ESM-only, which Jest cannot load in-process without switching the whole runner
* over. Running it through `tsx` sidesteps that and tests the real entry point.
*/
function generate(templateType: string, name: string): string {
function generateWith(
templateType: string,
name: string,
extraArgs: string[] = []
): Generated {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "composer-test-"));
execFileSync(
// spawnSync rather than execFileSync: a diagnostic printed to stderr is part
// of what these tests assert on, and execFileSync only hands back stdout.
const run = spawnSync(
path.join(repoRoot, "node_modules", ".bin", "tsx"),
[
path.join(repoRoot, "src", "index.ts"),
"create",
name,
"-t",
templateType,
...extraArgs,
"--skip-install",
// -y, because a flag no longer implies it. #411 makes only -y skip the
// prompts, so without this the fixture blocks on an interactive question
// with no TTY and dies on "readline was closed". Harmless before that
// lands, required after.
"-y",
],
{ cwd: root, stdio: "pipe" }
{ cwd: root, encoding: "utf8" }
);
return path.join(root, name);
if (run.status !== 0) {
throw new Error(
`create -t ${templateType} ${extraArgs.join(" ")} exited ${run.status}\n${run.stderr}`
);
}
return { projectPath: path.join(root, name), stderr: run.stderr };
}

function generate(templateType: string, name: string): string {
return generateWith(templateType, name).projectPath;
}

// Generating a project shells out to git; give it room.
Expand Down Expand Up @@ -152,3 +173,130 @@ describe("other templates are unaffected", () => {
expect(pkg.pnpm).toBeUndefined();
});
});

/**
* Four templates cannot honour `--wallet-provider`, for two different reasons,
* and one of them was not cosmetic. minipay writes `connect-button.tsx` and
* `wallet-provider.tsx`, the same two filenames the thirdweb template writes,
* and nothing stopped both actions from running: `-t minipay --wallet-provider
* thirdweb` aborted with "File already exists", exited 1, and left a project
* half on disk. The other three discarded the flag in silence.
*/
describe("an explicit --wallet-provider the template cannot honour", () => {
const roots: string[] = [];
const keep = (g: Generated): Generated => {
roots.push(path.dirname(g.projectPath));
return g;
};

afterAll(() => {
for (const root of roots) fs.removeSync(root);
});

it("does not collide with minipay's own components, and says why", () => {
// The regression. Before this, generateWith threw here on exit 1.
const { projectPath, stderr } = keep(
generateWith("minipay", "mp-fixture", ["--wallet-provider", "thirdweb"])
);

const components = fs.readdirSync(
path.join(projectPath, "apps/web/src/components")
);
// minipay's own three survive...
expect(components).toEqual(
expect.arrayContaining([
"connect-button.tsx",
"wallet-provider.tsx",
"user-balance.tsx",
])
);
// ...and thirdweb's lib never lands beside them. This is the half of the
// guard that was missing on the lib action while the components action
// had it.
expect(fs.pathExistsSync(path.join(projectPath, "apps/web/src/lib/client.ts"))).toBe(false);

// minipay is forced to rainbowkit rather than to "none", so the packages its
// own components import are still declared. The first version of this fix
// forced "none" and shipped a scaffold with the components and none of what
// they import — see the minipay default test below.
const pkg = fs.readJsonSync(path.join(projectPath, "apps/web/package.json"));
expect(pkg.dependencies).toHaveProperty("@rainbow-me/rainbowkit");
expect(pkg.dependencies).toHaveProperty("wagmi");

expect(stderr).toContain("Ignoring --wallet-provider thirdweb");
expect(stderr).toContain("rainbowkit-based wallet components");
expect(stderr).toContain("Using rainbowkit instead");
});

it("a default minipay scaffold keeps the stack its components import", () => {
// The assertion that was missing, and the one that catches the regression
// review found on #461: minipay's wallet-provider.tsx imports
// RainbowKitProvider, WagmiProvider and QueryClientProvider, and
// user-balance.tsx uses wagmi hooks. The base manifest gates all four
// packages on walletProvider === "rainbowkit", so forcing "none" produced a
// project whose own components import packages it does not declare.
//
// No flag at all: this is what every ordinary minipay user gets.
const { projectPath } = keep(generateWith("minipay", "mp-default"));
const pkg = fs.readJsonSync(path.join(projectPath, "apps/web/package.json"));

for (const dep of [
"@rainbow-me/rainbowkit",
"wagmi",
"viem",
"@tanstack/react-query",
]) {
expect(pkg.dependencies).toHaveProperty(dep);
}

// And the components that need them are actually there.
const components = fs.readdirSync(path.join(projectPath, "apps/web/src/components"));
expect(components).toEqual(
expect.arrayContaining(["connect-button.tsx", "wallet-provider.tsx", "user-balance.tsx"])
);
});

it("stays quiet when the flag names the provider the template requires", () => {
// `--wallet-provider rainbowkit` on minipay is agreement, not a conflict —
// the same case as `--wallet-provider none` on x402 below.
const { stderr } = keep(
generateWith("minipay", "mp-agrees", ["--wallet-provider", "rainbowkit"])
);
expect(stderr).not.toContain("Ignoring --wallet-provider");
});

it("gives the other reason for a template that ships no wallet layer", () => {
const { projectPath, stderr } = keep(
generateWith("x402", "x402-wallet-fixture", [
"--wallet-provider",
"thirdweb",
])
);
// x402 ships none, so the message must not claim it has its own.
expect(stderr).toContain("ships no wallet layer of its own");
expect(stderr).not.toContain("ships its own wallet components");
expect(fs.pathExistsSync(path.join(projectPath, "apps/web/src/lib/client.ts"))).toBe(false);
});

it("stays quiet when the flag agrees with the forcing", () => {
// `--wallet-provider none` is what the template gets anyway. Warning there
// would be noise about a conflict that does not exist.
const { stderr } = keep(
generateWith("x402", "x402-none-fixture", ["--wallet-provider", "none"])
);
expect(stderr).not.toContain("Ignoring --wallet-provider");
});

it("still wires thirdweb for a template that can take it", () => {
// The control. A guard that suppresses everything would pass every
// assertion above.
const { projectPath, stderr } = keep(
generateWith("basic", "basic-thirdweb-fixture", [
"--wallet-provider",
"thirdweb",
])
);
expect(fs.pathExistsSync(path.join(projectPath, "apps/web/src/lib/client.ts"))).toBe(true);
expect(stderr).not.toContain("Ignoring --wallet-provider");
});
});
23 changes: 10 additions & 13 deletions src/plopfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { NodePlopAPI } from "plop";
import path from "path";
import { getTemplatesPath } from "./utils/paths.js";
import { toPackageName } from "./utils/validation.js";
import { skipsWalletTemplateFiles, walletSkipReason } from "./utils/templates.js";

interface PlopData {
projectName: string;
Expand Down Expand Up @@ -74,12 +75,8 @@ export default function (plop: NodePlopAPI): void {
base: path.join(templatesPath, "wallets/rainbowkit/components/"),
templateFiles: path.join(templatesPath, "wallets/rainbowkit/components/*.tsx.hbs"),
skip: (data: PlopData): string | false => {
if (
data.templateType === "farcaster-miniapp" ||
data.templateType === "minipay" ||
data.templateType === "x402"
) {
return "Skipping RainbowKit - This template uses its own wallet components";
if (skipsWalletTemplateFiles(data.templateType)) {
return walletSkipReason("RainbowKit", data.templateType);
}
if (data.walletProvider !== "rainbowkit") {
return "Skipping RainbowKit - different wallet provider selected";
Expand Down Expand Up @@ -126,11 +123,8 @@ export default function (plop: NodePlopAPI): void {
base: path.join(templatesPath, "wallets/thirdweb/components/"),
templateFiles: path.join(templatesPath, "wallets/thirdweb/components/*.tsx.hbs"),
skip: (data: PlopData): string | false => {
if (
data.templateType === "farcaster-miniapp" ||
data.templateType === "x402"
) {
return "Skipping Thirdweb - this template uses its own wallet components";
if (skipsWalletTemplateFiles(data.templateType)) {
return walletSkipReason("Thirdweb", data.templateType);
}
if (data.walletProvider !== "thirdweb") {
return "Skipping Thirdweb - different wallet provider selected";
Expand All @@ -146,8 +140,11 @@ export default function (plop: NodePlopAPI): void {
base: path.join(templatesPath, "wallets/thirdweb/lib/"),
templateFiles: path.join(templatesPath, "wallets/thirdweb/lib/*.ts.hbs"),
skip: (data: PlopData): string | false => {
if (data.templateType === "farcaster-miniapp") {
return "Skipping Thirdweb lib - Farcaster Miniapp uses its own wallet components";
// Same guard as the components action above. It was narrower than
// that one, which was harmless only because walletProvider is forced
// to "none" for the templates it missed — a latent copy of #396.
if (skipsWalletTemplateFiles(data.templateType)) {
return walletSkipReason("Thirdweb lib", data.templateType);
}
if (data.walletProvider !== "thirdweb") {
return "Skipping Thirdweb lib - different wallet provider selected";
Expand Down
Loading