From 8e339b0c71a93d39e93aed8683b8b65561e0f778 Mon Sep 17 00:00:00 2001 From: Rinat Date: Tue, 30 Jun 2026 11:34:51 +0400 Subject: [PATCH 1/6] test: add node:test suites for pure CLI helpers - node:test runner (no deps) via --experimental-strip-types - tiny resolve hook maps extensionless rollup-style imports to .ts - cover external-extensions url parsing, validateNpmName, templates/utils - convert type-only imports to `import type` on tested path - lint test/mjs files without type-aware rules Co-Authored-By: Claude Opus 4.8 --- eslint.config.js | 4 +- package.json | 2 +- src/extensions/challenges.ts | 2 +- src/extensions/create-eth-extensions.ts | 2 +- src/extensions/index.ts | 5 +- src/extensions/organizations.ts | 2 +- src/templates-utils.test.ts | 52 ++++++++++++++++++ src/test/register.mjs | 4 ++ src/test/ts-extension-resolver.mjs | 21 ++++++++ src/utils/external-extensions.test.ts | 72 +++++++++++++++++++++++++ src/utils/external-extensions.ts | 4 +- src/utils/validate-name.test.ts | 19 +++++++ 12 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 src/templates-utils.test.ts create mode 100644 src/test/register.mjs create mode 100644 src/test/ts-extension-resolver.mjs create mode 100644 src/utils/external-extensions.test.ts create mode 100644 src/utils/validate-name.test.ts diff --git a/eslint.config.js b/eslint.config.js index a7562744df..55517c9550 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,7 +22,9 @@ export default tseslint.config( }, }, { - files: ["**/*.js"], + // Test files are excluded from tsconfig, and config/hook files are plain ESM, + // so lint them without type-aware rules to avoid the "not in project" error. + files: ["**/*.js", "**/*.mjs", "**/*.cjs", "**/*.test.ts"], ...tseslint.configs.disableTypeChecked, }, { diff --git a/package.json b/package.json index 28e06c74e7..cfe187beef 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "cli": "node bin/create-dapp-se2.js", "lint": "eslint .", "format": "prettier --write .", - "test": "echo \"Error: no test specified\" && exit 1", + "test": "node --disable-warning=ExperimentalWarning --experimental-strip-types --import ./src/test/register.mjs --test 'src/**/*.test.ts'", "type-check": "tsc --noEmit", "changeset:release": "yarn build && changeset publish" }, diff --git a/src/extensions/challenges.ts b/src/extensions/challenges.ts index c82edcfaf9..a69f3ea197 100644 --- a/src/extensions/challenges.ts +++ b/src/extensions/challenges.ts @@ -1,4 +1,4 @@ -import { Extension } from "./types"; +import type { Extension } from "./types"; export const challenges: Extension[] = [ { diff --git a/src/extensions/create-eth-extensions.ts b/src/extensions/create-eth-extensions.ts index 04c63ee871..e13efd4274 100644 --- a/src/extensions/create-eth-extensions.ts +++ b/src/extensions/create-eth-extensions.ts @@ -1,4 +1,4 @@ -import { Extension } from "./types"; +import type { Extension } from "./types"; export const createEthExtensions: Extension[] = [ { diff --git a/src/extensions/index.ts b/src/extensions/index.ts index 9091a3df6c..9a0b8ffade 100644 --- a/src/extensions/index.ts +++ b/src/extensions/index.ts @@ -1,9 +1,10 @@ import { createEthExtensions } from "./create-eth-extensions"; import { challenges } from "./challenges"; import { organizations } from "./organizations"; -import { Extension } from "./types"; +import type { Extension } from "./types"; const extensions: Extension[] = [...createEthExtensions, ...challenges, ...organizations]; export default extensions; -export { Extension, createEthExtensions, challenges, organizations }; +export { createEthExtensions, challenges, organizations }; +export type { Extension }; diff --git a/src/extensions/organizations.ts b/src/extensions/organizations.ts index 7f4fcf3aeb..fab65427f3 100644 --- a/src/extensions/organizations.ts +++ b/src/extensions/organizations.ts @@ -1,4 +1,4 @@ -import { Extension } from "./types"; +import type { Extension } from "./types"; export const organizations: Extension[] = [ { diff --git a/src/templates-utils.test.ts b/src/templates-utils.test.ts new file mode 100644 index 0000000000..1e7dc30bee --- /dev/null +++ b/src/templates-utils.test.ts @@ -0,0 +1,52 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +// templates/utils.js is plain JS shipped into generated apps and evaluated at template time. +import { upperCaseFirstLetter, deepMerge, withDefaults, stringify } from "../templates/utils.js"; + +describe("upperCaseFirstLetter", () => { + it("capitalizes the first letter", () => { + assert.equal(upperCaseFirstLetter("hello"), "Hello"); + }); +}); + +describe("deepMerge", () => { + it("merges objects, second wins on conflicts", () => { + assert.deepEqual(deepMerge({ a: 1, b: 2 }, { b: 3, c: 4 }), { a: 1, b: 3, c: 4 }); + }); + + it("replaces arrays instead of concatenating", () => { + assert.deepEqual(deepMerge({ arr: [1, 2] }, { arr: [3] }), { arr: [3] }); + }); +}); + +describe("stringify", () => { + it("unwraps $$...$$ markers into raw expressions", () => { + const out = stringify({ x: "$$someVar$$" }); + assert.match(out, /x:\s*someVar/); + }); + + it("converts strings containing template placeholders into template literals", () => { + const out = stringify({ x: "a${b}c" }); + assert.match(out, /`a\$\{b\}c`/); + }); +}); + +describe("withDefaults", () => { + // Each global arg default (e.g. solidityFramework) must be supplied as a single-element array. + const globals = { solidityFramework: ["hardhat"] }; + + it("passes received args through to the template", () => { + const tmpl = withDefaults((args: Record) => args.foo[0], { foo: "default" }); + assert.equal(tmpl({ foo: ["x"], ...globals }), "x"); + }); + + it("applies defaults for missing args", () => { + const tmpl = withDefaults((args: Record) => args.foo[0], { foo: "default" }); + assert.equal(tmpl({ ...globals }), "default"); + }); + + it("throws on unexpected args", () => { + const tmpl = withDefaults((args: Record) => args.foo[0], { foo: "default" }); + assert.throws(() => tmpl({ foo: ["x"], bar: ["y"], ...globals }), /unexpected argument/); + }); +}); diff --git a/src/test/register.mjs b/src/test/register.mjs new file mode 100644 index 0000000000..b0435d57a7 --- /dev/null +++ b/src/test/register.mjs @@ -0,0 +1,4 @@ +import { register } from "node:module"; + +// Registers the extensionless-import resolver for the test run. +register("./ts-extension-resolver.mjs", import.meta.url); diff --git a/src/test/ts-extension-resolver.mjs b/src/test/ts-extension-resolver.mjs new file mode 100644 index 0000000000..0da4dd8665 --- /dev/null +++ b/src/test/ts-extension-resolver.mjs @@ -0,0 +1,21 @@ +// Resolve hook used only by `yarn test`. +// The CLI source is bundled by rollup, so it uses extensionless relative +// imports (e.g. `./consts`, `../extensions`). Node's ESM resolver needs an +// explicit extension, so we map extensionless relative specifiers to their +// `.ts` (or directory `index.ts`) counterpart. No runtime dependency. +export async function resolve(specifier, context, nextResolve) { + const isRelative = specifier.startsWith("."); + const hasKnownExtension = /\.([mc]?[jt]sx?|json)$/.test(specifier); + + if (isRelative && !hasKnownExtension) { + for (const candidate of [`${specifier}.ts`, `${specifier}/index.ts`]) { + try { + return await nextResolve(candidate, context); + } catch { + // try the next candidate + } + } + } + + return nextResolve(specifier, context); +} diff --git a/src/utils/external-extensions.test.ts b/src/utils/external-extensions.test.ts new file mode 100644 index 0000000000..5ffefb3b7a --- /dev/null +++ b/src/utils/external-extensions.test.ts @@ -0,0 +1,72 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { getDataFromExternalExtensionArgument, getArgumentFromExternalExtensionOption } from "./external-extensions.ts"; + +describe("getDataFromExternalExtensionArgument", () => { + it("parses owner/project", () => { + const data = getDataFromExternalExtensionArgument("owner/project"); + assert.equal(data.owner, "owner"); + assert.equal(data.project, "project"); + assert.equal(data.branch, undefined); + assert.equal(data.githubUrl, "https://github.com/owner/project"); + assert.equal(data.githubBranchUrl, "https://github.com/owner/project"); + }); + + it("parses owner/project:branch", () => { + const data = getDataFromExternalExtensionArgument("owner/project:dev"); + assert.equal(data.owner, "owner"); + assert.equal(data.project, "project"); + assert.equal(data.branch, "dev"); + assert.equal(data.githubBranchUrl, "https://github.com/owner/project/tree/dev"); + }); + + it("parses a plain github url", () => { + const data = getDataFromExternalExtensionArgument("https://github.com/foo/bar"); + assert.equal(data.owner, "foo"); + assert.equal(data.project, "bar"); + assert.equal(data.branch, undefined); + assert.equal(data.githubUrl, "https://github.com/foo/bar"); + }); + + it("parses a github url with tree/branch", () => { + const data = getDataFromExternalExtensionArgument("https://github.com/foo/bar/tree/feat"); + assert.equal(data.owner, "foo"); + assert.equal(data.project, "bar"); + assert.equal(data.branch, "feat"); + assert.equal(data.githubBranchUrl, "https://github.com/foo/bar/tree/feat"); + }); + + it("resolves a curated extension flag to its repository", () => { + const data = getDataFromExternalExtensionArgument("subgraph"); + assert.equal(data.owner, "scaffold-eth"); + assert.equal(data.project, "create-eth-extensions"); + assert.equal(data.branch, "subgraph"); + }); + + it("throws on invalid format", () => { + assert.throws(() => getDataFromExternalExtensionArgument("notvalid"), /Invalid extension format/); + }); +}); + +describe("getArgumentFromExternalExtensionOption", () => { + it("rebuilds owner/project from repository url", () => { + const arg = getArgumentFromExternalExtensionOption({ + repository: "https://github.com/scaffold-eth/create-eth-extensions", + }); + assert.equal(arg, "scaffold-eth/create-eth-extensions"); + }); + + it("appends the branch when present", () => { + const arg = getArgumentFromExternalExtensionOption({ + repository: "https://github.com/scaffold-eth/create-eth-extensions", + branch: "subgraph", + }); + assert.equal(arg, "scaffold-eth/create-eth-extensions:subgraph"); + }); + + it("round-trips with getDataFromExternalExtensionArgument", () => { + const data = getDataFromExternalExtensionArgument("owner/project:dev"); + const arg = getArgumentFromExternalExtensionOption({ repository: data.githubUrl, branch: data.branch }); + assert.equal(arg, "owner/project:dev"); + }); +}); diff --git a/src/utils/external-extensions.ts b/src/utils/external-extensions.ts index 7ee79f0e11..9a38fc4a2e 100644 --- a/src/utils/external-extensions.ts +++ b/src/utils/external-extensions.ts @@ -2,8 +2,8 @@ import fs from "fs"; import path from "path"; import * as https from "https"; import { fileURLToPath } from "url"; -import { ExternalExtension, RawOptions, SolidityFramework } from "../types"; -import curatedExtension, { Extension } from "../extensions"; +import type { ExternalExtension, RawOptions, SolidityFramework } from "../types"; +import curatedExtension, { type Extension } from "../extensions"; import { SOLIDITY_FRAMEWORKS } from "./consts"; const TRUSTED_GITHUB_ORGANIZATIONS = ["scaffold-eth", "buidlguidl"]; diff --git a/src/utils/validate-name.test.ts b/src/utils/validate-name.test.ts new file mode 100644 index 0000000000..7db852c4be --- /dev/null +++ b/src/utils/validate-name.test.ts @@ -0,0 +1,19 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { validateNpmName } from "./validate-name.ts"; + +describe("validateNpmName", () => { + it("accepts a valid package name", () => { + assert.deepEqual(validateNpmName("my-dapp-example"), { valid: true }); + }); + + it("rejects names with uppercase letters and reports problems", () => { + const result = validateNpmName("MyDapp"); + assert.equal(result.valid, false); + assert.ok(result.valid === false && result.problems.length > 0); + }); + + it("uses the basename of a path", () => { + assert.deepEqual(validateNpmName("some/nested/my-dapp"), { valid: true }); + }); +}); From 36a24c437848766439819f2c523e6e2d51f0ffd9 Mon Sep 17 00:00:00 2001 From: Rinat Date: Tue, 30 Jun 2026 11:38:23 +0400 Subject: [PATCH 2/6] refactor: small fixes and cleanups (B1-B8) - drop dead isConfigRegex (never matched POSIX paths, no config.json files) - prettier-format: correct error message, remove dead result.failed check - create-project-directory: fs.mkdir instead of spawning mkdir - install-packages: dedupe stdout/stderr chunking into one helper - merge-package-json: drop useless TODO .dev placeholder write (+ isDev param) - rename merge-pacakges.d.ts -> merge-packages.d.ts - type SolidityFrameworkChoices value as SolidityFramework | null Co-Authored-By: Claude Opus 4.8 --- ...erge-pacakges.d.ts => merge-packages.d.ts} | 0 src/tasks/copy-template-files.ts | 18 +++------- src/tasks/create-project-directory.ts | 7 +--- src/tasks/install-packages.ts | 36 +++++++------------ src/tasks/prettier-format.ts | 8 ++--- src/types.ts | 2 +- src/utils/merge-package-json.ts | 6 +--- 7 files changed, 22 insertions(+), 55 deletions(-) rename src/declarations/{merge-pacakges.d.ts => merge-packages.d.ts} (100%) diff --git a/src/declarations/merge-pacakges.d.ts b/src/declarations/merge-packages.d.ts similarity index 100% rename from src/declarations/merge-pacakges.d.ts rename to src/declarations/merge-packages.d.ts diff --git a/src/tasks/copy-template-files.ts b/src/tasks/copy-template-files.ts index ff205a0ce0..18d35f9108 100644 --- a/src/tasks/copy-template-files.ts +++ b/src/tasks/copy-template-files.ts @@ -25,7 +25,6 @@ let copyOrLink = copy; const isTemplateRegex = /([^/\\]*?)\.template\./; const isPackageJsonRegex = /package\.json/; const isYarnLockRegex = /yarn\.lock/; -const isConfigRegex = /([^/\\]*?)\\config\.json/; const isArgsRegex = /([^/\\]*?)\.args\./; const isSolidityFrameworkFolderRegex = /solidity-frameworks$/; const isPackagesFolderRegex = /packages$/; @@ -60,7 +59,7 @@ const copyBaseFiles = async (basePath: string, targetDir: string, { dev: isDev } const basePackageJsonPaths = findFilesRecursiveSync(basePath, path => isPackageJsonRegex.test(path)); basePackageJsonPaths.forEach(packageJsonPath => { const partialPath = packageJsonPath.split(basePath)[1]; - mergePackageJson(path.join(targetDir, partialPath), path.join(basePath, partialPath), isDev); + mergePackageJson(path.join(targetDir, partialPath), path.join(basePath, partialPath)); }); const baseDeployedContractsPaths = findFilesRecursiveSync(basePath, path => isDeployedContractsRegex.test(path)); @@ -84,30 +83,23 @@ const isUnselectedSolidityFrameworkFile = ({ return unselectedSolidityFrameworks.map(sf => new RegExp(`${sf}`)).some(sfregex => sfregex.test(path)); }; -const copyExtensionFiles = async ( - { dev: isDev, solidityFramework }: Options, - extensionPath: string, - targetDir: string, -) => { +const copyExtensionFiles = async ({ solidityFramework }: Options, extensionPath: string, targetDir: string) => { // copy (or link if dev) root files await copyOrLink(extensionPath, path.join(targetDir), { clobber: false, filter: path => { - const isConfig = isConfigRegex.test(path); const isArgs = isArgsRegex.test(path); const isSolidityFrameworkFolder = isSolidityFrameworkFolderRegex.test(path) && fs.lstatSync(path).isDirectory(); const isPackagesFolder = isPackagesFolderRegex.test(path) && fs.lstatSync(path).isDirectory(); const isTemplate = isTemplateRegex.test(path); - // PR NOTE: this wasn't needed before because ncp had the clobber: false const isPackageJson = isPackageJsonRegex.test(path); - const shouldSkip = - isConfig || isArgs || isTemplate || isPackageJson || isSolidityFrameworkFolder || isPackagesFolder; + const shouldSkip = isArgs || isTemplate || isPackageJson || isSolidityFrameworkFolder || isPackagesFolder; return !shouldSkip; }, }); // merge root package.json - mergePackageJson(path.join(targetDir, "package.json"), path.join(extensionPath, "package.json"), isDev); + mergePackageJson(path.join(targetDir, "package.json"), path.join(extensionPath, "package.json")); const extensionPackagesPath = path.join(extensionPath, "packages"); const hasPackages = fs.existsSync(extensionPackagesPath); @@ -143,14 +135,12 @@ const copyExtensionFiles = async ( mergePackageJson( path.join(targetDir, "packages", packageName, "package.json"), path.join(extensionPath, "packages", packageName, "package.json"), - isDev, ); if (packageName === solidityFramework) { mergePackageJson( path.join(targetDir, "package.json"), path.join(extensionPath, "packages", packageName, "root.package.json"), - isDev, ); } }); diff --git a/src/tasks/create-project-directory.ts b/src/tasks/create-project-directory.ts index e4c3ace939..977cc9dd5a 100644 --- a/src/tasks/create-project-directory.ts +++ b/src/tasks/create-project-directory.ts @@ -1,4 +1,3 @@ -import { execa } from "execa"; import fs from "fs"; import path from "path"; @@ -14,11 +13,7 @@ export async function createProjectDirectory(projectName: string) { } try { - const result = await execa("mkdir", [projectName]); - - if (result.failed) { - throw new Error("There was a problem running the mkdir command"); - } + await fs.promises.mkdir(resolvedPath, { recursive: true }); } catch (error) { throw new Error("Failed to create directory", { cause: error }); } diff --git a/src/tasks/install-packages.ts b/src/tasks/install-packages.ts index 393024d4ae..04d93b88a7 100644 --- a/src/tasks/install-packages.ts +++ b/src/tasks/install-packages.ts @@ -9,43 +9,33 @@ export async function installPackages( const execute = execaCommand("yarn install", { cwd: targetDir }); let outputBuffer: string = ""; - const chunkSize = 1024; - execute?.stdout?.on("data", (data: Buffer) => { - outputBuffer += data.toString(); + // Keep only the last `chunkSize` chars and return them as the latest visible line. + const appendAndGetVisibleOutput = (data: Buffer) => { + outputBuffer += data.toString(); if (outputBuffer.length > chunkSize) { outputBuffer = outputBuffer.slice(-1 * chunkSize); } - const visibleOutput = + return ( outputBuffer .match(new RegExp(`.{1,${chunkSize}}`, "g")) ?.slice(-1) .map(chunk => chunk.trimEnd() + "\n") - .join("") ?? outputBuffer; + .join("") ?? outputBuffer + ); + }; - task.output = visibleOutput; - if (visibleOutput.includes("Link step")) { - task.output = chalk.yellow(`starting link step, this might take a little time...`); - } + execute?.stdout?.on("data", (data: Buffer) => { + const visibleOutput = appendAndGetVisibleOutput(data); + task.output = visibleOutput.includes("Link step") + ? chalk.yellow(`starting link step, this might take a little time...`) + : visibleOutput; }); execute?.stderr?.on("data", (data: Buffer) => { - outputBuffer += data.toString(); - - if (outputBuffer.length > chunkSize) { - outputBuffer = outputBuffer.slice(-1 * chunkSize); - } - - const visibleOutput = - outputBuffer - .match(new RegExp(`.{1,${chunkSize}}`, "g")) - ?.slice(-1) - .map(chunk => chunk.trimEnd() + "\n") - .join("") ?? outputBuffer; - - task.output = visibleOutput; + task.output = appendAndGetVisibleOutput(data); }); await execute; diff --git a/src/tasks/prettier-format.ts b/src/tasks/prettier-format.ts index b126ce2e68..75e855ae62 100644 --- a/src/tasks/prettier-format.ts +++ b/src/tasks/prettier-format.ts @@ -3,13 +3,9 @@ import { execa } from "execa"; // TODO: Instead of using execa, use prettier package from cli to format targetDir export async function prettierFormat(targetDir: string) { try { - const result = await execa("yarn", ["format"], { cwd: targetDir }); - - if (result.failed) { - throw new Error("There was a problem running the format command"); - } + await execa("yarn", ["format"], { cwd: targetDir }); } catch (error) { - throw new Error("Failed to create directory", { cause: error }); + throw new Error("Failed to format files", { cause: error }); } return true; diff --git a/src/types.ts b/src/types.ts index edffa1b6f0..3ec9574ea4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,4 +34,4 @@ export type TemplateDescriptor = { source: string; }; -export type SolidityFrameworkChoices = (SolidityFramework | { value: any; name: string })[]; +export type SolidityFrameworkChoices = (SolidityFramework | { value: SolidityFramework | null; name: string })[]; diff --git a/src/utils/merge-package-json.ts b/src/utils/merge-package-json.ts index 5a65f27c89..2e06f2ab23 100644 --- a/src/utils/merge-package-json.ts +++ b/src/utils/merge-package-json.ts @@ -1,7 +1,7 @@ import mergeJsonStr from "merge-packages"; import fs from "fs"; -export function mergePackageJson(targetPackageJsonPath: string, secondPackageJsonPath: string, isDev: boolean) { +export function mergePackageJson(targetPackageJsonPath: string, secondPackageJsonPath: string) { const existsTarget = fs.existsSync(targetPackageJsonPath); const existsSecond = fs.existsSync(secondPackageJsonPath); if (!existsTarget && !existsSecond) { @@ -17,8 +17,4 @@ export function mergePackageJson(targetPackageJsonPath: string, secondPackageJso const formattedPkgStr = JSON.stringify(JSON.parse(mergedPkgStr), null, 2); fs.writeFileSync(targetPackageJsonPath, formattedPkgStr, "utf8"); - if (isDev) { - const devStr = `TODO: write relevant information for the contributor`; - fs.writeFileSync(`${targetPackageJsonPath}.dev`, devStr, "utf8"); - } } From 7c56cdcf94f7164dc1aa9f13f79e0569fe11c12c Mon Sep 17 00:00:00 2001 From: Rinat Date: Tue, 30 Jun 2026 11:44:57 +0400 Subject: [PATCH 3/6] refactor: restructure copy-template-files (A1-A3) - A1: pass copyOrLink as a param, drop module-level mutable let - A2: single getExternalExtensionPath + SourcePaths object; stop recomputing the external-extension path in 3 places - A3: collectTemplateDescriptors / collectArgsFileUrl helpers replace 4 near-identical descriptor blocks and 3 args-lookup blocks Output verified byte-identical (incl. symlinks) vs prior commit for hardhat, none, and external-extension configs. Co-Authored-By: Claude Opus 4.8 --- src/tasks/copy-template-files.ts | 209 ++++++++++++++----------------- 1 file changed, 94 insertions(+), 115 deletions(-) diff --git a/src/tasks/copy-template-files.ts b/src/tasks/copy-template-files.ts index 18d35f9108..a34f2d3f04 100644 --- a/src/tasks/copy-template-files.ts +++ b/src/tasks/copy-template-files.ts @@ -1,5 +1,5 @@ import { execa } from "execa"; -import { ExternalExtension, Options, SolidityFramework, TemplateDescriptor } from "../types"; +import type { ExternalExtension, Options, SolidityFramework, TemplateDescriptor } from "../types"; import { findFilesRecursiveSync } from "../utils/find-files-recursively"; import { mergePackageJson } from "../utils/merge-package-json"; import fs from "fs"; @@ -20,7 +20,8 @@ import { const EXTERNAL_EXTENSION_TMP_DIR = "tmp-external-extension"; const copy = promisify(ncp); -let copyOrLink = copy; +// `copy` duplicates files; `link` symlinks them (dev mode). Both share ncp's signature. +type CopyOrLink = typeof copy; const isTemplateRegex = /([^/\\]*?)\.template\./; const isPackageJsonRegex = /package\.json/; @@ -30,10 +31,32 @@ const isSolidityFrameworkFolderRegex = /solidity-frameworks$/; const isPackagesFolderRegex = /packages$/; const isDeployedContractsRegex = /packages\/nextjs\/contracts\/deployedContracts\.ts/; +// Absolute paths of every template source for the chosen options. `null`/`undefined` means "not used". +type SourcePaths = { + base: string; + solidityFramework: string | null; + exampleContracts: string | null; + externalExtension: string | undefined; +}; + const getSolidityFrameworkPath = (solidityFramework: SolidityFramework, templatesDirectory: string) => path.resolve(templatesDirectory, SOLIDITY_FRAMEWORKS_DIR, solidityFramework); -const copyBaseFiles = async (basePath: string, targetDir: string, { dev: isDev }: Options) => { +// Resolves where the external extension lives: a local folder in dev mode, otherwise the cloned tmp dir. +const getExternalExtensionPath = (options: Options, templateDir: string, targetDir: string): string | undefined => { + const { externalExtension, dev: isDev } = options; + if (!externalExtension) { + return undefined; + } + if (isDev) { + return typeof externalExtension === "string" + ? path.join(templateDir, "../externalExtensions", externalExtension, "extension") + : undefined; + } + return path.join(targetDir, EXTERNAL_EXTENSION_TMP_DIR, "extension"); +}; + +const copyBaseFiles = async (basePath: string, targetDir: string, { dev: isDev }: Options, copyOrLink: CopyOrLink) => { await copyOrLink(basePath, targetDir, { clobber: false, filter: fileName => { @@ -83,7 +106,12 @@ const isUnselectedSolidityFrameworkFile = ({ return unselectedSolidityFrameworks.map(sf => new RegExp(`${sf}`)).some(sfregex => sfregex.test(path)); }; -const copyExtensionFiles = async ({ solidityFramework }: Options, extensionPath: string, targetDir: string) => { +const copyExtensionFiles = async ( + { solidityFramework }: Options, + extensionPath: string, + targetDir: string, + copyOrLink: CopyOrLink, +) => { // copy (or link if dev) root files await copyOrLink(extensionPath, path.join(targetDir), { clobber: false, @@ -147,101 +175,52 @@ const copyExtensionFiles = async ({ solidityFramework }: Options, extensionPath: } }; -const processTemplatedFiles = async ( - { solidityFramework, externalExtension, dev: isDev }: Options, - basePath: string, - solidityFrameworkPath: string | null, - exampleContractsPath: string | null, - targetDir: string, -) => { - const baseTemplatedFileDescriptors: TemplateDescriptor[] = findFilesRecursiveSync(basePath, path => - isTemplateRegex.test(path), - ).map(baseTemplatePath => ({ - path: baseTemplatePath, - fileUrl: pathToFileURL(baseTemplatePath).href, - relativePath: baseTemplatePath.split(basePath)[1], - source: "base", - })); - - const solidityFrameworkTemplatedFileDescriptors: TemplateDescriptor[] = solidityFrameworkPath - ? findFilesRecursiveSync(solidityFrameworkPath, filePath => isTemplateRegex.test(filePath)) - .map(solidityFrameworkTemplatePath => ({ - path: solidityFrameworkTemplatePath, - fileUrl: pathToFileURL(solidityFrameworkTemplatePath).href, - relativePath: solidityFrameworkTemplatePath.split(solidityFrameworkPath)[1], - source: `extension ${solidityFramework}`, - })) - .flat() +// Builds template descriptors for every `*.template.*` file under `dir` (no-op when `dir` is unset). +const collectTemplateDescriptors = (dir: string | null | undefined, source: string): TemplateDescriptor[] => + dir + ? findFilesRecursiveSync(dir, filePath => isTemplateRegex.test(filePath)).map(templatePath => ({ + path: templatePath, + fileUrl: pathToFileURL(templatePath).href, + relativePath: templatePath.split(dir)[1], + source, + })) : []; - const starterContractsTemplateFileDescriptors: TemplateDescriptor[] = exampleContractsPath - ? findFilesRecursiveSync(exampleContractsPath, filePath => isTemplateRegex.test(filePath)) - .map(exampleContractTemplatePath => ({ - path: exampleContractTemplatePath, - fileUrl: pathToFileURL(exampleContractTemplatePath).href, - relativePath: exampleContractTemplatePath.split(exampleContractsPath)[1], - source: `example-contracts ${solidityFramework}`, - })) - .flat() - : []; +// Returns the file URL of the args file matching `argsPath` in `dir`, or `null` when missing. +const collectArgsFileUrl = (dir: string | null | undefined, argsPath: string): string | null => { + if (!dir) { + return null; + } + const argsFilePath = path.join(dir, argsPath); + return fs.existsSync(argsFilePath) ? pathToFileURL(argsFilePath).href : null; +}; + +const processTemplatedFiles = async (options: Options, paths: SourcePaths, targetDir: string) => { + const { solidityFramework, externalExtension, dev: isDev } = options; - const externalExtensionFolder = isDev - ? typeof externalExtension === "string" - ? path.join(basePath, "../../externalExtensions", externalExtension, "extension") - : undefined - : path.join(targetDir, EXTERNAL_EXTENSION_TMP_DIR, "extension"); - - const externalExtensionTemplatedFileDescriptors: TemplateDescriptor[] = - externalExtension && externalExtensionFolder - ? findFilesRecursiveSync(externalExtensionFolder, filePath => isTemplateRegex.test(filePath)).map( - extensionTemplatePath => ({ - path: extensionTemplatePath, - fileUrl: pathToFileURL(extensionTemplatePath).href, - relativePath: extensionTemplatePath.split(externalExtensionFolder)[1], - source: `external extension ${isDev ? (externalExtension as string) : getArgumentFromExternalExtensionOption(externalExtension)}`, - }), - ) - : []; + const externalExtensionSource = externalExtension + ? `external extension ${isDev ? (externalExtension as string) : getArgumentFromExternalExtensionOption(externalExtension)}` + : ""; + + const templateDescriptors: TemplateDescriptor[] = [ + ...collectTemplateDescriptors(paths.base, "base"), + ...collectTemplateDescriptors(paths.solidityFramework, `extension ${solidityFramework}`), + ...collectTemplateDescriptors(paths.externalExtension, externalExtensionSource), + ...collectTemplateDescriptors(paths.exampleContracts, `example-contracts ${solidityFramework}`), + ]; await Promise.all( - [ - ...baseTemplatedFileDescriptors, - ...solidityFrameworkTemplatedFileDescriptors, - ...externalExtensionTemplatedFileDescriptors, - ...starterContractsTemplateFileDescriptors, - ].map(async templateFileDescriptor => { + templateDescriptors.map(async templateFileDescriptor => { const templateTargetName = templateFileDescriptor.path.match(isTemplateRegex)?.[1] as string; const argsPath = templateFileDescriptor.relativePath.replace(isTemplateRegex, `${templateTargetName}.args.`); - const argsFileUrls = []; - - if (solidityFrameworkPath) { - const argsFilePath = path.join(solidityFrameworkPath, argsPath); - const fileExists = fs.existsSync(argsFilePath); - if (fileExists) { - argsFileUrls.push(pathToFileURL(argsFilePath).href); - } - } - - if (exampleContractsPath) { - const argsFilePath = path.join(exampleContractsPath, argsPath); - const fileExists = fs.existsSync(argsFilePath); - if (fileExists) { - argsFileUrls.push(pathToFileURL(argsFilePath).href); - } - } - - if (externalExtension) { - const argsFilePath = isDev - ? path.join(basePath, "../../externalExtensions", externalExtension as string, "extension", argsPath) - : path.join(targetDir, EXTERNAL_EXTENSION_TMP_DIR, "extension", argsPath); - - const fileExists = fs.existsSync(argsFilePath); - if (fileExists) { - argsFileUrls?.push(pathToFileURL(argsFilePath).href); - } - } + // args files are looked up in the same sources as the templates (base never has args) + const argsFileUrls = [ + collectArgsFileUrl(paths.solidityFramework, argsPath), + collectArgsFileUrl(paths.exampleContracts, argsPath), + collectArgsFileUrl(paths.externalExtension, argsPath), + ].filter((url): url is string => url !== null); const args = await Promise.all( argsFileUrls.map(async argsFileUrl => (await import(argsFileUrl)) as Record), @@ -339,34 +318,29 @@ const setUpExternalExtensionFiles = async (options: Options, tmpDir: string) => }; export async function copyTemplateFiles(options: Options, templateDir: string, targetDir: string) { - copyOrLink = options.dev ? link : copy; + const copyOrLink: CopyOrLink = options.dev ? link : copy; const basePath = path.join(templateDir, BASE_DIR); const tmpDir = path.join(targetDir, EXTERNAL_EXTENSION_TMP_DIR); // 1. Copy base template to target directory - await copyBaseFiles(basePath, targetDir, options); + await copyBaseFiles(basePath, targetDir, options, copyOrLink); // 2. Copy solidity framework folder - const solidityFrameworkPath = - options.solidityFramework && getSolidityFrameworkPath(options.solidityFramework, templateDir); + const solidityFrameworkPath = options.solidityFramework + ? getSolidityFrameworkPath(options.solidityFramework, templateDir) + : null; if (solidityFrameworkPath) { - await copyExtensionFiles(options, solidityFrameworkPath, targetDir); + await copyExtensionFiles(options, solidityFrameworkPath, targetDir, copyOrLink); } - const exampleContractsPath = - options.solidityFramework && path.resolve(templateDir, EXAMPLE_CONTRACTS_DIR, options.solidityFramework); + const exampleContractsPath = options.solidityFramework + ? path.resolve(templateDir, EXAMPLE_CONTRACTS_DIR, options.solidityFramework) + : null; // 3. Set up external extension if needed - if (options.externalExtension) { - let externalExtensionPath = path.join(tmpDir, "extension"); - if (options.dev) { - externalExtensionPath = path.join( - templateDir, - "../externalExtensions", - options.externalExtension as string, - "extension", - ); - } else { + const externalExtensionPath = getExternalExtensionPath(options, templateDir, targetDir); + if (options.externalExtension && externalExtensionPath) { + if (!options.dev) { await setUpExternalExtensionFiles(options, tmpDir); } @@ -379,24 +353,29 @@ export async function copyTemplateFiles(options: Options, templateDir: string, t ); // if external extension does not have solidity framework, we copy the example contracts if (!fs.existsSync(externalExtensionSolidityPath) && exampleContractsPath) { - await copyExtensionFiles(options, exampleContractsPath, targetDir); + await copyExtensionFiles(options, exampleContractsPath, targetDir, copyOrLink); } } - await copyExtensionFiles(options, externalExtensionPath, targetDir); + await copyExtensionFiles(options, externalExtensionPath, targetDir, copyOrLink); } - const shouldCopyExampleContracts = !options.externalExtension && options.solidityFramework && exampleContractsPath; - if (shouldCopyExampleContracts) { - await copyExtensionFiles(options, exampleContractsPath, targetDir); + const shouldCopyExampleContracts = Boolean( + !options.externalExtension && options.solidityFramework && exampleContractsPath, + ); + if (shouldCopyExampleContracts && exampleContractsPath) { + await copyExtensionFiles(options, exampleContractsPath, targetDir, copyOrLink); } // 4. Process templated files and generate output await processTemplatedFiles( options, - basePath, - solidityFrameworkPath, - shouldCopyExampleContracts ? exampleContractsPath : null, + { + base: basePath, + solidityFramework: solidityFrameworkPath, + exampleContracts: shouldCopyExampleContracts ? exampleContractsPath : null, + externalExtension: externalExtensionPath, + }, targetDir, ); From 251a0cb2fa968de6be3f64cc60cc32e34f548fe5 Mon Sep 17 00:00:00 2001 From: Rinat Date: Tue, 30 Jun 2026 11:49:44 +0400 Subject: [PATCH 4/6] refactor: split arg parsing concerns + dedupe global args (A4, A6) - A4: extract pure parseCliArgs into parse-cli-args.ts (unit-tested); parse-arguments-into-options orchestrates small steps (resolveExternalExtension / ensureCompatibleCreateEthVersion / validateProjectName / resolveSolidityFrameworkChoices), keeping process.exit out of the pure parser - A6: export GLOBAL_ARGS_DEFAULTS from templates/utils.js + sync test asserting it matches src/utils/consts Output verified unchanged vs prior for hardhat, none, ext configs. Co-Authored-By: Claude Opus 4.8 --- src/templates-utils.test.ts | 15 +- src/utils/parse-arguments-into-options.ts | 195 +++++++++++----------- src/utils/parse-cli-args.test.ts | 45 +++++ src/utils/parse-cli-args.ts | 57 +++++++ templates/utils.js | 3 +- 5 files changed, 211 insertions(+), 104 deletions(-) create mode 100644 src/utils/parse-cli-args.test.ts create mode 100644 src/utils/parse-cli-args.ts diff --git a/src/templates-utils.test.ts b/src/templates-utils.test.ts index 1e7dc30bee..c230b579bc 100644 --- a/src/templates-utils.test.ts +++ b/src/templates-utils.test.ts @@ -1,7 +1,14 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; // templates/utils.js is plain JS shipped into generated apps and evaluated at template time. -import { upperCaseFirstLetter, deepMerge, withDefaults, stringify } from "../templates/utils.js"; +import { + upperCaseFirstLetter, + deepMerge, + withDefaults, + stringify, + GLOBAL_ARGS_DEFAULTS as templatesGlobalArgsDefaults, +} from "../templates/utils.js"; +import { GLOBAL_ARGS_DEFAULTS as srcGlobalArgsDefaults } from "./utils/consts.ts"; describe("upperCaseFirstLetter", () => { it("capitalizes the first letter", () => { @@ -50,3 +57,9 @@ describe("withDefaults", () => { assert.throws(() => tmpl({ foo: ["x"], bar: ["y"], ...globals }), /unexpected argument/); }); }); + +describe("GLOBAL_ARGS_DEFAULTS", () => { + it("stays in sync between src/utils/consts and templates/utils", () => { + assert.deepEqual(templatesGlobalArgsDefaults, srcGlobalArgsDefaults); + }); +}); diff --git a/src/utils/parse-arguments-into-options.ts b/src/utils/parse-arguments-into-options.ts index a7abc6971f..4b702d4cd0 100644 --- a/src/utils/parse-arguments-into-options.ts +++ b/src/utils/parse-arguments-into-options.ts @@ -1,5 +1,4 @@ -import type { Args, SolidityFramework, RawOptions, SolidityFrameworkChoices } from "../types"; -import arg from "arg"; +import type { Args, RawOptions, SolidityFrameworkChoices } from "../types"; import { getSolidityFrameworkDirsFromExternalExtension, validateExternalExtension } from "./external-extensions"; import chalk from "chalk"; import { SOLIDITY_FRAMEWORKS } from "./consts"; @@ -7,43 +6,15 @@ import { validateNpmName } from "./validate-name"; import { confirm } from "@inquirer/prompts"; import packageJson from "../../package.json"; import { execa } from "execa"; +import { parseCliArgs } from "./parse-cli-args"; -// TODO update smartContractFramework code with general extensions -export async function parseArgumentsIntoOptions( - rawArgs: Args, -): Promise<{ rawOptions: RawOptions; solidityFrameworkChoices: SolidityFrameworkChoices }> { - const args = arg( - { - "--skip-install": Boolean, - "--skip": "--skip-install", - - "--dev": Boolean, - - "--solidity-framework": solidityFrameworkHandler, - "-s": "--solidity-framework", - - "--extension": String, - "-e": "--extension", - - "--help": Boolean, - "-h": "--help", - }, - { - argv: rawArgs.slice(2), - }, - ); - - const skipInstall = args["--skip-install"] ?? null; +type ResolvedExtension = Awaited> | null; - const dev = args["--dev"] ?? false; // info: use false avoid asking user - - const help = args["--help"] ?? false; - - let project: string | null = args._[0] ?? null; - - // use the original extension arg - const extensionName = args["--extension"]; - // ToDo. Allow multiple +// Validates the extension argument (network in non-dev mode) and warns on untrusted third-party sources. +const resolveExternalExtension = async ( + extensionName: string | undefined, + dev: boolean, +): Promise => { const extension = extensionName ? await validateExternalExtension(extensionName, dev) : null; // if dev mode, extension would be a string @@ -57,73 +28,104 @@ export async function parseArgumentsIntoOptions( ); } - // Check if extension createEthVersion matches current version - if (extension && typeof extension === "object" && extension.recommendedCreateEthVersion) { - const currentVersion = packageJson.version; - - if (extension.recommendedCreateEthVersion !== currentVersion) { - console.log( - chalk.yellow( - `\n⚠️ This extension requires create-eth ${chalk.bold(`v${extension.recommendedCreateEthVersion}`)}, but you're running ${chalk.bold(`v${currentVersion}`)}.\n`, - ), - ); - - const switchVersion = await confirm({ - message: `Would you like to run with the correct version (${extension.recommendedCreateEthVersion})?`, - default: true, - }); - - if (switchVersion) { - console.log(chalk.gray(`\nSwitching to create-eth@${extension.recommendedCreateEthVersion}...\n`)); - - await execa("npx", [`create-eth@${extension.recommendedCreateEthVersion}`, ...rawArgs.slice(2)], { - stdio: "inherit", - }); - - process.exit(0); - } - - const proceed = await confirm({ - message: "Do you want to proceed with the current version anyway?", - default: false, - }); - - if (!proceed) { - console.log(chalk.gray("\nSetup cancelled. No project was created")); - process.exit(0); - } - } + return extension; +}; + +// Interactive guard: when the extension recommends a different create-eth version, offer to switch (and exit). +const ensureCompatibleCreateEthVersion = async (extension: ResolvedExtension, rawArgs: Args) => { + if (!(extension && typeof extension === "object" && extension.recommendedCreateEthVersion)) { + return; } - if (project) { - const validation = validateNpmName(project); - if (!validation.valid) { - console.error( - `Could not create a project called ${chalk.yellow(`"${project}"`)} because of naming restrictions:`, - ); + const currentVersion = packageJson.version; + if (extension.recommendedCreateEthVersion === currentVersion) { + return; + } - validation.problems.forEach(p => console.error(`${chalk.red(">>")} Project ${p}`)); - project = null; - } + console.log( + chalk.yellow( + `\n⚠️ This extension requires create-eth ${chalk.bold(`v${extension.recommendedCreateEthVersion}`)}, but you're running ${chalk.bold(`v${currentVersion}`)}.\n`, + ), + ); + + const switchVersion = await confirm({ + message: `Would you like to run with the correct version (${extension.recommendedCreateEthVersion})?`, + default: true, + }); + + if (switchVersion) { + console.log(chalk.gray(`\nSwitching to create-eth@${extension.recommendedCreateEthVersion}...\n`)); + + await execa("npx", [`create-eth@${extension.recommendedCreateEthVersion}`, ...rawArgs.slice(2)], { + stdio: "inherit", + }); + + process.exit(0); } - let solidityFrameworkChoices = [ + const proceed = await confirm({ + message: "Do you want to proceed with the current version anyway?", + default: false, + }); + + if (!proceed) { + console.log(chalk.gray("\nSetup cancelled. No project was created")); + process.exit(0); + } +}; + +// Returns the project name if valid, otherwise prints the problems and returns null (prompt later). +const validateProjectName = (project: string | null): string | null => { + if (!project) { + return null; + } + + const validation = validateNpmName(project); + if (validation.valid) { + return project; + } + + console.error(`Could not create a project called ${chalk.yellow(`"${project}"`)} because of naming restrictions:`); + validation.problems.forEach(p => console.error(`${chalk.red(">>")} Project ${p}`)); + return null; +}; + +const resolveSolidityFrameworkChoices = async (extension: ResolvedExtension): Promise => { + const defaultChoices: SolidityFrameworkChoices = [ SOLIDITY_FRAMEWORKS.HARDHAT, SOLIDITY_FRAMEWORKS.FOUNDRY, { value: null, name: "none" }, ]; - if (extension) { - const externalExtensionSolidityFrameworkDirs = await getSolidityFrameworkDirsFromExternalExtension(extension); - - if (externalExtensionSolidityFrameworkDirs.length !== 0) { - solidityFrameworkChoices = externalExtensionSolidityFrameworkDirs; - } + if (!extension) { + return defaultChoices; } + const externalExtensionSolidityFrameworkDirs = await getSolidityFrameworkDirsFromExternalExtension(extension); + return externalExtensionSolidityFrameworkDirs.length !== 0 ? externalExtensionSolidityFrameworkDirs : defaultChoices; +}; + +// TODO update smartContractFramework code with general extensions +export async function parseArgumentsIntoOptions( + rawArgs: Args, +): Promise<{ rawOptions: RawOptions; solidityFrameworkChoices: SolidityFrameworkChoices }> { + const { + skipInstall, + dev, + help, + project: projectArg, + extensionName, + solidityFramework: solidityFrameworkArg, + } = parseCliArgs(rawArgs); + + const extension = await resolveExternalExtension(extensionName, dev); + await ensureCompatibleCreateEthVersion(extension, rawArgs); + + const project = validateProjectName(projectArg); + const solidityFrameworkChoices = await resolveSolidityFrameworkChoices(extension); + // if length is 1, we don't give user a choice and set it ourselves. - const solidityFramework = - solidityFrameworkChoices.length === 1 ? solidityFrameworkChoices[0] : (args["--solidity-framework"] ?? null); + const solidityFramework = solidityFrameworkChoices.length === 1 ? solidityFrameworkChoices[0] : solidityFrameworkArg; return { rawOptions: { @@ -137,14 +139,3 @@ export async function parseArgumentsIntoOptions( solidityFrameworkChoices, }; } - -const SOLIDITY_FRAMEWORK_OPTIONS = [...Object.values(SOLIDITY_FRAMEWORKS), "none"]; -function solidityFrameworkHandler(value: string) { - const lowercasedValue = value.toLowerCase(); - if (SOLIDITY_FRAMEWORK_OPTIONS.includes(lowercasedValue)) { - return lowercasedValue as SolidityFramework | "none"; - } - - // choose from cli prompts - return null; -} diff --git a/src/utils/parse-cli-args.test.ts b/src/utils/parse-cli-args.test.ts new file mode 100644 index 0000000000..43a3662a5e --- /dev/null +++ b/src/utils/parse-cli-args.test.ts @@ -0,0 +1,45 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { parseCliArgs } from "./parse-cli-args.ts"; + +const argv = (...args: string[]) => ["node", "create-eth", ...args]; + +describe("parseCliArgs", () => { + it("returns defaults with no args", () => { + const r = parseCliArgs(argv()); + assert.equal(r.project, null); + assert.equal(r.dev, false); + assert.equal(r.help, false); + assert.equal(r.skipInstall, null); + assert.equal(r.extensionName, undefined); + assert.equal(r.solidityFramework, null); + }); + + it("parses the project name positional", () => { + assert.equal(parseCliArgs(argv("my-app")).project, "my-app"); + }); + + it("parses --skip-install and its --skip alias", () => { + assert.equal(parseCliArgs(argv("--skip-install")).skipInstall, true); + assert.equal(parseCliArgs(argv("--skip")).skipInstall, true); + }); + + it("parses -s / --solidity-framework and lowercases known values", () => { + assert.equal(parseCliArgs(argv("-s", "Hardhat")).solidityFramework, "hardhat"); + assert.equal(parseCliArgs(argv("--solidity-framework", "foundry")).solidityFramework, "foundry"); + assert.equal(parseCliArgs(argv("-s", "none")).solidityFramework, "none"); + }); + + it("returns null solidityFramework for an unknown value (prompt later)", () => { + assert.equal(parseCliArgs(argv("-s", "truffle")).solidityFramework, null); + }); + + it("parses -e / --extension", () => { + assert.equal(parseCliArgs(argv("-e", "owner/repo")).extensionName, "owner/repo"); + }); + + it("parses --dev and -h", () => { + assert.equal(parseCliArgs(argv("--dev")).dev, true); + assert.equal(parseCliArgs(argv("-h")).help, true); + }); +}); diff --git a/src/utils/parse-cli-args.ts b/src/utils/parse-cli-args.ts new file mode 100644 index 0000000000..e5ec04c9fe --- /dev/null +++ b/src/utils/parse-cli-args.ts @@ -0,0 +1,57 @@ +import arg from "arg"; +import type { Args, SolidityFramework } from "../types"; +import { SOLIDITY_FRAMEWORKS } from "./consts"; + +const SOLIDITY_FRAMEWORK_OPTIONS = [...Object.values(SOLIDITY_FRAMEWORKS), "none"]; + +function solidityFrameworkHandler(value: string) { + const lowercasedValue = value.toLowerCase(); + if (SOLIDITY_FRAMEWORK_OPTIONS.includes(lowercasedValue)) { + return lowercasedValue as SolidityFramework | "none"; + } + + // unknown value -> let the user choose from the cli prompts + return null; +} + +export type ParsedCliArgs = { + skipInstall: boolean | null; + dev: boolean; + help: boolean; + project: string | null; + extensionName: string | undefined; + solidityFramework: SolidityFramework | "none" | null; +}; + +// Pure parsing of raw argv into typed flags. No network, prompts or side effects. +export function parseCliArgs(rawArgs: Args): ParsedCliArgs { + const args = arg( + { + "--skip-install": Boolean, + "--skip": "--skip-install", + + "--dev": Boolean, + + "--solidity-framework": solidityFrameworkHandler, + "-s": "--solidity-framework", + + "--extension": String, + "-e": "--extension", + + "--help": Boolean, + "-h": "--help", + }, + { + argv: rawArgs.slice(2), + }, + ); + + return { + skipInstall: args["--skip-install"] ?? null, + dev: args["--dev"] ?? false, // info: use false avoid asking user + help: args["--help"] ?? false, + project: args._[0] ?? null, + extensionName: args["--extension"], + solidityFramework: args["--solidity-framework"] ?? null, + }; +} diff --git a/templates/utils.js b/templates/utils.js index 8d4095fd69..449c6b9e48 100644 --- a/templates/utils.js +++ b/templates/utils.js @@ -60,7 +60,8 @@ export const deepMerge = (...args) => { }; // copy of the defaults from the src/utils/consts.ts file -const GLOBAL_ARGS_DEFAULTS = { +// (kept in sync by templates-utils.test.ts since this plain-JS file can't import the TS const) +export const GLOBAL_ARGS_DEFAULTS = { solidityFramework: "", }; From 369d1c237cfe7ab01fedbec2162ea3f29ac65200 Mon Sep 17 00:00:00 2001 From: Rinat Date: Tue, 30 Jun 2026 12:18:31 +0400 Subject: [PATCH 5/6] chore: clean up stale TODO comments - drop obsolete solidity-framework TODO: 'generalize into general extensions' targets the extension system being deprecated - replace prettier-format TODO with a rationale: we run the generated project's own 'yarn format' on purpose (its config/plugins), so the suggested switch to the CLI's bundled prettier would be a regression Co-Authored-By: Claude Opus 4.8 --- src/tasks/prettier-format.ts | 3 ++- src/utils/parse-arguments-into-options.ts | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tasks/prettier-format.ts b/src/tasks/prettier-format.ts index 75e855ae62..c76fa8370c 100644 --- a/src/tasks/prettier-format.ts +++ b/src/tasks/prettier-format.ts @@ -1,6 +1,7 @@ import { execa } from "execa"; -// TODO: Instead of using execa, use prettier package from cli to format targetDir +// Run the generated project's own `yarn format` so its prettier config and plugins +// (e.g. solidity) apply — the CLI's bundled prettier would use the wrong config. export async function prettierFormat(targetDir: string) { try { await execa("yarn", ["format"], { cwd: targetDir }); diff --git a/src/utils/parse-arguments-into-options.ts b/src/utils/parse-arguments-into-options.ts index 4b702d4cd0..e01fb4d4f4 100644 --- a/src/utils/parse-arguments-into-options.ts +++ b/src/utils/parse-arguments-into-options.ts @@ -105,7 +105,6 @@ const resolveSolidityFrameworkChoices = async (extension: ResolvedExtension): Pr return externalExtensionSolidityFrameworkDirs.length !== 0 ? externalExtensionSolidityFrameworkDirs : defaultChoices; }; -// TODO update smartContractFramework code with general extensions export async function parseArgumentsIntoOptions( rawArgs: Args, ): Promise<{ rawOptions: RawOptions; solidityFrameworkChoices: SolidityFrameworkChoices }> { From 9886447fac9b4469b4d486b553058e877099e19a Mon Sep 17 00:00:00 2001 From: Rinat Date: Tue, 30 Jun 2026 16:17:02 +0400 Subject: [PATCH 6/6] fix: restore isConfigRegex skip + declare node>=22.6 engines - keep isConfigRegex config.json skip (only matched backslash/Windows paths but removing it changed extension-copy behaviour there); restores 1:1 parity with main - add engines.node >=22.6 (test script needs --experimental-strip-types) Co-Authored-By: Claude Opus 4.8 --- package.json | 3 +++ src/tasks/copy-template-files.ts | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index cfe187beef..5899aa1e6b 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,9 @@ "rainbowkit" ], "license": "MIT", + "engines": { + "node": ">=22.6" + }, "devDependencies": { "@eslint/js": "^9.31.0", "@rollup/plugin-json": "^6.1.0", diff --git a/src/tasks/copy-template-files.ts b/src/tasks/copy-template-files.ts index a34f2d3f04..af9c67cc4f 100644 --- a/src/tasks/copy-template-files.ts +++ b/src/tasks/copy-template-files.ts @@ -26,6 +26,7 @@ type CopyOrLink = typeof copy; const isTemplateRegex = /([^/\\]*?)\.template\./; const isPackageJsonRegex = /package\.json/; const isYarnLockRegex = /yarn\.lock/; +const isConfigRegex = /([^/\\]*?)\\config\.json/; const isArgsRegex = /([^/\\]*?)\.args\./; const isSolidityFrameworkFolderRegex = /solidity-frameworks$/; const isPackagesFolderRegex = /packages$/; @@ -116,12 +117,14 @@ const copyExtensionFiles = async ( await copyOrLink(extensionPath, path.join(targetDir), { clobber: false, filter: path => { + const isConfig = isConfigRegex.test(path); const isArgs = isArgsRegex.test(path); const isSolidityFrameworkFolder = isSolidityFrameworkFolderRegex.test(path) && fs.lstatSync(path).isDirectory(); const isPackagesFolder = isPackagesFolderRegex.test(path) && fs.lstatSync(path).isDirectory(); const isTemplate = isTemplateRegex.test(path); const isPackageJson = isPackageJsonRegex.test(path); - const shouldSkip = isArgs || isTemplate || isPackageJson || isSolidityFrameworkFolder || isPackagesFolder; + const shouldSkip = + isConfig || isArgs || isTemplate || isPackageJson || isSolidityFrameworkFolder || isPackagesFolder; return !shouldSkip; }, });