瀏覽代碼

chore(create-pyth-app): added a CLI bootstrapper

benduran 1 周之前
父節點
當前提交
b384cf0ec3

+ 2 - 0
packages/create-pyth-app/package.json

@@ -22,7 +22,9 @@
   "dependencies": {
     "app-root-path": "catalog:",
     "chalk": "catalog:",
+    "fast-glob": "catalog:",
     "fs-extra": "catalog:",
+    "micromustache": "catalog:",
     "prompts": "catalog:",
     "tsx": "catalog:"
   },

+ 128 - 86
packages/create-pyth-app/src/create-pyth-app.ts

@@ -1,11 +1,15 @@
 // this rule is absolutely broken for the typings the prompts library
 // provides, so we need to hard-disable it for all usages of prompts
 
+import { execSync } from "node:child_process";
 import os from "node:os";
 import path from "node:path";
 
+import appRootPath from "app-root-path";
 import chalk from "chalk";
+import glob from "fast-glob";
 import fs from "fs-extra";
+import { render as renderTemplate } from "micromustache";
 import prompts from "prompts";
 
 import { getAvailableFolders } from "./get-available-folders.js";
@@ -14,106 +18,144 @@ import type {
   CreatePythAppResponses,
   InProgressCreatePythAppResponses,
 } from "./types.js";
