diff --git a/package.json b/package.json index 8b3c9dd46..fe2811ef6 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,9 @@ "bin": "bin/create-dapp-se2.js", "scripts": { "build": "rollup -c rollup.config.js", - "build:dev": "yarn build && rollup -c src/dev/rollup.config.js", + "build:dev": "yarn build && rollup -c src/dev/rollup.config.js && rollup -c src/yolo/rollup.config.js", "create-extension": "node dist/create-extension/create-extension.js", + "create-extension-yolo": "node dist/create-extension-yolo/create-extension-yolo.js", "dev": "rollup -c rollup.config.js --watch", "cli": "node bin/create-dapp-se2.js", "lint": "eslint .", diff --git a/src/cli.ts b/src/cli.ts index 3447122b0..d76b5cb4b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import chalk from "chalk"; import { SOLIDITY_FRAMEWORKS } from "./utils/consts"; import { validateFoundryUp } from "./utils/system-validation"; import { showHelpMessage } from "./utils/show-help-message"; +import { createProjectYolo } from "./yolo"; export async function cli(args: Args) { try { @@ -22,7 +23,11 @@ export async function cli(args: Args) { await validateFoundryUp(); } - await createProject(options); + if (options.yolo) { + await createProjectYolo(options); + } else { + await createProject(options); + } } catch (error: any) { console.error(chalk.red.bold(error.message || "An unknown error occurred.")); return; diff --git a/src/types.ts b/src/types.ts index cb238a988..656d57a93 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,7 @@ type BaseOptions = { dev: boolean; externalExtension: ExternalExtension | ExternalExtensionNameDev | null; solidityFramework: SolidityFramework | "none" | null; + yolo: boolean; }; export type RawOptions = BaseOptions & { diff --git a/src/utils/parse-arguments-into-options.ts b/src/utils/parse-arguments-into-options.ts index ed23ffeb8..94dc75eb7 100644 --- a/src/utils/parse-arguments-into-options.ts +++ b/src/utils/parse-arguments-into-options.ts @@ -25,6 +25,8 @@ export async function parseArgumentsIntoOptions( "--help": Boolean, "-h": "--help", + + "--yolo": Boolean, }, { argv: rawArgs.slice(2), @@ -37,6 +39,8 @@ export async function parseArgumentsIntoOptions( const help = args["--help"] ?? false; + const yolo = args["--yolo"] ?? false; + let project: string | null = args._[0] ?? null; // use the original extension arg @@ -97,6 +101,7 @@ export async function parseArgumentsIntoOptions( externalExtension: extension, help, solidityFramework: solidityFramework as RawOptions["solidityFramework"], + yolo, }, solidityFrameworkChoices, }; diff --git a/src/utils/prompt-for-missing-options.ts b/src/utils/prompt-for-missing-options.ts index 7c5242dfa..64860dc16 100644 --- a/src/utils/prompt-for-missing-options.ts +++ b/src/utils/prompt-for-missing-options.ts @@ -12,6 +12,7 @@ const defaultOptions: RawOptions = { dev: false, externalExtension: null, help: false, + yolo: false, }; export async function promptForMissingOptions( @@ -51,6 +52,7 @@ export async function promptForMissingOptions( dev: options.dev ?? defaultOptions.dev, solidityFramework: solidityFramework === "none" ? null : solidityFramework, externalExtension: options.externalExtension, + yolo: options.yolo, }; return mergedOptions; diff --git a/src/yolo.ts b/src/yolo.ts new file mode 100644 index 000000000..cc60936e9 --- /dev/null +++ b/src/yolo.ts @@ -0,0 +1,192 @@ +import { execa } from "execa"; +import { createProjectDirectory, prettierFormat, installPackages } from "./tasks"; +import type { ExternalExtension, Options } from "./types"; +import { renderOutroMessage } from "./utils/render-outro-message"; +import chalk from "chalk"; +import { Listr } from "listr2"; +import path from "path"; +import { getArgumentFromExternalExtensionOption } from "./utils/external-extensions"; +import fs from "fs"; +import { promisify } from "util"; +import ncp from "ncp"; + +const DELETED_FILES_LOG = "deletedFiles.log"; +const COMMIT_HASH_LOG = "commitHash.log"; +const EXTERNAL_EXTENSION_TMP_DIR = "tmp-external-extension"; + +const copy = promisify(ncp); + +const cloneGitRepo = async (repositoryUrl: string, targetDir: string): Promise => { + try { + // 1. Create the target directory if it doesn't exist + await fs.promises.mkdir(targetDir, { recursive: true }); + + // 2. Clone the repository + await execa("git", ["clone", repositoryUrl, targetDir], { cwd: targetDir }); + + console.log(`Repository cloned to ${targetDir}`); + } catch (error: any) { + console.error(`Error cloning repository: ${error.message}`); + throw error; + } +}; + +const resetToCommitHash = async (externalExtensionPath: string, targetDir: string) => { + const logPath = path.join(externalExtensionPath, COMMIT_HASH_LOG); + + if (fs.existsSync(logPath)) { + const commitHash = (await fs.promises.readFile(logPath, "utf8")).trim(); + if (commitHash) { + try { + console.log(`Resetting repository to commit hash: ${commitHash}`); + await execa("git", ["reset", "--hard", commitHash], { cwd: targetDir }); + console.log(`Repository successfully reset to commit hash: ${commitHash}`); + } catch (error: any) { + console.error(`Error resetting to commit hash: ${error.message}`); + throw error; + } + } else { + console.warn("Commit hash log is empty. Skipping reset."); + } + } else { + console.warn(`No commit hash log found at: ${logPath}. Skipping reset.`); + } +}; + +const removeLoggedDeletedFiles = async (externalExtensionPath: string, targetDir: string) => { + const logPath = path.join(externalExtensionPath, DELETED_FILES_LOG); + console.log(`Checking for previously logged deleted files at: ${logPath}`); + if (fs.existsSync(logPath)) { + const deletedFilesContent = await fs.promises.readFile(logPath, "utf8"); + const deletedFiles = deletedFilesContent.split("\n").filter(Boolean); + + for (const file of deletedFiles) { + const filePath = path.join(targetDir, file); + console.log(`Checking deleted file: ${file}`, filePath); + if (fs.existsSync(filePath)) { + await fs.promises.unlink(filePath); + console.log(`Removed previously logged deleted file: ${file}`); + } + } + } +}; + +const commitChanges = async (targetDir: string) => { + try { + console.log("Staging all changes..."); + await execa("git", ["add", "--all"], { cwd: targetDir }); + + console.log("Committing changes..."); + await execa("git", ["commit", "-m", "Apply changes from extension"], { cwd: targetDir }); + + console.log("Changes committed successfully."); + } catch (error: any) { + console.error(`Error committing changes: ${error.message}`); + throw error; + } +}; + +const setUpExternalExtensionFiles = async (options: Options, tmpDir: string) => { + // 1. Create tmp directory to clone external extension + await fs.promises.mkdir(tmpDir); + + const { repository, branch } = options.externalExtension as ExternalExtension; + + // 2. Clone external extension + if (branch) { + await execa("git", ["clone", "--branch", branch, repository, tmpDir], { + cwd: tmpDir, + }); + } else { + await execa("git", ["clone", repository, tmpDir], { cwd: tmpDir }); + } +}; + +const createExtension = async (options: Options, targetDir: string) => { + await cloneGitRepo("https://github.com/scaffold-eth/scaffold-eth-2", targetDir); + + const tmpDir = path.join(targetDir, EXTERNAL_EXTENSION_TMP_DIR); + + let externalExtensionPath = path.join(tmpDir, "extension"); + + if (options.dev) { + externalExtensionPath = path.join("externalExtensions", options.externalExtension as string, "extension"); + } else { + await setUpExternalExtensionFiles(options, tmpDir); + } + + await resetToCommitHash(externalExtensionPath, targetDir); + + await copy(externalExtensionPath, targetDir, { + filter: file => { + const relativePath = path.relative(externalExtensionPath, file); + return ![DELETED_FILES_LOG, COMMIT_HASH_LOG].includes(relativePath); + }, + }); + + await removeLoggedDeletedFiles(externalExtensionPath, targetDir); + + await commitChanges(targetDir); +}; + +export async function createProjectYolo(options: Options) { + console.log(`\n`); + console.log("Yolo mode activated! 🚀"); + + // const currentFileUrl = import.meta.url; + + const targetDirectory = path.resolve(process.cwd(), options.project); + + const tasks = new Listr( + [ + { + title: `📁 Create project directory ${targetDirectory}`, + task: () => createProjectDirectory(options.project), + }, + { + title: `🚀 Creating a new Scaffold-ETH 2 app in ${chalk.green.bold( + options.project, + )}${options.externalExtension ? ` with the ${chalk.green.bold(options.dev ? options.externalExtension : getArgumentFromExternalExtensionOption(options.externalExtension))} extension` : ""}`, + //task: () => copyTemplateFiles(options, templateDirectory, targetDirectory), + task: () => createExtension(options, targetDirectory), + }, + { + title: "📦 Installing dependencies with yarn, this could take a while", + task: (_, task) => installPackages(targetDirectory, task), + skip: () => { + if (!options.install) { + return "Manually skipped, since `--skip-install` flag was passed"; + } + return false; + }, + rendererOptions: { + outputBar: 8, + persistentOutput: false, + }, + }, + { + title: "🪄 Formatting files", + task: () => prettierFormat(targetDirectory), + skip: () => { + if (!options.install) { + return "Can't use source prettier, since `yarn install` was skipped"; + } + return false; + }, + }, + // { + // title: `📡 Initializing Git repository${options.solidityFramework === SOLIDITY_FRAMEWORKS.FOUNDRY ? " and submodules" : ""}`, + // task: () => createFirstGitCommit(targetDirectory, options), + // }, + ], + { rendererOptions: { collapseSkips: false, suffixSkips: true } }, + ); + + try { + await tasks.run(); + renderOutroMessage(options); + } catch (error) { + console.log("%s Error occurred", chalk.red.bold("ERROR"), error); + console.log("%s Exiting...", chalk.red.bold("Uh oh! 😕 Sorry about that!")); + } +} diff --git a/src/yolo/create-extension-yolo.ts b/src/yolo/create-extension-yolo.ts new file mode 100644 index 000000000..9528275dd --- /dev/null +++ b/src/yolo/create-extension-yolo.ts @@ -0,0 +1,159 @@ +import arg from "arg"; +import path from "path"; +import fs from "fs"; +import { promisify } from "util"; +import { execa } from "execa"; +import ncp from "ncp"; +import chalk from "chalk"; +import { Args } from "../types"; + +const EXTERNAL_EXTENSIONS_DIR = "externalExtensions"; +const TARGET_EXTENSION_DIR = "extension"; +const DELETED_FILES_LOG = "deletedFiles.log"; +const COMMIT_HASH_LOG = "commitHash.log"; + +const prettyLog = { + info: (message: string, indent = 0) => console.log(chalk.cyan(`${" ".repeat(indent)}${message}`)), + success: (message: string, indent = 0) => console.log(chalk.green(`${" ".repeat(indent)}✔︎ ${message}`)), + warning: (message: string, indent = 0) => console.log(chalk.yellow(`${" ".repeat(indent)}⚠ ${message}`)), + error: (message: string, indent = 0) => console.log(chalk.red(`${" ".repeat(indent)}✖ ${message}`)), +}; + +const ncpPromise = promisify(ncp); + +const getProjectPathAndCommitHash = (rawArgs: string[]) => { + const args = arg({}, { argv: rawArgs.slice(2) }); + const projectPath = args._[0]; + const commitHash = args._[1]; + if (!projectPath || !commitHash) { + throw new Error("Project path and commit hash are required"); + } + return { projectPath, commitHash }; +}; + +const getDeletedAndRenamedFilesSinceCommit = async (projectPath: string, commitHash: string): Promise => { + const { stdout: gitOutput } = await execa( + "git", + ["diff", "--diff-filter=DR", "--name-status", `${commitHash}..HEAD`], + { cwd: projectPath }, + ); + + // Process the output to extract deleted and renamed files + const deletedAndRenamedFiles = gitOutput + .split("\n") // Split into lines + .filter(Boolean) // Remove empty lines + .map(line => { + const parts = line.split("\t"); + if (line.startsWith("D")) { + return parts[1]; // For deleted files, return the file name + } else if (line.startsWith("R")) { + return parts[1]; // For renamed files, return the original file name + } + return null; // Ignore other cases + }) + .filter(Boolean); // Remove null entries + + return deletedAndRenamedFiles as string[]; +}; + +const getChangedFilesSinceCommit = async (projectPath: string, commitHash: string): Promise => { + const { stdout } = await execa("git", ["diff", "--diff-filter=d", "--name-only", `${commitHash}..HEAD`], { + cwd: projectPath, + }); + + return stdout.split("\n").filter(Boolean); +}; + +const createDirectories = async (filePath: string, projectName: string) => { + const dirPath = path.join(EXTERNAL_EXTENSIONS_DIR, projectName, TARGET_EXTENSION_DIR, path.dirname(filePath)); + await fs.promises.mkdir(dirPath, { recursive: true }); +}; + +const copyChangedFiles = async (changedFiles: string[], projectName: string, projectPath: string) => { + for (const file of changedFiles) { + const sourcePath = path.resolve(projectPath, file); + const destPath = path.join(EXTERNAL_EXTENSIONS_DIR, projectName, TARGET_EXTENSION_DIR, file); + if (!fs.existsSync(sourcePath)) continue; + await createDirectories(file, projectName); + await ncpPromise(sourcePath, destPath); + prettyLog.success(`Copied changed file: ${file}`, 2); + } +}; + +const logCommitHash = async (commitHash: string, projectPath: string) => { + const logPath = path.join(EXTERNAL_EXTENSIONS_DIR, projectPath, TARGET_EXTENSION_DIR, COMMIT_HASH_LOG); + await fs.promises.writeFile(logPath, commitHash, "utf8"); + prettyLog.success(`Commit hash logged to ${logPath}\n`, 1); +}; + +const logDeletedFiles = async (deletedFiles: string[], projectPath: string) => { + const logPath = path.join(EXTERNAL_EXTENSIONS_DIR, projectPath, TARGET_EXTENSION_DIR, DELETED_FILES_LOG); + const logContent = deletedFiles.join("\n"); + await fs.promises.writeFile(logPath, logContent, "utf8"); + console.log(""); + console.log("Deleted files:", deletedFiles); + prettyLog.success(`Deleted files logged to ${logPath}\n`, 1); +}; + +const clearDirectoryContents = async (dirPath: string) => { + const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + + // Skip Git-related files/folders + if (entry.name === ".git" || entry.name.startsWith(".git")) { + continue; + } + + if (entry.isDirectory()) { + await fs.promises.rm(fullPath, { recursive: true, force: true }); + } else { + await fs.promises.unlink(fullPath); + } + } +}; + +const clearProjectFolderIfExists = async (projectName: string) => { + const projectDir = path.join(EXTERNAL_EXTENSIONS_DIR, projectName); + if (fs.existsSync(projectDir)) { + await clearDirectoryContents(projectDir); + prettyLog.success(`Cleared contents of directory: ${projectDir}\n`, 1); + } +}; + +const main = async (rawArgs: Args) => { + try { + const { projectPath, commitHash } = getProjectPathAndCommitHash(rawArgs); + const projectName = path.basename(projectPath); + + prettyLog.info(`Extension name: ${projectName}\n`); + + await clearProjectFolderIfExists(projectName); + + prettyLog.info("Getting list of changed files...", 1); + const changedFiles = await getChangedFilesSinceCommit(projectPath, commitHash); + const deletedAndRenamedFiles = await getDeletedAndRenamedFilesSinceCommit(projectPath, commitHash); + + if (!changedFiles.length && !deletedAndRenamedFiles.length) { + prettyLog.warning("No files to process."); + return; + } + + if (changedFiles.length) { + await copyChangedFiles(changedFiles, projectName, projectPath); + } + + if (deletedAndRenamedFiles.length) { + await logDeletedFiles(deletedAndRenamedFiles, projectPath); + } + + await logCommitHash(commitHash, projectName); + + prettyLog.info(`Files processed successfully, updated ${EXTERNAL_EXTENSIONS_DIR}/${projectName} directory.`); + } catch (err: any) { + prettyLog.error(`Error: ${err.message}`); + } +}; + +main(process.argv).catch(() => process.exit(1)); diff --git a/src/yolo/rollup.config.js b/src/yolo/rollup.config.js new file mode 100644 index 000000000..1690e1555 --- /dev/null +++ b/src/yolo/rollup.config.js @@ -0,0 +1,12 @@ +import typescript from "@rollup/plugin-typescript"; +import autoExternal from "rollup-plugin-auto-external"; + +export default { + input: "src/yolo/create-extension-yolo.ts", + output: { + dir: "dist/create-extension-yolo", + format: "es", + sourcemap: true, + }, + plugins: [autoExternal(), typescript({ exclude: ["templates/**", "externalExtensions/**"] })], +};