diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..0e445c0 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,119 @@ +name: Publish Release + +on: + push: + branches: + - main # Trigger on pushes to the main branch + +# Keep workflow_call secrets definition for potential future use or reference +# workflow_call: +# secrets: +# NPM_TOKEN: +# required: true +# SLACK_WEBHOOK_URL: +# required: true +# PUBLISH_DOCS_TOKEN: +# required: true + +jobs: + publish-release: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v1 + with: + is-high-risk-environment: true + ref: ${{ github.sha }} + # This assumes your build command is 'yarn build' and output is 'dist' + - run: yarn build + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: publish-release-artifacts-${{ github.sha }} + retention-days: 4 + include-hidden-files: true + path: | + ./dist + ./node_modules/.yarn-state.yml + + publish-npm-dry-run: + needs: publish-release + runs-on: ubuntu-latest + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v1 + with: + is-high-risk-environment: true + ref: ${{ github.sha }} + - name: Restore build artifacts + uses: actions/download-artifact@v4 + with: + name: publish-release-artifacts-${{ github.sha }} + - name: Dry Run Publish + # omit npm-token token to perform dry run publish + uses: MetaMask/action-npm-publish@v5 + with: + slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} + subteam: S042S7RE4AE # @metamask-npm-publishers + env: + SKIP_PREPACK: true + + publish-npm: + needs: publish-npm-dry-run + runs-on: ubuntu-latest + environment: npm-publish + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v1 + with: + is-high-risk-environment: true + ref: ${{ github.sha }} + - name: Restore build artifacts + uses: actions/download-artifact@v4 + with: + name: publish-release-artifacts-${{ github.sha }} + - name: Publish + uses: MetaMask/action-npm-publish@v5 + with: + npm-token: ${{ secrets.NPM_TOKEN }} + env: + SKIP_PREPACK: true + + get-release-version: + needs: publish-npm + runs-on: ubuntu-latest + outputs: + RELEASE_VERSION: ${{ steps.get-release-version.outputs.RELEASE_VERSION }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + - id: get-release-version + # Use Node.js to extract version from package.json + run: echo "RELEASE_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT + + # The following jobs depend on ./.github/workflows/publish-docs.yml + # Commenting them out for now. Uncomment and create publish-docs.yml if needed. + # publish-release-to-gh-pages: + # name: Publish docs to ${{ needs.get-release-version.outputs.RELEASE_VERSION }} directory of gh-pages branch + # needs: get-release-version + # permissions: + # contents: write + # uses: ./.github/workflows/publish-docs.yml + # with: + # destination_dir: ${{ needs.get-release-version.outputs.RELEASE_VERSION }} + # secrets: + # PUBLISH_DOCS_TOKEN: ${{ secrets.PUBLISH_DOCS_TOKEN }} # Requires PUBLISH_DOCS_TOKEN secret + + # publish-release-to-latest-gh-pages: + # name: Publish docs to latest directory of gh-pages branch + # needs: publish-npm + # permissions: + # contents: write + # uses: ./.github/workflows/publish-docs.yml + # with: + # destination_dir: latest + # secrets: + # PUBLISH_DOCS_TOKEN: ${{ secrets.PUBLISH_DOCS_TOKEN }} # Requires PUBLISH_DOCS_TOKEN secret diff --git a/.gitignore b/.gitignore index e6bce18..6efe8ff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,11 @@ node_modules demo *.yaml -dist \ No newline at end of file +dist +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +.env.development +.env.test \ No newline at end of file diff --git a/README.md b/README.md index 246877e..d606505 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,13 @@ To create a new project using the Web3 Template CLI, run one of the following co Using **pnpm**: ```bash -pnpm create @metamask/create-web3-app [project-name] +pnpm create @consensys/create-web3-app [project-name] ``` Using **npx**: ```bash -npx @metamask/create-web3-app [project-name] +npx @consensys/create-web3-app [project-name] ``` ### Interactive Setup @@ -42,7 +42,7 @@ After running the command, the CLI will guide you through the setup process with ### Example ```bash -npx @metamask/create-web3-app my-web3-project +npx @consensys/create-web3-app my-web3-project ``` ## Project Structure diff --git a/package.json b/package.json index 67d3815..e3976d5 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,16 @@ { - "name": "@metamask/create-web3-app", + "name": "@consensys/create-web3-app", "type": "module", "module": "dist/index.js", "bin": { "create-web3-app": "dist/index.js" }, - "version": "1.0.0", + "version": "1.1.5", "description": "CLI tool for generating Web3 starter projects, streamlining the setup of monorepo structures with a frontend (Next.js or React) and blockchain tooling (HardHat or Foundry). It leverages the commander library for command-line interactions and guides users through selecting a framework, package manager (npm, yarn, pnpm), and blockchain stack.", "main": "index.js", "scripts": { "dev": "tsc -w", + "build": "tsc", "link-cli": "yarn unlink --global create-web3-app && yarn link --global create-web3-app", "test": "vitest" }, @@ -26,10 +27,17 @@ "author": "cxalem", "license": "MIT", "dependencies": { + "@segment/analytics-node": "^2.2.1", + "@types/degit": "^2.8.6", + "chalk": "^5.4.1", "commander": "12.0.0", + "degit": "^2.8.4", + "dotenv": "^16.5.0", "fs": "0.0.1-security", "inquirer": "9.2.15", - "memfs": "^4.9.3" + "memfs": "^4.9.3", + "ora": "^8.2.0", + "uuid": "^11.1.0" }, "devDependencies": { "@types/inquirer": "9.0.7", @@ -41,6 +49,10 @@ "type": "git", "url": "git+https://github.com/MetaMask/create-web3-app.git" }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "bugs": { "url": "https://github.com/MetaMask/create-web3-app/issues" }, diff --git a/src/analytics/index.ts b/src/analytics/index.ts new file mode 100644 index 0000000..4342303 --- /dev/null +++ b/src/analytics/index.ts @@ -0,0 +1,52 @@ +import os from "os"; +import { v4 as uuid } from "uuid"; +import { Analytics } from "@segment/analytics-node"; + +const DEFAULT_WRITE_KEY = "AhYEnWamvR0lVj5JYLDsnxcySWdG6Q5J"; +const WRITE_KEY = process.env.SEGMENT_WRITE_KEY ?? DEFAULT_WRITE_KEY; +const analyticsDisabled = + process.env.SEGMENT_OPT_OUT === "1" || process.env.CI === "true"; + +const enabled = Boolean(WRITE_KEY) && !analyticsDisabled; + +type EVENTS = + | "project_created" + | "cli_started" + | "project_creation_failed" + | "foundry_not_installed" + | "git_not_installed" + | "cwd_not_writable"; + +const analytics = enabled + ? new Analytics({ writeKey: WRITE_KEY }) + : ({ + track: () => {}, + identify: () => {}, + flush: () => Promise.resolve(), + } as unknown as Analytics); + +const anonymousId = uuid(); + +export const identifyRun = () => { + if (!enabled) return; + analytics.identify({ + anonymousId, + traits: { + os_platform: process.platform, + os_release: os.release(), + node_version: process.version, + }, + }); +}; + +export const track = ( + event: EVENTS, + properties: Record = {} +) => + analytics.track({ + event, + anonymousId, + properties, + }); + +export const flush = () => analytics.flush(); diff --git a/src/constants/index.ts b/src/constants/index.ts index bad1e65..61b0919 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,13 +1,15 @@ +import { execAsync } from "../utils/index.js"; +import { TEMPLATES } from "./templates.js"; + +export { TEMPLATES } from "./templates.js"; + export const FRAMEWORK_CHOICES = [ - { - name: "React (with Vite)", - value: "react", - }, { name: "Next.js", - value: "nextjs", + value: "next-web3-starter", }, ] as const; + export const BLOCKCHAIN_TOOLING_CHOICES = [ { name: "HardHat", @@ -23,7 +25,7 @@ export const BLOCKCHAIN_TOOLING_CHOICES = [ }, ] as const; -export const PACAKGE_MANAGER_CHOICES = [ +export const PACKAGE_MANAGER_CHOICES = [ { name: "Yarn", value: "yarn", @@ -38,17 +40,27 @@ export const PACAKGE_MANAGER_CHOICES = [ }, ] as const; -export const NPM_COMMAND = (projectName: string, path: string) => - path - ? `cd ${path} && npm init vite@latest . -- --template react-ts` - : `npm init vite@latest ${projectName} -- --template react-ts`; +// Add a type helper to make working with templates easier +type Template = (typeof TEMPLATES)[number]; + +export type GitTemplate = Extract; +export type DegitTemplate = Extract; + +export function isDegitTemplate(template: Template): template is DegitTemplate { + return "degitSource" in template; +} + +export function isGitTemplate(template: Template): template is GitTemplate { + return "repo_url" in template; +} -export const YARN_COMMAND = (projectName: string, path: string) => - path - ? `cd ${path} && yarn create vite . --template react-ts` - : `yarn create vite ${projectName} --template react-ts`; +export const isGitAvailable = async (): Promise => { + try { + await execAsync("git --version"); + return true; + } catch { + return false; + } +}; -export const PNPM_COMMAND = (projectName: string, path: string) => - path - ? `cd ${path} && pnpm create vite . --template react-ts` - : `pnpm create vite ${projectName} --template react-ts`; +export const CLI_VERSION = "1.1.5"; diff --git a/src/constants/templates.ts b/src/constants/templates.ts index a3e1a27..54641eb 100644 --- a/src/constants/templates.ts +++ b/src/constants/templates.ts @@ -1,14 +1,19 @@ export const TEMPLATES = [ { - name: "Next Web3 Starter", - id: "next-web3-starter", - repo_url: "https://github.com/Consensys/next-web3-starter.git", - packageName: "@consensys/web3-starter", + name: "MetaMask <-> Next.js Wagmi Quickstart", + id: "metamask-nextjs-wagmi", + degitSource: "MetaMask/metamask-sdk-examples/examples/quickstart", }, { - name: "React Web3 Starter", - id: "react-web3-starter", - repo_url: "https://github.com/Consensys/react-web3-starter.git", - packageName: "@consensys/react-web3-starter", + name: "MetaMask <-> Web3Auth Quickstart", + id: "metamask-web3auth", + repo_url: "https://github.com/MetaMask/metamask-web3auth.git", + packageName: "metamask-web3auth", + }, + { + name: "MetaMask <-> Dynamic Quickstart", + id: "metamask-dynamic", + repo_url: "https://github.com/MetaMask/metamask-dynamic.git", + packageName: "metamask-dynamic", }, ] as const; diff --git a/src/index.ts b/src/index.ts index d7ad371..e0c9324 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ #!/usr/bin/env node +import "dotenv/config"; + import { Command } from "commander"; import { createProject } from "./utils/index.js"; diff --git a/src/utils/next.helpers.ts b/src/legacy_code/next.helpers.ts similarity index 63% rename from src/utils/next.helpers.ts rename to src/legacy_code/next.helpers.ts index 6d525cf..f917513 100644 --- a/src/utils/next.helpers.ts +++ b/src/legacy_code/next.helpers.ts @@ -13,7 +13,6 @@ import { createNoise, createArrow, createMetamaskLogo, - updateTailwindConfig, createComponentsFolder, createUtils, } from "./index.js"; @@ -56,8 +55,6 @@ export const createNextApp = async ( await createProvider(projectPathOrName); await createWagmiConfigFile(projectPathOrName, true); await createUtils(projectPathOrName); - await updateGlobalStyles(projectPathOrName); - await updateTailwindConfig(projectPathOrName); await addShadcnButton(projectPathOrName); await addShadcnCard(projectPathOrName); await addShadcnDropdownMenu(projectPathOrName); @@ -67,6 +64,7 @@ export const createNextApp = async ( await createMetamaskLogo(projectPathOrName); await createHero(projectPathOrName); await createNavbar(projectPathOrName); + await updateGlobalStyles(projectPathOrName); await updatePageFile(projectPathOrName); console.log("Next.js project created successfully!"); @@ -196,119 +194,119 @@ const updatePageFile = async (projectPath: string) => { await fs.writeFile( pageFilePath, ` -import { Separator } from "@/src/components/ui/separator"; -import { Card, CardContent, CardHeader, CardTitle } from "@/src/components/ui/card"; -import { ArrowRight } from "lucide-react"; -import { Hero } from "@/src/components/Hero"; - -export default function Home() { - return ( -
-
- - - - -
- - - -
-
- - - Add your own functionality - - - -
-