-import { CUSTOM_FOLDER_CHOICE, PACKAGE_PREFIX, PackageType } from "./types.js";
+import { PACKAGE_PREFIX, PackageType, TEMPLATES_FOLDER } from "./types.js";
+
+/**
+ * returns the folder that holds the correct templates, based on the user's
+ * package choice
+ */
+function getTemplatesInputFolder(packageType: PackageType) {
+  switch (packageType) {
+    case PackageType.CLI: {
+      return path.join(TEMPLATES_FOLDER, "cli");
+    }
+    case PackageType.LIBRARY: {
+      return path.join(TEMPLATES_FOLDER, "library");
+    }
+    case PackageType.WEBAPP: {
+      return path.join(TEMPLATES_FOLDER, "web-app");
+    }
+    default: {
+      throw new Error(
+        `unsupported package type of "${String(packageType)}" was found`,
+      );
+    }
+  }
+}
 
 async function createPythApp() {
-  const cwd = process.cwd();
-
-  const {
-    confirm,
-    customFolderPath,
-    description,
-    folder,
-    packageName,
-    packageType,
-  } = (await prompts([
-    {
-      choices: Object.values(PackageType).map((val) => ({
-        title: val,
-        value: val,
-      })),
-      message: "Which type of package do you want to create?",
-      name: "packageType",
-      type: "select",
-    },
-    {
-      format: (val: string) => `${PACKAGE_PREFIX}${val}`,
-      message: (_, responses: InProgressCreatePythAppResponses) =>
-        `Enter the name for your ${responses.packageType ?? ""} package. ${chalk.magenta(PACKAGE_PREFIX)}`,
-      name: "packageName",
-      type: "text",
-      validate: (name: string) => {
-        const proposedName = `${PACKAGE_PREFIX}${name}`;
-        const pjsonNameRegexp = /^@pythnetwork\/(\w)(\w|\d|_|-)+$/;
-        return (
-          pjsonNameRegexp.test(proposedName) ||
-          "Please enter a valid package name (you do not need to add @pythnetwork/ as a prefix, it will be added automatically)"
-        );
+  const { confirm, description, folder, packageName, packageType } =
+    (await prompts([
+      {
+        choices: Object.values(PackageType).map((val) => ({
+          title: val,
+          value: val,
+        })),
+        message: "Which type of package do you want to create?",
+        name: "packageType",
+        type: "select",
+      },
+      {
+        format: (val: string) => `${PACKAGE_PREFIX}${val}`,
+        message: (_, responses: InProgressCreatePythAppResponses) =>
+          `Enter the name for your ${responses.packageType ?? ""} package. ${chalk.magenta(PACKAGE_PREFIX)}`,
+        name: "packageName",
+        type: "text",
+        validate: (name: string) => {
+          const proposedName = `${PACKAGE_PREFIX}${name}`;
+          const pjsonNameRegexp = /^@pythnetwork\/(\w)(\w|\d|_|-)+$/;
+          return (
+            pjsonNameRegexp.test(proposedName) ||
+            "Please enter a valid package name (you do not need to add @pythnetwork/ as a prefix, it will be added automatically)"
+          );
+        },
       },
-    },
-    {
-      message: "Enter a brief, friendly description for your package",
-      name: "description",
-      type: "text",
-    },
-    {
-      choices: (_, { packageType }: InProgressCreatePythAppResponses) =>
-        [
+      {
+        message: "Enter a brief, friendly description for your package",
+        name: "description",
+        type: "text",
+      },
+      {
+        choices: (_, { packageType }: InProgressCreatePythAppResponses) =>
+          getAvailableFolders()
+            .map((val) => ({
+              title: val,
+              value: val,
+            }))
+            .filter(
+              ({ value: relPath }) =>
+                packageType !== PackageType.WEBAPP &&
+                !relPath.startsWith("apps"),
+            ),
+        message: "Where do you want your package to live?",
+        name: "folder",
+        type: (_, { packageType }: InProgressCreatePythAppResponses) =>
+          packageType === PackageType.WEBAPP ? false : "select",
+      },
+      {
+        message: (
+          _,
           {
-            title: "** let me enter my own path **",
-            value: CUSTOM_FOLDER_CHOICE,
-          },
-          ...getAvailableFolders().map((val) => ({
-            title: val,
-            value: val,
-          })),
-        ].filter(
-          ({ value: relPath }) =>
-            packageType !== PackageType.WEBAPP && !relPath.startsWith("apps"),
-        ),
-      message: "Where do you want your package to live?",
-      name: "folder",
-      type: (_, { packageType }: InProgressCreatePythAppResponses) =>
-        packageType === PackageType.WEBAPP ? false : "select",
-    },
-    {
-      message:
-        "Enter the relative path to the folder where you would like to create your package",
-      name: "customFolderPath",
-      type: (_, { folder }: InProgressCreatePythAppResponses) =>
-        folder === CUSTOM_FOLDER_CHOICE ? "text" : false,
-    },
-    {
-      message: (
-        _,
-        {
-          customFolderPath,
-          folder,
-          packageName,
-          packageType,
-        }: InProgressCreatePythAppResponses,
-      ) => {
-        let msg = `Please confirm your choices:${os.EOL}`;
-        msg += `Creating a ${chalk.magenta(packageType)} package, named ${chalk.magenta(packageName)}, in ${chalk.magenta(customFolderPath ?? folder)}.${os.EOL}`;
-        msg += "Look good?";
-
-        return msg;
+            folder,
+            packageName,
+            packageType,
+          }: InProgressCreatePythAppResponses,
+        ) => {
+          let msg = `Please confirm your choices:${os.EOL}`;
+          msg += `Creating a ${chalk.magenta(packageType)} package, named ${chalk.magenta(packageName)}, in ${chalk.magenta(packageType === PackageType.WEBAPP ? `apps/${packageName?.split("/")[1] ?? ""}` : folder)}.${os.EOL}`;
+          msg += "Look good?";
+
+          return msg;
+        },
+        name: "confirm",
+        type: "confirm",
       },
-      name: "confirm",
-      type: "confirm",
-    },
-  ])) as CreatePythAppResponses;
+    ])) as CreatePythAppResponses;
 
   if (!confirm) {
     Logger.warn("oops, you did not confirm your choices.");
     return;
   }
 
-  const relDest = customFolderPath ?? folder;
-  const absDest = path.join(cwd, relDest);
+  const [, packageNameWithoutOrg = ""] = packageName.split("/");
+
+  const relDest =
+    packageType === PackageType.WEBAPP
+      ? path.join("apps", packageNameWithoutOrg)
+      : folder;
+  const absDest = path.join(appRootPath.toString(), relDest);
 
   Logger.info("ensuring", relDest, "exists");
   await fs.ensureDir(absDest);
+
+  Logger.info("copying files");
+  const templateInputFolder = getTemplatesInputFolder(packageType);
+  await fs.copy(templateInputFolder, absDest, { overwrite: true });
+
+  const destFiles = await glob(path.join(absDest, "**", "*"), {
+    absolute: true,
+    dot: true,
+    onlyFiles: true,
+  });
+
+  Logger.info(
+    "updating files with the choices you made in the initial prompts",
+  );
+  await Promise.all(
+    destFiles.map(async (fp) => {
+      const contents = await fs.readFile(fp, "utf8");
+      await fs.writeFile(
+        fp,
+        renderTemplate(contents, { description, name: packageName }),
+        "utf8",
+      );
+    }),
+  );
+
+  Logger.info("installing deps");
+  execSync("pnpm i", { cwd: appRootPath.toString(), stdio: "inherit" });
+
+  Logger.info(`Done! ${packageName} is ready for development`);
 }
 
 await createPythApp();

+ 140 - 0
packages/create-pyth-app/src/templates/cli/.gitignore

@@ -0,0 +1,140 @@
+.env*.local
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+web_modules/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional stylelint cache
+.stylelintcache
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variable files
+.env
+.env.*
+!.env.example
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+.parcel-cache
+
+# Next.js build output
+.next
+out
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and not Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# vuepress v2.x temp and cache directory
+.temp
+.cache
+
+# Sveltekit cache directory
+.svelte-kit/
+
+# vitepress build output
+**/.vitepress/dist
+
+# vitepress cache directory
+**/.vitepress/cache
+
+# Docusaurus cache and generated files
+.docusaurus
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# Firebase cache directory
+.firebase/
+
+# TernJS port file
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+.vscode-test
+
+# yarn v3
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/sdks
+!.yarn/versions
+
+# Vite logs files
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*

+ 13 - 0
packages/create-pyth-app/src/templates/cli/.prettierignore

@@ -0,0 +1,13 @@
+.next/
+coverage/
+node_modules/
+*.tsbuildinfo
+.env*.local
+.env
+.DS_Store
+dist/
+lib/
+build/
+node_modules/
+package.json
+tsconfig*.json

+ 3 - 0
packages/create-pyth-app/src/templates/cli/bin.js

@@ -0,0 +1,3 @@
+#!/usr/bin/env node
+
+import "./dist/index.mjs";

+ 1 - 0
packages/create-pyth-app/src/templates/cli/eslint.config.js

@@ -0,0 +1 @@
+export { base as default } from "@cprussin/eslint-config";

+ 44 - 0
packages/create-pyth-app/src/templates/cli/package.json

@@ -0,0 +1,44 @@
+{
+  "name": "{{name}}",
+  "description": "{{description}}",
+  "version": "0.0.0",
+  "type": "module",
+  "bin": {
+    "{{name}}": "./bin.js"
+  },
+  "files": [
+    "bin.js",
+    "dist/**"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/pyth-network/pyth-crosschain",
+    "directory": "{{relativeFolder}}"
+  },
+  "engines": {
+    "node": ">=22.14.0"
+  },
+  "scripts": {
+    "build": "ts-duality --noCjs",
+    "clean": "rm -rf dist/",
+    "fix:lint": "eslint src/ --fix --max-warnings 0",
+    "fix:format": "prettier --write \"src/**/*.ts\"",
+    "prepublishOnly": "pnpm run build && pnpm run test:lint",
+    "test:lint": "eslint src/ --max-warnings 0",
+    "test:format": "prettier --check \"src/**/*.ts\""
+  },
+  "devDependencies": {
+    "@cprussin/eslint-config": "catalog:",
+    "@cprussin/prettier-config": "catalog:",
+    "@cprussin/tsconfig": "catalog:",
+    "@pythnetwork/jest-config": "workspace:",
+    "@types/jest": "catalog:",
+    "@types/node": "catalog:",
+    "@types/yargs": "catalog:",
+    "eslint": "catalog:",
+    "jest": "catalog:"
+  },
+  "dependencies": {
+    "yargs": "catalog:"
+  }
+}

+ 17 - 0
packages/create-pyth-app/src/templates/cli/src/cli.ts

@@ -0,0 +1,17 @@
+import createCLI from "yargs";
+import { hideBin } from "yargs/helpers";
+import { setupHelloWorldCommand } from "./commands/hello-world-cmd.js";
+
+async function setupCLI() {
+  let yargs = createCLI(hideBin(process.argv));
+  yargs = setupHelloWorldCommand(yargs);
+
+  const { _ } = await yargs.help().argv;
+
+  if (!_.length) {
+    yargs.showHelp();
+    return;
+  }
+}
+
+setupCLI();

+ 19 - 0
packages/create-pyth-app/src/templates/cli/src/commands/hello-world-cmd.ts

@@ -0,0 +1,19 @@
+import type { Argv } from "yargs";
+import { createCommand } from "../create-command.js";
+
+export function setupHelloWorldCommand(yargs: Argv) {
+  return createCommand(
+    yargs,
+    "hello-world",
+    "prints hello world and your name, if provided",
+    (y) =>
+      y.option("name", {
+        alias: "n",
+        description: "your name",
+        type: "string",
+      }),
+    async ({ name }) => {
+      console.info("hello, world!", name || "");
+    },
+  );
+}

+ 1 - 0
packages/create-pyth-app/src/templates/cli/src/commands/index.ts

@@ -0,0 +1 @@
+export * from "./hello-world-cmd.js";

+ 15 - 0
packages/create-pyth-app/src/templates/cli/src/create-command.ts

@@ -0,0 +1,15 @@
+import type { ArgumentsCamelCase, Argv } from "yargs";
+
+/**
+ * helper for creating a command and registering it
+ * to your command line interface with proper typings
+ */
+export function createCommand<T extends object>(
+  yargs: Argv,
+  commandName: string,
+  description: string,
+  builder: (y: Argv) => Argv<T>,
+  executor: (args: ArgumentsCamelCase<T>) => Promise<void>,
+) {
+  return yargs.command(commandName, description, builder, executor);
+}

+ 1 - 0
packages/create-pyth-app/src/templates/cli/src/index.ts

@@ -0,0 +1 @@
+export * from "./cli.js";

+ 10 - 0
packages/create-pyth-app/src/templates/cli/tsconfig.build.json

@@ -0,0 +1,10 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "noEmit": false,
+    "incremental": false,
+    "declaration": true,
+    "isolatedModules": false
+  },
+  "exclude": ["node_modules", "dist", "src/examples/", "**/__tests__/*"]
+}

+ 8 - 0
packages/create-pyth-app/src/templates/cli/tsconfig.json

@@ -0,0 +1,8 @@
+{
+  "extends": "@cprussin/tsconfig/base.json",
+  "include": ["src"],
+  "compilerOptions": {
+    "lib": ["DOM", "ESNext"]
+  },
+  "exclude": ["node_modules"]
+}

+ 3 - 0
packages/create-pyth-app/src/templates/web-app/.gitignore

@@ -0,0 +1,3 @@
+.env*.local
+
+dist/

+ 13 - 0
packages/create-pyth-app/src/templates/web-app/.prettierignore

@@ -0,0 +1,13 @@
+.next/
+coverage/
+node_modules/
+*.tsbuildinfo
+.env*.local
+.env
+.DS_Store
+dist/
+lib/
+build/
+node_modules/
+package.json
+tsconfig*.json

+ 5 - 0
packages/create-pyth-app/src/templates/web-app/package.json

@@ -7,6 +7,11 @@
   "engines": {
     "node": ">=22.14.0"
   },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/pyth-network/pyth-crosschain",
+    "directory": "{{relativeFolder}}"
+  },
   "scripts": {
     "build:vercel": "next build",
     "fix:format": "prettier --write .",

+ 4 - 0
packages/create-pyth-app/src/types.ts

@@ -1,3 +1,5 @@
+import path from "node:path";
+
 export enum PackageType {
   CLI = "Command-line application",
   LIBRARY = "JavaScript / TypeScript Library",
@@ -18,3 +20,5 @@ export type InProgressCreatePythAppResponses = Partial<CreatePythAppResponses>;
 export const PACKAGE_PREFIX = "@pythnetwork/";
 
 export const CUSTOM_FOLDER_CHOICE = "__custom__";
+
+export const TEMPLATES_FOLDER = path.join(import.meta.dirname, "templates");

+ 18 - 0
pnpm-lock.yaml

@@ -192,6 +192,9 @@ catalogs:
     eslint:
       specifier: ^9.23.0
       version: 9.23.0
+    fast-glob:
+      specifier: ^3.3.3
+      version: 3.3.3
     framer-motion:
       specifier: ^12.6.3
       version: 12.9.2
@@ -231,6 +234,9 @@ catalogs:
     match-sorter:
       specifier: ^8.1.0
       version: 8.1.0
+    micromustache:
+      specifier: ^8.0.3
+      version: 8.0.3
     modern-normalize:
       specifier: ^3.0.1
       version: 3.0.1
@@ -2173,9 +2179,15 @@ importers:
       chalk:
         specifier: 'catalog:'
         version: 5.6.2
+      fast-glob:
+        specifier: 'catalog:'
+        version: 3.3.3
       fs-extra:
         specifier: 'catalog:'
         version: 11.3.2
+      micromustache:
+        specifier: 'catalog:'
+        version: 8.0.3
       prompts:
         specifier: 'catalog:'
         version: 2.4.2
@@ -17258,6 +17270,10 @@ packages:
     resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
     engines: {node: '>=8.6'}
 
+  micromustache@8.0.3:
+    resolution: {integrity: sha512-SXjrEPuYNtWq0reR9LR2nHdzdQx/3re9HPcDGjm00L7hi2RsH5KMRBhYEBvPdyQC51RW/2TznjwX/sQLPPyHNw==}
+    engines: {node: '>=8'}
+
   miller-rabin@4.0.1:
     resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==}
     hasBin: true
@@ -45528,6 +45544,8 @@ snapshots:
       braces: 3.0.3
       picomatch: 2.3.1
 
+  micromustache@8.0.3: {}
+
   miller-rabin@4.0.1:
     dependencies:
       bn.js: 4.12.1

+ 2 - 0
pnpm-workspace.yaml

@@ -117,6 +117,7 @@ catalog:
   dnum: ^2.14.0
   eslint: ^9.23.0
   fuels: 0.101.3
+  fast-glob: ^3.3.3
   framer-motion: ^12.6.3
   fumadocs-core: ^15.7.12
   fumadocs-mdx: ^11.10.0
@@ -131,6 +132,7 @@ catalog:
   lightweight-charts: ^5.0.5
   lucide-react: ^0.487.0
   match-sorter: ^8.1.0
+  micromustache: ^8.0.3
   modern-normalize: ^3.0.1
   motion: ^12.9.2
   next: ^15.5.0