Guides

-
- {[ - {url: "https://docs.metamask.io/sdk/guides/network-management/", text: "Manage Networks"}, - {url: "https://docs.metamask.io/sdk/guides/transaction-handling/", text: "Handle Transactions"}, - {url: "https://docs.metamask.io/sdk/guides/interact-with-contracts/", text: "Interact with Smart Contracts"}, - ].map((item) => ( - - {item.text} + import { Separator } from "@/src/components/ui/separator"; + import { Card, CardContent, CardHeader, CardTitle } from "@/src/components/ui/card"; + import { ArrowRight } from "lucide-react"; + import { Hero } from "@/src/components/Hero"; + + export default function Home() { + return ( +
+ -
-

Examples

-
- {[ - {url: "https://github.com/MetaMask/metamask-sdk-examples/tree/main/examples/quickstart", text: "Next.js + Wagmi"}, - ].map((item) => ( - - {item.text} + + + +

+ Find in-depth information about the SDK features +

+
+
+ + {/* Get ETH Card */} + +
+
+ + + Get ETH on testnet -
- ))} -
+ + + +

+ Get testnet tokens to use when testing your smart contracts. +

+
+
- - -
-
-
- ); -} - ` + + +
+
+ + + Add your own functionality + + + +
+

Guides

+
+ {[ + {url: "https://docs.metamask.io/sdk/guides/network-management/", text: "Manage Networks"}, + {url: "https://docs.metamask.io/sdk/guides/transaction-handling/", text: "Handle Transactions"}, + {url: "https://docs.metamask.io/sdk/guides/interact-with-contracts/", text: "Interact with Smart Contracts"}, + ].map((item) => ( + + {item.text} + + + ))} +
+
+
+

Examples

+
+ {[ + {url: "https://github.com/MetaMask/metamask-sdk-examples/tree/main/examples/quickstart", text: "Next.js + Wagmi"}, + ].map((item) => ( + + {item.text} + + + ))} +
+
+
+
+ + + + ); + } + ` ); }; @@ -466,9 +464,63 @@ const updateGlobalStyles = async (projectPath: string) => { await fs.writeFile( globalStylesFilePath, ` -@tailwind base; -@tailwind components; -@tailwind utilities; +@import 'tailwindcss'; + +@plugin 'tailwindcss-animate'; + +@custom-variant dark (&:is(.dark *)); + +@theme { + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + + --color-chart-1: hsl(var(--chart-1)); + --color-chart-2: hsl(var(--chart-2)); + --color-chart-3: hsl(var(--chart-3)); + --color-chart-4: hsl(var(--chart-4)); + --color-chart-5: hsl(var(--chart-5)); + + --background-image-noise: url('/noise.svg'); + + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); +} + +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentColor); + } +} @layer base { @font-face { @@ -530,8 +582,10 @@ const updateGlobalStyles = async (projectPath: string) => { } } -body { - font-family: Arial, Helvetica, sans-serif; +@layer utilities { + body { + font-family: Arial, Helvetica, sans-serif; + } } @layer base { diff --git a/src/utils/vite.helpers.ts b/src/legacy_code/vite.helpers.ts similarity index 100% rename from src/utils/vite.helpers.ts rename to src/legacy_code/vite.helpers.ts diff --git a/src/utils/index.test.ts b/src/utils/index.test.ts new file mode 100644 index 0000000..52bed43 --- /dev/null +++ b/src/utils/index.test.ts @@ -0,0 +1,469 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import inquirer from "inquirer"; +import { promises as fsPromises } from "fs"; +import path from "path"; +import degit from "degit"; // Import the actual degit + +// Import functions to test and mocks +import * as utils from "./index.js"; // Import all exports +import { + TEMPLATES, + BLOCKCHAIN_TOOLING_CHOICES, + PACKAGE_MANAGER_CHOICES, + GitTemplate, // Import type for clarity + DegitTemplate, // Import type for clarity +} from "../constants/index.js"; + +// --- Mocks --- +vi.mock("inquirer"); + +// Mock fs promises +vi.mock("fs", async (importOriginal) => { + const originalFs = await importOriginal(); + return { + ...originalFs, + promises: { + mkdir: vi.fn(), + writeFile: vi.fn(), + readFile: vi.fn(), + rm: vi.fn(), + }, + }; +}); + +// Mock execAsync +vi.mock("./index", async (importOriginal) => { + const originalModule = await importOriginal(); + return { + ...originalModule, + execAsync: vi.fn(), + }; +}); + +// --- Refined Degit Mock --- +// 1. Define the structure the mock factory will return +const singleDegitInstance = { clone: vi.fn() }; +// 2. Mock the factory to *always* return this single instance +vi.mock("degit", () => ({ + default: vi.fn().mockImplementation(() => singleDegitInstance) +})); + +// --- Access Mocks via Modules --- +const mockedFsMkdir = vi.mocked(fsPromises.mkdir); +const mockedFsWriteFile = vi.mocked(fsPromises.writeFile); +const mockedFsReadFile = vi.mocked(fsPromises.readFile); +const mockedFsRm = vi.mocked(fsPromises.rm); +const mockedExecAsync = vi.mocked(utils.execAsync); +// 3. Get a reference to the mocked factory +const mockedDegitFactory = vi.mocked(degit); +// 4. Get a direct reference to the clone method on our single instance +const mockedDegitClone = vi.mocked(singleDegitInstance.clone); + + +// Mock console logging +vi.spyOn(console, "log").mockImplementation(() => {}); +vi.spyOn(console, "error").mockImplementation(() => {}); +vi.spyOn(console, "warn").mockImplementation(() => {}); + +describe("create-web3-app Utils", () => { + beforeEach(() => { + // Reset mocks using the direct references + vi.clearAllMocks(); // Clears call history, reset spies + mockedFsMkdir.mockReset(); + mockedFsWriteFile.mockReset(); + mockedFsReadFile.mockReset(); + mockedFsRm.mockReset(); + mockedExecAsync.mockReset(); + mockedDegitFactory.mockClear(); // Clear calls to the factory itself + mockedDegitClone.mockReset(); // Reset the clone method on the single instance + + vi.mocked(inquirer.prompt).mockReset(); + }); + + // --- Test promptForOptions --- + describe("promptForOptions", () => { + it("should return correct options when all prompts are answered", async () => { + const mockArgs = "my-test-project"; + const mockTemplate = TEMPLATES[0]; // Use the first template + const mockTooling = BLOCKCHAIN_TOOLING_CHOICES[0]; + const mockPackageManager = PACKAGE_MANAGER_CHOICES[0]; + const mockAnswers = { + frameworkName: mockTemplate.name, + tooling: mockTooling.name, + packageManager: mockPackageManager.name, + }; + // Set mock for this test only + vi.mocked(inquirer.prompt).mockResolvedValue(mockAnswers); + + const options = await utils.promptForOptions(mockArgs); + + expect(inquirer.prompt).toHaveBeenCalledTimes(3); + expect(options).toEqual({ + projectName: mockArgs, + templateId: mockTemplate.id, + blockchain_tooling: mockTooling.value, + packageManager: mockPackageManager.value, + }); + }); + + it("should prompt for projectName if args are empty", async () => { + const mockProjectName = "prompted-project"; + const mockTemplate = TEMPLATES[1]; // Use second template + const mockTooling = BLOCKCHAIN_TOOLING_CHOICES[1]; + const mockPackageManager = PACKAGE_MANAGER_CHOICES[1]; + const mockAnswers = { + projectName: mockProjectName, + frameworkName: mockTemplate.name, + tooling: mockTooling.name, + packageManager: mockPackageManager.name, + }; + // Chain mocks for this test + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({ projectName: mockProjectName }) + .mockResolvedValueOnce({ frameworkName: mockAnswers.frameworkName }) + .mockResolvedValueOnce({ tooling: mockAnswers.tooling }) + .mockResolvedValueOnce({ packageManager: mockAnswers.packageManager }); + + const options = await utils.promptForOptions(""); + + expect(inquirer.prompt).toHaveBeenCalledTimes(4); + expect(options).toEqual({ + projectName: mockProjectName, + templateId: mockTemplate.id, + blockchain_tooling: mockTooling.value, + packageManager: mockPackageManager.value, + }); + }); + + // Updated test for invalid template name + it("should throw error if template selection is invalid", async () => { + const mockArgs = "my-test-project"; + const mockAnswers = { + frameworkName: "NonExistentTemplate", // Invalid name + tooling: "HardHat", + packageManager: "npm", + }; + vi.mocked(inquirer.prompt).mockResolvedValue(mockAnswers); + + // Expect the error thrown by promptForFramework + await expect(utils.promptForOptions(mockArgs)).rejects.toThrow( + 'Internal error: Could not find template data for selected name "NonExistentTemplate"' + ); + }); + }); + + // --- Test cloneTemplate --- + describe("cloneTemplate", () => { + const degitTemplate = TEMPLATES.find(t => t.id === 'metamask-nextjs-wagmi') as DegitTemplate; + // const gitTemplate = TEMPLATES.find(t => t.id === 'react-web3-starter') as GitTemplate; + const destinationPath = "/path/to/project"; + const projectName = "my-project"; + const gitPath = path.join(destinationPath, ".git"); + const packageJsonPath = path.join(destinationPath, "package.json"); + const options: utils.ProjectOptions = { projectName, templateId: degitTemplate.id, blockchain_tooling: 'none', packageManager: 'yarn' }; + + + beforeEach(() => { + // Reset mocks used within cloneTemplate tests + mockedExecAsync.mockResolvedValue({ stdout: "", stderr: "" }); + mockedFsReadFile.mockResolvedValue( + JSON.stringify({ name: "template-name", version: "1.0.0" }) + ); + mockedFsWriteFile.mockResolvedValue(undefined); + mockedFsRm.mockResolvedValue(undefined); + // Reset the clone mock specifically + mockedDegitClone.mockReset().mockResolvedValue(undefined); // Ensure clone defaults to success + }); + + // --- Degit Path Tests --- + it("should call degit factory and clone method for DegitTemplate", async () => { + await utils.cloneTemplate(options, destinationPath); + + // Check the factory call + expect(mockedDegitFactory).toHaveBeenCalledWith(degitTemplate.degitSource, expect.anything()); + // Check the clone call on the instance + expect(mockedDegitClone).toHaveBeenCalledWith(destinationPath); + + expect(mockedExecAsync).not.toHaveBeenCalled(); + expect(mockedFsRm).not.toHaveBeenCalledWith(gitPath, expect.anything()); + }); + + it("should read, update, and write package.json for DegitTemplate", async () => { + mockedFsReadFile.mockResolvedValue(JSON.stringify({ name: "old-name", version: "1.0.0" })); // Provide specific content + await utils.cloneTemplate(options, destinationPath); + + // Verify degit clone was called first + expect(mockedDegitClone).toHaveBeenCalled(); + + // Verify package.json handling + expect(mockedFsReadFile).toHaveBeenCalledWith(packageJsonPath, "utf-8"); + const expectedPackageJson = { name: projectName, version: "1.0.0" }; + expect(mockedFsWriteFile).toHaveBeenCalledWith( + packageJsonPath, + JSON.stringify(expectedPackageJson, null, 2), + "utf-8" + ); + }); + + it("should handle errors during degit clone", async () => { + const cloneError = new Error("Degit clone failed"); + mockedDegitClone.mockRejectedValueOnce(cloneError); // Make degit fail + + await expect( + utils.cloneTemplate(options, destinationPath) + ).rejects.toThrow(cloneError); + + // Ensure subsequent steps didn't run + expect(mockedFsReadFile).not.toHaveBeenCalled(); + expect(mockedFsWriteFile).not.toHaveBeenCalled(); + }); + + it("should warn if package.json update fails for DegitTemplate", async () => { + const writeError = new Error("Failed to write file"); + mockedFsWriteFile.mockRejectedValueOnce(writeError); // Fail writing package.json + + await utils.cloneTemplate(options, destinationPath); + + expect(mockedDegitClone).toHaveBeenCalled(); // Ensure clone happened + expect(mockedFsReadFile).toHaveBeenCalled(); // Read should have happened + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining("Warning: Could not update package.json name"), + expect.stringContaining(writeError.message) + ); + }); + + + // --- Git Clone Path Tests --- + it("should call git clone with correct arguments for GitTemplate", async () => { + await utils.cloneTemplate(options, destinationPath); + + // expect(mockedExecAsync).toHaveBeenCalledWith( + // `git clone ${gitTemplate.repo_url} ${destinationPath}` + // ); + expect(mockedDegitFactory).not.toHaveBeenCalled(); // Ensure degit factory wasn't called + expect(mockedDegitClone).not.toHaveBeenCalled(); // Ensure degit clone wasn't called + }); + + it("should remove the .git directory after cloning for GitTemplate", async () => { + await utils.cloneTemplate(options, destinationPath); + + expect(mockedExecAsync).toHaveBeenCalled(); // Ensure clone happened + expect(mockedFsRm).toHaveBeenCalledWith(gitPath, { + recursive: true, + force: true, + }); + }); + + it("should read, update, and write package.json for GitTemplate", async () => { + mockedFsReadFile.mockResolvedValue(JSON.stringify({ name: "old-name", version: "1.0.0" })); + await utils.cloneTemplate(options, destinationPath); + + // Verify clone happened first + expect(mockedExecAsync).toHaveBeenCalled(); + expect(mockedFsRm).toHaveBeenCalled(); + + // Verify package.json handling + expect(mockedFsReadFile).toHaveBeenCalledWith(packageJsonPath, "utf-8"); + const expectedPackageJson = { name: projectName, version: "1.0.0" }; + expect(mockedFsWriteFile).toHaveBeenCalledWith( + packageJsonPath, + JSON.stringify(expectedPackageJson, null, 2), + "utf-8" + ); + }); + + it("should handle errors during git clone for GitTemplate", async () => { + const cloneError = new Error("Git clone failed"); + mockedExecAsync.mockRejectedValueOnce(cloneError); // Make git clone fail + + await expect( + utils.cloneTemplate(options, destinationPath) + ).rejects.toThrow(cloneError); + + // Ensure subsequent steps didn't run + expect(mockedFsRm).not.toHaveBeenCalled(); + expect(mockedFsReadFile).not.toHaveBeenCalled(); + }); + + // --- Common Tests --- + it("should throw error if templateId is not found", async () => { + await expect( + utils.cloneTemplate(options, destinationPath) + ).rejects.toThrow('Template with id "invalid-id" not found.'); + expect(mockedExecAsync).not.toHaveBeenCalled(); + expect(mockedDegitClone).not.toHaveBeenCalled(); // Check clone mock here + }); + }); + + // --- Test initializeMonorepo --- + describe("initializeMonorepo", () => { + const projectName = "my-monorepo"; + const packagesPath = path.join(projectName, "packages"); + const blockchainPath = path.join(projectName, "packages", "blockchain"); + const sitePath = path.join(projectName, "packages", "site"); + const gitignorePath = path.join(projectName, ".gitignore"); + const rootPackageJsonPath = path.join(projectName, "package.json"); + const pnpmWorkspacePath = path.join(projectName, "pnpm-workspace.yaml"); + + + it("should create base directories", async () => { + const options: utils.ProjectOptions = { projectName, templateId: "t", blockchain_tooling: 'none', packageManager: 'npm' }; + await utils.initializeMonorepo(options); + + // Check that mkdir was called for all necessary paths + expect(mockedFsMkdir).toHaveBeenCalledWith(packagesPath, { recursive: true }); + expect(mockedFsMkdir).toHaveBeenCalledWith(blockchainPath, { recursive: true }); + expect(mockedFsMkdir).toHaveBeenCalledWith(sitePath, { recursive: true }); + }); + + it("should create .gitignore", async () => { + const options: utils.ProjectOptions = { projectName, templateId: "t", blockchain_tooling: 'none', packageManager: 'npm' }; + await utils.initializeMonorepo(options); + + // Check that writeFile was called for .gitignore + expect(mockedFsWriteFile).toHaveBeenCalledWith( + gitignorePath, + expect.stringContaining("node_modules") + ); + }); + + it("should create root package.json", async () => { + const options: utils.ProjectOptions = { projectName, templateId: "t", blockchain_tooling: 'none', packageManager: 'yarn' }; + await utils.initializeMonorepo(options); + + const expectedPackageJson = { + name: projectName, + private: true, + workspaces: ["packages/*"], + scripts: {}, + }; + // Check that writeFile was called for package.json + expect(mockedFsWriteFile).toHaveBeenCalledWith( + rootPackageJsonPath, + JSON.stringify(expectedPackageJson, null, 2) + ); + }); + + it("should create pnpm-workspace.yaml for pnpm", async () => { + const options: utils.ProjectOptions = { projectName, templateId: "t", blockchain_tooling: 'none', packageManager: 'pnpm' }; + await utils.initializeMonorepo(options); + + // Check that writeFile was called for pnpm-workspace.yaml + expect(mockedFsWriteFile).toHaveBeenCalledWith( + pnpmWorkspacePath, + expect.stringContaining("packages:") + ); + }); + + // This test was passing, should still pass + it("should not create pnpm-workspace.yaml for npm/yarn", async () => { + const npmOptions: utils.ProjectOptions = { projectName, templateId: "t", blockchain_tooling: 'none', packageManager: 'npm' }; + await utils.initializeMonorepo(npmOptions); + expect(mockedFsWriteFile).not.toHaveBeenCalledWith(pnpmWorkspacePath, expect.anything()); + + vi.clearAllMocks(); // Reset mocks for the next part of the test + mockedFsWriteFile.mockReset(); // Specifically reset writeFile + + + const yarnOptions: utils.ProjectOptions = { projectName, templateId: "t", blockchain_tooling: 'none', packageManager: 'yarn' }; + await utils.initializeMonorepo(yarnOptions); + expect(mockedFsWriteFile).not.toHaveBeenCalledWith(pnpmWorkspacePath, expect.anything()); + }); + }); + + + // --- Test createProject (integration-like) --- + describe("createProject", () => { + const projectName = "final-project"; + const templateId = TEMPLATES[0].id; // Use the degit template for these tests + const installCommand = "yarn install"; + + const mockOptions: utils.ProjectOptions = { projectName, templateId, blockchain_tooling: 'none', packageManager: 'yarn' }; + const mockHardhatOptions: utils.ProjectOptions = { projectName, templateId, blockchain_tooling: 'hardhat', packageManager: 'yarn' }; + const mockFoundryOptions: utils.ProjectOptions = { projectName, templateId, blockchain_tooling: 'foundry', packageManager: 'pnpm' }; + + + // Mock promptForOptions directly for these integration tests + const promptOptionsMock = vi.spyOn(utils, 'promptForOptions'); + + + // Keep stubs for lower-level functions if needed for fine-grained checks, + // but the main goal here is to test the createProject logic flow. + const initializeMonorepoMock = vi.spyOn(utils, 'initializeMonorepo').mockResolvedValue(undefined); + const cloneTemplateMock = vi.spyOn(utils, 'cloneTemplate').mockResolvedValue(undefined); + + + beforeEach(() => { + // Reset spies and mocks specific to this suite + promptOptionsMock.mockClear(); + initializeMonorepoMock.mockClear(); + cloneTemplateMock.mockClear(); + mockedExecAsync.mockReset(); // Ensure execAsync is clean for the install command check + mockedExecAsync.mockResolvedValue({ stdout: "", stderr: "" }); // Default success for install command + }); + + + it("should call cloneTemplate directly for 'none' tooling", async () => { + promptOptionsMock.mockResolvedValue(mockOptions); // Control options returned + + await utils.createProject(projectName); + + expect(promptOptionsMock).toHaveBeenCalled(); // Verify options were prompted/retrieved + expect(initializeMonorepoMock).not.toHaveBeenCalled(); // Should not init monorepo + expect(cloneTemplateMock).toHaveBeenCalledWith(templateId, projectName, projectName); // Called directly + expect(mockedExecAsync).toHaveBeenCalledWith(`cd ${projectName} && ${installCommand}`); // Install called + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("Success! Created")); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("yarn run dev")); // Standalone guidance + expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining("blockchain")); // No monorepo guidance + }); + + it("should call initializeMonorepo and cloneTemplate (via createHardhatProject) for 'hardhat' tooling", async () => { + promptOptionsMock.mockResolvedValue(mockHardhatOptions); + + + await utils.createProject(projectName); + + expect(promptOptionsMock).toHaveBeenCalled(); + expect(initializeMonorepoMock).toHaveBeenCalledWith(mockHardhatOptions); + // Hardhat template clone uses execAsync, frontend uses cloneTemplate + expect(mockedExecAsync).toHaveBeenCalledWith(expect.stringContaining('git clone https://github.com/Consensys/hardhat-template.git')); + expect(cloneTemplateMock).toHaveBeenCalledWith(templateId, path.join(projectName, "packages", "site"), projectName); + expect(mockedExecAsync).toHaveBeenCalledWith(`cd ${projectName} && ${installCommand}`); // Install command + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("Success! Created")); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("yarn run compile")); // Monorepo guidance + }); + + it("should call initializeMonorepo and cloneTemplate (via createFoundryProject) for 'foundry' tooling", async () => { + const pnpmInstallCommand = "pnpm install"; + promptOptionsMock.mockResolvedValue(mockFoundryOptions); + + await utils.createProject(projectName); + + expect(promptOptionsMock).toHaveBeenCalled(); + expect(initializeMonorepoMock).toHaveBeenCalledWith(mockFoundryOptions); + // Foundry init uses execAsync, frontend uses cloneTemplate + expect(mockedExecAsync).toHaveBeenCalledWith(expect.stringContaining('forge init . --no-commit')); + expect(cloneTemplateMock).toHaveBeenCalledWith(templateId, path.join(projectName, "packages", "site"), projectName); + expect(mockedExecAsync).toHaveBeenCalledWith(`cd ${projectName} && ${pnpmInstallCommand}`); // Install command + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("Success! Created")); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining("pnpm run compile")); // Monorepo guidance + }); + + + it("should handle errors during creation and not install", async () => { + const creationError = new Error("Setup failed"); + promptOptionsMock.mockResolvedValue(mockOptions); + // Make cloneTemplate fail + cloneTemplateMock.mockRejectedValueOnce(creationError); + + await utils.createProject(projectName); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("An error occurred during project creation:"), + creationError + ); + // Ensure install command is NOT run if setup fails + expect(mockedExecAsync).not.toHaveBeenCalledWith(expect.stringContaining("install")); + }); + }); +}); diff --git a/src/utils/index.ts b/src/utils/index.ts index 7f3f8c0..f9912bb 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,50 +1,80 @@ import { exec } from "child_process"; -import { promises as fs } from "fs"; +import { promises as fs, constants as fsConstants } from "fs"; import { BLOCKCHAIN_TOOLING_CHOICES, - FRAMEWORK_CHOICES, - PACAKGE_MANAGER_CHOICES, + PACKAGE_MANAGER_CHOICES, + TEMPLATES, + isDegitTemplate, + isGitTemplate, + CLI_VERSION, + isGitAvailable, } from "../constants/index.js"; -import { createReactApp } from "./vite.helpers.js"; -import { createNextApp } from "./next.helpers.js"; import path from "path"; import util from "util"; import inquirer from "inquirer"; +import degit from "degit"; +import ora, { Ora } from "ora"; +import chalk from "chalk"; +import { identifyRun, track, flush } from "../analytics/index.js"; export const execAsync = util.promisify(exec); const promptForFramework = async (): Promise => { - const frameworkChoice = FRAMEWORK_CHOICES.map((choice) => choice.name); - const { framework }: { framework: string } = await inquirer.prompt([ + const templateChoices = TEMPLATES.map((template) => { + if (template.id === "metamask-nextjs-wagmi") { + return { + name: `${template.name} ${chalk.hex("#FFA500")("(Recommended)")}`, + value: template.name, + }; + } + return template.name; + }); + const { frameworkName }: { frameworkName: string } = await inquirer.prompt([ { type: "list", - name: "framework", - message: "Please select the framework you want to use:", - choices: ["Next.js"], + name: "frameworkName", + message: "Please select the template you want to use:", + choices: templateChoices, }, ]); - console.log(`Selected framework: ${framework}`); + console.log(`Selected template: ${frameworkName}`); - return framework; + const selectedTemplate = TEMPLATES.find( + (template) => template.name === frameworkName + ); + if (!selectedTemplate) { + throw new Error( + `Internal error: Could not find template data for selected name "${frameworkName}"` + ); + } + return selectedTemplate.id; }; -const promptForTooling = async (): Promise => { +const promptForBlockchainTooling = async (): Promise => { const toolingChoice = BLOCKCHAIN_TOOLING_CHOICES.map((choice) => choice.name); const { tooling }: { tooling: string } = await inquirer.prompt([ { type: "list", name: "tooling", - message: "Would you like to use HardHat or Foundry?", + message: "Would you like to include blockchain tooling?", choices: toolingChoice, }, ]); console.log(`Selected tooling: ${tooling}`); + if (tooling === "Foundry") { + console.log( + chalk.yellow( + "\nNote: Foundry's 'forge' CLI must be installed and in your PATH to use this option." + ) + ); + } + return tooling; }; const promptForPackageManager = async (): Promise => { - const packageManagerChoice = PACAKGE_MANAGER_CHOICES.map( + const packageManagerChoice = PACKAGE_MANAGER_CHOICES.map( (choice) => choice.name ); const { packageManager }: { packageManager: string } = await inquirer.prompt([ @@ -57,6 +87,46 @@ const promptForPackageManager = async (): Promise => { ]); console.log(`Selected package manager: ${packageManager}`); + if (packageManager === "pnpm") { + let pnpmAvailable = true; + try { + await execAsync("pnpm -v"); + } catch { + pnpmAvailable = false; + } + + if (!pnpmAvailable) { + console.log( + chalk.yellow("pnpm is not installed or not found in your PATH.") + ); + + const { installPnpmNow } = await inquirer.prompt([ + { + type: "confirm", + name: "installPnpmNow", + message: "Would you like to install pnpm globally using npm now?", + default: true, + }, + ]); + + if (installPnpmNow) { + try { + console.log(chalk.blue("Installing pnpm globally via npm...")); + await execAsync("npm install -g pnpm"); + console.log(chalk.green("pnpm installed successfully.")); + } catch (installError) { + throw new Error( + "Failed to install pnpm automatically. Please install it manually and re-run the command." + ); + } + } else { + throw new Error( + "pnpm installation declined. Please install pnpm manually or choose a different package manager." + ); + } + } + } + return packageManager; }; @@ -67,7 +137,16 @@ const promptForProjectDetails = async (args: string): Promise => { type: "input", name: "projectName", message: "Please specify a name for your project: ", - validate: (input) => (input ? true : "Project name cannot be empty"), + validate: (input) => { + if (!input) { + return "Project name cannot be empty"; + } + const kebabCaseRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + if (!kebabCaseRegex.test(input)) { + return "Project name must be in kebab-case (e.g., my-awesome-project)"; + } + return true; + }, }, ]); console.log("Creating project with name:", projectName); @@ -76,793 +155,408 @@ const promptForProjectDetails = async (args: string): Promise => { return args; }; -const promptForOptions = async (args: string) => { +export interface ProjectOptions { + projectName: string; + templateId: string; + blockchain_tooling: "hardhat" | "foundry" | "none"; + packageManager: "npm" | "yarn" | "pnpm"; + dynamicEnvId?: string; +} + +export const promptForOptions = async ( + args: string +): Promise => { const projectName = await promptForProjectDetails(args); - const framework = await promptForFramework(); - const tooling = await promptForTooling(); + const templateId = await promptForFramework(); + const tooling = await promptForBlockchainTooling(); const packageManager = await promptForPackageManager(); - const options = { + let dynamicEnvId: string | undefined = undefined; + + if (templateId === "metamask-dynamic") { + const { addDynamicIdNow } = await inquirer.prompt([ + { + type: "confirm", + name: "addDynamicIdNow", + message: `The selected template uses Dynamic.xyz. You'll need a Dynamic Environment ID added to a .env file. Would you like to add it now? You can get one from https://app.dynamic.xyz/dashboard/developer/api`, + default: true, + }, + ]); + + if (addDynamicIdNow) { + const { providedDynamicId } = await inquirer.prompt([ + { + type: "password", + name: "providedDynamicId", + message: "Please paste your Dynamic Environment ID:", + mask: "*", + validate: (input) => + input ? true : "Dynamic Environment ID cannot be empty", + }, + ]); + dynamicEnvId = providedDynamicId; + console.log("Dynamic Environment ID received."); + } else { + console.log( + chalk.yellow( + "Okay, please remember to add NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID= to the .env file in your site's directory later." + ) + ); + } + } else if (templateId === "metamask-web3auth") { + console.log( + chalk.yellow( + "\nNote: The selected template requires a Web3Auth client ID. You can obtain one from https://dashboard.web3auth.io/ and later add NEXT_PUBLIC_WEB3AUTH_CLIENT_ID= to a .env file in your site's directory." + ) + ); + } + + const options: ProjectOptions = { projectName: projectName, - framework: FRAMEWORK_CHOICES.find((choice) => choice.name === framework) - ?.value, + templateId: templateId, blockchain_tooling: BLOCKCHAIN_TOOLING_CHOICES.find( (choice) => choice.name === tooling - )?.value, - packageManager: PACAKGE_MANAGER_CHOICES.find( + )?.value as ProjectOptions["blockchain_tooling"], + packageManager: PACKAGE_MANAGER_CHOICES.find( (choice) => choice.name === packageManager - )?.value!, + )?.value as ProjectOptions["packageManager"], + dynamicEnvId: dynamicEnvId, }; - return options as any; -}; - -const initializeMonorepo = async (options: ProjectOptions) => { - const { projectName, packageManager } = options; - console.log("Initializing monorepo..."); - if (packageManager === "pnpm") { - await fs.writeFile( - path.join(projectName, "pnpm-workspace.yaml"), - `packages: - - 'packages/*'` - ); + if (!TEMPLATES.some((t) => t.id === options.templateId)) { + throw new Error(`Invalid template ID resolved: ${options.templateId}`); } - await fs.writeFile(path.join(projectName, ".gitignore"), `node_modules`); - await execAsync(`cd ${projectName} && npm init -y`); - await execAsync(`cd ${projectName} && npm init -w ./packages/blockchain -y`); - await execAsync(`cd ${projectName} && npm init -w ./packages/site -y`); - - await fs.rm(path.join(projectName, "packages", "blockchain", "package.json")); - await fs.rm(path.join(projectName, "packages", "site", "package.json")); - await fs.rm(path.join(projectName, "node_modules"), { recursive: true }); + return options; }; -const createHardhatProject = async (options: ProjectOptions) => { - const { projectName, framework } = options; - await fs.mkdir(projectName); +export const cloneTemplate = async ( + options: Pick< + ProjectOptions, + "templateId" | "projectName" | "dynamicEnvId" | "blockchain_tooling" + >, + destinationPath: string +) => { + const { templateId, projectName, dynamicEnvId, blockchain_tooling } = options; + const template = TEMPLATES.find((t) => t.id === templateId); + if (!template) { + throw new Error(`Template with id "${templateId}" not found.`); + } - console.log("Creating a project with HardHat..."); + const spinner = ora( + `Preparing template "${template.name}" into ${destinationPath}...` + ).start(); - await initializeMonorepo(options); - await execAsync( - `git clone https://github.com/Consensys/hardhat-template.git ${path.join( - projectName, - "packages", - "blockchain" - )}` - ); + if (!(await isGitAvailable())) { + spinner.fail("Git is not installed or not found in your PATH."); + + track("git_not_installed", { + destination_path: destinationPath, + template_id: templateId, + }); - if (framework === "nextjs") { - await createNextApp(options, path.join(projectName, "packages", "site")); - } else { - await createReactApp(options, path.join(projectName, "packages", "site")); + throw new Error( + "Git is required to clone templates. Please install Git (https://git-scm.com/downloads) and try again." + ); } -}; -const createFoundryProject = async (options: ProjectOptions) => { - const { projectName, framework } = options; - await fs.mkdir(projectName); + try { + if (isDegitTemplate(template)) { + spinner.text = `Cloning template "${template.name}" from ${template.degitSource} using degit...`; + const emitter = degit(template.degitSource, { + cache: false, + force: true, + verbose: false, + }); + + await emitter.clone(destinationPath); + } else if (isGitTemplate(template)) { + spinner.text = `Cloning template "${template.name}" from ${template.repo_url} using git...`; + await execAsync(`git clone ${template.repo_url} ${destinationPath}`); + await fs.rm(path.join(destinationPath, ".git"), { + recursive: true, + force: true, + }); + } else { + spinner.fail(`Template preparation failed.`); + throw new Error(`Template has neither repo_url nor degitSource defined.`); + } - console.log("Creating a project with Foundry..."); + const packageJsonPath = path.join(destinationPath, "package.json"); + try { + spinner.text = `Updating package name to ${path.basename( + projectName + )}...`; + const packageJsonContent = await fs.readFile(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(packageJsonContent); + packageJson.name = path.basename(projectName); + if (blockchain_tooling !== "none") { + packageJson.name = `site`; + } + const newPackageJsonContent = JSON.stringify(packageJson, null, 2); + await fs.writeFile(packageJsonPath, newPackageJsonContent, "utf-8"); + } catch (pkgError) { + console.warn( + `Warning: Could not update package.json name in ${destinationPath}. Manual update might be needed. Error: ${ + pkgError instanceof Error ? pkgError.message : pkgError + }` + ); + } - await initializeMonorepo(options); - if (framework === "nextjs") { - await createNextApp(options, path.join(projectName, "packages", "site")); - } else { - await createReactApp(options, path.join(projectName, "packages", "site")); - } + if (dynamicEnvId) { + spinner.text = `Creating .env file with Dynamic Environment ID...`; + const envContent = `NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID=${dynamicEnvId}\n`; + const envPath = path.join(destinationPath, ".env"); + await fs.writeFile(envPath, envContent, "utf-8"); + spinner.text = `.env file created successfully.`; + } - await execAsync(` - cd ${projectName}/packages/blockchain && forge init . --no-commit - `); + spinner.succeed( + `Template "${template.name}" prepared successfully in ${destinationPath}.` + ); + } catch (error) { + spinner.fail(`Error preparing template "${template.name}".`); + console.error(`Error details:`, error); + throw error; + } }; -export const pathOrProjectName = ( - projectName: string, - projectPath?: string -) => { - return projectPath ? projectPath : projectName; -}; +export const initializeMonorepo = async (options: ProjectOptions) => { + const { projectName, packageManager } = options; + console.log("Initializing monorepo structure..."); -export const updatePackageJsonDependencies = async ( - dependencies: Record, - projectPath: string -) => { - const packageJsonPath = path.join(projectPath, "package.json"); - const packageJsonContent = await fs.readFile(packageJsonPath, "utf-8"); - const packageJson = JSON.parse(packageJsonContent); + await fs.mkdir(path.join(projectName, "packages"), { recursive: true }); - packageJson.dependencies = { - ...packageJson.dependencies, - ...dependencies, + if (packageManager === "pnpm") { + await fs.writeFile( + path.join(projectName, "pnpm-workspace.yaml"), + `packages:\n - 'packages/*'` + ); + } + + await fs.writeFile( + path.join(projectName, ".gitignore"), + `node_modules\n.DS_Store\npackages/*/node_modules\npackages/*/.DS_Store\npackages/*/dist\npackages/*/.env\npackages/*/.turbo\npackages/*/coverage` + ); + const rootPackageJson = { + name: projectName, + private: true, + workspaces: ["packages/*"], + scripts: {}, }; + await fs.writeFile( + path.join(projectName, "package.json"), + JSON.stringify(rootPackageJson, null, 2) + ); - const newPackageJsonContent = JSON.stringify(packageJson, null, 2); - await fs.writeFile(packageJsonPath, newPackageJsonContent, "utf-8"); + await fs.mkdir(path.join(projectName, "packages", "site"), { + recursive: true, + }); - console.log("Dependencies added to package.json"); + console.log("Monorepo structure initialized."); }; -export const createWagmiConfigFile = async ( - projectPath: string, - ssr: boolean = false -) => { - await fs.writeFile( - path.join(projectPath, "wagmi.config.ts"), - ` -import { createConfig, http, cookieStorage, createStorage } from "wagmi"; -import { lineaSepolia, linea, mainnet } from "wagmi/chains"; -import { metaMask } from "wagmi/connectors"; - -export function getConfig() { - return createConfig({ - chains: [lineaSepolia, linea, mainnet], - connectors: [metaMask()], - ssr: ${ssr}, - storage: createStorage({ - storage: cookieStorage, - }), - transports: { - [lineaSepolia.id]: http(), - [linea.id]: http(), - [mainnet.id]: http(), - }, +export const createHardhatProject = async (options: ProjectOptions) => { + const { projectName, templateId } = options; + console.log("Setting up project with HardHat..."); + + await initializeMonorepo(options); + + console.log("Cloning Hardhat template..."); + await execAsync( + `git clone https://github.com/Consensys/hardhat-template.git ${path.join( + projectName, + "packages", + "blockchain" + )}` + ); + await fs.rm(path.join(projectName, "packages", "blockchain", ".git"), { + recursive: true, + force: true, }); -} -` + await cloneTemplate( + { + templateId, + projectName, + dynamicEnvId: options.dynamicEnvId, + blockchain_tooling: "hardhat", + }, + path.join(projectName, "packages", "site") ); -}; -export const usePackageManager = (packageManager: string) => { - switch (packageManager) { - case "npm": - return "--use-npm"; - case "yarn": - return "--use-yarn"; - case "pnpm": - return "--use-pnpm"; - default: - return "--use-npm"; - } + console.log("Hardhat project setup complete."); }; -export const addShadcnButton = async (projectPath: string) => { - const buttonFilePath = path.join( - projectPath, - "src", - "components", - "ui", - "button.tsx" - ); +export const createFoundryProject = async ( + options: ProjectOptions, + spinner?: Ora +) => { + const { projectName, templateId } = options; + let forgeAvailable = true; try { - console.log("Adding Shadcn button..."); - - await fs.writeFile( - buttonFilePath, - ` - import * as React from "react" - import { Slot } from "@radix-ui/react-slot" - import { cva, type VariantProps } from "class-variance-authority" - - import { cn } from "@/src/lib/utils" - - const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/90", - outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-10 px-4 py-2", - sm: "h-9 rounded-md px-3", - lg: "h-11 rounded-md px-8", - icon: "h-10 w-10", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } - ) - - export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { - asChild?: boolean - } - - const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button" - return ( - - ) - } + await execAsync("forge --version"); + console.log( + chalk.green( + "Foundry (forge) installation verified. Proceeding with setup..." ) - Button.displayName = "Button" - - export { Button, buttonVariants } - ` ); } catch (error) { - console.error("An error occurred during button creation:", error); + forgeAvailable = false; } -}; -export const addShadcnCard = async (projectPath: string) => { - const cardFilePath = path.join( - projectPath, - "src", - "components", - "ui", - "card.tsx" - ); - await fs.writeFile( - cardFilePath, - ` - import * as React from "react" - import { cn } from "@/src/lib/utils" - - const Card = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes - >(({ className, ...props }, ref) => ( -
- )) - Card.displayName = "Card" - - const CardHeader = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes - >(({ className, ...props }, ref) => ( -
- )) - CardHeader.displayName = "CardHeader" - - const CardTitle = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes - >(({ className, ...props }, ref) => ( -
- )) - CardTitle.displayName = "CardTitle" - - const CardDescription = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes - >(({ className, ...props }, ref) => ( -
- )) - CardDescription.displayName = "CardDescription" - - const CardContent = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes - >(({ className, ...props }, ref) => ( -
- )) - CardContent.displayName = "CardContent" - - const CardFooter = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes - >(({ className, ...props }, ref) => ( -
- )) - CardFooter.displayName = "CardFooter" - - export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } - - ` - ); -}; - -export const addShadcnDropdownMenu = async (projectPath: string) => { - const dropdownMenuFilePath = path.join( - projectPath, - "src", - "components", - "ui", - "dropdown-menu.tsx" - ); - await fs.writeFile( - dropdownMenuFilePath, - ` - "use client" - - import * as React from "react" - import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" - import { Check, ChevronRight, Circle } from "lucide-react" - - import { cn } from "@/src/lib/utils" + if (!forgeAvailable) { + spinner?.stop(); - const DropdownMenu = DropdownMenuPrimitive.Root - - const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger - - const DropdownMenuGroup = DropdownMenuPrimitive.Group + console.log( + chalk.yellow( + "Looks like Foundry is not installed or not found in your PATH." + ) + ); - const DropdownMenuPortal = DropdownMenuPrimitive.Portal + const { switchToHardhat } = await inquirer.prompt([ + { + type: "confirm", + name: "switchToHardhat", + message: "Would you like to switch to Hardhat instead?", + default: true, + }, + ]); - const DropdownMenuSub = DropdownMenuPrimitive.Sub + track("foundry_not_installed", { + attempted_blockchain_tooling: "foundry", + switched_to_hardhat: switchToHardhat, + }); - const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + if (switchToHardhat) { + console.log(chalk.blue("Switching to Hardhat setup...")); + Object.assign(options, { blockchain_tooling: "hardhat" as const }); - const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - } - >(({ className, inset, children, ...props }, ref) => ( - - {children} - - - )) - DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName - - const DropdownMenuSubContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef - >(({ className, ...props }, ref) => ( - - )) - DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName - - const DropdownMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef - >(({ className, sideOffset = 4, ...props }, ref) => ( - - - - )) - DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName - - const DropdownMenuItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - } - >(({ className, inset, ...props }, ref) => ( - - )) - DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName - - const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef - >(({ className, children, checked, ...props }, ref) => ( - - - - - - - {children} - - )) - DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName - - const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef - >(({ className, children, ...props }, ref) => ( - - - - - - - {children} - - )) - DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName - - const DropdownMenuLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean + if (spinner) { + spinner.text = "Creating Hardhat project structure..."; + spinner.start(); } - >(({ className, inset, ...props }, ref) => ( - - )) - DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName - - const DropdownMenuSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef - >(({ className, ...props }, ref) => ( - - )) - DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName - - const DropdownMenuShortcut = ({ - className, - ...props - }: React.HTMLAttributes) => { - return ( - - ) - } - DropdownMenuShortcut.displayName = "DropdownMenuShortcut" - - export { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, - } - ` - ); -}; - -export const addShadcnSeparator = async (projectPath: string) => { - const separatorFilePath = path.join( - projectPath, - "src", - "components", - "ui", - "separator.tsx" - ); - await fs.writeFile( - separatorFilePath, - ` - "use client" - - import * as React from "react" - import * as SeparatorPrimitive from "@radix-ui/react-separator" - - import { cn } from "@/src/lib/utils" - - const Separator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef - >( - ( - { className, orientation = "horizontal", decorative = true, ...props }, - ref - ) => ( - - ) - ) - Separator.displayName = SeparatorPrimitive.Root.displayName - export { Separator } - ` - ); -}; + await createHardhatProject(options); + return; + } -export const createArrow = async (projectPath: string) => { - const arrowFilePath = path.join(projectPath, "public", "arrow.svg"); - await fs.writeFile( - arrowFilePath, - ` - - - - ` - ); -}; + spinner?.start(); + throw new Error( + "Forge (Foundry) is not installed or not found in your PATH. Please install it to continue.\nInstallation guide: https://book.getfoundry.sh/getting-started/installation" + ); + } -export const createMetamaskLogo = async (projectPath: string) => { - const metamaskLogoFilePath = path.join( - projectPath, - "public", - "metamask-logo.svg" - ); - await fs.writeFile( - metamaskLogoFilePath, - ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ` - ); -}; + console.log("Setting up project with Foundry..."); + await initializeMonorepo(options); -export const createNoise = async (projectPath: string) => { - const noiseFilePath = path.join(projectPath, "public", "noise.svg"); - await fs.writeFile( - noiseFilePath, - ` - - ` - ); -}; + console.log("Initializing Foundry project with 'forge init'..."); + const blockchainPath = path.join(projectName, "packages", "blockchain"); + await fs.mkdir(blockchainPath, { recursive: true }); + await execAsync(`cd ${blockchainPath} && foundryup && forge init . --no-git`); -export const updateTailwindConfig = async (projectPath: string) => { - const tailwindConfigFilePath = path.join(projectPath, "tailwind.config.ts"); - await fs.writeFile( - tailwindConfigFilePath, - ` -import type { Config } from "tailwindcss"; - -export default { - darkMode: ["class"], - content: [ - "./src/**/*.{js,ts,jsx,tsx,mdx}", - "./pages/**/*.{js,ts,jsx,tsx,mdx}", - "./components/**/*.{js,ts,jsx,tsx,mdx}", - "./app/**/*.{js,ts,jsx,tsx,mdx}", - ], - theme: { - extend: { - colors: { - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", - }, - popover: { - DEFAULT: "hsl(var(--popover))", - foreground: "hsl(var(--popover-foreground))", - }, - primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", - }, - secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", - }, - muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", - }, - accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", - }, - destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", - }, - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - chart: { - "1": "hsl(var(--chart-1))", - "2": "hsl(var(--chart-2))", - "3": "hsl(var(--chart-3))", - "4": "hsl(var(--chart-4))", - "5": "hsl(var(--chart-5))", - }, - }, - backgroundImage: { - noise: "url('/noise.svg')", - }, - borderRadius: { - lg: "var(--radius)", - md: "calc(var(--radius) - 2px)", - sm: "calc(var(--radius) - 4px)", - }, + await cloneTemplate( + { + templateId, + projectName, + dynamicEnvId: options.dynamicEnvId, + blockchain_tooling: "foundry", }, - }, - plugins: [require("tailwindcss-animate")], -} satisfies Config; - ` + path.join(projectName, "packages", "site") ); + + console.log("Foundry project setup complete."); }; export const createProject = async (args: string) => { - const options = await promptForOptions(args); - - if (options.blockchain_tooling === "hardhat") { - createHardhatProject(options); - return; - } - - if (options.blockchain_tooling === "foundry") { - createFoundryProject(options); + try { + await fs.access(process.cwd(), fsConstants.W_OK); + } catch { + console.error( + chalk.red( + "The directory you're in is read-only for your user. Please cd to a writable folder (e.g. your home directory) or run the terminal as Administrator." + ) + ); + track("cwd_not_writable", { + cwd: process.cwd(), + }); return; } - switch (options.framework) { - case "nextjs": - await createNextApp(options); - break; - case "react": - await createReactApp(options); - break; - default: - break; - } -}; - -export const createComponentsFolder = async (projectPath: string) => { - await fs.mkdir(path.join(projectPath, "src", "components", "ui"), { - recursive: true, - }); -}; + identifyRun(); + const options = await promptForOptions(args); -export const createUtils = async (projectPath: string) => { - await fs.mkdir(path.join(projectPath, "src", "lib"), { - recursive: true, - }); - const utilsPath = path.join(projectPath, "src", "lib", "utils.ts"); + const installCommand = `${options.packageManager} install`; + const mainSpinner = ora("Setting up your Web3 project...").start(); + const t0 = Date.now(); - await fs.writeFile( - utilsPath, - ` -import { clsx, type ClassValue } from "clsx"; -import { twMerge } from "tailwind-merge"; + try { + track("cli_started", { + cli_version: CLI_VERSION, + }); + if (options.blockchain_tooling === "hardhat") { + mainSpinner.text = "Creating Hardhat project structure..."; + await createHardhatProject(options); + } else if (options.blockchain_tooling === "foundry") { + mainSpinner.text = "Creating Foundry project structure..."; + await createFoundryProject(options, mainSpinner); + } else { + mainSpinner.text = "Cloning base template..."; + await cloneTemplate( + { + templateId: options.templateId, + projectName: options.projectName, + dynamicEnvId: options.dynamicEnvId, + blockchain_tooling: "none", + }, + options.projectName + ); + } -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} + mainSpinner.text = `Installing dependencies using ${options.packageManager}... (This may take a few minutes)`; + const projectPath = options.projectName; + await execAsync(`cd ${projectPath} && ${installCommand}`); + + mainSpinner.succeed("Project setup complete!"); + + console.log(`\nSuccess! Created ${options.projectName}.`); + console.log("Inside that directory, you can run several commands:"); + + if (options.blockchain_tooling !== "none") { + console.log(`\n In the root directory (${options.projectName}):`); + console.log(` ${options.packageManager} run dev`); + console.log(" Runs the frontend development server."); + console.log(`\n In packages/blockchain:`); + console.log(` ${options.packageManager} run compile`); + console.log(" Compiles the smart contracts."); + console.log(` ${options.packageManager} run test`); + console.log(" Runs the contract tests."); + console.log(`\n In packages/site:`); + console.log(` ${options.packageManager} run dev`); + console.log(" Runs the frontend development server."); + } else { + console.log(`\n cd ${options.projectName} && ${options.packageManager} run dev`); + console.log(" Starts the development server."); + } -export const formatAddress = (addr: string | undefined) => { - if (!addr || addr.length < 10) { - throw new Error("Invalid wallet address"); + track("project_created", { + template_id: options.templateId, + blockchain_tooling: options.blockchain_tooling, + package_manager: options.packageManager, + dynamic_env: Boolean(options.dynamicEnvId), + exec_time_ms: Date.now() - t0, + }); + console.log("\nHappy Hacking!"); + } catch (error) { + mainSpinner.fail("An error occurred during project creation."); + track("project_creation_failed", { + template_id: options?.templateId, + blockchain_tooling: options?.blockchain_tooling, + error_message: (error as Error).message, + }); + console.error("Error details:", error); + } finally { + flush(); } - return addr.slice(0, 6) + "..." + addr.slice(-4); -}; - ` - ); }; diff --git a/tsconfig.json b/tsconfig.json index a58db86..6f0181d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,5 +14,6 @@ "forceConsistentCasingInFileNames": true, "moduleResolution": "node16" }, - "include": ["**/*.ts", "**/*.tsx", "types.d.ts", "src"] + "include": ["**/*.ts", "**/*.tsx", "types.d.ts", "src"], + "exclude": ["node_modules", "dist", "src/legacy_code"] } diff --git a/types.d.ts b/types.d.ts index 05108c8..38abf6d 100644 --- a/types.d.ts +++ b/types.d.ts @@ -14,4 +14,5 @@ type ProjectOptions = { framework: "react" | "nextjs" | undefined; blockchain_tooling: "hardhat" | "foundry" | "none" | undefined; packageManager: "yarn" | "npm" | "pnpm"; + dynamicEnvId?: string; };